Wave 0: models, interfaces, BulkOperationRunner, test scaffolds
Wave 1: CsvValidationService, TemplateRepository, FileTransferService,
BulkMemberService, BulkSiteService, TemplateService, FolderStructureService
Wave 2: Localization, shared dialogs, example CSV resources
Wave 3: TransferVM+View, BulkMembers/BulkSites/FolderStructure VMs+Views,
TemplatesVM+View, DI registration, MainWindow wiring
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
898 lines
38 KiB
Markdown
898 lines
38 KiB
Markdown
---
|
|
phase: 04
|
|
plan: 09
|
|
title: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views
|
|
status: pending
|
|
wave: 3
|
|
depends_on:
|
|
- 04-02
|
|
- 04-04
|
|
- 04-05
|
|
- 04-06
|
|
- 04-07
|
|
files_modified:
|
|
- SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs
|
|
- SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs
|
|
- SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs
|
|
- SharepointToolbox/Views/Tabs/BulkMembersView.xaml
|
|
- SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs
|
|
- SharepointToolbox/Views/Tabs/BulkSitesView.xaml
|
|
- SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs
|
|
- SharepointToolbox/Views/Tabs/FolderStructureView.xaml
|
|
- SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs
|
|
autonomous: true
|
|
requirements:
|
|
- BULK-02
|
|
- BULK-03
|
|
- BULK-04
|
|
- BULK-05
|
|
- FOLD-01
|
|
- FOLD-02
|
|
|
|
must_haves:
|
|
truths:
|
|
- "All three CSV tabs follow the same flow: Import CSV -> Validate -> Preview DataGrid -> Confirm -> Execute"
|
|
- "Each DataGrid preview shows valid/invalid row indicators"
|
|
- "Invalid rows highlighted — user can fix and re-import before executing"
|
|
- "Confirmation dialog shown before execution"
|
|
- "Retry Failed button appears after partial failures"
|
|
- "Failed-items CSV export available after any failure"
|
|
- "Load Example button loads bundled CSV from embedded resources"
|
|
artifacts:
|
|
- path: "SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs"
|
|
provides: "Bulk Members tab ViewModel"
|
|
exports: ["BulkMembersViewModel"]
|
|
- path: "SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs"
|
|
provides: "Bulk Sites tab ViewModel"
|
|
exports: ["BulkSitesViewModel"]
|
|
- path: "SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs"
|
|
provides: "Folder Structure tab ViewModel"
|
|
exports: ["FolderStructureViewModel"]
|
|
- path: "SharepointToolbox/Views/Tabs/BulkMembersView.xaml"
|
|
provides: "Bulk Members tab UI"
|
|
- path: "SharepointToolbox/Views/Tabs/BulkSitesView.xaml"
|
|
provides: "Bulk Sites tab UI"
|
|
- path: "SharepointToolbox/Views/Tabs/FolderStructureView.xaml"
|
|
provides: "Folder Structure tab UI"
|
|
key_links:
|
|
- from: "BulkMembersViewModel.cs"
|
|
to: "IBulkMemberService.AddMembersAsync"
|
|
via: "RunOperationAsync override"
|
|
pattern: "AddMembersAsync"
|
|
- from: "BulkSitesViewModel.cs"
|
|
to: "IBulkSiteService.CreateSitesAsync"
|
|
via: "RunOperationAsync override"
|
|
pattern: "CreateSitesAsync"
|
|
- from: "FolderStructureViewModel.cs"
|
|
to: "IFolderStructureService.CreateFoldersAsync"
|
|
via: "RunOperationAsync override"
|
|
pattern: "CreateFoldersAsync"
|
|
---
|
|
|
|
# Plan 04-09: BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel + Views
|
|
|
|
## Goal
|
|
|
|
Create all three CSV-based bulk operation tabs (Bulk Members, Bulk Sites, Folder Structure). Each follows the same flow: Import CSV -> Validate via CsvValidationService -> Preview in DataGrid -> Confirm via ConfirmBulkOperationDialog -> Execute via respective service -> Report results. Includes Retry Failed button and failed-items CSV export.
|
|
|
|
## Context
|
|
|
|
Services: `IBulkMemberService` (04-04), `IBulkSiteService` (04-05), `IFolderStructureService` (04-06), `ICsvValidationService` (04-02). Shared UI: `ConfirmBulkOperationDialog` (04-07), `BulkResultCsvExportService` (04-01). Localization keys from Plan 04-07.
|
|
|
|
All three ViewModels follow FeatureViewModelBase pattern. The CSV import flow is identical across all three — only the row model, validation, and service call differ.
|
|
|
|
Example CSVs are embedded resources accessed via `Assembly.GetExecutingAssembly().GetManifestResourceStream()`.
|
|
|
|
## Tasks
|
|
|
|
### Task 1: Create BulkMembersViewModel + BulkSitesViewModel + FolderStructureViewModel
|
|
|
|
**Files:**
|
|
- `SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs`
|
|
- `SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs`
|
|
- `SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs`
|
|
|
|
**Action:**
|
|
|
|
1. Create `BulkMembersViewModel.cs`:
|
|
```csharp
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using Serilog;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
public partial class BulkMembersViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IBulkMemberService _memberService;
|
|
private readonly ICsvValidationService _csvService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly BulkResultCsvExportService _exportService;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private TenantProfile? _currentProfile;
|
|
private List<BulkMemberRow>? _validRows;
|
|
private List<BulkMemberRow>? _failedRowsForRetry;
|
|
private BulkOperationSummary<BulkMemberRow>? _lastResult;
|
|
|
|
[ObservableProperty] private string _previewSummary = string.Empty;
|
|
[ObservableProperty] private string _resultSummary = string.Empty;
|
|
[ObservableProperty] private bool _hasFailures;
|
|
[ObservableProperty] private bool _hasPreview;
|
|
|
|
private ObservableCollection<CsvValidationRow<BulkMemberRow>> _previewRows = new();
|
|
public ObservableCollection<CsvValidationRow<BulkMemberRow>> PreviewRows
|
|
{
|
|
get => _previewRows;
|
|
private set { _previewRows = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
public IRelayCommand ImportCsvCommand { get; }
|
|
public IRelayCommand LoadExampleCommand { get; }
|
|
public IAsyncRelayCommand ExportFailedCommand { get; }
|
|
public IAsyncRelayCommand RetryFailedCommand { get; }
|
|
|
|
public Func<string, bool>? ShowConfirmDialog { get; set; }
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
public BulkMembersViewModel(
|
|
IBulkMemberService memberService,
|
|
ICsvValidationService csvService,
|
|
ISessionManager sessionManager,
|
|
BulkResultCsvExportService exportService,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_memberService = memberService;
|
|
_csvService = csvService;
|
|
_sessionManager = sessionManager;
|
|
_exportService = exportService;
|
|
_logger = logger;
|
|
|
|
ImportCsvCommand = new RelayCommand(ImportCsv);
|
|
LoadExampleCommand = new RelayCommand(LoadExample);
|
|
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
|
RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
|
|
}
|
|
|
|
private void ImportCsv()
|
|
{
|
|
var dlg = new OpenFileDialog
|
|
{
|
|
Title = TranslationSource.Instance["bulk.csvimport.title"],
|
|
Filter = TranslationSource.Instance["bulk.csvimport.filter"],
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
using var stream = File.OpenRead(dlg.FileName);
|
|
LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadExample()
|
|
{
|
|
var assembly = Assembly.GetExecutingAssembly();
|
|
var resourceName = assembly.GetManifestResourceNames()
|
|
.FirstOrDefault(n => n.EndsWith("bulk_add_members.csv", StringComparison.OrdinalIgnoreCase));
|
|
if (resourceName == null) return;
|
|
|
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream != null) LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadAndPreview(Stream stream)
|
|
{
|
|
var rows = _csvService.ParseAndValidateMembers(stream);
|
|
PreviewRows = new ObservableCollection<CsvValidationRow<BulkMemberRow>>(rows);
|
|
_validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
|
var invalidCount = rows.Count - _validRows.Count;
|
|
PreviewSummary = string.Format(TranslationSource.Instance["bulkmembers.preview"],
|
|
rows.Count, _validRows.Count, invalidCount);
|
|
HasPreview = true;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
|
if (_validRows == null || _validRows.Count == 0)
|
|
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
|
|
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
|
$"{_validRows.Count} members will be added");
|
|
if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
|
|
return;
|
|
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
|
|
_lastResult = await _memberService.AddMembersAsync(ctx, _validRows, progress, ct);
|
|
|
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
{
|
|
HasFailures = _lastResult.HasFailures;
|
|
_failedRowsForRetry = _lastResult.HasFailures
|
|
? _lastResult.FailedItems.Select(r => r.Item).ToList()
|
|
: null;
|
|
ExportFailedCommand.NotifyCanExecuteChanged();
|
|
RetryFailedCommand.NotifyCanExecuteChanged();
|
|
|
|
ResultSummary = _lastResult.HasFailures
|
|
? string.Format(TranslationSource.Instance["bulk.result.success"],
|
|
_lastResult.SuccessCount, _lastResult.FailedCount)
|
|
: string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
|
|
_lastResult.TotalCount);
|
|
});
|
|
}
|
|
|
|
private async Task RetryFailedAsync()
|
|
{
|
|
if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
|
|
_validRows = _failedRowsForRetry;
|
|
HasFailures = false;
|
|
await RunCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private async Task ExportFailedAsync()
|
|
{
|
|
if (_lastResult == null || !_lastResult.HasFailures) return;
|
|
var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_members.csv" };
|
|
if (dlg.ShowDialog() == true)
|
|
{
|
|
await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
|
|
Log.Information("Exported failed member rows to {Path}", dlg.FileName);
|
|
}
|
|
}
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
PreviewRows = new();
|
|
_validRows = null;
|
|
PreviewSummary = string.Empty;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
HasPreview = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
2. Create `BulkSitesViewModel.cs` — follows same pattern as BulkMembersViewModel but uses `IBulkSiteService` and `BulkSiteRow`:
|
|
```csharp
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using Serilog;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
public partial class BulkSitesViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IBulkSiteService _siteService;
|
|
private readonly ICsvValidationService _csvService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly BulkResultCsvExportService _exportService;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private TenantProfile? _currentProfile;
|
|
private List<BulkSiteRow>? _validRows;
|
|
private List<BulkSiteRow>? _failedRowsForRetry;
|
|
private BulkOperationSummary<BulkSiteRow>? _lastResult;
|
|
|
|
[ObservableProperty] private string _previewSummary = string.Empty;
|
|
[ObservableProperty] private string _resultSummary = string.Empty;
|
|
[ObservableProperty] private bool _hasFailures;
|
|
[ObservableProperty] private bool _hasPreview;
|
|
|
|
private ObservableCollection<CsvValidationRow<BulkSiteRow>> _previewRows = new();
|
|
public ObservableCollection<CsvValidationRow<BulkSiteRow>> PreviewRows
|
|
{
|
|
get => _previewRows;
|
|
private set { _previewRows = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
public IRelayCommand ImportCsvCommand { get; }
|
|
public IRelayCommand LoadExampleCommand { get; }
|
|
public IAsyncRelayCommand ExportFailedCommand { get; }
|
|
public IAsyncRelayCommand RetryFailedCommand { get; }
|
|
|
|
public Func<string, bool>? ShowConfirmDialog { get; set; }
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
public BulkSitesViewModel(
|
|
IBulkSiteService siteService,
|
|
ICsvValidationService csvService,
|
|
ISessionManager sessionManager,
|
|
BulkResultCsvExportService exportService,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_siteService = siteService;
|
|
_csvService = csvService;
|
|
_sessionManager = sessionManager;
|
|
_exportService = exportService;
|
|
_logger = logger;
|
|
|
|
ImportCsvCommand = new RelayCommand(ImportCsv);
|
|
LoadExampleCommand = new RelayCommand(LoadExample);
|
|
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
|
RetryFailedCommand = new AsyncRelayCommand(RetryFailedAsync, () => HasFailures);
|
|
}
|
|
|
|
private void ImportCsv()
|
|
{
|
|
var dlg = new OpenFileDialog
|
|
{
|
|
Title = TranslationSource.Instance["bulk.csvimport.title"],
|
|
Filter = TranslationSource.Instance["bulk.csvimport.filter"],
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
using var stream = File.OpenRead(dlg.FileName);
|
|
LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadExample()
|
|
{
|
|
var assembly = Assembly.GetExecutingAssembly();
|
|
var resourceName = assembly.GetManifestResourceNames()
|
|
.FirstOrDefault(n => n.EndsWith("bulk_create_sites.csv", StringComparison.OrdinalIgnoreCase));
|
|
if (resourceName == null) return;
|
|
|
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream != null) LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadAndPreview(Stream stream)
|
|
{
|
|
var rows = _csvService.ParseAndValidateSites(stream);
|
|
PreviewRows = new ObservableCollection<CsvValidationRow<BulkSiteRow>>(rows);
|
|
_validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
|
var invalidCount = rows.Count - _validRows.Count;
|
|
PreviewSummary = string.Format(TranslationSource.Instance["bulksites.preview"],
|
|
rows.Count, _validRows.Count, invalidCount);
|
|
HasPreview = true;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
|
if (_validRows == null || _validRows.Count == 0)
|
|
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
|
|
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
|
$"{_validRows.Count} sites will be created");
|
|
if (ShowConfirmDialog != null && !ShowConfirmDialog(message))
|
|
return;
|
|
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, ct);
|
|
_lastResult = await _siteService.CreateSitesAsync(ctx, _validRows, progress, ct);
|
|
|
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
{
|
|
HasFailures = _lastResult.HasFailures;
|
|
_failedRowsForRetry = _lastResult.HasFailures
|
|
? _lastResult.FailedItems.Select(r => r.Item).ToList()
|
|
: null;
|
|
ExportFailedCommand.NotifyCanExecuteChanged();
|
|
RetryFailedCommand.NotifyCanExecuteChanged();
|
|
|
|
ResultSummary = _lastResult.HasFailures
|
|
? string.Format(TranslationSource.Instance["bulk.result.success"],
|
|
_lastResult.SuccessCount, _lastResult.FailedCount)
|
|
: string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
|
|
_lastResult.TotalCount);
|
|
});
|
|
}
|
|
|
|
private async Task RetryFailedAsync()
|
|
{
|
|
if (_failedRowsForRetry == null || _failedRowsForRetry.Count == 0) return;
|
|
_validRows = _failedRowsForRetry;
|
|
HasFailures = false;
|
|
await RunCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
private async Task ExportFailedAsync()
|
|
{
|
|
if (_lastResult == null || !_lastResult.HasFailures) return;
|
|
var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_sites.csv" };
|
|
if (dlg.ShowDialog() == true)
|
|
{
|
|
await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
PreviewRows = new();
|
|
_validRows = null;
|
|
PreviewSummary = string.Empty;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
HasPreview = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
3. Create `FolderStructureViewModel.cs`:
|
|
```csharp
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using Serilog;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
public partial class FolderStructureViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IFolderStructureService _folderService;
|
|
private readonly ICsvValidationService _csvService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly BulkResultCsvExportService _exportService;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private TenantProfile? _currentProfile;
|
|
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;
|
|
[ObservableProperty] private bool _hasFailures;
|
|
[ObservableProperty] private bool _hasPreview;
|
|
|
|
private ObservableCollection<CsvValidationRow<FolderStructureRow>> _previewRows = new();
|
|
public ObservableCollection<CsvValidationRow<FolderStructureRow>> PreviewRows
|
|
{
|
|
get => _previewRows;
|
|
private set { _previewRows = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
public IRelayCommand ImportCsvCommand { get; }
|
|
public IRelayCommand LoadExampleCommand { get; }
|
|
public IAsyncRelayCommand ExportFailedCommand { get; }
|
|
|
|
public Func<string, bool>? ShowConfirmDialog { get; set; }
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
public FolderStructureViewModel(
|
|
IFolderStructureService folderService,
|
|
ICsvValidationService csvService,
|
|
ISessionManager sessionManager,
|
|
BulkResultCsvExportService exportService,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_folderService = folderService;
|
|
_csvService = csvService;
|
|
_sessionManager = sessionManager;
|
|
_exportService = exportService;
|
|
_logger = logger;
|
|
|
|
ImportCsvCommand = new RelayCommand(ImportCsv);
|
|
LoadExampleCommand = new RelayCommand(LoadExample);
|
|
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
|
}
|
|
|
|
private void ImportCsv()
|
|
{
|
|
var dlg = new OpenFileDialog
|
|
{
|
|
Title = TranslationSource.Instance["bulk.csvimport.title"],
|
|
Filter = TranslationSource.Instance["bulk.csvimport.filter"],
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
using var stream = File.OpenRead(dlg.FileName);
|
|
LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadExample()
|
|
{
|
|
var assembly = Assembly.GetExecutingAssembly();
|
|
var resourceName = assembly.GetManifestResourceNames()
|
|
.FirstOrDefault(n => n.EndsWith("folder_structure.csv", StringComparison.OrdinalIgnoreCase));
|
|
if (resourceName == null) return;
|
|
|
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream != null) LoadAndPreview(stream);
|
|
}
|
|
|
|
private void LoadAndPreview(Stream stream)
|
|
{
|
|
var rows = _csvService.ParseAndValidateFolders(stream);
|
|
PreviewRows = new ObservableCollection<CsvValidationRow<FolderStructureRow>>(rows);
|
|
_validRows = rows.Where(r => r.IsValid && r.Record != null).Select(r => r.Record!).ToList();
|
|
|
|
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
|
PreviewSummary = string.Format(TranslationSource.Instance["folderstruct.preview"], uniquePaths.Count);
|
|
HasPreview = true;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
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 uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
|
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
|
$"{uniquePaths.Count} folders will be created in {LibraryTitle}");
|
|
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);
|
|
|
|
_lastResult = await _folderService.CreateFoldersAsync(ctx, LibraryTitle, _validRows, progress, ct);
|
|
|
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
{
|
|
HasFailures = _lastResult.HasFailures;
|
|
ExportFailedCommand.NotifyCanExecuteChanged();
|
|
|
|
ResultSummary = _lastResult.HasFailures
|
|
? string.Format(TranslationSource.Instance["bulk.result.success"],
|
|
_lastResult.SuccessCount, _lastResult.FailedCount)
|
|
: string.Format(TranslationSource.Instance["bulk.result.allsuccess"],
|
|
_lastResult.TotalCount);
|
|
});
|
|
}
|
|
|
|
private async Task ExportFailedAsync()
|
|
{
|
|
if (_lastResult == null || !_lastResult.HasFailures) return;
|
|
var dlg = new SaveFileDialog { Filter = "CSV Files (*.csv)|*.csv", FileName = "failed_folders.csv" };
|
|
if (dlg.ShowDialog() == true)
|
|
{
|
|
await _exportService.WriteFailedItemsCsvAsync(_lastResult.FailedItems.ToList(), dlg.FileName, CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
SiteUrl = string.Empty;
|
|
LibraryTitle = string.Empty;
|
|
PreviewRows = new();
|
|
_validRows = null;
|
|
PreviewSummary = string.Empty;
|
|
ResultSummary = string.Empty;
|
|
HasFailures = false;
|
|
HasPreview = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Verify:**
|
|
```bash
|
|
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
|
|
```
|
|
|
|
**Done:** All three ViewModels compile with Import CSV, Load Example, Validate, Preview, Confirm, Execute, Retry Failed, Export Failed flows.
|
|
|
|
### Task 2: Create BulkMembersView + BulkSitesView + FolderStructureView
|
|
|
|
**Files:**
|
|
- `SharepointToolbox/Views/Tabs/BulkMembersView.xaml`
|
|
- `SharepointToolbox/Views/Tabs/BulkMembersView.xaml.cs`
|
|
- `SharepointToolbox/Views/Tabs/BulkSitesView.xaml`
|
|
- `SharepointToolbox/Views/Tabs/BulkSitesView.xaml.cs`
|
|
- `SharepointToolbox/Views/Tabs/FolderStructureView.xaml`
|
|
- `SharepointToolbox/Views/Tabs/FolderStructureView.xaml.cs`
|
|
|
|
**Action:**
|
|
|
|
1. Create `BulkMembersView.xaml`:
|
|
```xml
|
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
|
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
|
<DockPanel Margin="10">
|
|
<!-- Options Panel (Left) -->
|
|
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
|
|
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
|
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
|
|
|
<TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />
|
|
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.execute]}"
|
|
Command="{Binding RunCommand}" Margin="0,0,0,5"
|
|
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
|
|
Command="{Binding CancelCommand}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
|
|
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
|
|
|
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
|
|
Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
|
Command="{Binding ExportFailedCommand}" />
|
|
</StackPanel>
|
|
|
|
<!-- Preview DataGrid (Right) -->
|
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
|
IsReadOnly="True" CanUserSortColumns="True">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.groupname]}"
|
|
Binding="{Binding Record.GroupName}" Width="*" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.email]}"
|
|
Binding="{Binding Record.Email}" Width="*" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.role]}"
|
|
Binding="{Binding Record.Role}" Width="80" />
|
|
<DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
|
Width="*" Foreground="Red" />
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</DockPanel>
|
|
</UserControl>
|
|
```
|
|
|
|
Note: If `ListToStringConverter` doesn't exist, create one in `Views/Converters/` that joins a `List<string>` with "; ". Alternatively, the executor can use a simpler approach: bind to `Errors[0]` or create a `ValidationErrors` computed property on the view model row wrapper.
|
|
|
|
2. Create `BulkMembersView.xaml.cs`:
|
|
```csharp
|
|
using System.Windows.Controls;
|
|
using SharepointToolbox.Views.Dialogs;
|
|
|
|
namespace SharepointToolbox.Views.Tabs;
|
|
|
|
public partial class BulkMembersView : UserControl
|
|
{
|
|
public BulkMembersView(ViewModels.Tabs.BulkMembersViewModel viewModel)
|
|
{
|
|
InitializeComponent();
|
|
DataContext = viewModel;
|
|
|
|
viewModel.ShowConfirmDialog = message =>
|
|
{
|
|
var dlg = new ConfirmBulkOperationDialog(message)
|
|
{ Owner = System.Windows.Window.GetWindow(this) };
|
|
dlg.ShowDialog();
|
|
return dlg.IsConfirmed;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
3. Create `BulkSitesView.xaml` — same layout as BulkMembersView but with site-specific columns:
|
|
```xml
|
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
|
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
|
<DockPanel Margin="10">
|
|
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
|
|
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
|
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
|
|
|
<TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />
|
|
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.execute]}"
|
|
Command="{Binding RunCommand}" Margin="0,0,0,5"
|
|
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
|
|
Command="{Binding CancelCommand}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
|
|
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
|
|
|
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.retryfailed]}"
|
|
Command="{Binding RetryFailedCommand}" Margin="0,0,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
|
Command="{Binding ExportFailedCommand}" />
|
|
</StackPanel>
|
|
|
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
|
IsReadOnly="True" CanUserSortColumns="True">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.name]}"
|
|
Binding="{Binding Record.Name}" Width="*" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.alias]}"
|
|
Binding="{Binding Record.Alias}" Width="100" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.type]}"
|
|
Binding="{Binding Record.Type}" Width="100" />
|
|
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.owners]}"
|
|
Binding="{Binding Record.Owners}" Width="*" />
|
|
<DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
|
Width="*" Foreground="Red" />
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</DockPanel>
|
|
</UserControl>
|
|
```
|
|
|
|
4. Create `BulkSitesView.xaml.cs`:
|
|
```csharp
|
|
using System.Windows.Controls;
|
|
using SharepointToolbox.Views.Dialogs;
|
|
|
|
namespace SharepointToolbox.Views.Tabs;
|
|
|
|
public partial class BulkSitesView : UserControl
|
|
{
|
|
public BulkSitesView(ViewModels.Tabs.BulkSitesViewModel viewModel)
|
|
{
|
|
InitializeComponent();
|
|
DataContext = viewModel;
|
|
|
|
viewModel.ShowConfirmDialog = message =>
|
|
{
|
|
var dlg = new ConfirmBulkOperationDialog(message)
|
|
{ Owner = System.Windows.Window.GetWindow(this) };
|
|
dlg.ShowDialog();
|
|
return dlg.IsConfirmed;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
5. Create `FolderStructureView.xaml`:
|
|
```xml
|
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
|
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
|
<DockPanel 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" />
|
|
|
|
<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" />
|
|
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.import]}"
|
|
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.example]}"
|
|
Command="{Binding LoadExampleCommand}" Margin="0,0,0,10" />
|
|
|
|
<TextBlock Text="{Binding PreviewSummary}" TextWrapping="Wrap" Margin="0,0,0,10" />
|
|
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.execute]}"
|
|
Command="{Binding RunCommand}" Margin="0,0,0,5"
|
|
IsEnabled="{Binding IsRunning, Converter={StaticResource InverseBoolConverter}}" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[status.cancelled]}"
|
|
Command="{Binding CancelCommand}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
|
|
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
|
|
|
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
|
Command="{Binding ExportFailedCommand}" />
|
|
</StackPanel>
|
|
|
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
|
IsReadOnly="True" CanUserSortColumns="True">
|
|
<DataGrid.Columns>
|
|
<DataGridTextColumn Header="Valid" Binding="{Binding IsValid}" Width="50" />
|
|
<DataGridTextColumn Header="Level 1" Binding="{Binding Record.Level1}" Width="*" />
|
|
<DataGridTextColumn Header="Level 2" Binding="{Binding Record.Level2}" Width="*" />
|
|
<DataGridTextColumn Header="Level 3" Binding="{Binding Record.Level3}" Width="*" />
|
|
<DataGridTextColumn Header="Level 4" Binding="{Binding Record.Level4}" Width="*" />
|
|
<DataGridTextColumn Header="Errors" Binding="{Binding Errors, Converter={StaticResource ListToStringConverter}}"
|
|
Width="*" Foreground="Red" />
|
|
</DataGrid.Columns>
|
|
</DataGrid>
|
|
</DockPanel>
|
|
</UserControl>
|
|
```
|
|
|
|
6. Create `FolderStructureView.xaml.cs`:
|
|
```csharp
|
|
using System.Windows.Controls;
|
|
using SharepointToolbox.Views.Dialogs;
|
|
|
|
namespace SharepointToolbox.Views.Tabs;
|
|
|
|
public partial class FolderStructureView : UserControl
|
|
{
|
|
public FolderStructureView(ViewModels.Tabs.FolderStructureViewModel viewModel)
|
|
{
|
|
InitializeComponent();
|
|
DataContext = viewModel;
|
|
|
|
viewModel.ShowConfirmDialog = message =>
|
|
{
|
|
var dlg = new ConfirmBulkOperationDialog(message)
|
|
{ Owner = System.Windows.Window.GetWindow(this) };
|
|
dlg.ShowDialog();
|
|
return dlg.IsConfirmed;
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Important:** If `ListToStringConverter` does not exist in the project, create `SharepointToolbox/Views/Converters/ListToStringConverter.cs`:
|
|
```csharp
|
|
using System.Globalization;
|
|
using System.Windows.Data;
|
|
|
|
namespace SharepointToolbox.Views.Converters;
|
|
|
|
public class ListToStringConverter : IValueConverter
|
|
{
|
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
|
{
|
|
if (value is IEnumerable<string> list)
|
|
return string.Join("; ", list);
|
|
return string.Empty;
|
|
}
|
|
|
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
|
=> throw new NotImplementedException();
|
|
}
|
|
```
|
|
|
|
Register it in `App.xaml` Application.Resources alongside existing converters. Also register `EnumBoolConverter` if needed by TransferView:
|
|
```csharp
|
|
// In App.xaml or wherever converters are registered
|
|
```
|
|
|
|
**Verify:**
|
|
```bash
|
|
dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q
|
|
```
|
|
|
|
**Done:** All three CSV tab Views + code-behind compile. Each wires ConfirmBulkOperationDialog for confirmation. DataGrid shows preview with validation indicators. Import CSV, Load Example, Execute, Retry Failed, Export Failed all connected.
|
|
|
|
**Commit:** `feat(04-09): create BulkMembers, BulkSites, and FolderStructure ViewModels and Views`
|