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:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit f4cc81bb71
64 changed files with 3315 additions and 405 deletions
@@ -22,6 +22,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;
@@ -136,7 +139,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 +150,16 @@ 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);
if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
}
/// <summary>Test constructor — omits export services.</summary>
@@ -194,6 +206,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 +221,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 +293,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)
@@ -324,6 +377,9 @@ 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 void UpdateChartSeries()
{
var metrics = FileTypeMetrics.ToList();
@@ -361,6 +417,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 +436,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 +446,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 +457,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)
}
};
}