From 6a4cd8ab562c19e7c144bd2aeb76893e3083891b Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 8 Apr 2026 15:18:38 +0200 Subject: [PATCH] feat(12-01): add Base64ToImageSourceConverter, localization keys, and ClientLogoPreview property - Base64ToImageSourceConverter converts data URI strings to BitmapImage with null-safe error handling - Registered converter in App.xaml as Base64ToImageConverter global resource - Added 9 localization keys (EN+FR) for logo UI labels in Settings and Profile dialogs - Added ClientLogoPreview string property to ProfileManagementViewModel with FormatLogoPreview helper - Updated OnSelectedProfileChanged, BrowseClientLogoAsync, ClearClientLogoAsync, AutoPullClientLogoAsync - 17 tests pass (6 converter + 11 profile VM logo tests) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Base64ToImageSourceConverterTests.cs | 53 +++++++++++++++ .../ProfileManagementViewModelLogoTests.cs | 67 +++++++++++++++++++ SharepointToolbox/App.xaml | 1 + .../Localization/Strings.fr.resx | 10 +++ SharepointToolbox/Localization/Strings.resx | 10 +++ .../ViewModels/ProfileManagementViewModel.cs | 14 ++++ .../Base64ToImageSourceConverter.cs | 46 +++++++++++++ 7 files changed, 201 insertions(+) create mode 100644 SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs create mode 100644 SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs diff --git a/SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs b/SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs new file mode 100644 index 0000000..6817023 --- /dev/null +++ b/SharepointToolbox.Tests/Converters/Base64ToImageSourceConverterTests.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using SharepointToolbox.Views.Converters; + +namespace SharepointToolbox.Tests.Converters; + +[Trait("Category", "Unit")] +public class Base64ToImageSourceConverterTests +{ + private readonly Base64ToImageSourceConverter _converter = new(); + + [Fact] + public void Convert_NullValue_ReturnsNull() + { + var result = _converter.Convert(null, typeof(object), null, CultureInfo.InvariantCulture); + Assert.Null(result); + } + + [Fact] + public void Convert_EmptyString_ReturnsNull() + { + var result = _converter.Convert(string.Empty, typeof(object), null, CultureInfo.InvariantCulture); + Assert.Null(result); + } + + [Fact] + public void Convert_NonStringValue_ReturnsNull() + { + var result = _converter.Convert(42, typeof(object), null, CultureInfo.InvariantCulture); + Assert.Null(result); + } + + [Fact] + public void Convert_MalformedString_NoBase64Marker_ReturnsNull() + { + var result = _converter.Convert("not-a-data-uri", typeof(object), null, CultureInfo.InvariantCulture); + Assert.Null(result); + } + + [Fact] + public void Convert_InvalidBase64AfterMarker_ReturnsNull() + { + // Has the marker but invalid base64 content — should not throw + var result = _converter.Convert("data:image/png;base64,!!!invalid!!!", typeof(object), null, CultureInfo.InvariantCulture); + Assert.Null(result); + } + + [Fact] + public void ConvertBack_ThrowsNotImplementedException() + { + Assert.Throws(() => + _converter.ConvertBack(null, typeof(object), null, CultureInfo.InvariantCulture)); + } +} diff --git a/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs index 5f57a48..ee49ccf 100644 --- a/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs +++ b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs @@ -115,4 +115,71 @@ public class ProfileManagementViewModelLogoTests : IDisposable var persisted = profiles.First(p => p.Name == "TestTenant"); Assert.Null(persisted.ClientLogo); } + + [Fact] + public void ClientLogoPreview_IsNull_WhenNoProfileSelected() + { + var vm = CreateViewModel(); + Assert.Null(vm.ClientLogoPreview); + } + + [Fact] + public void ClientLogoPreview_UpdatesToDataUri_WhenProfileWithLogoSelected() + { + var vm = CreateViewModel(); + var profile = new TenantProfile + { + Name = "WithLogo", + TenantUrl = "https://test.sharepoint.com", + ClientId = "00000000-0000-0000-0000-000000000002", + ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" } + }; + + vm.SelectedProfile = profile; + + Assert.Equal("data:image/png;base64,dGVzdA==", vm.ClientLogoPreview); + } + + [Fact] + public void ClientLogoPreview_IsNull_WhenProfileWithoutLogoSelected() + { + var vm = CreateViewModel(); + var profile = new TenantProfile + { + Name = "NoLogo", + TenantUrl = "https://test.sharepoint.com", + ClientId = "00000000-0000-0000-0000-000000000003" + }; + + vm.SelectedProfile = profile; + + Assert.Null(vm.ClientLogoPreview); + } + + [Fact] + public async Task ClearClientLogoCommand_SetsClientLogoPreviewToNull() + { + var profileService = new ProfileService(new ProfileRepository(_tempFile)); + var profile = new TenantProfile + { + Name = "ClearTest", + TenantUrl = "https://test.sharepoint.com", + ClientId = "00000000-0000-0000-0000-000000000004", + ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" } + }; + await profileService.AddProfileAsync(profile); + + var vm = new ProfileManagementViewModel( + profileService, + _mockBranding.Object, + _graphClientFactory, + _logger); + + vm.SelectedProfile = profile; + Assert.NotNull(vm.ClientLogoPreview); + + await vm.ClearClientLogoCommand.ExecuteAsync(null); + + Assert.Null(vm.ClientLogoPreview); + } } diff --git a/SharepointToolbox/App.xaml b/SharepointToolbox/App.xaml index 6e556b6..e40f29a 100644 --- a/SharepointToolbox/App.xaml +++ b/SharepointToolbox/App.xaml @@ -11,6 +11,7 @@ + diff --git a/SharepointToolbox/Localization/Strings.fr.resx b/SharepointToolbox/Localization/Strings.fr.resx index 6d0f8a1..2693aa7 100644 --- a/SharepointToolbox/Localization/Strings.fr.resx +++ b/SharepointToolbox/Localization/Strings.fr.resx @@ -384,4 +384,14 @@ Graphique en barres Type de graphique : Exécutez une analyse pour voir la répartition par type de fichier. + + Logo MSP + Importer + Effacer + Aucun logo configuré + Logo client + Importer + Effacer + Importer depuis Entra + Aucun logo configuré diff --git a/SharepointToolbox/Localization/Strings.resx b/SharepointToolbox/Localization/Strings.resx index d000dcb..06383ff 100644 --- a/SharepointToolbox/Localization/Strings.resx +++ b/SharepointToolbox/Localization/Strings.resx @@ -384,4 +384,14 @@ Bar Chart Chart View: Run a storage scan to see file type breakdown. + + MSP Logo + Import + Clear + No logo configured + Client Logo + Import + Clear + Pull from Entra + No logo configured diff --git a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs index 61ed552..38b1e6b 100644 --- a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs +++ b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs @@ -32,6 +32,13 @@ public partial class ProfileManagementViewModel : ObservableObject [ObservableProperty] private string _validationMessage = string.Empty; + private string? _clientLogoPreview; + public string? ClientLogoPreview + { + get => _clientLogoPreview; + private set { _clientLogoPreview = value; OnPropertyChanged(); } + } + public ObservableCollection Profiles { get; } = new(); public IAsyncRelayCommand AddCommand { get; } @@ -81,6 +88,7 @@ public partial class ProfileManagementViewModel : ObservableObject partial void OnSelectedProfileChanged(TenantProfile? value) { + ClientLogoPreview = FormatLogoPreview(value?.ClientLogo); BrowseClientLogoCommand.NotifyCanExecuteChanged(); ClearClientLogoCommand.NotifyCanExecuteChanged(); AutoPullClientLogoCommand.NotifyCanExecuteChanged(); @@ -88,6 +96,9 @@ public partial class ProfileManagementViewModel : ObservableObject DeleteCommand.NotifyCanExecuteChanged(); } + private static string? FormatLogoPreview(LogoData? logo) + => logo is not null ? $"data:{logo.MimeType};base64,{logo.Base64}" : null; + private void NotifyCommandsCanExecuteChanged() { AddCommand.NotifyCanExecuteChanged(); @@ -172,6 +183,7 @@ public partial class ProfileManagementViewModel : ObservableObject { var logo = await _brandingService.ImportLogoAsync(dialog.FileName); SelectedProfile.ClientLogo = logo; + ClientLogoPreview = FormatLogoPreview(logo); await _profileService.UpdateProfileAsync(SelectedProfile); ValidationMessage = string.Empty; } @@ -188,6 +200,7 @@ public partial class ProfileManagementViewModel : ObservableObject try { SelectedProfile.ClientLogo = null; + ClientLogoPreview = null; await _profileService.UpdateProfileAsync(SelectedProfile); ValidationMessage = string.Empty; } @@ -229,6 +242,7 @@ public partial class ProfileManagementViewModel : ObservableObject var logo = await _brandingService.ImportLogoFromBytesAsync(bytes); SelectedProfile.ClientLogo = logo; + ClientLogoPreview = FormatLogoPreview(logo); await _profileService.UpdateProfileAsync(SelectedProfile); ValidationMessage = "Client logo pulled from Entra branding."; } diff --git a/SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs b/SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs new file mode 100644 index 0000000..b29cd02 --- /dev/null +++ b/SharepointToolbox/Views/Converters/Base64ToImageSourceConverter.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using System.IO; +using System.Windows.Data; +using System.Windows.Media.Imaging; + +namespace SharepointToolbox.Views.Converters; + +/// +/// Converts a data URI string (e.g. "data:image/png;base64,iVBOR...") to a BitmapImage +/// for use with WPF Image controls. Returns null for null, empty, or malformed input. +/// +[ValueConversion(typeof(string), typeof(BitmapImage))] +public class Base64ToImageSourceConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string dataUri || string.IsNullOrEmpty(dataUri)) + return null; + + try + { + var marker = "base64,"; + var idx = dataUri.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) return null; + + var base64 = dataUri[(idx + marker.Length)..]; + var bytes = System.Convert.FromBase64String(base64); + + var image = new BitmapImage(); + using var ms = new MemoryStream(bytes); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + image.StreamSource = ms; + image.EndInit(); + image.Freeze(); + return image; + } + catch + { + return null; + } + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +}