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 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
@@ -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('-');
}
}