12dd1de9f2
- 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>
271 lines
9.8 KiB
C#
271 lines
9.8 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.Logging;
|
|
using Serilog;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Infrastructure.Persistence;
|
|
using SharepointToolbox.Localization;
|
|
using SharepointToolbox.Services;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
public partial class TemplatesViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly ITemplateService _templateService;
|
|
private readonly TemplateRepository _templateRepo;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
private TenantProfile? _currentProfile;
|
|
|
|
// Template list
|
|
private ObservableCollection<SiteTemplate> _templates = new();
|
|
public ObservableCollection<SiteTemplate> Templates
|
|
{
|
|
get => _templates;
|
|
private set { _templates = value; OnPropertyChanged(); }
|
|
}
|
|
|
|
[ObservableProperty] private SiteTemplate? _selectedTemplate;
|
|
|
|
// Capture options
|
|
[ObservableProperty] private string _templateName = string.Empty;
|
|
[ObservableProperty] private bool _captureLibraries = true;
|
|
[ObservableProperty] private bool _captureFolders = true;
|
|
[ObservableProperty] private bool _capturePermissions = true;
|
|
[ObservableProperty] private bool _captureLogo = true;
|
|
[ObservableProperty] private bool _captureSettings = true;
|
|
|
|
// 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; }
|
|
public IAsyncRelayCommand RenameCommand { get; }
|
|
public IAsyncRelayCommand DeleteCommand { get; }
|
|
public IAsyncRelayCommand RefreshCommand { get; }
|
|
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
// Factory for rename dialog — set by View code-behind
|
|
public Func<string, string?>? RenameDialogFactory { get; set; }
|
|
|
|
public TemplatesViewModel(
|
|
ITemplateService templateService,
|
|
TemplateRepository templateRepo,
|
|
ISessionManager sessionManager,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_templateService = templateService;
|
|
_templateRepo = templateRepo;
|
|
_sessionManager = sessionManager;
|
|
_logger = logger;
|
|
|
|
CaptureCommand = new AsyncRelayCommand(CaptureAsync, () => !IsRunning);
|
|
ApplyCommand = new AsyncRelayCommand(ApplyAsync, () => !IsRunning && SelectedTemplate != null);
|
|
RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedTemplate != null);
|
|
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedTemplate != null);
|
|
RefreshCommand = new AsyncRelayCommand(RefreshListAsync);
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
// Not used directly — Capture and Apply have their own async commands
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
private async Task CaptureAsync()
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
if (_currentProfile == null)
|
|
throw new InvalidOperationException(T["err.no_tenant"]);
|
|
if (string.IsNullOrWhiteSpace(TemplateName))
|
|
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(T["err.no_sites_selected"]);
|
|
|
|
try
|
|
{
|
|
IsRunning = true;
|
|
StatusMessage = T["templates.status.capturing"];
|
|
|
|
var profile = new TenantProfile
|
|
{
|
|
Name = _currentProfile.Name,
|
|
TenantUrl = captureSiteUrl,
|
|
ClientId = _currentProfile.ClientId,
|
|
};
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
|
|
|
var options = new SiteTemplateOptions
|
|
{
|
|
CaptureLibraries = CaptureLibraries,
|
|
CaptureFolders = CaptureFolders,
|
|
CapturePermissionGroups = CapturePermissions,
|
|
CaptureLogo = CaptureLogo,
|
|
CaptureSettings = CaptureSettings,
|
|
};
|
|
|
|
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
|
var template = await _templateService.CaptureTemplateAsync(ctx, options, progress, CancellationToken.None);
|
|
template.Name = TemplateName;
|
|
|
|
await _templateRepo.SaveAsync(template);
|
|
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
|
|
|
|
await RefreshListAsync();
|
|
StatusMessage = T["templates.status.success"];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message);
|
|
Log.Error(ex, "Template capture failed");
|
|
}
|
|
finally
|
|
{
|
|
IsRunning = false;
|
|
}
|
|
}
|
|
|
|
private async Task ApplyAsync()
|
|
{
|
|
if (_currentProfile == null || SelectedTemplate == null) return;
|
|
var T = TranslationSource.Instance;
|
|
if (string.IsNullOrWhiteSpace(NewSiteTitle))
|
|
throw new InvalidOperationException(T["err.site_title_required"]);
|
|
|
|
// Auto-fill alias from title if user left it blank
|
|
if (string.IsNullOrWhiteSpace(NewSiteAlias))
|
|
{
|
|
var generated = GenerateAliasFromTitle(NewSiteTitle);
|
|
if (string.IsNullOrWhiteSpace(generated))
|
|
throw new InvalidOperationException(T["err.site_alias_required"]);
|
|
NewSiteAlias = generated;
|
|
}
|
|
|
|
try
|
|
{
|
|
IsRunning = true;
|
|
StatusMessage = T["templates.status.applying"];
|
|
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
|
|
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
|
|
|
var siteUrl = await _templateService.ApplyTemplateAsync(
|
|
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
|
|
progress, CancellationToken.None);
|
|
|
|
StatusMessage = string.Format(T["templates.status.applied"], siteUrl);
|
|
Log.Information("Template applied. New site: {Url}", siteUrl);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message);
|
|
Log.Error(ex, "Template apply failed");
|
|
}
|
|
finally
|
|
{
|
|
IsRunning = false;
|
|
}
|
|
}
|
|
|
|
private async Task RenameAsync()
|
|
{
|
|
if (SelectedTemplate == null) return;
|
|
|
|
if (RenameDialogFactory != null)
|
|
{
|
|
var newName = RenameDialogFactory(SelectedTemplate.Name);
|
|
if (!string.IsNullOrWhiteSpace(newName))
|
|
{
|
|
await _templateRepo.RenameAsync(SelectedTemplate.Id, newName);
|
|
await RefreshListAsync();
|
|
Log.Information("Template renamed.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task DeleteAsync()
|
|
{
|
|
if (SelectedTemplate == null) return;
|
|
|
|
await _templateRepo.DeleteAsync(SelectedTemplate.Id);
|
|
await RefreshListAsync();
|
|
Log.Information("Template deleted.");
|
|
}
|
|
|
|
private async Task RefreshListAsync()
|
|
{
|
|
var templates = await _templateRepo.GetAllAsync();
|
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
{
|
|
Templates = new ObservableCollection<SiteTemplate>(templates);
|
|
});
|
|
}
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
TemplateName = string.Empty;
|
|
NewSiteTitle = string.Empty;
|
|
NewSiteAlias = string.Empty;
|
|
_aliasManuallyEdited = false;
|
|
StatusMessage = string.Empty;
|
|
|
|
_ = RefreshListAsync();
|
|
}
|
|
|
|
partial void OnSelectedTemplateChanged(SiteTemplate? value)
|
|
{
|
|
ApplyCommand.NotifyCanExecuteChanged();
|
|
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('-');
|
|
}
|
|
}
|