chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
||||
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
|
||||
if (_validRows == null || _validRows.Count == 0)
|
||||
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
||||
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
|
||||
|
||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||
$"{_validRows.Count} members will be added");
|
||||
|
||||
@@ -105,9 +105,9 @@ public partial class BulkSitesViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
||||
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
|
||||
if (_validRows == null || _validRows.Count == 0)
|
||||
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
|
||||
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
|
||||
|
||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||
$"{_validRows.Count} sites will be created");
|
||||
|
||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -31,6 +32,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
private readonly IDuplicatesService _duplicatesService;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly DuplicatesHtmlExportService _htmlExportService;
|
||||
private readonly DuplicatesCsvExportService _csvExportService;
|
||||
private readonly IBrandingService _brandingService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
@@ -46,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
[ObservableProperty] private bool _includeSubsites;
|
||||
[ObservableProperty] private string _library = string.Empty;
|
||||
|
||||
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||
[ObservableProperty] private int _splitModeIndex;
|
||||
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||
[ObservableProperty] private int _htmlLayoutIndex;
|
||||
|
||||
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||
|
||||
private ObservableCollection<DuplicateRow> _results = new();
|
||||
public ObservableCollection<DuplicateRow> Results
|
||||
{
|
||||
@@ -55,16 +65,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
_results = value;
|
||||
OnPropertyChanged();
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||
public TenantProfile? CurrentProfile => _currentProfile;
|
||||
|
||||
public DuplicatesViewModel(
|
||||
IDuplicatesService duplicatesService,
|
||||
ISessionManager sessionManager,
|
||||
DuplicatesHtmlExportService htmlExportService,
|
||||
DuplicatesCsvExportService csvExportService,
|
||||
IBrandingService brandingService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
@@ -72,24 +85,26 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
_duplicatesService = duplicatesService;
|
||||
_sessionManager = sessionManager;
|
||||
_htmlExportService = htmlExportService;
|
||||
_csvExportService = csvExportService;
|
||||
_brandingService = brandingService;
|
||||
_logger = logger;
|
||||
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
}
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
{
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||
return;
|
||||
}
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,6 +167,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||
OnPropertyChanged(nameof(CurrentProfile));
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||
@@ -179,9 +195,28 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
branding = new ReportBranding(mspLogo, clientLogo);
|
||||
}
|
||||
|
||||
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding);
|
||||
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
|
||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
|
||||
}
|
||||
|
||||
private async Task ExportCsvAsync()
|
||||
{
|
||||
if (_lastGroups.Count == 0) return;
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Export duplicates report to CSV",
|
||||
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
||||
DefaultExt = "csv",
|
||||
FileName = "duplicates_report"
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
|
||||
var T = TranslationSource.Instance;
|
||||
if (_currentProfile == null) throw new InvalidOperationException(T["err.no_tenant"]);
|
||||
if (_validRows == null || _validRows.Count == 0)
|
||||
throw new InvalidOperationException("No valid rows. Import a CSV first.");
|
||||
throw new InvalidOperationException(T["err.no_valid_rows"]);
|
||||
if (string.IsNullOrWhiteSpace(LibraryTitle))
|
||||
throw new InvalidOperationException("Library title is required.");
|
||||
throw new InvalidOperationException(T["err.library_title_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.");
|
||||
throw new InvalidOperationException(T["err.no_sites_selected"]);
|
||||
|
||||
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
|
||||
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Messages;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Services.Export;
|
||||
@@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _isDetailView = true;
|
||||
|
||||
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||
[ObservableProperty] private int _splitModeIndex;
|
||||
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||
[ObservableProperty] private int _htmlLayoutIndex;
|
||||
|
||||
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||
|
||||
/// <summary>
|
||||
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
||||
/// </summary>
|
||||
@@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,6 +317,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
/// Derives the tenant admin URL from a standard tenant URL.
|
||||
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
|
||||
/// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr
|
||||
/// Falls back to scheme+host for root-collection URLs.
|
||||
/// </summary>
|
||||
internal static string DeriveSiteCollectionUrl(string objectUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
|
||||
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
|
||||
return objectUrl.TrimEnd('/');
|
||||
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Managed paths: /sites/<name> or /teams/<name>
|
||||
if (segments.Length >= 2 &&
|
||||
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
|
||||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return $"{baseUrl}/{segments[0]}/{segments[1]}";
|
||||
}
|
||||
|
||||
// Root site collection
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
internal static string DeriveAdminUrl(string tenantUrl)
|
||||
{
|
||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||
@@ -374,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
try
|
||||
{
|
||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
else
|
||||
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -408,36 +443,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
|
||||
if (_groupResolver != null && Results.Count > 0)
|
||||
if (_groupResolver != null && Results.Count > 0 && _currentProfile != null)
|
||||
{
|
||||
var groupNames = Results
|
||||
// SharePoint groups live per site collection. Bucket each group
|
||||
// by the site it was observed on, then resolve against that
|
||||
// site's context. Using the root tenant ctx for a group that
|
||||
// lives on a sub-site makes CSOM fail with "Group not found".
|
||||
var groupsBySite = Results
|
||||
.Where(r => r.PrincipalType == "SharePointGroup")
|
||||
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(n => n.Trim())
|
||||
.Where(n => n.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(r => r.Users
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
|
||||
.Where(x => x.GroupName.Length > 0)
|
||||
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (groupNames.Count > 0 && _currentProfile != null)
|
||||
if (groupsBySite.Count > 0)
|
||||
{
|
||||
try
|
||||
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var bucket in groupsBySite)
|
||||
{
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
_currentProfile, CancellationToken.None);
|
||||
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
||||
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
||||
var distinctNames = bucket
|
||||
.Select(x => x.GroupName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = bucket.Key,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
siteProfile, CancellationToken.None);
|
||||
var resolved = await _groupResolver.ResolveGroupsAsync(
|
||||
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
|
||||
foreach (var kv in resolved)
|
||||
merged[kv.Key] = kv.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Group resolution failed for {Site} — continuing without member expansion.",
|
||||
bucket.Key);
|
||||
}
|
||||
}
|
||||
|
||||
groupMembers = merged;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
else
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
{
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||
return;
|
||||
}
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
private readonly IBrandingService _brandingService;
|
||||
private readonly ThemeManager _themeManager;
|
||||
|
||||
private string _selectedLanguage = "en";
|
||||
public string SelectedLanguage
|
||||
@@ -39,6 +40,19 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedTheme = "System";
|
||||
public string SelectedTheme
|
||||
{
|
||||
get => _selectedTheme;
|
||||
set
|
||||
{
|
||||
if (_selectedTheme == value) return;
|
||||
_selectedTheme = value;
|
||||
OnPropertyChanged();
|
||||
_ = ApplyThemeAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _autoTakeOwnership;
|
||||
public bool AutoTakeOwnership
|
||||
{
|
||||
@@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
||||
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
|
||||
public IAsyncRelayCommand ClearMspLogoCommand { get; }
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger)
|
||||
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger<FeatureViewModelBase> logger)
|
||||
: base(logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_brandingService = brandingService;
|
||||
_themeManager = themeManager;
|
||||
BrowseFolderCommand = new RelayCommand(BrowseFolder);
|
||||
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
|
||||
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
|
||||
@@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
||||
_selectedLanguage = settings.Lang;
|
||||
_dataFolder = settings.DataFolder;
|
||||
_autoTakeOwnership = settings.AutoTakeOwnership;
|
||||
_selectedTheme = settings.Theme;
|
||||
OnPropertyChanged(nameof(SelectedLanguage));
|
||||
OnPropertyChanged(nameof(DataFolder));
|
||||
OnPropertyChanged(nameof(AutoTakeOwnership));
|
||||
OnPropertyChanged(nameof(SelectedTheme));
|
||||
|
||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
|
||||
}
|
||||
|
||||
private async Task ApplyThemeAsync(string mode)
|
||||
{
|
||||
try
|
||||
{
|
||||
_themeManager.ApplyFromString(mode);
|
||||
await _settingsService.SetThemeAsync(mode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyLanguageAsync(string code)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Services.Export;
|
||||
using SkiaSharp;
|
||||
@@ -22,6 +23,9 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private readonly StorageCsvExportService _csvExportService;
|
||||
private readonly StorageHtmlExportService _htmlExportService;
|
||||
private readonly IBrandingService? _brandingService;
|
||||
private readonly IOwnershipElevationService? _ownershipService;
|
||||
private readonly SettingsService? _settingsService;
|
||||
private readonly ThemeManager? _themeManager;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
|
||||
@@ -37,6 +41,14 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _isDonutChart = true;
|
||||
|
||||
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||
[ObservableProperty] private int _splitModeIndex;
|
||||
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||
[ObservableProperty] private int _htmlLayoutIndex;
|
||||
|
||||
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||
|
||||
private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
|
||||
public ObservableCollection<FileTypeMetric> FileTypeMetrics
|
||||
{
|
||||
@@ -79,6 +91,15 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
private set { _barYAxes = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
// Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference
|
||||
// into their geometry on first Initialize() and never re-read the chart's paint
|
||||
// properties. Replacing instances on theme change has no effect — we mutate
|
||||
// .Color in place so the new theme color renders on the next frame.
|
||||
public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor));
|
||||
public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor));
|
||||
public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor));
|
||||
public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor));
|
||||
|
||||
public bool IsMaxDepth
|
||||
{
|
||||
get => FolderDepth >= 999;
|
||||
@@ -136,7 +157,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
StorageCsvExportService csvExportService,
|
||||
StorageHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> logger,
|
||||
IOwnershipElevationService? ownershipService = null,
|
||||
SettingsService? settingsService = null,
|
||||
ThemeManager? themeManager = null)
|
||||
: base(logger)
|
||||
{
|
||||
_storageService = storageService;
|
||||
@@ -144,10 +168,17 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
_csvExportService = csvExportService;
|
||||
_htmlExportService = htmlExportService;
|
||||
_brandingService = brandingService;
|
||||
_ownershipService = ownershipService;
|
||||
_settingsService = settingsService;
|
||||
_themeManager = themeManager;
|
||||
_logger = logger;
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||
|
||||
ApplyChartThemeColors();
|
||||
if (_themeManager is not null)
|
||||
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
||||
}
|
||||
|
||||
/// <summary>Test constructor — omits export services.</summary>
|
||||
@@ -173,14 +204,14 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
{
|
||||
if (_currentProfile == null)
|
||||
{
|
||||
StatusMessage = "No tenant selected. Please connect to a tenant first.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
|
||||
return;
|
||||
}
|
||||
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,6 +225,8 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
var allNodes = new List<StorageNode>();
|
||||
var allTypeMetrics = new List<FileTypeMetric>();
|
||||
|
||||
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
||||
|
||||
int i = 0;
|
||||
foreach (var url in nonEmpty)
|
||||
{
|
||||
@@ -207,9 +240,30 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
|
||||
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
IReadOnlyList<StorageNode> nodes;
|
||||
try
|
||||
{
|
||||
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
}
|
||||
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
|
||||
{
|
||||
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
|
||||
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
|
||||
var adminProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = adminUrl,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
|
||||
|
||||
ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
||||
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
|
||||
}
|
||||
|
||||
// Backfill any libraries where StorageMetrics returned zeros
|
||||
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
||||
@@ -258,6 +312,24 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
private async Task<bool> IsAutoTakeOwnershipEnabled()
|
||||
{
|
||||
if (_settingsService == null) return false;
|
||||
var settings = await _settingsService.GetSettingsAsync();
|
||||
return settings.AutoTakeOwnership;
|
||||
}
|
||||
|
||||
internal static string DeriveAdminUrl(string tenantUrl)
|
||||
{
|
||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||
var host = uri.Host;
|
||||
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||
return tenantUrl;
|
||||
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
return $"{uri.Scheme}://{adminHost}";
|
||||
}
|
||||
|
||||
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||
|
||||
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
@@ -278,7 +350,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -309,7 +381,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
branding = new ReportBranding(mspLogo, clientLogo);
|
||||
}
|
||||
|
||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding);
|
||||
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -324,11 +396,25 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
UpdateChartSeries();
|
||||
}
|
||||
|
||||
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
|
||||
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
|
||||
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
|
||||
|
||||
private void ApplyChartThemeColors()
|
||||
{
|
||||
LegendTextPaint.Color = ChartFgColor;
|
||||
LegendBackgroundPaint.Color = ChartSurfaceColor;
|
||||
TooltipTextPaint.Color = ChartFgColor;
|
||||
TooltipBackgroundPaint.Color = ChartSurfaceColor;
|
||||
}
|
||||
|
||||
private void UpdateChartSeries()
|
||||
{
|
||||
var metrics = FileTypeMetrics.ToList();
|
||||
OnPropertyChanged(nameof(HasChartData));
|
||||
|
||||
ApplyChartThemeColors();
|
||||
|
||||
if (metrics.Count == 0)
|
||||
{
|
||||
PieChartSeries = Enumerable.Empty<ISeries>();
|
||||
@@ -361,6 +447,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
HoverPushout = 8,
|
||||
MaxRadialColumnWidth = 60,
|
||||
DataLabelsFormatter = _ => m.DisplayLabel,
|
||||
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
|
||||
ToolTipLabelFormatter = _ =>
|
||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
||||
IsVisibleAtLegend = true,
|
||||
@@ -379,7 +466,8 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
{
|
||||
int idx = (int)point.Index;
|
||||
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
|
||||
}
|
||||
},
|
||||
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -388,7 +476,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
new Axis
|
||||
{
|
||||
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
|
||||
LabelsRotation = -45
|
||||
LabelsRotation = -45,
|
||||
LabelsPaint = new SolidColorPaint(ChartFgColor),
|
||||
TicksPaint = new SolidColorPaint(ChartFgColor),
|
||||
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -396,7 +487,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
{
|
||||
new Axis
|
||||
{
|
||||
Labeler = value => FormatBytes((long)value)
|
||||
Labeler = value => FormatBytes((long)value),
|
||||
LabelsPaint = new SolidColorPaint(ChartFgColor),
|
||||
TicksPaint = new SolidColorPaint(ChartFgColor),
|
||||
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Serilog;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Infrastructure.Persistence;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
|
||||
namespace SharepointToolbox.ViewModels.Tabs;
|
||||
@@ -39,6 +40,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
// Apply options
|
||||
[ObservableProperty] private string _newSiteTitle = string.Empty;
|
||||
[ObservableProperty] private string _newSiteAlias = string.Empty;
|
||||
private bool _aliasManuallyEdited;
|
||||
|
||||
public IAsyncRelayCommand CaptureCommand { get; }
|
||||
public IAsyncRelayCommand ApplyCommand { get; }
|
||||
@@ -78,19 +80,20 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
|
||||
private async Task CaptureAsync()
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
if (_currentProfile == null)
|
||||
throw new InvalidOperationException("No tenant connected.");
|
||||
throw new InvalidOperationException(T["err.no_tenant"]);
|
||||
if (string.IsNullOrWhiteSpace(TemplateName))
|
||||
throw new InvalidOperationException("Template name is required.");
|
||||
throw new InvalidOperationException(T["err.template_name_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.");
|
||||
throw new InvalidOperationException(T["err.no_sites_selected"]);
|
||||
|
||||
try
|
||||
{
|
||||
IsRunning = true;
|
||||
StatusMessage = "Capturing template...";
|
||||
StatusMessage = T["templates.status.capturing"];
|
||||
|
||||
var profile = new TenantProfile
|
||||
{
|
||||
@@ -117,11 +120,11 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
||||
|
||||
await RefreshListAsync();
|
||||
StatusMessage = $"Template captured successfully.";
|
||||
StatusMessage = T["templates.status.success"];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Capture failed: {ex.Message}";
|
||||
StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message);
|
||||
Log.Error(ex, "Template capture failed");
|
||||
}
|
||||
finally
|
||||
@@ -133,15 +136,23 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
private async Task ApplyAsync()
|
||||
{
|
||||
if (_currentProfile == null || SelectedTemplate == null) return;
|
||||
var T = TranslationSource.Instance;
|
||||
if (string.IsNullOrWhiteSpace(NewSiteTitle))
|
||||
throw new InvalidOperationException("New site title is required.");
|
||||
throw new InvalidOperationException(T["err.site_title_required"]);
|
||||
|
||||
// Auto-fill alias from title if user left it blank
|
||||
if (string.IsNullOrWhiteSpace(NewSiteAlias))
|
||||
throw new InvalidOperationException("New site alias is required.");
|
||||
{
|
||||
var generated = GenerateAliasFromTitle(NewSiteTitle);
|
||||
if (string.IsNullOrWhiteSpace(generated))
|
||||
throw new InvalidOperationException(T["err.site_alias_required"]);
|
||||
NewSiteAlias = generated;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsRunning = true;
|
||||
StatusMessage = $"Applying template...";
|
||||
StatusMessage = T["templates.status.applying"];
|
||||
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
|
||||
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
||||
@@ -150,12 +161,12 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
|
||||
progress, CancellationToken.None);
|
||||
|
||||
StatusMessage = $"Template applied. Site created at: {siteUrl}";
|
||||
StatusMessage = string.Format(T["templates.status.applied"], siteUrl);
|
||||
Log.Information("Template applied. New site: {Url}", siteUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Apply failed: {ex.Message}";
|
||||
StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message);
|
||||
Log.Error(ex, "Template apply failed");
|
||||
}
|
||||
finally
|
||||
@@ -204,6 +215,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
TemplateName = string.Empty;
|
||||
NewSiteTitle = string.Empty;
|
||||
NewSiteAlias = string.Empty;
|
||||
_aliasManuallyEdited = false;
|
||||
StatusMessage = string.Empty;
|
||||
|
||||
_ = RefreshListAsync();
|
||||
@@ -215,4 +227,44 @@ public partial class TemplatesViewModel : FeatureViewModelBase
|
||||
RenameCommand.NotifyCanExecuteChanged();
|
||||
DeleteCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnNewSiteTitleChanged(string value)
|
||||
{
|
||||
if (_aliasManuallyEdited) return;
|
||||
var generated = GenerateAliasFromTitle(value);
|
||||
if (NewSiteAlias != generated)
|
||||
{
|
||||
// Bypass user-edit flag while we sync alias to title
|
||||
_suppressAliasEditedFlag = true;
|
||||
NewSiteAlias = generated;
|
||||
_suppressAliasEditedFlag = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool _suppressAliasEditedFlag;
|
||||
|
||||
partial void OnNewSiteAliasChanged(string value)
|
||||
{
|
||||
if (_suppressAliasEditedFlag) return;
|
||||
_aliasManuallyEdited = !string.IsNullOrWhiteSpace(value)
|
||||
&& value != GenerateAliasFromTitle(NewSiteTitle);
|
||||
if (string.IsNullOrWhiteSpace(value)) _aliasManuallyEdited = false;
|
||||
}
|
||||
|
||||
internal static string GenerateAliasFromTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return string.Empty;
|
||||
|
||||
var normalized = title.Normalize(System.Text.NormalizationForm.FormD);
|
||||
var sb = new System.Text.StringBuilder(normalized.Length);
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var cat = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (cat == System.Globalization.UnicodeCategory.NonSpacingMark) continue;
|
||||
if (char.IsLetterOrDigit(c)) sb.Append(c);
|
||||
else if (c == ' ' || c == '-' || c == '_') sb.Append('-');
|
||||
}
|
||||
var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "-+", "-");
|
||||
return collapsed.Trim('-');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using Serilog;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -15,6 +16,8 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
private readonly IFileTransferService _transferService;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly BulkResultCsvExportService _exportService;
|
||||
private readonly IOwnershipElevationService? _ownershipService;
|
||||
private readonly SettingsService? _settingsService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
private bool _hasLocalSourceSiteOverride;
|
||||
@@ -32,6 +35,17 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
// Transfer options
|
||||
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
|
||||
[ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;
|
||||
[ObservableProperty] private bool _includeSourceFolder;
|
||||
[ObservableProperty] private bool _copyFolderContents = true;
|
||||
|
||||
/// <summary>
|
||||
/// Library-relative file paths the user checked in the source picker.
|
||||
/// When non-empty, only these files are transferred — folder recursion is skipped.
|
||||
/// </summary>
|
||||
public List<string> SelectedFilePaths { get; } = new();
|
||||
|
||||
/// <summary>Count of per-file selections, for display in the view.</summary>
|
||||
public int SelectedFileCount => SelectedFilePaths.Count;
|
||||
|
||||
// Results
|
||||
[ObservableProperty] private string _resultSummary = string.Empty;
|
||||
@@ -51,12 +65,16 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
IFileTransferService transferService,
|
||||
ISessionManager sessionManager,
|
||||
BulkResultCsvExportService exportService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> logger,
|
||||
IOwnershipElevationService? ownershipService = null,
|
||||
SettingsService? settingsService = null)
|
||||
: base(logger)
|
||||
{
|
||||
_transferService = transferService;
|
||||
_sessionManager = sessionManager;
|
||||
_exportService = exportService;
|
||||
_ownershipService = ownershipService;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
|
||||
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
||||
@@ -84,14 +102,15 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
var T = TranslationSource.Instance;
|
||||
if (_currentProfile == null)
|
||||
throw new InvalidOperationException("No tenant connected.");
|
||||
throw new InvalidOperationException(T["err.no_tenant"]);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
|
||||
throw new InvalidOperationException("Source site and library must be selected.");
|
||||
throw new InvalidOperationException(T["err.transfer_source_required"]);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
|
||||
throw new InvalidOperationException("Destination site and library must be selected.");
|
||||
throw new InvalidOperationException(T["err.transfer_dest_required"]);
|
||||
|
||||
// Confirmation dialog
|
||||
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
|
||||
@@ -108,6 +127,9 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
DestinationFolderPath = DestFolderPath,
|
||||
Mode = TransferMode,
|
||||
ConflictPolicy = ConflictPolicy,
|
||||
SelectedFilePaths = SelectedFilePaths.ToList(),
|
||||
IncludeSourceFolder = IncludeSourceFolder,
|
||||
CopyFolderContents = CopyFolderContents,
|
||||
};
|
||||
|
||||
// Build per-site profiles so SessionManager can resolve contexts
|
||||
@@ -127,7 +149,33 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
|
||||
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
|
||||
|
||||
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
|
||||
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
||||
|
||||
try
|
||||
{
|
||||
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
|
||||
}
|
||||
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException ex)
|
||||
when (_ownershipService != null && autoOwnership)
|
||||
{
|
||||
_logger.LogWarning(ex, "Transfer hit access denied — auto-elevating on source and destination.");
|
||||
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? SourceSiteUrl);
|
||||
var adminProfile = new TenantProfile
|
||||
{
|
||||
Name = _currentProfile.Name,
|
||||
TenantUrl = adminUrl,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
};
|
||||
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||
|
||||
await _ownershipService.ElevateAsync(adminCtx, SourceSiteUrl, string.Empty, ct);
|
||||
await _ownershipService.ElevateAsync(adminCtx, DestSiteUrl, string.Empty, ct);
|
||||
|
||||
// Retry with fresh contexts so the new admin membership is honoured.
|
||||
srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
|
||||
dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
|
||||
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
|
||||
}
|
||||
|
||||
// Update UI on dispatcher
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
@@ -182,6 +230,34 @@ public partial class TransferViewModel : FeatureViewModelBase
|
||||
DestFolderPath = string.Empty;
|
||||
ResultSummary = string.Empty;
|
||||
HasFailures = false;
|
||||
SelectedFilePaths.Clear();
|
||||
OnPropertyChanged(nameof(SelectedFileCount));
|
||||
_lastResult = null;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the current per-file selection and notifies the view.</summary>
|
||||
public void SetSelectedFiles(IEnumerable<string> libraryRelativePaths)
|
||||
{
|
||||
SelectedFilePaths.Clear();
|
||||
SelectedFilePaths.AddRange(libraryRelativePaths);
|
||||
OnPropertyChanged(nameof(SelectedFileCount));
|
||||
}
|
||||
|
||||
private async Task<bool> IsAutoTakeOwnershipEnabled()
|
||||
{
|
||||
if (_settingsService == null) return false;
|
||||
var settings = await _settingsService.GetSettingsAsync();
|
||||
return settings.AutoTakeOwnership;
|
||||
}
|
||||
|
||||
internal static string DeriveAdminUrl(string tenantUrl)
|
||||
{
|
||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||
var host = uri.Host;
|
||||
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||
return tenantUrl;
|
||||
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
return $"{uri.Scheme}://{adminHost}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Services.Export;
|
||||
|
||||
@@ -27,6 +28,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
private readonly UserAccessHtmlExportService? _htmlExportService;
|
||||
private readonly IBrandingService? _brandingService;
|
||||
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
|
||||
private readonly IOwnershipElevationService? _ownershipService;
|
||||
private readonly SettingsService? _settingsService;
|
||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||
|
||||
// ── People picker debounce ──────────────────────────────────────────────
|
||||
@@ -105,6 +108,19 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _mergePermissions;
|
||||
|
||||
/// <summary>0 = Single file, 1 = Split by site, 2 = Split by user.</summary>
|
||||
[ObservableProperty] private int _splitModeIndex;
|
||||
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||
[ObservableProperty] private int _htmlLayoutIndex;
|
||||
|
||||
private ReportSplitMode CurrentSplit => SplitModeIndex switch
|
||||
{
|
||||
1 => ReportSplitMode.BySite,
|
||||
2 => ReportSplitMode.ByUser,
|
||||
_ => ReportSplitMode.Single
|
||||
};
|
||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||
|
||||
private CancellationTokenSource? _directoryCts = null;
|
||||
|
||||
// ── Computed summary properties ─────────────────────────────────────────
|
||||
@@ -163,7 +179,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
UserAccessHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
IGraphUserDirectoryService graphUserDirectoryService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> logger,
|
||||
IOwnershipElevationService? ownershipService = null,
|
||||
SettingsService? settingsService = null)
|
||||
: base(logger)
|
||||
{
|
||||
_auditService = auditService;
|
||||
@@ -173,6 +191,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
_htmlExportService = htmlExportService;
|
||||
_brandingService = brandingService;
|
||||
_graphUserDirectoryService = graphUserDirectoryService;
|
||||
_ownershipService = ownershipService;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
|
||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||
@@ -248,13 +268,13 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
{
|
||||
if (SelectedUsers.Count == 0)
|
||||
{
|
||||
StatusMessage = "Add at least one user to audit.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_users_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
if (GlobalSites.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,10 +289,39 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
|
||||
if (_currentProfile == null)
|
||||
{
|
||||
StatusMessage = "No tenant profile selected. Please connect first.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
||||
|
||||
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null;
|
||||
if (_ownershipService != null && autoOwnership)
|
||||
{
|
||||
onAccessDenied = async (siteUrl, token) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", siteUrl);
|
||||
var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? siteUrl);
|
||||
var adminProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = adminUrl,
|
||||
ClientId = _currentProfile?.ClientId ?? string.Empty,
|
||||
Name = _currentProfile?.Name ?? string.Empty
|
||||
};
|
||||
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, token);
|
||||
await _ownershipService.ElevateAsync(adminCtx, siteUrl, string.Empty, token);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Auto-elevation failed for {Url}", siteUrl);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var entries = await _auditService.AuditUsersAsync(
|
||||
_sessionManager,
|
||||
_currentProfile,
|
||||
@@ -280,7 +329,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
effectiveSites,
|
||||
scanOptions,
|
||||
progress,
|
||||
ct);
|
||||
ct,
|
||||
onAccessDenied);
|
||||
|
||||
// Update Results on the UI thread — clear + repopulate (not replace)
|
||||
// so the CollectionViewSource bound to ResultsView stays connected.
|
||||
@@ -307,6 +357,26 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
// ── Auto-ownership helpers ───────────────────────────────────────────────
|
||||
|
||||
private async Task<bool> IsAutoTakeOwnershipEnabled()
|
||||
{
|
||||
if (_settingsService == null) return false;
|
||||
var settings = await _settingsService.GetSettingsAsync();
|
||||
return settings.AutoTakeOwnership;
|
||||
}
|
||||
|
||||
internal static string DeriveAdminUrl(string tenantUrl)
|
||||
{
|
||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||
var host = uri.Host;
|
||||
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
||||
return tenantUrl;
|
||||
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
return $"{uri.Scheme}://{adminHost}";
|
||||
}
|
||||
|
||||
// ── Tenant switching ─────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnTenantSwitched(TenantProfile profile)
|
||||
@@ -402,7 +472,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
var clientId = _currentProfile?.ClientId;
|
||||
if (string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
StatusMessage = "No tenant profile selected. Please connect first.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -496,7 +566,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
try
|
||||
{
|
||||
await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions);
|
||||
await _csvExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None, MergePermissions);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -527,7 +597,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
branding = new ReportBranding(mspLogo, clientLogo);
|
||||
}
|
||||
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding);
|
||||
await _htmlExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, MergePermissions, branding);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user