chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s
v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete: - Global site selection (toolbar picker, all tabs consume) - User access audit (Graph people-picker, direct/group/inherited) - Simplified permissions (plain-language labels, risk levels, detail toggle) - Storage visualization (LiveCharts2 pie/donut + bar charts) Post-phase polish: centralized site selection (removed per-tab pickers), claims prefix stripping, StorageMetrics backfill, chart tooltip fix, summary stats in app + HTML exports. 205 tests passing, 10,484 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,23 +8,19 @@ A C#/WPF desktop application for IT administrators and MSPs to audit and manage
|
||||
|
||||
Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
|
||||
|
||||
## Current Milestone: v1.1 Enhanced Reports
|
||||
|
||||
**Goal:** Add user access audit, simplified permissions, storage visualization, and global multi-site selection
|
||||
|
||||
**Target features:**
|
||||
- Export all SharePoint/Teams accesses a specific user has across selected sites
|
||||
- Simplified permissions reports with plain-language labels and summary views
|
||||
- Storage metrics graph by file type (pie/donut and bar chart, toggleable)
|
||||
- Global multi-site selection in toolbar (pick sites once, all tabs use them)
|
||||
|
||||
## Current State
|
||||
|
||||
**Shipped:** v1.0 MVP (2026-04-07)
|
||||
**Status:** Feature-complete for v1 parity; v1.1 in progress
|
||||
**Shipped:** v1.1 Enhanced Reports (2026-04-08)
|
||||
**Status:** Feature-complete for v1.1; no active milestone
|
||||
|
||||
Tech stack: C# / WPF / .NET 10 / PnP Framework / Microsoft Graph SDK / MSAL / Serilog / CommunityToolkit.Mvvm
|
||||
Tests: 134 automated (xUnit), 22 skipped (require live SharePoint tenant)
|
||||
**v1.1 shipped features:**
|
||||
- Global multi-site selection in toolbar (pick sites once, all tabs use them)
|
||||
- User access audit tab with Graph API people-picker, direct/group/inherited access distinction
|
||||
- Simplified permissions with plain-language labels, color-coded risk levels, detail-level toggle
|
||||
- Storage visualization with LiveCharts2 pie/donut and bar charts by file type
|
||||
|
||||
Tech stack: C# / WPF / .NET 10 / PnP Framework / Microsoft Graph SDK / MSAL / Serilog / CommunityToolkit.Mvvm / LiveCharts2
|
||||
Tests: 205 automated (xUnit), 22 skipped (require live SharePoint tenant)
|
||||
Distribution: 200 MB self-contained EXE (win-x64)
|
||||
|
||||
## Requirements
|
||||
@@ -37,11 +33,12 @@ Distribution: 200 MB self-contained EXE (win-x64)
|
||||
- Modular architecture (separate files per feature area, DI, MVVM) — v1.0
|
||||
- Self-contained single EXE distribution — v1.0
|
||||
|
||||
### Active
|
||||
### Shipped in v1.1
|
||||
|
||||
- [ ] Export all SharePoint/Teams accesses a specific user has across selected sites (UACC-01/02)
|
||||
- [ ] Simplified permissions reports (plain language, summary views) (SIMP-01/02/03)
|
||||
- [ ] Storage metrics graph by file type (pie/donut and bar chart, toggleable) (VIZZ-01/02/03)
|
||||
- [x] Global multi-site selection in toolbar (SITE-01/02) — v1.1
|
||||
- [x] Export all SharePoint/Teams accesses a specific user has across selected sites (UACC-01/02) — v1.1
|
||||
- [x] Simplified permissions reports (plain language, summary views) (SIMP-01/02/03) — v1.1
|
||||
- [x] Storage metrics graph by file type (pie/donut and bar chart, toggleable) (VIZZ-01/02/03) — v1.1
|
||||
|
||||
### Out of Scope
|
||||
|
||||
@@ -57,9 +54,9 @@ Distribution: 200 MB self-contained EXE (win-x64)
|
||||
## Context
|
||||
|
||||
- **v1.0 shipped** with full feature parity: permissions, storage, search, duplicates, bulk operations, templates, folder provisioning
|
||||
- **Known tech debt:** FeatureTabBase dead code removed post-v1.0; bulk DataGrid row highlighting added post-v1.0; cancel test locale fix applied post-v1.0
|
||||
- **Localization:** 199 EN/FR keys, full parity verified
|
||||
- **Architecture:** 106 C# files + 16 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
|
||||
- **v1.1 shipped** with enhanced reports: user access audit, simplified permissions, storage charts, global site selection
|
||||
- **Localization:** 220+ EN/FR keys, full parity verified
|
||||
- **Architecture:** 120+ C# files + 17 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -83,4 +80,4 @@ Distribution: 200 MB self-contained EXE (win-x64)
|
||||
| Wave 0 scaffold pattern | Models + interfaces + test stubs before implementation | ✓ Good — all phases had test targets from day 1 |
|
||||
|
||||
---
|
||||
*Last updated: 2026-04-07 after v1.1 milestone start*
|
||||
*Last updated: 2026-04-08 after v1.1 milestone shipped*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Milestones
|
||||
|
||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
|
||||
- 🔄 **v1.1 Enhanced Reports** — Phases 6-9 (in progress)
|
||||
- ✅ **v1.1 Enhanced Reports** — Phases 6-9 (shipped 2026-04-08) — [archive](milestones/v1.1-ROADMAP.md)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -18,98 +18,19 @@
|
||||
|
||||
</details>
|
||||
|
||||
**v1.1 Enhanced Reports**
|
||||
<details>
|
||||
<summary>✅ v1.1 Enhanced Reports (Phases 6-9) — SHIPPED 2026-04-08</summary>
|
||||
|
||||
- [x] **Phase 6: Global Site Selection** — Toolbar-level multi-site picker that all feature tabs consume as their default target (completed 2026-04-07)
|
||||
- [x] **Phase 7: User Access Audit** — New feature tab: export every SharePoint/Teams access a specific user holds across selected sites (completed 2026-04-07)
|
||||
- [x] **Phase 8: Simplified Permissions** — Plain-language labels, summary counts, color coding, and detail-level toggle on the permissions report (completed 2026-04-07)
|
||||
- [x] **Phase 9: Storage Visualization** — Charting dependency + pie/donut and bar chart views of storage by file type in the Storage Metrics tab (completed 2026-04-07)
|
||||
- [x] Phase 6: Global Site Selection (5/5 plans) — completed 2026-04-07
|
||||
- [x] Phase 7: User Access Audit (10/10 plans) — completed 2026-04-07
|
||||
- [x] Phase 8: Simplified Permissions (6/6 plans) — completed 2026-04-07
|
||||
- [x] Phase 9: Storage Visualization (4/4 plans) — completed 2026-04-07
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 6: Global Site Selection
|
||||
**Goal**: Administrators can select one or more target sites once from the toolbar and have every feature tab use that selection by default
|
||||
**Depends on**: Phase 5 (v1.0 foundation — toolbar and tab architecture in place)
|
||||
**Requirements**: SITE-01, SITE-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A multi-site picker control is visible in the main toolbar at all times, regardless of which tab is active
|
||||
2. Selecting sites in the toolbar causes all feature tabs to default to those sites when an operation is run
|
||||
3. A user can override the global selection on any individual tab without clearing the global state
|
||||
4. The global site selection persists across tab switches within the same session
|
||||
**Plans:** 5/5 plans complete
|
||||
Plans:
|
||||
- [ ] 06-01-PLAN.md — GlobalSitesChangedMessage + FeatureViewModelBase extension
|
||||
- [ ] 06-02-PLAN.md — MainWindowViewModel global selection state + command
|
||||
- [ ] 06-03-PLAN.md — Toolbar UI, dialog wiring, and localization keys
|
||||
- [ ] 06-04-PLAN.md — Tab VM updates for global site consumption
|
||||
- [ ] 06-05-PLAN.md — Unit tests for global site selection flow
|
||||
|
||||
### Phase 7: User Access Audit
|
||||
**Goal**: Administrators can audit every permission a specific user holds across selected sites, distinguish access types (direct/group/inherited), and export results to CSV or HTML
|
||||
**Depends on**: Phase 6
|
||||
**Requirements**: UACC-01, UACC-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A User Access Audit tab (or panel) is accessible and accepts a user identifier and site selection as inputs
|
||||
2. Running the audit returns a list of all access entries the user holds across the selected sites
|
||||
3. Results distinguish between direct role assignments, SharePoint group memberships, and inherited access
|
||||
4. Results can be exported to CSV or HTML in the same format established by v1.0 export patterns
|
||||
**Plans:** 10/10 plans complete
|
||||
Plans:
|
||||
- [ ] 07-01-PLAN.md — UserAccessEntry model + service interfaces (Wave 1)
|
||||
- [ ] 07-02-PLAN.md — UserAccessAuditService implementation (Wave 2)
|
||||
- [ ] 07-03-PLAN.md — GraphUserSearchService implementation (Wave 2)
|
||||
- [ ] 07-04-PLAN.md — UserAccessAuditViewModel (Wave 3)
|
||||
- [ ] 07-05-PLAN.md — UserAccessAuditView XAML layout (Wave 4)
|
||||
- [ ] 07-06-PLAN.md — CSV + HTML export services (Wave 2)
|
||||
- [ ] 07-07-PLAN.md — Tab wiring, DI, localization (Wave 4)
|
||||
- [ ] 07-08-PLAN.md — Unit tests (Wave 5)
|
||||
- [ ] 07-09-PLAN.md — Gap closure: DataGrid visual indicators + ObjectType column (Wave 6)
|
||||
- [ ] 07-10-PLAN.md — Gap closure: Debounced search unit test (Wave 6)
|
||||
|
||||
### Phase 8: Simplified Permissions
|
||||
**Goal**: Permissions reports are readable by non-technical users through plain-language labels, color coding, and a configurable detail level
|
||||
**Depends on**: Phase 6
|
||||
**Requirements**: SIMP-01, SIMP-02, SIMP-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. The permissions report displays human-readable labels (e.g., "Can edit files") alongside or instead of raw SharePoint role names (e.g., "Contribute") when the simplified mode toggle is on
|
||||
2. The report shows summary counts per permission level with color indicators distinguishing high, medium, and low access levels
|
||||
3. A detail-level selector (simple / detailed) controls whether individual item-level rows are shown or collapsed into summary rows
|
||||
4. Toggling modes and detail level does not require re-running the scan — it re-renders from the already-fetched data
|
||||
**Plans:** 6/6 plans complete
|
||||
Plans:
|
||||
- [ ] 08-01-PLAN.md — RiskLevel enum, PermissionLevelMapping, SimplifiedPermissionEntry, PermissionSummary (Wave 1)
|
||||
- [ ] 08-02-PLAN.md — PermissionsViewModel simplified mode, detail toggle, summary computation (Wave 2)
|
||||
- [ ] 08-03-PLAN.md — PermissionsView XAML: toggles, summary panel, color-coded DataGrid (Wave 3)
|
||||
- [ ] 08-04-PLAN.md — HTML + CSV export simplified overloads (Wave 3)
|
||||
- [ ] 08-05-PLAN.md — Localization keys (EN/FR) + export command wiring (Wave 4)
|
||||
- [ ] 08-06-PLAN.md — Unit tests: mapping, summary, ViewModel toggle behavior (Wave 5)
|
||||
|
||||
### Phase 9: Storage Visualization
|
||||
**Goal**: The Storage Metrics tab displays an interactive chart of space consumption by file type, togglable between pie/donut and bar chart views
|
||||
**Depends on**: Phase 6
|
||||
**Requirements**: VIZZ-01, VIZZ-02, VIZZ-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A WPF charting library (LiveCharts2 or OxyPlot) is integrated as a NuGet dependency and renders correctly in the self-contained EXE build
|
||||
2. After a storage scan completes, a chart appears in the Storage Metrics tab showing space broken down by file type
|
||||
3. A toggle control switches the chart between pie/donut and bar chart representations without re-running the scan
|
||||
4. The chart updates automatically whenever a new storage scan finishes, without requiring manual refresh
|
||||
**Plans:** 4/4 plans complete
|
||||
Plans:
|
||||
- [ ] 09-01-PLAN.md — LiveCharts2 NuGet + FileTypeMetric model + IStorageService extension (Wave 1)
|
||||
- [ ] 09-02-PLAN.md — StorageService file-type enumeration implementation (Wave 2)
|
||||
- [ ] 09-03-PLAN.md — ViewModel chart properties + View XAML + localization (Wave 3)
|
||||
- [ ] 09-04-PLAN.md — Unit tests for chart ViewModel behavior (Wave 4)
|
||||
</details>
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Milestone | Plans Complete | Status | Completed |
|
||||
|-------|-----------|----------------|--------|-----------|
|
||||
| 1. Foundation | v1.0 | 8/8 | Complete | 2026-04-02 |
|
||||
| 2. Permissions | v1.0 | 7/7 | Complete | 2026-04-02 |
|
||||
| 3. Storage and File Operations | v1.0 | 8/8 | Complete | 2026-04-02 |
|
||||
| 4. Bulk Operations and Provisioning | v1.0 | 10/10 | Complete | 2026-04-03 |
|
||||
| 5. Distribution and Hardening | v1.0 | 3/3 | Complete | 2026-04-03 |
|
||||
| 6. Global Site Selection | v1.1 | 5/5 | Complete | 2026-04-07 |
|
||||
| 7. User Access Audit | v1.1 | 10/10 | Complete | 2026-04-07 |
|
||||
| 8. Simplified Permissions | v1.1 | 6/6 | Complete | 2026-04-07 |
|
||||
| 9. Storage Visualization | v1.1 | 4/4 | Complete | 2026-04-07 |
|
||||
| Phase | Milestone | Plans | Status | Completed |
|
||||
|-------|-----------|-------|--------|-----------|
|
||||
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: completed
|
||||
stopped_at: Completed 09-04-PLAN.md
|
||||
last_updated: "2026-04-07T13:40:30Z"
|
||||
last_activity: 2026-04-07 — Completed 09-04 (StorageViewModel chart unit tests)
|
||||
milestone: v1.1
|
||||
milestone_name: v1.1 Enhanced Reports
|
||||
status: shipped
|
||||
stopped_at: Milestone archived
|
||||
last_updated: "2026-04-08T00:00:00Z"
|
||||
last_activity: 2026-04-08 — v1.1 milestone archived and tagged
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 4
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Requirements: SharePoint Toolbox v1.1 Enhanced Reports
|
||||
# Requirements Archive: SharePoint Toolbox v1.1 Enhanced Reports
|
||||
|
||||
**Defined:** 2026-04-07
|
||||
**Core Value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
|
||||
**Completed:** 2026-04-08
|
||||
**Coverage:** 10/10 requirements complete
|
||||
|
||||
## v1.1 Requirements
|
||||
## Requirements
|
||||
|
||||
### Global Site Selection
|
||||
|
||||
- [x] **SITE-01**: User can select one or multiple target sites from the toolbar and all feature tabs use that selection as default
|
||||
- [x] **SITE-02**: User can override global site selection per-tab for single-site operations
|
||||
- *Outcome: Initially implemented, later removed — per-tab selectors replaced by centralized global-only selection*
|
||||
|
||||
### User Access Audit
|
||||
|
||||
@@ -27,39 +29,29 @@
|
||||
- [x] **VIZZ-02**: User can toggle between pie/donut chart and bar chart views
|
||||
- [x] **VIZZ-03**: Graph updates automatically when storage scan completes
|
||||
|
||||
## Future Requirements
|
||||
## Traceability
|
||||
|
||||
None deferred — all active requirements scoped to v1.1.
|
||||
| Requirement | Phase | Status | Notes |
|
||||
|-------------|-------|--------|-------|
|
||||
| SITE-01 | Phase 6 | Complete | |
|
||||
| SITE-02 | Phase 6 | Complete | Per-tab override later removed in favor of global-only |
|
||||
| UACC-01 | Phase 7 | Complete | |
|
||||
| UACC-02 | Phase 7 | Complete | |
|
||||
| SIMP-01 | Phase 8 | Complete | 11 standard SharePoint roles mapped |
|
||||
| SIMP-02 | Phase 8 | Complete | 4 risk levels: High/Medium/Low/ReadOnly |
|
||||
| SIMP-03 | Phase 8 | Complete | |
|
||||
| VIZZ-01 | Phase 9 | Complete | LiveCharts2 SkiaSharp backend |
|
||||
| VIZZ-02 | Phase 9 | Complete | |
|
||||
| VIZZ-03 | Phase 9 | Complete | |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Cross-platform (Mac/Linux) | WPF is Windows-only; not justified for current user base |
|
||||
| Real-time monitoring / alerts | Requires background service, beyond scope |
|
||||
| Cross-platform (Mac/Linux) | WPF is Windows-only |
|
||||
| Real-time monitoring / alerts | Requires background service |
|
||||
| Automated remediation (auto-revoke) | Liability risk |
|
||||
| Content migration between tenants | Separate product category |
|
||||
| Version history management | Surface totals in storage metrics only |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| SITE-01 | Phase 6 | Complete |
|
||||
| SITE-02 | Phase 6 | Complete |
|
||||
| UACC-01 | Phase 7 | Complete |
|
||||
| UACC-02 | Phase 7 | Complete |
|
||||
| SIMP-01 | Phase 8 | Complete |
|
||||
| SIMP-02 | Phase 8 | Complete |
|
||||
| SIMP-03 | Phase 8 | Complete |
|
||||
| VIZZ-01 | Phase 9 | Complete |
|
||||
| VIZZ-02 | Phase 9 | Complete |
|
||||
| VIZZ-03 | Phase 9 | Complete |
|
||||
|
||||
**Coverage:**
|
||||
- v1.1 requirements: 10 total
|
||||
- Mapped to phases: 10
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-04-07 | Traceability updated: 2026-04-07*
|
||||
*Archived: 2026-04-08*
|
||||
81
.planning/milestones/v1.1-ROADMAP.md
Normal file
81
.planning/milestones/v1.1-ROADMAP.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# v1.1 Enhanced Reports — Milestone Archive
|
||||
|
||||
**Goal:** Add user access audit, simplified permissions, storage visualization, and global multi-site selection
|
||||
**Status:** Shipped 2026-04-08
|
||||
**Timeline:** 2026-04-07 to 2026-04-08
|
||||
|
||||
## Stats
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Phases | 4 (Phases 6-9) |
|
||||
| Plans | 25 |
|
||||
| Commits | 29 |
|
||||
| C# LOC (total) | 10,484 |
|
||||
| Tests | 205 pass / 22 skip |
|
||||
| Requirements | 10/10 complete |
|
||||
|
||||
## Key Accomplishments
|
||||
|
||||
1. **Global Site Selection (Phase 6)** — Toolbar-level multi-site picker consumed by all feature tabs. Per-tab site selectors removed in favor of centralized selection. WeakReferenceMessenger broadcast pattern.
|
||||
|
||||
2. **User Access Audit (Phase 7)** — New feature tab: people-picker with Graph API autocomplete, audit every permission a specific user holds across selected sites, distinguish direct/group/inherited access, export to CSV/HTML. Claims prefix stripping for clean display.
|
||||
|
||||
3. **Simplified Permissions (Phase 8)** — Plain-language labels mapped from 11 standard SharePoint roles, color-coded risk levels (High/Medium/Low/ReadOnly), summary cards with counts, detail-level toggle (simple/detailed), simplified export overloads for both CSV and HTML.
|
||||
|
||||
4. **Storage Visualization (Phase 9)** — LiveCharts2 (SkiaSharp) integration for pie/donut and bar chart views of storage by file type. CamlQuery-based file enumeration to work around StorageMetrics API zeros. Custom single-slice tooltip. Per-library backfill for accurate folder-level metrics. Chart data included in HTML/CSV exports with summary stat cards.
|
||||
|
||||
5. **Post-phase Polish** — Removed per-tab site selectors from 8 tabs (centralized to global toolbar), fixed UserAccessAudit DataGrid binding (CollectionViewSource disconnect), added site-level summary totals to Storage tab and HTML reports, suppressed NU1701 NuGet warnings.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 6: Global Site Selection (5 plans)
|
||||
- GlobalSitesChangedMessage + FeatureViewModelBase extension
|
||||
- MainWindowViewModel global selection state + command
|
||||
- Toolbar UI, dialog wiring, and localization keys
|
||||
- Tab VM updates for global site consumption
|
||||
- Unit tests for global site selection flow
|
||||
|
||||
### Phase 7: User Access Audit (10 plans)
|
||||
- UserAccessEntry model + service interfaces
|
||||
- UserAccessAuditService implementation
|
||||
- GraphUserSearchService implementation
|
||||
- UserAccessAuditViewModel
|
||||
- UserAccessAuditView XAML layout
|
||||
- CSV + HTML export services
|
||||
- Tab wiring, DI, localization
|
||||
- Unit tests
|
||||
- Gap closure: DataGrid visual indicators + ObjectType column
|
||||
- Gap closure: Debounced search unit test
|
||||
|
||||
### Phase 8: Simplified Permissions (6 plans)
|
||||
- RiskLevel enum, PermissionLevelMapping, SimplifiedPermissionEntry, PermissionSummary
|
||||
- PermissionsViewModel simplified mode, detail toggle, summary computation
|
||||
- PermissionsView XAML: toggles, summary panel, color-coded DataGrid
|
||||
- HTML + CSV export simplified overloads
|
||||
- Localization keys (EN/FR) + export command wiring
|
||||
- Unit tests: mapping, summary, ViewModel toggle behavior
|
||||
|
||||
### Phase 9: Storage Visualization (4 plans)
|
||||
- LiveCharts2 NuGet + FileTypeMetric model + IStorageService extension
|
||||
- StorageService file-type enumeration implementation
|
||||
- ViewModel chart properties + View XAML + localization
|
||||
- Unit tests for chart ViewModel behavior
|
||||
|
||||
## Requirements Covered
|
||||
|
||||
| Requirement | Description | Status |
|
||||
|-------------|-------------|--------|
|
||||
| SITE-01 | Global multi-site selection from toolbar | Complete |
|
||||
| SITE-02 | Per-tab override capability | Complete (later removed — centralized) |
|
||||
| UACC-01 | Export all user accesses across sites | Complete |
|
||||
| UACC-02 | Distinguish direct/group/inherited access | Complete |
|
||||
| SIMP-01 | Plain-language permission labels | Complete |
|
||||
| SIMP-02 | Summary counts with color coding | Complete |
|
||||
| SIMP-03 | Detail-level selector | Complete |
|
||||
| VIZZ-01 | Charting library integration | Complete |
|
||||
| VIZZ-02 | Toggle pie/donut vs bar chart | Complete |
|
||||
| VIZZ-03 | Auto-update chart on scan complete | Complete |
|
||||
|
||||
---
|
||||
*Archived: 2026-04-08*
|
||||
110
.planning/phases/08-simplified-permissions/VERIFICATION.md
Normal file
110
.planning/phases/08-simplified-permissions/VERIFICATION.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
phase: 08-simplified-permissions
|
||||
verified: 2026-04-07T14:30:00Z
|
||||
status: passed
|
||||
score: 4/4 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 8: Simplified Permissions Verification Report
|
||||
|
||||
**Phase Goal:** Permissions reports are readable by non-technical users through plain-language labels, color coding, and a configurable detail level
|
||||
**Verified:** 2026-04-07T14:30:00Z
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No -- initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (Success Criteria)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | The permissions report displays human-readable labels (e.g., "Can edit files") alongside or instead of raw SharePoint role names when the simplified mode toggle is on | VERIFIED | `PermissionLevelMapping.cs` maps 11 standard SP roles to plain-language labels (e.g., "Contribute" -> "Can edit files and list items"). `SimplifiedPermissionEntry` wraps `PermissionEntry` with computed `SimplifiedLabels`. `PermissionsView.xaml` has a "Simplified" DataGrid column (line 254) bound to `SimplifiedLabels`, visible only when `IsSimplifiedMode=True`. ViewModel `OnIsSimplifiedModeChanged` calls `RebuildSimplifiedData()` which populates `SimplifiedResults`. Test `IsSimplifiedMode_WhenToggled_RebuildsSimplifiedResults` confirms. |
|
||||
| 2 | The report shows summary counts per permission level with color indicators distinguishing high, medium, and low access levels | VERIFIED | `PermissionSummaryBuilder.Build()` groups entries by `RiskLevel` and returns 4 `PermissionSummary` records with `Count` and `DistinctUsers`. `PermissionsView.xaml` lines 143-201 render an `ItemsControl` bound to `Summaries` with `DataTrigger`s that apply distinct background colors per risk level: High=#FEE2E2 (red), Medium=#FEF3C7 (amber), Low=#D1FAE5 (green), ReadOnly=#DBEAFE (blue). DataGrid rows are also color-coded via `RowStyle` DataTriggers (lines 226-243). Tests `Build_ReturnsAllFourRiskLevels` and `Summaries_ContainsCorrectRiskBreakdown` confirm. |
|
||||
| 3 | A detail-level selector (simple / detailed) controls whether individual item-level rows are shown or collapsed into summary rows | VERIFIED | `PermissionsView.xaml` lines 89-96 have two RadioButtons ("Simple (summary only)" / "Detailed (all rows)") bound to `IsDetailView` via `InvertBoolConverter`. DataGrid has a `MultiDataTrigger` (lines 214-220) that collapses the grid when `IsSimplifiedMode=True AND IsDetailView=False`, showing only summary cards. Test `IsDetailView_Toggle_DoesNotChangeCounts` confirms toggle does not re-compute data. |
|
||||
| 4 | Toggling modes and detail level does not require re-running the scan -- it re-renders from the already-fetched data | VERIFIED | `OnIsSimplifiedModeChanged` calls `RebuildSimplifiedData()` which wraps the existing `Results` collection -- no service call. `OnIsDetailViewChanged` only fires `OnPropertyChanged(nameof(ActiveItemsSource))`. `ActiveItemsSource` is a computed property that returns `Results` or `SimplifiedResults` based on mode. Export services also branch on `IsSimplifiedMode` without re-scanning. Test `IsDetailView_Toggle_DoesNotChangeCounts` confirms counts remain stable across toggles. |
|
||||
|
||||
**Score:** 4/4 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `SharepointToolbox/Core/Models/RiskLevel.cs` | Risk level enum (High, Medium, Low, ReadOnly) | VERIFIED | 4-value enum, 17 lines, well-documented |
|
||||
| `SharepointToolbox/Core/Helpers/PermissionLevelMapping.cs` | Static mapping from SP role names to labels + risk | VERIFIED | 11 mappings, GetMapping/GetMappings/GetHighestRisk/GetSimplifiedLabels, case-insensitive, unknown->Medium fallback |
|
||||
| `SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs` | Wrapper model with SimplifiedLabels + RiskLevel | VERIFIED | Wraps PermissionEntry via Inner, 9 passthrough properties, WrapAll factory |
|
||||
| `SharepointToolbox/Core/Models/PermissionSummary.cs` | Summary record + builder | VERIFIED | PermissionSummary record + PermissionSummaryBuilder.Build returns all 4 risk levels |
|
||||
| `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs` | IsSimplifiedMode, IsDetailView, SimplifiedResults, Summaries, ActiveItemsSource, RebuildSimplifiedData | VERIFIED | All properties present. Toggle handlers wired. Export branches for simplified mode. |
|
||||
| `SharepointToolbox/Views/Tabs/PermissionsView.xaml` | Toggle controls, summary panel, color-coded DataGrid | VERIFIED | Display Options GroupBox with checkbox + radio buttons, ItemsControl summary panel with color DataTriggers, DataGrid RowStyle with risk-level coloring, Simplified column visible in simplified mode |
|
||||
| `SharepointToolbox/Services/Export/CsvExportService.cs` | Simplified overload with SimplifiedLabels + RiskLevel columns | VERIFIED | BuildCsv(IReadOnlyList<SimplifiedPermissionEntry>) adds SimplifiedLabels and RiskLevel columns |
|
||||
| `SharepointToolbox/Services/Export/HtmlExportService.cs` | Simplified overload with risk cards and color-coded rows | VERIFIED | BuildHtml(IReadOnlyList<SimplifiedPermissionEntry>) adds risk-card summary section, risk-badge per row, Simplified column |
|
||||
| `SharepointToolbox/Core/Converters/InvertBoolConverter.cs` | Bool inverter for radio button binding | VERIFIED | IValueConverter, Convert and ConvertBack both invert |
|
||||
| `SharepointToolbox/Localization/Strings.resx` | New keys: chk.simplified.mode, grp.display.opts, lbl.detail.level, rad.detail.simple, rad.detail.detailed, lbl.summary.users | VERIFIED | All 6 keys present in EN |
|
||||
| `SharepointToolbox/Localization/Strings.fr.resx` | French translations for new keys | VERIFIED | All 6 keys present in FR |
|
||||
| `SharepointToolbox.Tests/Helpers/PermissionLevelMappingTests.cs` | Tests for mapping correctness | VERIFIED | 8 test methods covering known roles (11 InlineData), unknown fallback, case insensitivity, semicolon split, risk ranking, labels |
|
||||
| `SharepointToolbox.Tests/Models/PermissionSummaryBuilderTests.cs` | Tests for summary builder | VERIFIED | 4 test methods: all 4 risk levels, empty collection, distinct users, WrapAll preserves Inner |
|
||||
| `SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs` | Simplified mode tests | VERIFIED | 4 new tests: default false, toggle rebuilds, detail toggle no-op, risk breakdown |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| SimplifiedPermissionEntry | PermissionLevelMapping | `PermissionLevelMapping.GetMappings/GetSimplifiedLabels/GetHighestRisk` | WIRED | Constructor calls all three mapping methods (lines 48-50) |
|
||||
| SimplifiedPermissionEntry | PermissionEntry | `Inner` property | WIRED | Constructor stores entry as `Inner`, 9 passthrough properties delegate to `Inner` |
|
||||
| PermissionsViewModel | SimplifiedPermissionEntry | `SimplifiedPermissionEntry.WrapAll(Results)` in `RebuildSimplifiedData` | WIRED | Line 234 |
|
||||
| PermissionsViewModel | PermissionSummaryBuilder | `PermissionSummaryBuilder.Build(SimplifiedResults)` in `RebuildSimplifiedData` | WIRED | Line 235 |
|
||||
| PermissionsView.xaml | PermissionsViewModel | DataGrid binds `ActiveItemsSource`, summary binds `Summaries`, toggles bind `IsSimplifiedMode`/`IsDetailView` | WIRED | XAML bindings at lines 72-96 (toggles), 143 (Summaries), 205 (ActiveItemsSource) |
|
||||
| CsvExportService | SimplifiedPermissionEntry | Overloaded `BuildCsv`/`WriteAsync` | WIRED | ViewModel calls simplified overload when `IsSimplifiedMode && SimplifiedResults.Count > 0` (line 346) |
|
||||
| HtmlExportService | SimplifiedPermissionEntry | Overloaded `BuildHtml`/`WriteAsync` | WIRED | ViewModel calls simplified overload when `IsSimplifiedMode && SimplifiedResults.Count > 0` (line 372) |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Description | Status | Evidence |
|
||||
|-------------|-------------|--------|----------|
|
||||
| SIMP-01 | User can toggle plain-language permission labels | SATISFIED | PermissionLevelMapping + SimplifiedPermissionEntry + IsSimplifiedMode toggle + Simplified column in DataGrid |
|
||||
| SIMP-02 | Permissions report includes summary counts and color coding | SATISFIED | PermissionSummaryBuilder + summary ItemsControl with color DataTriggers + DataGrid RowStyle coloring + HTML risk cards |
|
||||
| SIMP-03 | User can choose detail level (simple/detailed) for reports | SATISFIED | IsDetailView radio buttons + MultiDataTrigger hides DataGrid in simple mode + summary-only display |
|
||||
|
||||
No orphaned requirements found for Phase 8.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | - | - | - | No TODO, FIXME, placeholder, or stub patterns found in any Phase 8 file |
|
||||
|
||||
### Build and Test Results
|
||||
|
||||
- **Main project build:** 0 errors, 9 warnings (all pre-existing NuGet compatibility warnings)
|
||||
- **Test project build:** 0 errors, 12 warnings (same NuGet warnings)
|
||||
- **Targeted tests:** 27 passed, 0 failed (PermissionLevelMappingTests + PermissionSummaryBuilderTests + PermissionsViewModelTests)
|
||||
- **PermissionEntry.cs:** Confirmed unmodified (git diff empty)
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Simplified Mode Visual Toggle
|
||||
|
||||
**Test:** Run the app, scan a site's permissions, then check the "Simplified mode" checkbox.
|
||||
**Expected:** The "Simplified" column appears in the DataGrid showing labels like "Can edit files and list items" next to raw "Contribute". Summary cards appear above the grid with colored backgrounds (red for High, amber for Medium, green for Low, blue for ReadOnly) and correct counts.
|
||||
**Why human:** Visual layout, color rendering, and DataGrid column sizing cannot be verified programmatically.
|
||||
|
||||
### 2. Detail Level Switching
|
||||
|
||||
**Test:** With simplified mode on, click "Simple (summary only)" radio button, then "Detailed (all rows)".
|
||||
**Expected:** In simple mode, the DataGrid hides and only summary cards are visible. In detailed mode, the DataGrid reappears with all rows. No loading indicator or delay -- instant re-render.
|
||||
**Why human:** Visual collapse/expand behavior and perceived latency require human observation.
|
||||
|
||||
### 3. Export in Simplified Mode
|
||||
|
||||
**Test:** With simplified mode on, export to CSV and HTML. Open both files.
|
||||
**Expected:** CSV includes "SimplifiedLabels" and "RiskLevel" columns. HTML includes risk-level colored summary cards at the top and a "Simplified" + "Risk" column in the table with colored badges.
|
||||
**Why human:** File content rendering and visual appearance of HTML export need manual inspection.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. All 4 success criteria are verified through code inspection, build confirmation, and passing unit tests. The implementation is complete: data models, mapping layer, ViewModel logic, XAML UI with color-coded summary panel and detail toggle, export service overloads, localization in EN and FR, and comprehensive unit test coverage.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-04-07T14:30:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
103
.planning/phases/09-storage-visualization/VERIFICATION.md
Normal file
103
.planning/phases/09-storage-visualization/VERIFICATION.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
phase: 09-storage-visualization
|
||||
verified: 2026-04-07T15:00:00Z
|
||||
status: passed
|
||||
score: 4/4 success criteria verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 9: Storage Visualization Verification Report
|
||||
|
||||
**Phase Goal:** The Storage Metrics tab displays an interactive chart of space consumption by file type, togglable between pie/donut and bar chart views
|
||||
**Verified:** 2026-04-07
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No -- initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (Success Criteria)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | A WPF charting library (LiveCharts2) is integrated as a NuGet dependency and renders correctly in the self-contained EXE build | VERIFIED | `LiveChartsCore.SkiaSharpView.WPF 2.0.0-rc5.4` in csproj line 43; `IncludeNativeLibrariesForSelfExtract=true` in csproj line 16; `dotnet build` succeeds with 0 errors |
|
||||
| 2 | After a storage scan completes, a chart appears in the Storage Metrics tab showing space broken down by file type | VERIFIED | `StorageService.CollectFileTypeMetricsAsync` (lines 68-159) enumerates files via CamlQuery with `FileLeafRef`/`File_x0020_Size`, groups by extension; `StorageViewModel.RunOperationAsync` calls it (line 218) and sets `FileTypeMetrics` (line 224); `StorageView.xaml` binds `lvc:PieChart Series="{Binding PieChartSeries}"` (line 170) and `lvc:CartesianChart Series="{Binding BarChartSeries}"` (line 190) |
|
||||
| 3 | A toggle control switches the chart between pie/donut and bar chart representations without re-running the scan | VERIFIED | `IsDonutChart` property (line 41) with `OnIsDonutChartChanged` (line 298) calls `UpdateChartSeries`; RadioButtons in StorageView.xaml (lines 67-71) bind to `IsDonutChart`; PieChart visibility bound via `MultiDataTrigger` on `IsDonutChart=True` (lines 160-161); CartesianChart visibility on `IsDonutChart=False` (lines 180-181); toggle only regenerates series from in-memory `FileTypeMetrics`, no re-scan |
|
||||
| 4 | The chart updates automatically whenever a new storage scan finishes, without requiring manual refresh | VERIFIED | `RunOperationAsync` (line 169) calls `CollectStorageAsync` then `CollectFileTypeMetricsAsync` (line 218), sets `FileTypeMetrics` (line 224) whose private setter calls `UpdateChartSeries()` (line 51); every scan execution path updates chart data automatically |
|
||||
|
||||
**Score:** 4/4 success criteria verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `SharepointToolbox/SharepointToolbox.csproj` | LiveChartsCore.SkiaSharpView.WPF PackageReference + IncludeNativeLibrariesForSelfExtract | VERIFIED | Line 43: PackageReference version 2.0.0-rc5.4; Line 16: IncludeNativeLibrariesForSelfExtract=true |
|
||||
| `SharepointToolbox/Core/Models/FileTypeMetric.cs` | Record with Extension, TotalSizeBytes, FileCount, DisplayLabel | VERIFIED | 21-line record with computed DisplayLabel property |
|
||||
| `SharepointToolbox/Services/IStorageService.cs` | CollectFileTypeMetricsAsync method signature | VERIFIED | Returns `Task<IReadOnlyList<FileTypeMetric>>` with ClientContext, IProgress, CancellationToken parameters |
|
||||
| `SharepointToolbox/Services/StorageService.cs` | CollectFileTypeMetricsAsync implementation with CSOM CamlQuery | VERIFIED | Lines 68-159: paged CamlQuery with FileLeafRef/File_x0020_Size, extension grouping, sorted result |
|
||||
| `SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs` | Chart properties, toggle, UpdateChartSeries, auto-update from RunOperationAsync | VERIFIED | 393 lines: FileTypeMetrics, PieChartSeries, BarChartSeries, BarXAxes, BarYAxes, IsDonutChart, UpdateChartSeries with top-10+Other logic |
|
||||
| `SharepointToolbox/Views/Tabs/StorageView.xaml` | PieChart, CartesianChart controls, RadioButton toggle, data bindings | VERIFIED | 199 lines: lvc:PieChart and lvc:CartesianChart with MultiDataTrigger visibility, RadioButtons for toggle |
|
||||
| `SharepointToolbox.Tests/ViewModels/StorageViewModelChartTests.cs` | Unit tests for chart series, toggle, aggregation | VERIFIED | 7 tests covering series creation, bar structure, donut toggle, top-10+Other, tenant switch, empty data |
|
||||
| `SharepointToolbox/Localization/Strings.resx` | Chart localization keys (stor.chart.*) | VERIFIED | 5 keys: stor.chart.title, stor.chart.donut, stor.chart.bar, stor.chart.toggle, stor.chart.nodata |
|
||||
| `SharepointToolbox/Localization/Strings.fr.resx` | French chart localization keys | VERIFIED | All 5 keys present with French translations |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| StorageViewModel.RunOperationAsync | StorageService.CollectFileTypeMetricsAsync | `_storageService.CollectFileTypeMetricsAsync(ctx, progress, ct)` | WIRED | Line 218 of ViewModel calls service; result assigned to FileTypeMetrics at line 224 |
|
||||
| FileTypeMetrics setter | UpdateChartSeries | Private setter calls `UpdateChartSeries()` | WIRED | Line 51: setter triggers chart rebuild |
|
||||
| IsDonutChart toggle | UpdateChartSeries | OnIsDonutChartChanged partial method | WIRED | Line 298-301: property change handler calls UpdateChartSeries |
|
||||
| StorageView.xaml PieChart | PieChartSeries | `Series="{Binding PieChartSeries}"` | WIRED | Line 170 in XAML |
|
||||
| StorageView.xaml CartesianChart | BarChartSeries | `Series="{Binding BarChartSeries}"` | WIRED | Line 190 in XAML |
|
||||
| StorageView.xaml RadioButtons | IsDonutChart | `IsChecked="{Binding IsDonutChart}"` | WIRED | Lines 68-71 in XAML |
|
||||
| IStorageService.CollectFileTypeMetricsAsync | FileTypeMetric | Return type `IReadOnlyList<FileTypeMetric>` | WIRED | Interface line 25 returns FileTypeMetric list |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| VIZZ-01 | 09-01, 09-04 | Storage Metrics tab includes a graph showing space by file type | SATISFIED | LiveCharts2 integrated; PieChart and CartesianChart in StorageView.xaml; CollectFileTypeMetricsAsync provides data grouped by extension |
|
||||
| VIZZ-02 | 09-02, 09-03, 09-04 | User can toggle between pie/donut chart and bar chart views | SATISFIED | IsDonutChart property with RadioButton toggle; MultiDataTrigger visibility switching between PieChart and CartesianChart |
|
||||
| VIZZ-03 | 09-03, 09-04 | Graph updates when storage scan completes | SATISFIED | RunOperationAsync calls CollectFileTypeMetricsAsync then sets FileTypeMetrics, whose setter triggers UpdateChartSeries automatically |
|
||||
|
||||
No orphaned requirements found. All 3 VIZZ requirements are covered by plans and satisfied by implementation.
|
||||
|
||||
### Build and Test Verification
|
||||
|
||||
| Check | Status | Details |
|
||||
|-------|--------|---------|
|
||||
| `dotnet build SharepointToolbox.csproj` | PASSED | 0 errors, 6 NuGet compatibility warnings (SkiaSharp/OpenTK on net10.0 -- informational only) |
|
||||
| `dotnet test --filter StorageViewModelChart` | PASSED | 7 passed, 0 failed, 0 skipped |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | - | - | - | No anti-patterns detected |
|
||||
|
||||
No TODO/FIXME/HACK markers, no empty implementations, no stub returns, no console.log-only handlers found in any phase 9 artifacts.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
### 1. Chart renders visually after a real storage scan
|
||||
|
||||
**Test:** Connect to a SharePoint tenant, run a storage scan, observe the chart area below the DataGrid.
|
||||
**Expected:** A donut chart appears showing file types (e.g., DOCX, PDF, XLSX) with legend on the right. Each slice is labeled and has a tooltip showing size and file count.
|
||||
**Why human:** Chart rendering depends on SkiaSharp GPU/software rendering pipeline; cannot verify visual output programmatically.
|
||||
|
||||
### 2. Toggle between donut and bar chart
|
||||
|
||||
**Test:** After a scan completes and chart is visible, click the "Bar Chart" radio button in the Chart View group.
|
||||
**Expected:** The donut chart disappears and a bar chart appears with file types on the X axis (rotated -45 degrees) and formatted byte sizes on the Y axis. Toggling back to "Donut Chart" restores the donut view.
|
||||
**Why human:** Visual transition and layout correctness require human eye.
|
||||
|
||||
### 3. Self-contained EXE publish includes SkiaSharp native libraries
|
||||
|
||||
**Test:** Run `dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true` and verify the resulting EXE launches and renders charts.
|
||||
**Expected:** Single EXE runs without missing DLL errors; charts render in the published build.
|
||||
**Why human:** Native library extraction and SkiaSharp initialization behavior varies by machine and can only be confirmed at runtime.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-04-07_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -119,7 +119,8 @@ public class UserAccessAuditServiceTests
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(claimLogin, result[0].UserLogin);
|
||||
// Claims prefix is stripped: "i:0#.f|membership|alice@contoso.com" -> "alice@contoso.com"
|
||||
Assert.Equal("alice@contoso.com", result[0].UserLogin);
|
||||
}
|
||||
|
||||
// ── Test 3: Classifies Direct access ─────────────────────────────────────
|
||||
|
||||
@@ -126,161 +126,49 @@ public class GlobalSiteSelectionTests
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", vm.TestGlobalSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 3: StorageViewModel pre-fills SiteUrl from first global site ────
|
||||
// ── Test 3: All tabs receive GlobalSites via base class ────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_WithSites_PreFillsSiteUrlOnStorageTab()
|
||||
public void AllTabs_ReceiveGlobalSites_ViaBaseClass()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreateStorageViewModel();
|
||||
var storageVm = CreateStorageViewModel();
|
||||
var permissionsVm = CreatePermissionsViewModel();
|
||||
var sites = TwoSites();
|
||||
|
||||
// Act
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
// Assert: base class TestGlobalSites (exposed via TestFeatureViewModel)
|
||||
// is not accessible on concrete VMs, but we can verify by creating another VM
|
||||
var testVm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
Assert.Equal(2, testVm.TestGlobalSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", testVm.TestGlobalSites[0].Url);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", testVm.TestGlobalSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 4: StorageViewModel local override prevents global update ────────
|
||||
// ── Test 4: GlobalSites updated when new message arrives ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_AfterLocalSiteEntry_DoesNotOverrideSiteUrl()
|
||||
public void GlobalSites_UpdatedOnNewMessage_ReplacesOldSites()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreateStorageViewModel();
|
||||
// User types a local URL — this sets the local override flag
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
|
||||
// Act: global sites message arrives
|
||||
var vm = new TestFeatureViewModel(NullLogger<FeatureViewModelBase>.Instance);
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
Assert.Equal(2, vm.TestGlobalSites.Count);
|
||||
|
||||
// Assert: local override takes precedence
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl);
|
||||
}
|
||||
|
||||
// ── Test 5: StorageViewModel clearing SiteUrl reverts to global ──────────
|
||||
|
||||
[Fact]
|
||||
public void OnSiteUrlChanged_WhenClearedAfterLocalOverride_RevertsToGlobalSite()
|
||||
{
|
||||
// Arrange: establish global sites first, then set a local override
|
||||
var vm = CreateStorageViewModel();
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
// Confirm global was applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
|
||||
// User types their own URL — sets local override
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl);
|
||||
|
||||
// Act: user clears the SiteUrl field
|
||||
vm.SiteUrl = string.Empty;
|
||||
|
||||
// Assert: override is cleared and global site is re-applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
}
|
||||
|
||||
// ── Test 6: PermissionsViewModel pre-populates SelectedSites from global ─
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_WithSites_PrePopulatesSelectedSitesOnPermissionsTab()
|
||||
{
|
||||
// Arrange
|
||||
var vm = CreatePermissionsViewModel();
|
||||
var sites = TwoSites();
|
||||
|
||||
// Act
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SelectedSites[0].Url);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/finance", vm.SelectedSites[1].Url);
|
||||
}
|
||||
|
||||
// ── Test 7: PermissionsViewModel local picker override prevents global ───
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_AfterLocalPickerOverride_DoesNotChangeSelectedSites()
|
||||
{
|
||||
// Arrange: add a site locally to simulate local site picker usage
|
||||
var vm = CreatePermissionsViewModel();
|
||||
|
||||
// Simulate what ExecuteOpenSitePicker does when user picks sites locally:
|
||||
// set _hasLocalSiteOverride = true (via reflection since it's private) and add a site.
|
||||
// We do this by using the TenantSwitchedMessage pattern to first let global sites
|
||||
// populate, then simulate a local override by directly reflecting the flag.
|
||||
var globalSites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(globalSites));
|
||||
// Global applied — SelectedSites has 2 entries
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
|
||||
// Simulate local override: clear and add a local site, then set the override flag
|
||||
var localSite = new SiteInfo("https://contoso.sharepoint.com/sites/local", "Local");
|
||||
vm.SelectedSites.Clear();
|
||||
vm.SelectedSites.Add(localSite);
|
||||
|
||||
// Use reflection to set the private _hasLocalSiteOverride flag (same as site picker would)
|
||||
var field = typeof(PermissionsViewModel)
|
||||
.GetField("_hasLocalSiteOverride", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
field!.SetValue(vm, true);
|
||||
|
||||
// Act: new global sites arrive
|
||||
var newGlobalSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/new1", "New1")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(newGlobalSites));
|
||||
|
||||
// Assert: local override prevents global from replacing SelectedSites
|
||||
Assert.Single(vm.SelectedSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/local", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 8: Tenant switch resets local override; new global sites are applied ─
|
||||
|
||||
[Fact]
|
||||
public void TenantSwitched_AfterLocalOverride_ResetsOverrideSoNewGlobalSitesAreApplied()
|
||||
{
|
||||
// Arrange: user has a local override (different from global)
|
||||
var vm = CreateStorageViewModel();
|
||||
var sites = TwoSites();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
// Confirm global was applied
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SiteUrl);
|
||||
|
||||
// User types a different URL — sets local override
|
||||
vm.SiteUrl = "https://contoso.sharepoint.com/sites/custom";
|
||||
|
||||
// New global sites message should NOT change SiteUrl because of local override
|
||||
var updatedGlobal = new List<SiteInfo>
|
||||
// Act: send new sites
|
||||
var newSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/marketing", "Marketing")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(updatedGlobal));
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/custom", vm.SiteUrl); // override still active
|
||||
|
||||
// Act: tenant switch clears the override
|
||||
var newProfile = new TenantProfile
|
||||
{
|
||||
Name = "NewTenant",
|
||||
TenantUrl = "https://newtenant.sharepoint.com",
|
||||
ClientId = "new-client-id"
|
||||
};
|
||||
WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(newProfile));
|
||||
|
||||
// Send new global sites after tenant switch — override should be gone
|
||||
var newSites = new List<SiteInfo>
|
||||
{
|
||||
new("https://newtenant.sharepoint.com/sites/sales", "Sales")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(newSites));
|
||||
|
||||
// Assert: new global site is applied (override was reset by tenant switch)
|
||||
Assert.Equal("https://newtenant.sharepoint.com/sites/sales", vm.SiteUrl);
|
||||
// Assert: old sites replaced
|
||||
Assert.Single(vm.TestGlobalSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/marketing", vm.TestGlobalSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 9: TransferViewModel pre-fills SourceSiteUrl from first global ──
|
||||
|
||||
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.SharePoint.Client;
|
||||
using Moq;
|
||||
using SharepointToolbox.Core.Messages;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.ViewModels;
|
||||
@@ -44,9 +45,13 @@ public class PermissionsViewModelTests
|
||||
mockSessionManager.Object,
|
||||
new NullLogger<FeatureViewModelBase>());
|
||||
|
||||
// Set up two site URLs via SelectedSites
|
||||
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/alpha", "Alpha"));
|
||||
vm.SelectedSites.Add(new SiteInfo("https://tenant1.sharepoint.com/sites/beta", "Beta"));
|
||||
// Set up two site URLs via global site selection
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo>
|
||||
{
|
||||
new("https://tenant1.sharepoint.com/sites/alpha", "Alpha"),
|
||||
new("https://tenant1.sharepoint.com/sites/beta", "Beta")
|
||||
}.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile
|
||||
{
|
||||
Name = "Test",
|
||||
@@ -118,7 +123,8 @@ public class PermissionsViewModelTests
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
|
||||
// Simulate scan completing
|
||||
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -143,7 +149,8 @@ public class PermissionsViewModelTests
|
||||
};
|
||||
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
vm.SelectedSites.Add(new SiteInfo("https://test.sharepoint.com", "Test"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://test.sharepoint.com", "Test") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://test.sharepoint.com", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -169,7 +176,8 @@ public class PermissionsViewModelTests
|
||||
};
|
||||
|
||||
var vm = CreateViewModelWithResults(entries);
|
||||
vm.SelectedSites.Add(new SiteInfo("https://s1", "S1"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://s1", "S1") }.AsReadOnly()));
|
||||
vm.SetCurrentProfile(new TenantProfile { Name = "Test", TenantUrl = "https://s1", ClientId = "cid" });
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
|
||||
@@ -122,13 +122,13 @@ public class StorageViewModelChartTests
|
||||
SetFileTypeMetrics(vm, metrics);
|
||||
|
||||
// Initially IsDonutChart=true => InnerRadius=50
|
||||
var pieBefore = vm.PieChartSeries.Cast<PieSeries<long>>().ToList();
|
||||
var pieBefore = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
|
||||
Assert.All(pieBefore, s => Assert.Equal(50, s.InnerRadius));
|
||||
|
||||
// Toggle to bar (not donut) => InnerRadius=0
|
||||
vm.IsDonutChart = false;
|
||||
|
||||
var pieAfter = vm.PieChartSeries.Cast<PieSeries<long>>().ToList();
|
||||
var pieAfter = vm.PieChartSeries.Cast<PieSeries<double>>().ToList();
|
||||
Assert.All(pieAfter, s => Assert.Equal(0, s.InnerRadius));
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, auditMock, _) = CreateViewModel();
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -115,7 +116,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -137,7 +139,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
@@ -156,7 +159,8 @@ public class UserAccessAuditViewModelTests
|
||||
|
||||
// Populate state
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
Assert.NotEmpty(vm.Results);
|
||||
Assert.NotEmpty(vm.SelectedUsers);
|
||||
@@ -173,54 +177,31 @@ public class UserAccessAuditViewModelTests
|
||||
// Assert: state cleared
|
||||
Assert.Empty(vm.Results);
|
||||
Assert.Empty(vm.SelectedUsers);
|
||||
Assert.Empty(vm.SelectedSites);
|
||||
Assert.Empty(vm.FilterText);
|
||||
}
|
||||
|
||||
// ── Test 5: GlobalSitesChanged updates SelectedSites ─────────────────────
|
||||
// ── Test 5: RunOperation uses GlobalSites directly ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_updates_selected_sites()
|
||||
public async Task RunOperation_fails_gracefully_without_global_sites()
|
||||
{
|
||||
var (vm, _, _) = CreateViewModel();
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/hr", "HR"),
|
||||
new("https://contoso.sharepoint.com/sites/finance", "Finance")
|
||||
}.AsReadOnly();
|
||||
var (vm, auditMock, _) = CreateViewModel();
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
// Do NOT send GlobalSitesChangedMessage — no sites selected
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
Assert.Equal(2, vm.SelectedSites.Count);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/hr", vm.SelectedSites[0].Url);
|
||||
}
|
||||
|
||||
// ── Test 6: GlobalSitesChanged skipped when override active ──────────────
|
||||
|
||||
[Fact]
|
||||
public void OnGlobalSitesChanged_skipped_when_override()
|
||||
{
|
||||
var (vm, _, _) = CreateViewModel();
|
||||
|
||||
// Add a local site and set the override flag via reflection
|
||||
var localSite = new SiteInfo("https://contoso.sharepoint.com/sites/local", "Local");
|
||||
vm.SelectedSites.Add(localSite);
|
||||
|
||||
var field = typeof(UserAccessAuditViewModel)
|
||||
.GetField("_hasLocalSiteOverride",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
field!.SetValue(vm, true);
|
||||
|
||||
// Act: send global sites message
|
||||
var sites = new List<SiteInfo>
|
||||
{
|
||||
new("https://contoso.sharepoint.com/sites/global1", "Global1")
|
||||
}.AsReadOnly();
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(sites));
|
||||
|
||||
// Assert: SelectedSites unchanged (override prevented update)
|
||||
Assert.Single(vm.SelectedSites);
|
||||
Assert.Equal("https://contoso.sharepoint.com/sites/local", vm.SelectedSites[0].Url);
|
||||
// Should not call audit service — early return with status message
|
||||
auditMock.Verify(
|
||||
s => s.AuditUsersAsync(
|
||||
It.IsAny<ISessionManager>(),
|
||||
It.IsAny<TenantProfile>(),
|
||||
It.IsAny<IReadOnlyList<string>>(),
|
||||
It.IsAny<IReadOnlyList<SiteInfo>>(),
|
||||
It.IsAny<ScanOptions>(),
|
||||
It.IsAny<IProgress<OperationProgress>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ── Test 7: CanExport false when no results ───────────────────────────────
|
||||
@@ -245,7 +226,8 @@ public class UserAccessAuditViewModelTests
|
||||
var (vm, _, _) = CreateViewModel(entries);
|
||||
|
||||
vm.SelectedUsers.Add(MakeUser());
|
||||
vm.SelectedSites.Add(new SiteInfo("https://contoso.sharepoint.com", "Contoso"));
|
||||
WeakReferenceMessenger.Default.Send(new GlobalSitesChangedMessage(
|
||||
new List<SiteInfo> { new("https://contoso.sharepoint.com", "Contoso") }.AsReadOnly()));
|
||||
|
||||
await vm.TestRunOperationAsync(CancellationToken.None, new Progress<OperationProgress>());
|
||||
|
||||
|
||||
@@ -48,18 +48,7 @@ public partial class MainWindow : Window
|
||||
TemplatesTabItem.Content = serviceProvider.GetRequiredService<TemplatesView>();
|
||||
|
||||
// Phase 7: User Access Audit
|
||||
var auditView = serviceProvider.GetRequiredService<UserAccessAuditView>();
|
||||
UserAccessAuditTabItem.Content = auditView;
|
||||
|
||||
// Wire site picker dialog factory for audit tab (same pattern as PermissionsView)
|
||||
if (auditView.DataContext is ViewModels.Tabs.UserAccessAuditViewModel auditVm)
|
||||
{
|
||||
auditVm.OpenSitePickerDialog = () =>
|
||||
{
|
||||
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
|
||||
return factory(auditVm.CurrentProfile ?? new TenantProfile());
|
||||
};
|
||||
}
|
||||
UserAccessAuditTabItem.Content = serviceProvider.GetRequiredService<UserAccessAuditView>();
|
||||
|
||||
// Replace Settings tab placeholder with the DI-resolved SettingsView
|
||||
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();
|
||||
|
||||
@@ -36,7 +36,49 @@ public class StorageCsvExportService
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(nodes);
|
||||
// UTF-8 with BOM for Excel compatibility
|
||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CSV with library details followed by a file-type breakdown section.
|
||||
/// </summary>
|
||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Library details
|
||||
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(node.Name),
|
||||
Csv(node.SiteTitle),
|
||||
node.TotalFileCount.ToString(),
|
||||
FormatMb(node.TotalSizeBytes),
|
||||
FormatMb(node.VersionSizeBytes),
|
||||
node.LastModified.HasValue
|
||||
? Csv(node.LastModified.Value.ToString("yyyy-MM-dd"))
|
||||
: string.Empty));
|
||||
}
|
||||
|
||||
// File type breakdown
|
||||
if (fileTypeMetrics.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("File Type,Size (MB),File Count");
|
||||
foreach (var m in fileTypeMetrics)
|
||||
{
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
|
||||
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||
{
|
||||
var csv = BuildCsv(nodes, fileTypeMetrics);
|
||||
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,151 @@ public class StorageHtmlExportService
|
||||
<h1>SharePoint Storage Metrics</h1>
|
||||
""");
|
||||
|
||||
// Summary cards
|
||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
long siteTotal0 = rootNodes0.Sum(n => n.TotalSizeBytes);
|
||||
long versionTotal0 = rootNodes0.Sum(n => n.VersionSizeBytes);
|
||||
long fileTotal0 = rootNodes0.Sum(n => n.TotalFileCount);
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
|
||||
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
|
||||
</div>
|
||||
""");
|
||||
|
||||
sb.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Library / Folder</th>
|
||||
<th>Site</th>
|
||||
<th class="num">Files</th>
|
||||
<th class="num">Total Size</th>
|
||||
<th class="num">Version Size</th>
|
||||
<th>Last Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
RenderNode(sb, node);
|
||||
}
|
||||
|
||||
sb.AppendLine("""
|
||||
</tbody>
|
||||
</table>
|
||||
""");
|
||||
|
||||
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an HTML report including a file-type breakdown chart section.
|
||||
/// </summary>
|
||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||
{
|
||||
_togIdx = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SharePoint Storage Metrics</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||
h1 { color: #0078d4; }
|
||||
h2 { color: #333; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||
th { background: #0078d4; color: #fff; padding: 8px 12px; text-align: left; font-weight: 600; }
|
||||
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; vertical-align: top; }
|
||||
tr:hover { background: #f0f7ff; }
|
||||
.toggle-btn { background: none; border: 1px solid #0078d4; color: #0078d4; border-radius: 3px;
|
||||
cursor: pointer; font-size: 11px; padding: 1px 6px; margin-right: 6px; }
|
||||
.toggle-btn:hover { background: #e5f1fb; }
|
||||
.sf-tbl { width: 100%; border: none; box-shadow: none; margin: 0; }
|
||||
.sf-tbl td { background: #fafcff; font-size: 12px; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.generated { font-size: 11px; color: #888; margin-top: 12px; }
|
||||
.chart-section { margin: 20px 0; padding: 16px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||
.bar-row { display: flex; align-items: center; margin: 4px 0; }
|
||||
.bar-label { width: 80px; font-size: 12px; font-weight: 600; text-align: right; padding-right: 10px; }
|
||||
.bar-track { flex: 1; background: #eee; border-radius: 4px; height: 22px; position: relative; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; background: #0078d4; min-width: 2px; }
|
||||
.bar-value { font-size: 11px; color: #555; padding-left: 8px; white-space: nowrap; min-width: 140px; }
|
||||
.stats { display: flex; gap: 16px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #0078d4; }
|
||||
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
||||
</style>
|
||||
<script>
|
||||
function toggle(i) {
|
||||
var row = document.getElementById('sf-' + i);
|
||||
if (row) row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SharePoint Storage Metrics</h1>
|
||||
""");
|
||||
|
||||
// ── Summary cards ──
|
||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
long siteTotal = rootNodes.Sum(n => n.TotalSizeBytes);
|
||||
long versionTotal = rootNodes.Sum(n => n.VersionSizeBytes);
|
||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
||||
|
||||
sb.AppendLine("<div class=\"stats\">");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
|
||||
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
|
||||
sb.AppendLine("</div>");
|
||||
|
||||
// ── File type chart section ──
|
||||
if (fileTypeMetrics.Count > 0)
|
||||
{
|
||||
var maxSize = fileTypeMetrics.Max(m => m.TotalSizeBytes);
|
||||
var totalSize = fileTypeMetrics.Sum(m => m.TotalSizeBytes);
|
||||
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
||||
|
||||
sb.AppendLine("<div class=\"chart-section\">");
|
||||
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
|
||||
|
||||
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
||||
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
||||
|
||||
int idx = 0;
|
||||
foreach (var m in fileTypeMetrics.Take(15))
|
||||
{
|
||||
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
|
||||
string color = colors[idx % colors.Length];
|
||||
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
|
||||
|
||||
sb.AppendLine($"""
|
||||
<div class="bar-row">
|
||||
<span class="bar-label">{HtmlEncode(label)}</span>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{pct:F1}%;background:{color}"></div></div>
|
||||
<span class="bar-value">{FormatSize(m.TotalSizeBytes)} · {m.FileCount:N0} files</span>
|
||||
</div>
|
||||
""");
|
||||
idx++;
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// ── Storage table ──
|
||||
sb.AppendLine("<h2>Library Details</h2>");
|
||||
sb.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
@@ -88,6 +233,12 @@ public class StorageHtmlExportService
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, string filePath, CancellationToken ct)
|
||||
{
|
||||
var html = BuildHtml(nodes, fileTypeMetrics);
|
||||
await File.WriteAllTextAsync(filePath, html, Encoding.UTF8, ct);
|
||||
}
|
||||
|
||||
// ── Private rendering ────────────────────────────────────────────────────
|
||||
|
||||
private void RenderNode(StringBuilder sb, StorageNode node)
|
||||
|
||||
@@ -26,4 +26,16 @@ public interface IStorageService
|
||||
ClientContext ctx,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Backfills StorageNodes that have zero TotalFileCount/TotalSizeBytes
|
||||
/// by enumerating files per library via CamlQuery.
|
||||
/// This works around the StorageMetrics API returning zeros when the
|
||||
/// caller lacks sufficient permissions or metrics haven't been calculated.
|
||||
/// </summary>
|
||||
Task BackfillZeroNodesAsync(
|
||||
ClientContext ctx,
|
||||
IReadOnlyList<StorageNode> nodes,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,152 @@ public class StorageService : IStorageService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task BackfillZeroNodesAsync(
|
||||
ClientContext ctx,
|
||||
IReadOnlyList<StorageNode> nodes,
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Find root-level library nodes that have any zero-valued nodes in their tree
|
||||
var libNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||
var needsBackfill = libNodes.Where(lib =>
|
||||
lib.TotalFileCount == 0 || HasZeroChild(lib)).ToList();
|
||||
if (needsBackfill.Count == 0) return;
|
||||
|
||||
// Load libraries to get RootFolder.ServerRelativeUrl for path matching
|
||||
ctx.Load(ctx.Web, w => w.ServerRelativeUrl,
|
||||
w => w.Lists.Include(
|
||||
l => l.Title, l => l.Hidden, l => l.BaseType,
|
||||
l => l.RootFolder.ServerRelativeUrl));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
var libs = ctx.Web.Lists
|
||||
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||
.ToDictionary(l => l.Title, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int idx = 0;
|
||||
foreach (var libNode in needsBackfill)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
idx++;
|
||||
|
||||
if (!libs.TryGetValue(libNode.Name, out var lib)) continue;
|
||||
|
||||
progress.Report(new OperationProgress(idx, needsBackfill.Count,
|
||||
$"Counting files: {libNode.Name} ({idx}/{needsBackfill.Count})"));
|
||||
|
||||
string libRootSrl = lib.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
|
||||
// Build a lookup of all folder nodes in this library's tree (by server-relative path)
|
||||
var folderLookup = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
|
||||
BuildFolderLookup(libNode, libRootSrl, folderLookup);
|
||||
|
||||
// Reset all nodes in this tree to zero before accumulating
|
||||
ResetNodeCounts(libNode);
|
||||
|
||||
// Enumerate all files with their folder path
|
||||
var query = new CamlQuery
|
||||
{
|
||||
ViewXml = @"<View Scope='RecursiveAll'>
|
||||
<Query><Where>
|
||||
<Eq><FieldRef Name='FSObjType' /><Value Type='Integer'>0</Value></Eq>
|
||||
</Where></Query>
|
||||
<ViewFields>
|
||||
<FieldRef Name='FileDirRef' />
|
||||
<FieldRef Name='File_x0020_Size' />
|
||||
</ViewFields>
|
||||
<RowLimit Paged='TRUE'>500</RowLimit>
|
||||
</View>"
|
||||
};
|
||||
|
||||
ListItemCollection items;
|
||||
do
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
items = lib.GetItems(query);
|
||||
ctx.Load(items, ic => ic.ListItemCollectionPosition,
|
||||
ic => ic.Include(
|
||||
i => i["FileDirRef"],
|
||||
i => i["File_x0020_Size"]));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
long size = 0;
|
||||
if (long.TryParse(item["File_x0020_Size"]?.ToString() ?? "0", out long s))
|
||||
size = s;
|
||||
|
||||
string fileDirRef = item["FileDirRef"]?.ToString() ?? "";
|
||||
|
||||
// Always count toward the library root
|
||||
libNode.TotalSizeBytes += size;
|
||||
libNode.FileStreamSizeBytes += size;
|
||||
libNode.TotalFileCount++;
|
||||
|
||||
// Also count toward the most specific matching subfolder
|
||||
var matchedFolder = FindDeepestFolder(fileDirRef, folderLookup);
|
||||
if (matchedFolder != null && matchedFolder != libNode)
|
||||
{
|
||||
matchedFolder.TotalSizeBytes += size;
|
||||
matchedFolder.FileStreamSizeBytes += size;
|
||||
matchedFolder.TotalFileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
query.ListItemCollectionPosition = items.ListItemCollectionPosition;
|
||||
}
|
||||
while (items.ListItemCollectionPosition != null);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasZeroChild(StorageNode node)
|
||||
{
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
if (child.TotalFileCount == 0) return true;
|
||||
if (HasZeroChild(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ResetNodeCounts(StorageNode node)
|
||||
{
|
||||
node.TotalSizeBytes = 0;
|
||||
node.FileStreamSizeBytes = 0;
|
||||
node.TotalFileCount = 0;
|
||||
foreach (var child in node.Children)
|
||||
ResetNodeCounts(child);
|
||||
}
|
||||
|
||||
private static void BuildFolderLookup(StorageNode node, string parentPath,
|
||||
Dictionary<string, StorageNode> lookup)
|
||||
{
|
||||
string nodePath = node.IndentLevel == 0
|
||||
? parentPath
|
||||
: parentPath + "/" + node.Name;
|
||||
lookup[nodePath] = node;
|
||||
|
||||
foreach (var child in node.Children)
|
||||
BuildFolderLookup(child, nodePath, lookup);
|
||||
}
|
||||
|
||||
private static StorageNode? FindDeepestFolder(string fileDirRef,
|
||||
Dictionary<string, StorageNode> lookup)
|
||||
{
|
||||
// fileDirRef is the server-relative folder path, e.g. "/sites/hr/Shared Documents/Reports"
|
||||
// Try exact match, then walk up until we find a match
|
||||
string path = fileDirRef.TrimEnd('/');
|
||||
while (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
if (lookup.TryGetValue(path, out var node))
|
||||
return node;
|
||||
int lastSlash = path.LastIndexOf('/');
|
||||
if (lastSlash <= 0) break;
|
||||
path = path[..lastSlash];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Private helpers -----------------------------------------------------
|
||||
|
||||
private static async Task<StorageNode> LoadFolderNodeAsync(
|
||||
|
||||
@@ -114,7 +114,7 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
|
||||
yield return new UserAccessEntry(
|
||||
UserDisplayName: displayName,
|
||||
UserLogin: login,
|
||||
UserLogin: StripClaimsPrefix(login),
|
||||
SiteUrl: site.Url,
|
||||
SiteTitle: site.Title,
|
||||
ObjectType: entry.ObjectType,
|
||||
@@ -133,6 +133,16 @@ public class UserAccessAuditService : IUserAccessAuditService
|
||||
/// <summary>
|
||||
/// Classifies a PermissionEntry into Direct, Group, or Inherited access type.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Removes SharePoint claims prefixes like "i:0#.f|membership|" so the
|
||||
/// UI displays a plain email instead of the full claim string.
|
||||
/// </summary>
|
||||
private static string StripClaimsPrefix(string login)
|
||||
{
|
||||
int pipe = login.LastIndexOf('|');
|
||||
return pipe >= 0 ? login[(pipe + 1)..] : login;
|
||||
}
|
||||
|
||||
private static AccessType ClassifyAccessType(PermissionEntry entry)
|
||||
{
|
||||
// Inherited: object does not have unique permissions
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<UseWPF>true</UseWPF>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<StartupObject>SharepointToolbox.App</StartupObject>
|
||||
<!-- LiveCharts2 transitive deps (SkiaSharp.Views.WPF, OpenTK) lack net10.0 targets but work at runtime -->
|
||||
<NoWarn>$(NoWarn);NU1701</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
|
||||
|
||||
@@ -33,10 +33,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
private readonly DuplicatesHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
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;
|
||||
@@ -77,26 +75,6 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -104,36 +82,45 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
var allGroups = new List<DuplicateGroup>();
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.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 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;
|
||||
var groups = await _duplicatesService.ScanDuplicatesAsync(ctx, opts, progress, ct);
|
||||
allGroups.AddRange(groups);
|
||||
}
|
||||
|
||||
_lastGroups = allGroups;
|
||||
|
||||
// Flatten groups to display rows
|
||||
var rows = groups
|
||||
var rows = allGroups
|
||||
.SelectMany(g => g.Items.Select(item => new DuplicateRow
|
||||
{
|
||||
GroupName = g.Name,
|
||||
@@ -158,10 +145,8 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<DuplicateRow>();
|
||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -22,11 +22,9 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
private readonly BulkResultCsvExportService _exportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
private List<FolderStructureRow>? _validRows;
|
||||
private BulkOperationSummary<string>? _lastResult;
|
||||
|
||||
[ObservableProperty] private string _siteUrl = string.Empty;
|
||||
[ObservableProperty] private string _libraryTitle = string.Empty;
|
||||
[ObservableProperty] private string _previewSummary = string.Empty;
|
||||
[ObservableProperty] private string _resultSummary = string.Empty;
|
||||
@@ -66,26 +64,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ImportCsv()
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
@@ -128,26 +106,36 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
||||
if (_validRows == null || _validRows.Count == 0)
|
||||
throw new InvalidOperationException("No valid rows. Import a CSV first.");
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
throw new InvalidOperationException("Site URL is required.");
|
||||
if (string.IsNullOrWhiteSpace(LibraryTitle))
|
||||
throw new InvalidOperationException("Library title is required.");
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
||||
|
||||
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||
$"{uniquePaths.Count} folders will be created in {LibraryTitle}");
|
||||
$"{uniquePaths.Count} folders will be created in {LibraryTitle} on {urls.Count} site(s)");
|
||||
if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
|
||||
return;
|
||||
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = SiteUrl,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||
var allResults = new List<BulkItemResult<string>>();
|
||||
|
||||
_lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
|
||||
foreach (var url in urls)
|
||||
{
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = url,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
||||
|
||||
var result = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
|
||||
allResults.AddRange(result.Results);
|
||||
}
|
||||
|
||||
_lastResult = new BulkOperationSummary<string>(allResults);
|
||||
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
@@ -175,8 +163,6 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
SiteUrl = string.Empty;
|
||||
LibraryTitle = string.Empty;
|
||||
PreviewRows = new();
|
||||
_validRows = null;
|
||||
|
||||
@@ -30,9 +30,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
// ── Observable properties ───────────────────────────────────────────────
|
||||
|
||||
[ObservableProperty]
|
||||
private string _siteUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _includeInherited;
|
||||
|
||||
@@ -115,43 +112,11 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public RelayCommand OpenSitePickerCommand { get; }
|
||||
|
||||
// ── Multi-site ──────────────────────────────────────────────────────────
|
||||
|
||||
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has manually selected sites via the site picker on this tab.
|
||||
/// Prevents global site changes from overwriting the user's local selection.
|
||||
/// </summary>
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── Dialog factory (set by View layer — keeps Window out of ViewModel) ──
|
||||
|
||||
/// <summary>
|
||||
/// Factory function set by the View layer to open the SitePickerDialog.
|
||||
/// Returns the opened Window; ViewModel calls ShowDialog() on it.
|
||||
/// </summary>
|
||||
public Func<Window>? OpenSitePickerDialog { get; set; }
|
||||
|
||||
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
|
||||
|
||||
internal TenantProfile? _currentProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Public accessor for the current tenant profile — used by View layer dialog factory.
|
||||
/// </summary>
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Label shown in the UI: "3 site(s) selected" or empty when none are selected.
|
||||
/// </summary>
|
||||
public string SitesSelectedLabel =>
|
||||
SelectedSites.Count > 0
|
||||
? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count)
|
||||
: string.Empty;
|
||||
|
||||
// ── Constructors ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -175,8 +140,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -198,21 +161,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
}
|
||||
|
||||
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in sites)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
|
||||
partial void OnIsSimplifiedModeChanged(bool value)
|
||||
{
|
||||
if (value && Results.Count > 0)
|
||||
@@ -237,17 +189,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
var urls = SelectedSites.Count > 0
|
||||
? SelectedSites.Select(s => s.Url).ToList()
|
||||
: new List<string> { SiteUrl };
|
||||
|
||||
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (nonEmpty.Count == 0)
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Enter a site URL or select sites.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var nonEmpty = urls;
|
||||
|
||||
var allEntries = new List<PermissionEntry>();
|
||||
var scanOptions = new ScanOptions(
|
||||
IncludeInherited: IncludeInherited,
|
||||
@@ -303,15 +253,10 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<PermissionEntry>();
|
||||
SimplifiedResults = Array.Empty<SimplifiedPermissionEntry>();
|
||||
Summaries = Array.Empty<PermissionSummary>();
|
||||
OnPropertyChanged(nameof(ActiveItemsSource));
|
||||
SiteUrl = string.Empty;
|
||||
SelectedSites.Clear();
|
||||
OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
@@ -381,19 +326,6 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteOpenSitePicker()
|
||||
{
|
||||
if (OpenSitePickerDialog == null) return;
|
||||
var dialog = OpenSitePickerDialog.Invoke();
|
||||
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in picker.SelectedUrls)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -19,11 +19,9 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
private readonly SearchHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── 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;
|
||||
@@ -74,26 +72,6 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -101,48 +79,54 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
var allItems = new List<SearchResult>();
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
TenantUrl = SiteUrl.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.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 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: url.TrimEnd('/')
|
||||
);
|
||||
|
||||
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
|
||||
var items = await _searchService.SearchFilesAsync(ctx, opts, progress, ct);
|
||||
allItems.AddRange(items);
|
||||
}
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(items));
|
||||
await dispatcher.InvokeAsync(() => Results = new ObservableCollection<SearchResult>(allItems));
|
||||
else
|
||||
Results = new ObservableCollection<SearchResult>(items);
|
||||
Results = new ObservableCollection<SearchResult>(allItems);
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(Core.Models.TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<SearchResult>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
|
||||
@@ -23,10 +23,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private readonly StorageHtmlExportService _htmlExportService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _siteUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _perLibrary = true;
|
||||
@@ -101,11 +97,33 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
{
|
||||
_results = value;
|
||||
OnPropertyChanged();
|
||||
NotifySummaryProperties();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary properties (computed from root-level library nodes) ─────────
|
||||
|
||||
/// <summary>Sum of TotalSizeBytes across root-level library nodes.</summary>
|
||||
public long SummaryTotalSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalSizeBytes);
|
||||
|
||||
/// <summary>Sum of VersionSizeBytes across root-level library nodes.</summary>
|
||||
public long SummaryVersionSize => Results.Where(n => n.IndentLevel == 0).Sum(n => n.VersionSizeBytes);
|
||||
|
||||
/// <summary>Sum of TotalFileCount across root-level library nodes.</summary>
|
||||
public long SummaryFileCount => Results.Where(n => n.IndentLevel == 0).Sum(n => n.TotalFileCount);
|
||||
|
||||
public bool HasResults => Results.Count > 0;
|
||||
|
||||
private void NotifySummaryProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(SummaryTotalSize));
|
||||
OnPropertyChanged(nameof(SummaryVersionSize));
|
||||
OnPropertyChanged(nameof(SummaryFileCount));
|
||||
OnPropertyChanged(nameof(HasResults));
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
|
||||
@@ -146,26 +164,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
SiteUrl = sites.Count > 0 ? sites[0].Url : string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSiteUrlChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
_hasLocalSiteOverride = false;
|
||||
if (GlobalSites.Count > 0)
|
||||
SiteUrl = GlobalSites[0].Url;
|
||||
}
|
||||
else if (GlobalSites.Count == 0 || value != GlobalSites[0].Url)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
@@ -173,70 +171,83 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(SiteUrl))
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Please enter a site URL.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
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 nonEmpty = urls;
|
||||
|
||||
var options = new StorageScanOptions(
|
||||
PerLibrary: PerLibrary,
|
||||
IncludeSubsites: IncludeSubsites,
|
||||
FolderDepth: FolderDepth);
|
||||
|
||||
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
var allNodes = new List<StorageNode>();
|
||||
var allTypeMetrics = new List<FileTypeMetric>();
|
||||
|
||||
// Flatten tree to one level for DataGrid display (assign IndentLevel during flatten)
|
||||
int i = 0;
|
||||
foreach (var url in nonEmpty)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
i++;
|
||||
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
|
||||
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = url.TrimEnd('/'),
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
|
||||
// Backfill any libraries where StorageMetrics returned zeros
|
||||
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
||||
|
||||
allNodes.AddRange(nodes);
|
||||
|
||||
progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
|
||||
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
|
||||
allTypeMetrics.AddRange(typeMetrics);
|
||||
}
|
||||
|
||||
// Flatten tree for DataGrid display
|
||||
var flat = new List<StorageNode>();
|
||||
foreach (var node in nodes)
|
||||
foreach (var node in allNodes)
|
||||
FlattenNode(node, 0, flat);
|
||||
|
||||
// Merge file-type metrics across sites (same extension -> sum)
|
||||
var mergedMetrics = allTypeMetrics
|
||||
.GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount)))
|
||||
.OrderByDescending(m => m.TotalSizeBytes)
|
||||
.ToList();
|
||||
|
||||
if (Application.Current?.Dispatcher is { } dispatcher)
|
||||
{
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Results = new ObservableCollection<StorageNode>(flat);
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Results = new ObservableCollection<StorageNode>(flat);
|
||||
}
|
||||
|
||||
// Collect file-type metrics for chart visualization
|
||||
progress.Report(OperationProgress.Indeterminate("Scanning file types for chart..."));
|
||||
var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
|
||||
|
||||
if (Application.Current?.Dispatcher is { } chartDispatcher)
|
||||
{
|
||||
await chartDispatcher.InvokeAsync(() =>
|
||||
{
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(typeMetrics);
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<StorageNode>();
|
||||
FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
|
||||
SiteUrl = string.Empty;
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
@@ -262,7 +273,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -285,7 +296,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -323,18 +334,26 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (otherSize > 0)
|
||||
chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));
|
||||
|
||||
// Pie/Donut series
|
||||
// Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors).
|
||||
// Tooltip only shows the hovered slice because each series has exactly one value.
|
||||
double innerRadius = IsDonutChart ? 50 : 0;
|
||||
PieChartSeries = chartItems.Select(m => new PieSeries<long>
|
||||
var pieList = new List<ISeries>();
|
||||
foreach (var m in chartItems)
|
||||
{
|
||||
Values = new[] { m.TotalSizeBytes },
|
||||
Name = m.DisplayLabel,
|
||||
InnerRadius = innerRadius,
|
||||
DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
|
||||
DataLabelsFormatter = point => m.DisplayLabel,
|
||||
ToolTipLabelFormatter = point =>
|
||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)"
|
||||
}).ToList();
|
||||
pieList.Add(new PieSeries<double>
|
||||
{
|
||||
Values = new[] { (double)m.TotalSizeBytes },
|
||||
Name = m.DisplayLabel,
|
||||
InnerRadius = innerRadius,
|
||||
HoverPushout = 8,
|
||||
MaxRadialColumnWidth = 60,
|
||||
DataLabelsFormatter = _ => m.DisplayLabel,
|
||||
ToolTipLabelFormatter = _ =>
|
||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
||||
IsVisibleAtLegend = true,
|
||||
});
|
||||
}
|
||||
PieChartSeries = pieList;
|
||||
|
||||
// Bar chart series
|
||||
BarChartSeries = new ISeries[]
|
||||
@@ -388,6 +407,6 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
|
||||
catch { /* ignore — file may open but this is best-effort */ }
|
||||
catch { /* ignore -- file may open but this is best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
[ObservableProperty] private SiteTemplate? _selectedTemplate;
|
||||
|
||||
// Capture options
|
||||
[ObservableProperty] private string _captureSiteUrl = string.Empty;
|
||||
[ObservableProperty] private string _templateName = string.Empty;
|
||||
[ObservableProperty] private bool _captureLibraries = true;
|
||||
[ObservableProperty] private bool _captureFolders = true;
|
||||
@@ -81,11 +80,13 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
throw new InvalidOperationException("No tenant connected.");
|
||||
if (string.IsNullOrWhiteSpace(CaptureSiteUrl))
|
||||
throw new InvalidOperationException("Site URL is required.");
|
||||
if (string.IsNullOrWhiteSpace(TemplateName))
|
||||
throw new InvalidOperationException("Template name is required.");
|
||||
|
||||
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
|
||||
if (string.IsNullOrWhiteSpace(captureSiteUrl))
|
||||
throw new InvalidOperationException("Select at least one site from the toolbar.");
|
||||
|
||||
try
|
||||
{
|
||||
IsRunning = true;
|
||||
@@ -94,7 +95,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = CaptureSiteUrl,
|
||||
TenantUrl = captureSiteUrl,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
||||
@@ -113,7 +114,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
template.Name = TemplateName;
|
||||
|
||||
await _templateRepo.SaveAsync(template);
|
||||
Log.Information("Template captured: {Name} from {Url}", template.Name, CaptureSiteUrl);
|
||||
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
||||
|
||||
await RefreshListAsync();
|
||||
StatusMessage = $"Template captured successfully.";
|
||||
@@ -200,7 +201,6 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
CaptureSiteUrl = string.Empty;
|
||||
TemplateName = string.Empty;
|
||||
NewSiteTitle = string.Empty;
|
||||
NewSiteAlias = string.Empty;
|
||||
|
||||
@@ -99,25 +99,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public RelayCommand OpenSitePickerCommand { get; }
|
||||
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
||||
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
||||
|
||||
// ── Multi-site ──────────────────────────────────────────────────────────
|
||||
|
||||
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when the user has manually selected sites via the site picker.
|
||||
/// Prevents global site changes from overwriting the local selection.
|
||||
/// </summary>
|
||||
private bool _hasLocalSiteOverride;
|
||||
|
||||
// ── Dialog factory ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel.</summary>
|
||||
public Func<Window>? OpenSitePickerDialog { get; set; }
|
||||
|
||||
// ── Current tenant profile ──────────────────────────────────────────────
|
||||
|
||||
internal TenantProfile? _currentProfile;
|
||||
@@ -125,12 +109,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
/// <summary>Public accessor for the current tenant profile — used by View layer dialog factory.</summary>
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
/// <summary>Label shown in the UI: "3 site(s) selected" or empty when none are selected.</summary>
|
||||
public string SitesSelectedLabel =>
|
||||
SelectedSites.Count > 0
|
||||
? $"{SelectedSites.Count} site(s) selected"
|
||||
: string.Empty;
|
||||
|
||||
// ── Constructors ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Full constructor — used by DI and production code.</summary>
|
||||
@@ -152,11 +130,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
@@ -181,11 +157,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||
|
||||
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||
|
||||
var cvs = new CollectionViewSource { Source = Results };
|
||||
@@ -195,15 +169,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
||||
|
||||
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
||||
{
|
||||
if (_hasLocalSiteOverride) return;
|
||||
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in sites)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (SelectedUsers.Count == 0)
|
||||
@@ -212,16 +177,14 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveSites = SelectedSites.Count > 0
|
||||
? SelectedSites.ToList()
|
||||
: GlobalSites.ToList();
|
||||
|
||||
if (effectiveSites.Count == 0)
|
||||
if (GlobalSites.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site to scan.";
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveSites = GlobalSites.ToList();
|
||||
|
||||
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
|
||||
var scanOptions = new ScanOptions(
|
||||
IncludeInherited: IncludeInherited,
|
||||
@@ -244,19 +207,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
progress,
|
||||
ct);
|
||||
|
||||
// Update Results on the UI thread
|
||||
// Update Results on the UI thread — clear + repopulate (not replace)
|
||||
// so the CollectionViewSource bound to ResultsView stays connected.
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher != null)
|
||||
{
|
||||
await dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Results = new ObservableCollection<UserAccessEntry>(entries);
|
||||
Results.Clear();
|
||||
foreach (var entry in entries)
|
||||
Results.Add(entry);
|
||||
NotifySummaryProperties();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Results = new ObservableCollection<UserAccessEntry>(entries);
|
||||
Results.Clear();
|
||||
foreach (var entry in entries)
|
||||
Results.Add(entry);
|
||||
NotifySummaryProperties();
|
||||
}
|
||||
|
||||
@@ -269,14 +237,11 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
{
|
||||
_currentProfile = profile;
|
||||
_hasLocalSiteOverride = false;
|
||||
Results = new ObservableCollection<UserAccessEntry>();
|
||||
Results.Clear();
|
||||
SelectedUsers.Clear();
|
||||
SearchQuery = string.Empty;
|
||||
SearchResults.Clear();
|
||||
SelectedSites.Clear();
|
||||
FilterText = string.Empty;
|
||||
OnPropertyChanged(nameof(SitesSelectedLabel));
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
NotifySummaryProperties();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
@@ -308,15 +273,10 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
partial void OnResultsChanged(ObservableCollection<UserAccessEntry> value)
|
||||
{
|
||||
// Rebind CollectionViewSource when the collection reference changes
|
||||
if (ResultsView is CollectionView cv && cv.SourceCollection != value)
|
||||
{
|
||||
// CollectionViewSource.View is already live-bound in constructor;
|
||||
// for a new collection reference we need to refresh grouping/filter
|
||||
ApplyGrouping();
|
||||
ResultsView.Filter = FilterPredicate;
|
||||
ResultsView.Refresh();
|
||||
}
|
||||
// Safety net: if the collection reference ever changes, rebind grouping/filter
|
||||
ApplyGrouping();
|
||||
ResultsView.Filter = FilterPredicate;
|
||||
ResultsView.Refresh();
|
||||
NotifySummaryProperties();
|
||||
}
|
||||
|
||||
@@ -379,19 +339,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteOpenSitePicker()
|
||||
{
|
||||
if (OpenSitePickerDialog == null) return;
|
||||
var dialog = OpenSitePickerDialog.Invoke();
|
||||
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
||||
{
|
||||
_hasLocalSiteOverride = true;
|
||||
SelectedSites.Clear();
|
||||
foreach (var site in picker.SelectedUrls)
|
||||
SelectedSites.Add(site);
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteAddUser(GraphUserResult? user)
|
||||
{
|
||||
if (user == null) return;
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
<!-- 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]}"
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||
<DockPanel Margin="10">
|
||||
<StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0">
|
||||
<!-- Site URL and Library inputs -->
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.siteurl]}"
|
||||
Margin="0,0,0,3" />
|
||||
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
|
||||
|
||||
<!-- Library input -->
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
|
||||
Margin="0,0,0,3" />
|
||||
<TextBox Text="{Binding LibraryTitle, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
|
||||
|
||||
@@ -28,21 +28,6 @@
|
||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Site URL -->
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.site.url]}"
|
||||
Margin="0,0,0,2" />
|
||||
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||
Margin="0,0,0,6" />
|
||||
|
||||
<!-- View Sites + selected label -->
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.or.select]}"
|
||||
Margin="0,0,0,2" />
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
|
||||
Command="{Binding OpenSitePickerCommand}"
|
||||
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
|
||||
<TextBlock Text="{Binding SitesSelectedLabel}"
|
||||
FontStyle="Italic" Foreground="Gray" Margin="0,0,0,8" />
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
|
||||
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.ViewModels.Tabs;
|
||||
using SharepointToolbox.Views.Dialogs;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
@@ -11,12 +9,6 @@ public partial class PermissionsView : UserControl
|
||||
public PermissionsView(IServiceProvider serviceProvider)
|
||||
{
|
||||
InitializeComponent();
|
||||
var vm = serviceProvider.GetRequiredService<PermissionsViewModel>();
|
||||
DataContext = vm;
|
||||
vm.OpenSitePickerDialog = () =>
|
||||
{
|
||||
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
|
||||
return factory(vm.CurrentProfile ?? new TenantProfile());
|
||||
};
|
||||
DataContext = serviceProvider.GetRequiredService<PermissionsViewModel>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
<!-- 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">
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
<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]}"
|
||||
@@ -78,17 +72,46 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Right content area: DataGrid on top, Chart on bottom -->
|
||||
<!-- Right content area: Summary + DataGrid on top, Chart on bottom -->
|
||||
<Grid Margin="4,8,8,8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" MinHeight="150" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="300" MinHeight="200" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Summary bar -->
|
||||
<Border Grid.Row="0" Background="#F0F7FF" CornerRadius="4" Padding="12,8" Margin="0,0,0,6">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Visibility" Value="Collapsed" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasResults}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Margin="0,0,24,0">
|
||||
<Run Text="Total Size: " FontWeight="SemiBold" />
|
||||
<Run Text="{Binding SummaryTotalSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<TextBlock Margin="0,0,24,0">
|
||||
<Run Text="Version Size: " FontWeight="SemiBold" />
|
||||
<Run Text="{Binding SummaryVersionSize, Converter={StaticResource BytesConverter}, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<TextBlock>
|
||||
<Run Text="Files: " FontWeight="SemiBold" />
|
||||
<Run Text="{Binding SummaryFileCount, StringFormat=N0, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Results DataGrid -->
|
||||
<DataGrid x:Name="ResultsGrid"
|
||||
Grid.Row="0"
|
||||
Grid.Row="1"
|
||||
ItemsSource="{Binding Results}"
|
||||
IsReadOnly="True"
|
||||
AutoGenerateColumns="False"
|
||||
@@ -123,11 +146,11 @@
|
||||
</DataGrid>
|
||||
|
||||
<!-- Splitter between DataGrid and Chart -->
|
||||
<GridSplitter Grid.Row="1" Height="5" HorizontalAlignment="Stretch"
|
||||
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
|
||||
Background="#DDD" ResizeDirection="Rows" />
|
||||
|
||||
<!-- Chart panel -->
|
||||
<Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
|
||||
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
|
||||
Padding="8" Background="White">
|
||||
<Grid>
|
||||
<!-- Chart title -->
|
||||
@@ -167,7 +190,8 @@
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Grid.Style>
|
||||
<lvc:PieChart Series="{Binding PieChartSeries}"
|
||||
<lvc:PieChart x:Name="StoragePieChart"
|
||||
Series="{Binding PieChartSeries}"
|
||||
LegendPosition="Right" />
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Windows.Controls;
|
||||
using LiveChartsCore;
|
||||
using LiveChartsCore.Kernel;
|
||||
using LiveChartsCore.Kernel.Sketches;
|
||||
|
||||
namespace SharepointToolbox.Views.Tabs;
|
||||
|
||||
@@ -8,5 +11,43 @@ public partial class StorageView : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
|
||||
StoragePieChart.Tooltip = new SingleSliceTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom tooltip that only shows the single closest/hovered pie slice
|
||||
/// instead of LiveCharts2's default which shows multiple nearby slices.
|
||||
/// </summary>
|
||||
internal sealed class SingleSliceTooltip : IChartTooltip
|
||||
{
|
||||
private readonly System.Windows.Controls.ToolTip _tip = new()
|
||||
{
|
||||
Padding = new System.Windows.Thickness(8, 4, 8, 4),
|
||||
FontSize = 13,
|
||||
Background = new System.Windows.Media.SolidColorBrush(
|
||||
System.Windows.Media.Color.FromRgb(255, 255, 255)),
|
||||
BorderBrush = new System.Windows.Media.SolidColorBrush(
|
||||
System.Windows.Media.Color.FromRgb(200, 200, 200)),
|
||||
BorderThickness = new System.Windows.Thickness(1),
|
||||
};
|
||||
|
||||
public void Show(IEnumerable<ChartPoint> foundPoints, Chart chart)
|
||||
{
|
||||
// Only show the first (closest) point
|
||||
var point = foundPoints.FirstOrDefault();
|
||||
if (point == null) { Hide(chart); return; }
|
||||
|
||||
var label = point.Context.Series.GetPrimaryToolTipText(point);
|
||||
if (string.IsNullOrEmpty(label)) label = point.Context.Series.Name ?? "";
|
||||
|
||||
_tip.Content = label;
|
||||
_tip.IsOpen = true;
|
||||
}
|
||||
|
||||
public void Hide(Chart chart)
|
||||
{
|
||||
_tip.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
|
||||
Margin="0,0,0,10">
|
||||
<StackPanel Margin="5">
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.siteurl]}"
|
||||
Margin="0,0,0,3" />
|
||||
<TextBox Text="{Binding CaptureSiteUrl, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,5" />
|
||||
|
||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.name]}"
|
||||
Margin="0,0,0,3" />
|
||||
<TextBox Text="{Binding TemplateName, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,10" />
|
||||
|
||||
@@ -77,16 +77,6 @@
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.sites]}"
|
||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||
<StackPanel>
|
||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
|
||||
Command="{Binding OpenSitePickerCommand}"
|
||||
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
|
||||
<TextBlock Text="{Binding SitesSelectedLabel}" FontStyle="Italic" Foreground="Gray" />
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
|
||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||
<StackPanel>
|
||||
@@ -244,7 +234,7 @@
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="⚠" Foreground="#E74C3C" Margin="0,0,4,0"
|
||||
<TextBlock Text="⚠" Foreground="#E74C3C" Margin="0,0,4,0"
|
||||
FontSize="12" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
|
||||
Reference in New Issue
Block a user