3 Commits

Author SHA1 Message Date
Dev f4cc81bb71 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>
2026-04-20 11:23:11 +02:00
Dev 8f30a60d2a Merge branch 'main' of https://git.azuze.fr/kawa/Sharepoint-Toolbox 2026-04-15 14:27:36 +02:00
kawa 6e05d26114 Update README.md 2026-04-15 14:27:31 +02:00
66 changed files with 3360 additions and 410 deletions
@@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
{
var vm = new TestViewModel();
int midProgress = -1;
string? midStatus = null;
vm.OperationFunc = async (ct, progress) =>
{
progress.Report(new OperationProgress(50, 100, "halfway"));
await Task.Yield();
// Let the Progress<T> callback dispatch before sampling.
await Task.Delay(20, ct);
midProgress = vm.ProgressValue;
midStatus = vm.StatusMessage;
};
await vm.RunCommand.ExecuteAsync(null);
// Allow dispatcher to process
await Task.Delay(20);
Assert.Equal(50, vm.ProgressValue);
Assert.Equal("halfway", vm.StatusMessage);
// Mid-operation snapshot confirms IProgress reaches bound properties.
// Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
// so stale "Scanning X" labels don't linger after a successful run.
Assert.Equal(50, midProgress);
Assert.Equal("halfway", midStatus);
}
[Fact]
@@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
return new SettingsViewModel(settingsService, mockBranding, logger);
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
}
[Fact]
@@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
var mockBranding = new Mock<IBrandingService>().Object;
var logger = NullLogger<FeatureViewModelBase>.Instance;
var vm = new SettingsViewModel(settingsService, mockBranding, logger);
var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
await vm.LoadAsync();
vm.AutoTakeOwnership = true;
+7
View File
@@ -4,6 +4,11 @@
xmlns:local="clr-namespace:SharepointToolbox"
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Themes/LightPalette.xaml" />
<ResourceDictionary Source="/Themes/ModernTheme.xaml" />
</ResourceDictionary.MergedDictionaries>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<conv:IndentConverter x:Key="IndentConverter" />
<conv:BytesConverter x:Key="BytesConverter" />
@@ -14,6 +19,8 @@
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>
+27
View File
@@ -34,9 +34,34 @@ public partial class App : Application
.Build();
host.Start();
// Apply persisted language before any UI is created so bindings resolve to the saved culture.
try
{
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
if (!string.IsNullOrWhiteSpace(settings.Lang))
Localization.TranslationSource.Instance.CurrentCulture = new System.Globalization.CultureInfo(settings.Lang);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted language at startup");
}
App app = new();
app.InitializeComponent();
// Apply persisted theme (System/Light/Dark) before MainWindow constructs so brushes resolve correctly.
try
{
var theme = host.Services.GetRequiredService<ThemeManager>();
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
theme.ApplyFromString(settings.Theme);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to apply persisted theme at startup");
}
var mainWindow = host.Services.GetRequiredService<MainWindow>();
// Wire LogPanelSink now that we have the RichTextBox
@@ -88,6 +113,7 @@ public partial class App : Application
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>();
services.AddSingleton<ThemeManager>();
services.AddSingleton<MainWindowViewModel>();
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
services.AddTransient<ProfileManagementViewModel>();
@@ -112,6 +138,7 @@ public partial class App : Application
// Phase 3: Duplicates
services.AddTransient<IDuplicatesService, DuplicatesService>();
services.AddTransient<DuplicatesHtmlExportService>();
services.AddTransient<DuplicatesCsvExportService>();
services.AddTransient<DuplicatesViewModel>();
services.AddTransient<DuplicatesView>();
@@ -5,4 +5,5 @@ public class AppSettings
public string DataFolder { get; set; } = string.Empty;
public string Lang { get; set; } = "en";
public bool AutoTakeOwnership { get; set; } = false;
public string Theme { get; set; } = "System"; // System | Light | Dark
}
@@ -1,7 +1,7 @@
namespace SharepointToolbox.Core.Models;
public record OperationProgress(int Current, int Total, string Message)
public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false)
{
public static OperationProgress Indeterminate(string message) =>
new(0, 0, message);
new(0, 0, message, IsIndeterminate: true);
}
@@ -10,4 +10,24 @@ public class TransferJob
public string DestinationFolderPath { get; set; } = string.Empty;
public TransferMode Mode { get; set; } = TransferMode.Copy;
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
/// <summary>
/// Optional library-relative file paths. When non-empty, only these files
/// are transferred; SourceFolderPath recursive enumeration is skipped.
/// </summary>
public IReadOnlyList<string> SelectedFilePaths { get; set; } = Array.Empty<string>();
/// <summary>
/// When true, recreate the source folder name under the destination folder
/// (dest/srcFolderName/... ). When false, the source folder's contents land
/// directly inside the destination folder.
/// </summary>
public bool IncludeSourceFolder { get; set; }
/// <summary>
/// When true (default), transfer the files inside the source folder.
/// When false, only create the folder structure (useful together with
/// <see cref="IncludeSourceFolder"/> to clone an empty scaffold).
/// </summary>
public bool CopyFolderContents { get; set; } = true;
}
@@ -109,6 +109,18 @@
<data name="settings.lang.fr" xml:space="preserve">
<value>Français</value>
</data>
<data name="settings.theme" xml:space="preserve">
<value>Thème</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Utiliser le paramètre système</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Clair</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Sombre</value>
</data>
<data name="settings.folder" xml:space="preserve">
<value>Dossier de sortie des données</value>
</data>
@@ -124,6 +136,9 @@
<data name="profile.clientid" xml:space="preserve">
<value>ID client</value>
</data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optionnel — laissez vide pour enregistrer l'application automatiquement</value>
</data>
<data name="profile.add" xml:space="preserve">
<value>Ajouter</value>
</data>
@@ -136,6 +151,9 @@
<data name="status.ready" xml:space="preserve">
<value>Prêt</value>
</data>
<data name="status.complete" xml:space="preserve">
<value>Termin&#233;</value>
</data>
<data name="status.cancelled" xml:space="preserve">
<value>Opération annulée</value>
</data>
@@ -434,4 +452,89 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propri&#233;t&#233; d'administrateur de collection de sites en cas de refus d'acc&#232;s</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activ&#233;, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'acc&#232;s. N&#233;cessite les permissions d'administrateur de tenant.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a &#233;t&#233; &#233;lev&#233; automatiquement — la propri&#233;t&#233; a &#233;t&#233; prise pour compl&#233;ter le scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>Rapport d'audit des acc&#232;s utilisateurs (consolid&#233;)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>Rapport des permissions SharePoint</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>Rapport des permissions SharePoint (simplifi&#233;)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>M&#233;triques de stockage SharePoint</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>Rapport de d&#233;tection de doublons SharePoint</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de d&#233;tection de doublons</value></data>
<data name="report.title.search" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers SharePoint</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>R&#233;sultats de recherche de fichiers</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Acc&#232;s totaux</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audit&#233;s</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analys&#233;s</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>Privil&#232;ge &#233;lev&#233;</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>Utilisateurs externes</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Entr&#233;es totales</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Ensembles de permissions uniques</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Utilisateurs/Groupes distincts</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Biblioth&#232;ques</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Fichiers</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Taille totale</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Taille des versions</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Invit&#233;</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Groupe</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>H&#233;rit&#233;</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>Par utilisateur</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>Par site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filtrer les r&#233;sultats...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filtrer les permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filtrer les lignes…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filtre :</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Type d'objet</value></data>
<data name="report.col.object" xml:space="preserve"><value>Objet</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Niveau de permission</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Type d'acc&#232;s</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Accord&#233; via</value></data>
<data name="report.col.user" xml:space="preserve"><value>Utilisateur</value></data>
<data name="report.col.title" xml:space="preserve"><value>Titre</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Utilisateurs/Groupes</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplifi&#233;</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risque</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Biblioth&#232;que / Dossier</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Derni&#232;re modification</value></data>
<data name="report.col.name" xml:space="preserve"><value>Nom</value></data>
<data name="report.col.library" xml:space="preserve"><value>Biblioth&#232;que</value></data>
<data name="report.col.path" xml:space="preserve"><value>Chemin</value></data>
<data name="report.col.size" xml:space="preserve"><value>Taille</value></data>
<data name="report.col.created" xml:space="preserve"><value>Cr&#233;&#233;</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modifi&#233;</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Cr&#233;&#233; par</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modifi&#233; par</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>Nom de fichier</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>Type de fichier</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>Nombre de fichiers</value></data>
<data name="report.col.error" xml:space="preserve"><value>Erreur</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Horodatage</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Taille totale (Mo)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Taille des versions (Mo)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Taille (Mo)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Taille (octets)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>acc&#232;s</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>groupe(s) de doublons trouv&#233;(s).</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>r&#233;sultat(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>sur</value></data>
<data name="report.text.shown" xml:space="preserve"><value>affich&#233;(s)</value></data>
<data name="report.text.generated" xml:space="preserve"><value>G&#233;n&#233;r&#233;</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>G&#233;n&#233;r&#233; :</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>membres indisponibles</value></data>
<data name="report.text.link" xml:space="preserve"><value>Lien</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(sans ext.)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(sans extension)</value></data>
<data name="report.text.high_priv" xml:space="preserve"><value>priv. &#233;lev&#233;</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Stockage par type de fichier</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>D&#233;tails des biblioth&#232;ques</value></data>
</root>
+103
View File
@@ -109,6 +109,18 @@
<data name="settings.lang.fr" xml:space="preserve">
<value>French</value>
</data>
<data name="settings.theme" xml:space="preserve">
<value>Theme</value>
</data>
<data name="settings.theme.system" xml:space="preserve">
<value>Use system setting</value>
</data>
<data name="settings.theme.light" xml:space="preserve">
<value>Light</value>
</data>
<data name="settings.theme.dark" xml:space="preserve">
<value>Dark</value>
</data>
<data name="settings.folder" xml:space="preserve">
<value>Data output folder</value>
</data>
@@ -124,6 +136,9 @@
<data name="profile.clientid" xml:space="preserve">
<value>Client ID</value>
</data>
<data name="profile.clientid.hint" xml:space="preserve">
<value>Optional — leave blank to register the app automatically</value>
</data>
<data name="profile.add" xml:space="preserve">
<value>Add</value>
</data>
@@ -136,6 +151,9 @@
<data name="status.ready" xml:space="preserve">
<value>Ready</value>
</data>
<data name="status.complete" xml:space="preserve">
<value>Complete</value>
</data>
<data name="status.cancelled" xml:space="preserve">
<value>Operation cancelled</value>
</data>
@@ -434,4 +452,89 @@
<data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data>
<data name="settings.ownership.description" xml:space="preserve"><value>When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions.</value></data>
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>This site was automatically elevated — ownership was taken to complete the scan</value></data>
<!-- Report export localization -->
<data name="report.title.user_access" xml:space="preserve"><value>User Access Audit Report</value></data>
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>User Access Audit Report (Consolidated)</value></data>
<data name="report.title.permissions" xml:space="preserve"><value>SharePoint Permissions Report</value></data>
<data name="report.title.permissions_simplified" xml:space="preserve"><value>SharePoint Permissions Report (Simplified)</value></data>
<data name="report.title.storage" xml:space="preserve"><value>SharePoint Storage Metrics</value></data>
<data name="report.title.duplicates" xml:space="preserve"><value>SharePoint Duplicate Detection Report</value></data>
<data name="report.title.duplicates_short" xml:space="preserve"><value>Duplicate Detection Report</value></data>
<data name="report.title.search" xml:space="preserve"><value>SharePoint File Search Results</value></data>
<data name="report.title.search_short" xml:space="preserve"><value>File Search Results</value></data>
<data name="report.stat.total_accesses" xml:space="preserve"><value>Total Accesses</value></data>
<data name="report.stat.users_audited" xml:space="preserve"><value>Users Audited</value></data>
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites Scanned</value></data>
<data name="report.stat.high_privilege" xml:space="preserve"><value>High Privilege</value></data>
<data name="report.stat.external_users" xml:space="preserve"><value>External Users</value></data>
<data name="report.stat.total_entries" xml:space="preserve"><value>Total Entries</value></data>
<data name="report.stat.unique_permission_sets" xml:space="preserve"><value>Unique Permission Sets</value></data>
<data name="report.stat.distinct_users_groups" xml:space="preserve"><value>Distinct Users/Groups</value></data>
<data name="report.stat.libraries" xml:space="preserve"><value>Libraries</value></data>
<data name="report.stat.files" xml:space="preserve"><value>Files</value></data>
<data name="report.stat.total_size" xml:space="preserve"><value>Total Size</value></data>
<data name="report.stat.version_size" xml:space="preserve"><value>Version Size</value></data>
<data name="report.badge.guest" xml:space="preserve"><value>Guest</value></data>
<data name="report.badge.direct" xml:space="preserve"><value>Direct</value></data>
<data name="report.badge.group" xml:space="preserve"><value>Group</value></data>
<data name="report.badge.inherited" xml:space="preserve"><value>Inherited</value></data>
<data name="report.badge.unique" xml:space="preserve"><value>Unique</value></data>
<data name="report.view.by_user" xml:space="preserve"><value>By User</value></data>
<data name="report.view.by_site" xml:space="preserve"><value>By Site</value></data>
<data name="report.filter.placeholder_results" xml:space="preserve"><value>Filter results...</value></data>
<data name="report.filter.placeholder_permissions" xml:space="preserve"><value>Filter permissions...</value></data>
<data name="report.filter.placeholder_rows" xml:space="preserve"><value>Filter rows…</value></data>
<data name="report.filter.label" xml:space="preserve"><value>Filter:</value></data>
<data name="report.col.site" xml:space="preserve"><value>Site</value></data>
<data name="report.col.sites" xml:space="preserve"><value>Sites</value></data>
<data name="report.col.object_type" xml:space="preserve"><value>Object Type</value></data>
<data name="report.col.object" xml:space="preserve"><value>Object</value></data>
<data name="report.col.permission_level" xml:space="preserve"><value>Permission Level</value></data>
<data name="report.col.access_type" xml:space="preserve"><value>Access Type</value></data>
<data name="report.col.granted_through" xml:space="preserve"><value>Granted Through</value></data>
<data name="report.col.user" xml:space="preserve"><value>User</value></data>
<data name="report.col.title" xml:space="preserve"><value>Title</value></data>
<data name="report.col.url" xml:space="preserve"><value>URL</value></data>
<data name="report.col.users_groups" xml:space="preserve"><value>Users/Groups</value></data>
<data name="report.col.simplified" xml:space="preserve"><value>Simplified</value></data>
<data name="report.col.risk" xml:space="preserve"><value>Risk</value></data>
<data name="report.col.library_folder" xml:space="preserve"><value>Library / Folder</value></data>
<data name="report.col.last_modified" xml:space="preserve"><value>Last Modified</value></data>
<data name="report.col.name" xml:space="preserve"><value>Name</value></data>
<data name="report.col.library" xml:space="preserve"><value>Library</value></data>
<data name="report.col.path" xml:space="preserve"><value>Path</value></data>
<data name="report.col.size" xml:space="preserve"><value>Size</value></data>
<data name="report.col.created" xml:space="preserve"><value>Created</value></data>
<data name="report.col.modified" xml:space="preserve"><value>Modified</value></data>
<data name="report.col.created_by" xml:space="preserve"><value>Created By</value></data>
<data name="report.col.modified_by" xml:space="preserve"><value>Modified By</value></data>
<data name="report.col.file_name" xml:space="preserve"><value>File Name</value></data>
<data name="report.col.extension" xml:space="preserve"><value>Extension</value></data>
<data name="report.col.file_type" xml:space="preserve"><value>File Type</value></data>
<data name="report.col.file_count" xml:space="preserve"><value>File Count</value></data>
<data name="report.col.error" xml:space="preserve"><value>Error</value></data>
<data name="report.col.timestamp" xml:space="preserve"><value>Timestamp</value></data>
<data name="report.col.number" xml:space="preserve"><value>#</value></data>
<data name="report.col.total_size_mb" xml:space="preserve"><value>Total Size (MB)</value></data>
<data name="report.col.version_size_mb" xml:space="preserve"><value>Version Size (MB)</value></data>
<data name="report.col.size_mb" xml:space="preserve"><value>Size (MB)</value></data>
<data name="report.col.size_bytes" xml:space="preserve"><value>Size (bytes)</value></data>
<data name="report.text.accesses" xml:space="preserve"><value>accesses</value></data>
<data name="report.text.access_es" xml:space="preserve"><value>access(es)</value></data>
<data name="report.text.sites_parens" xml:space="preserve"><value>site(s)</value></data>
<data name="report.text.permissions_parens" xml:space="preserve"><value>permission(s)</value></data>
<data name="report.text.copies" xml:space="preserve"><value>copies</value></data>
<data name="report.text.duplicate_groups_found" xml:space="preserve"><value>duplicate group(s) found.</value></data>
<data name="report.text.results_parens" xml:space="preserve"><value>result(s)</value></data>
<data name="report.text.of" xml:space="preserve"><value>of</value></data>
<data name="report.text.shown" xml:space="preserve"><value>shown</value></data>
<data name="report.text.generated" xml:space="preserve"><value>Generated</value></data>
<data name="report.text.generated_colon" xml:space="preserve"><value>Generated:</value></data>
<data name="report.text.members_unavailable" xml:space="preserve"><value>members unavailable</value></data>
<data name="report.text.link" xml:space="preserve"><value>Link</value></data>
<data name="report.text.no_ext" xml:space="preserve"><value>(no ext)</value></data>
<data name="report.text.no_extension" xml:space="preserve"><value>(no extension)</value></data>
<data name="report.text.high_priv" xml:space="preserve"><value>high-priv</value></data>
<data name="report.section.storage_by_file_type" xml:space="preserve"><value>Storage by File Type</value></data>
<data name="report.section.library_details" xml:space="preserve"><value>Library Details</value></data>
</root>
+4 -1
View File
@@ -8,6 +8,9 @@
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
mc:Ignorable="d"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
MinWidth="900" MinHeight="600" Height="700" Width="1100">
<DockPanel>
<!-- Toolbar -->
@@ -28,7 +31,7 @@
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" />
<TextBlock Text="{Binding GlobalSitesSelectedLabel}"
VerticalAlignment="Center" Margin="6,0,0,0"
Foreground="Gray" />
Foreground="{DynamicResource TextMutedBrush}" />
</ToolBar>
<!-- StatusBar: tenant name | operation status text | progress % -->
@@ -7,29 +7,72 @@ public static class BulkOperationRunner
/// <summary>
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
/// and cancellation support. OperationCanceledException propagates immediately.
///
/// Progress is reported AFTER each item completes (success or failure), so the bar
/// reflects actual work done rather than work queued. A final "Complete" report
/// guarantees 100% when the total was determinate.
///
/// Set <paramref name="maxConcurrency"/> > 1 to run items in parallel. Callers must
/// ensure processItem is safe to invoke concurrently (e.g. each invocation uses its
/// own CSOM ClientContext — a shared CSOM context is NOT thread-safe).
/// </summary>
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
IReadOnlyList<TItem> items,
Func<TItem, int, CancellationToken, Task> processItem,
IProgress<OperationProgress> progress,
CancellationToken ct)
CancellationToken ct,
int maxConcurrency = 1)
{
var results = new List<BulkItemResult<TItem>>();
for (int i = 0; i < items.Count; i++)
if (items.Count == 0)
{
progress.Report(new OperationProgress(0, 0, "Nothing to do."));
return new BulkOperationSummary<TItem>(Array.Empty<BulkItemResult<TItem>>());
}
progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}..."));
var results = new BulkItemResult<TItem>[items.Count];
int completed = 0;
async Task RunOne(int i, CancellationToken token)
{
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
try
{
await processItem(items[i], i, ct);
results.Add(BulkItemResult<TItem>.Success(items[i]));
await processItem(items[i], i, token);
results[i] = BulkItemResult<TItem>.Success(items[i]);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message));
results[i] = BulkItemResult<TItem>.Failed(items[i], ex.Message);
}
finally
{
int done = Interlocked.Increment(ref completed);
progress.Report(new OperationProgress(done, items.Count,
$"Processed {done}/{items.Count}"));
}
}
if (maxConcurrency <= 1)
{
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested();
await RunOne(i, ct);
}
}
else
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = maxConcurrency,
CancellationToken = ct
};
await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options,
async (i, token) => await RunOne(i, token));
}
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
return new BulkOperationSummary<TItem>(results);
}
@@ -102,10 +102,25 @@ public class DuplicatesService : IDuplicatesService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
foreach (var rawRow in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
// CSOM has returned ResultRows as either Hashtable or
// Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
string path = GetStr(dict, "Path");
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
@@ -3,6 +3,7 @@ using System.IO;
using System.Text;
using CsvHelper;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,12 +11,13 @@ public class BulkResultCsvExportService
{
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{
var TL = TranslationSource.Instance;
using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.WriteHeader<T>();
csv.WriteField("Error");
csv.WriteField("Timestamp");
csv.WriteField(TL["report.col.error"]);
csv.WriteField(TL["report.col.timestamp"]);
csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess))
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class CsvExportService
{
private const string Header =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
private static string BuildHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
}
/// <summary>
/// Builds a CSV string from the supplied permission entries.
@@ -20,7 +24,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(Header);
sb.AppendLine(BuildHeader());
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
var merged = entries
@@ -61,8 +65,11 @@ public class CsvExportService
/// <summary>
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
/// </summary>
private const string SimplifiedHeader =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
private static string BuildSimplifiedHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
}
/// <summary>
/// Builds a CSV string from simplified permission entries.
@@ -72,7 +79,7 @@ public class CsvExportService
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(SimplifiedHeader);
sb.AppendLine(BuildSimplifiedHeader());
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
@@ -0,0 +1,74 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row;
/// the Group column ties copies together and a Copies column gives the group size.
/// Header row is built at write-time so culture switches are honoured.
/// </summary>
public class DuplicatesCsvExportService
{
public async Task WriteAsync(
IReadOnlyList<DuplicateGroup> groups,
string filePath,
CancellationToken ct)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary
sb.AppendLine($"\"{T["report.title.duplicates_short"]}\"");
sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine(string.Join(",", new[]
{
Csv(T["report.col.number"]),
Csv("Group"),
Csv(T["report.text.copies"]),
Csv(T["report.col.name"]),
Csv(T["report.col.library"]),
Csv(T["report.col.path"]),
Csv(T["report.col.size_bytes"]),
Csv(T["report.col.created"]),
Csv(T["report.col.modified"]),
}));
// Rows
foreach (var g in groups)
{
int i = 0;
foreach (var item in g.Items)
{
i++;
sb.AppendLine(string.Join(",", new[]
{
Csv(i.ToString()),
Csv(g.Name),
Csv(g.Items.Count.ToString()),
Csv(item.Name),
Csv(item.Library),
Csv(item.Path),
Csv(item.SizeBytes?.ToString() ?? string.Empty),
Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty),
Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty),
}));
}
}
await File.WriteAllTextAsync(filePath, sb.ToString(),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.Text;
namespace SharepointToolbox.Services.Export;
@@ -12,15 +13,16 @@ public class DuplicatesHtmlExportService
{
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.duplicates"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Duplicate Detection Report</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -54,11 +56,9 @@ public class DuplicatesHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>Duplicate Detection Report</h1>
""");
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} duplicate group(s) found.</p>");
sb.AppendLine($"<p class=\"summary\">{groups.Count:N0} {T["report.text.duplicate_groups_found"]}</p>");
for (int i = 0; i < groups.Count; i++)
{
@@ -70,19 +70,19 @@ public class DuplicatesHtmlExportService
<div class="group-card">
<div class="group-header" onclick="toggleGroup({i})">
<span class="group-name">{H(g.Name)}</span>
<span class="badge {badgeClass}">{count} copies</span>
<span class="badge {badgeClass}">{count} {T["report.text.copies"]}</span>
</div>
<div class="group-body" id="gb-{i}">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Library</th>
<th>Path</th>
<th>Size</th>
<th>Created</th>
<th>Modified</th>
<th>{T["report.col.number"]}</th>
<th>{T["report.col.name"]}</th>
<th>{T["report.col.library"]}</th>
<th>{T["report.col.path"]}</th>
<th>{T["report.col.size"]}</th>
<th>{T["report.col.created"]}</th>
<th>{T["report.col.modified"]}</th>
</tr>
</thead>
<tbody>
@@ -116,7 +116,7 @@ public class DuplicatesHtmlExportService
""");
}
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -19,6 +20,7 @@ public class HtmlExportService
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var T = TranslationSource.Instance;
// Compute stats
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
@@ -37,7 +39,7 @@ public class HtmlExportService
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report</title>");
sb.AppendLine($"<title>{T["report.title.permissions"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -78,25 +80,25 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
@@ -105,7 +107,7 @@ a:hover { text-decoration: underline; }
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -136,7 +138,7 @@ a:hover { text-decoration: underline; }
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
@@ -151,7 +153,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
@@ -215,6 +217,7 @@ function toggleGroup(id) {
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
@@ -233,7 +236,7 @@ function toggleGroup(id) {
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>SharePoint Permissions Report (Simplified)</title>");
sb.AppendLine($"<title>{T["report.title.permissions_simplified"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -276,13 +279,13 @@ function toggleGroup(id) {
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
@@ -300,14 +303,14 @@ function toggleGroup(id) {
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
sb.AppendLine($" <th>{T["report.col.object"]}</th><th>{T["report.col.title"]}</th><th>{T["report.col.url"]}</th><th>{T["report.badge.unique"]}</th><th>{T["report.col.users_groups"]}</th><th>{T["report.col.permission_level"]}</th><th>{T["report.col.simplified"]}</th><th>{T["report.col.risk"]}</th><th>{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
@@ -316,7 +319,7 @@ function toggleGroup(id) {
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"];
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -347,7 +350,7 @@ function toggleGroup(id) {
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
@@ -362,7 +365,7 @@ function toggleGroup(id) {
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">{T["report.text.link"]}</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -12,10 +13,11 @@ public class SearchCsvExportService
{
public string BuildCsv(IReadOnlyList<SearchResult> results)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)");
sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}");
foreach (var r in results)
{
@@ -1,6 +1,7 @@
using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -13,15 +14,16 @@ public class SearchHtmlExportService
{
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.search"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint File Search Results</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -45,27 +47,27 @@ public class SearchHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>File Search Results</h1>
sb.AppendLine($"""
<h1>{T["report.title.search_short"]}</h1>
<div class="toolbar">
<label for="filterInput">Filter:</label>
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
<label for="filterInput">{T["report.filter.label"]}</label>
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
<span id="resultCount"></span>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table id="resultsTable">
<thead>
<tr>
<th onclick="sortTable(0)">File Name</th>
<th onclick="sortTable(1)">Extension</th>
<th onclick="sortTable(2)">Path</th>
<th onclick="sortTable(3)">Created</th>
<th onclick="sortTable(4)">Created By</th>
<th onclick="sortTable(5)">Modified</th>
<th onclick="sortTable(6)">Modified By</th>
<th class="num" onclick="sortTable(7)">Size</th>
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
</tr>
</thead>
<tbody>
@@ -93,7 +95,7 @@ public class SearchHtmlExportService
sb.AppendLine(" </tbody>\n</table>");
int count = results.Count;
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} result(s)</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm} — {count:N0} {T["report.text.results_parens"]}</p>");
sb.AppendLine($$"""
<script>
@@ -126,10 +128,10 @@ public class SearchHtmlExportService
rows[i].className = match ? '' : 'hidden';
if (match) visible++;
}
document.getElementById('resultCount').innerText = q ? (visible + ' of {{count:N0}} shown') : '';
document.getElementById('resultCount').innerText = q ? (visible + ' {{T["report.text.of"]}} {{count:N0}} {{T["report.text.shown"]}}') : '';
}
window.onload = function() {
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
};
</script>
</body></html>
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -12,10 +13,11 @@ public class StorageCsvExportService
{
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Header
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes)
{
@@ -44,10 +46,11 @@ public class StorageCsvExportService
/// </summary>
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Library details
sb.AppendLine("Library,Site,Files,Total Size (MB),Version Size (MB),Last Modified");
sb.AppendLine($"{T["report.col.library"]},{T["report.col.site"]},{T["report.stat.files"]},{T["report.col.total_size_mb"]},{T["report.col.version_size_mb"]},{T["report.col.last_modified"]}");
foreach (var node in nodes)
{
sb.AppendLine(string.Join(",",
@@ -65,10 +68,10 @@ public class StorageCsvExportService
if (fileTypeMetrics.Count > 0)
{
sb.AppendLine();
sb.AppendLine("File Type,Size (MB),File Count");
sb.AppendLine($"{T["report.col.file_type"]},{T["report.col.size_mb"]},{T["report.col.file_count"]}");
foreach (var m in fileTypeMetrics)
{
string label = string.IsNullOrEmpty(m.Extension) ? "(no extension)" : m.Extension;
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_extension"] : m.Extension;
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
}
}
@@ -1,4 +1,5 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using System.IO;
using System.Text;
@@ -15,16 +16,17 @@ public class StorageHtmlExportService
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -50,9 +52,7 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// Summary cards
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -62,22 +62,22 @@ public class StorageHtmlExportService
sb.AppendLine($"""
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">Total Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">Version Size</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">Files</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(siteTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.total_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{FormatSize(versionTotal0)}</div><div style="font-size:.8rem;color:#666">{T["report.stat.version_size"]}</div></div>
<div style="background:#fff;border-radius:8px;padding:14px 20px;min-width:160px;box-shadow:0 1px 4px rgba(0,0,0,.1)"><div style="font-size:1.8rem;font-weight:700;color:#0078d4">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
</div>
""");
sb.AppendLine("""
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
@@ -93,7 +93,7 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -104,16 +104,17 @@ public class StorageHtmlExportService
/// </summary>
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
{
var T = TranslationSource.Instance;
_togIdx = 0;
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"en\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{T["report.title.storage"]}</title>");
sb.AppendLine("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SharePoint Storage Metrics</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
h1 { color: #0078d4; }
@@ -150,9 +151,7 @@ public class StorageHtmlExportService
<body>
""");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("""
<h1>SharePoint Storage Metrics</h1>
""");
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
// ── Summary cards ──
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
@@ -161,10 +160,10 @@ public class StorageHtmlExportService
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">Total Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">Version Size</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">Files</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">Libraries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(siteTotal)}</div><div class=\"label\">{T["report.stat.total_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{FormatSize(versionTotal)}</div><div class=\"label\">{T["report.stat.version_size"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{fileTotal:N0}</div><div class=\"label\">{T["report.stat.files"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{rootNodes.Count}</div><div class=\"label\">{T["report.stat.libraries"]}</div></div>");
sb.AppendLine("</div>");
// ── File type chart section ──
@@ -175,7 +174,7 @@ public class StorageHtmlExportService
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
sb.AppendLine("<div class=\"chart-section\">");
sb.AppendLine($"<h2>Storage by File Type ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
sb.AppendLine($"<h2>{T["report.section.storage_by_file_type"]} ({totalFiles:N0} files, {FormatSize(totalSize)})</h2>");
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
@@ -185,7 +184,7 @@ public class StorageHtmlExportService
{
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
string color = colors[idx % colors.Length];
string label = string.IsNullOrEmpty(m.Extension) ? "(no ext)" : m.Extension;
string label = string.IsNullOrEmpty(m.Extension) ? T["report.text.no_ext"] : m.Extension;
sb.AppendLine($"""
<div class="bar-row">
@@ -201,17 +200,17 @@ public class StorageHtmlExportService
}
// ── Storage table ──
sb.AppendLine("<h2>Library Details</h2>");
sb.AppendLine("""
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
sb.AppendLine($"""
<table>
<thead>
<tr>
<th>Library / Folder</th>
<th>Site</th>
<th class="num">Files</th>
<th class="num">Total Size</th>
<th class="num">Version Size</th>
<th>Last Modified</th>
<th>{T["report.col.library_folder"]}</th>
<th>{T["report.col.site"]}</th>
<th class="num">{T["report.stat.files"]}</th>
<th class="num">{T["report.stat.total_size"]}</th>
<th class="num">{T["report.stat.version_size"]}</th>
<th>{T["report.col.last_modified"]}</th>
</tr>
</thead>
<tbody>
@@ -227,7 +226,7 @@ public class StorageHtmlExportService
</table>
""");
sb.AppendLine($"<p class=\"generated\">Generated: {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine($"<p class=\"generated\">{T["report.text.generated_colon"]} {DateTime.Now:yyyy-MM-dd HH:mm}</p>");
sb.AppendLine("</body></html>");
return sb.ToString();
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
/// </summary>
public class UserAccessCsvExportService
{
private const string DataHeader =
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
private static string BuildDataHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
}
/// <summary>
/// Builds a CSV string for a single user's access entries.
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
/// </summary>
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
{
var T = TranslationSource.Instance;
var sb = new StringBuilder();
// Summary section
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine(); // Blank line separating summary from data
// Data rows
sb.AppendLine(DataHeader);
sb.AppendLine(BuildDataHeader());
foreach (var entry in entries)
{
sb.AppendLine(string.Join(",", new[]
@@ -99,20 +104,21 @@ public class UserAccessCsvExportService
CancellationToken ct,
bool mergePermissions = false)
{
var T = TranslationSource.Instance;
if (mergePermissions)
{
var consolidated = PermissionConsolidator.Consolidate(entries);
var sb = new StringBuilder();
// Summary section
sb.AppendLine("\"User Access Audit Report (Consolidated)\"");
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
// Header
sb.AppendLine("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
@@ -136,14 +142,14 @@ public class UserAccessCsvExportService
{
var sb = new StringBuilder();
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
// Summary
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
sb.AppendLine($"\"User Access Audit Report\"");
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
sb.AppendLine();
sb.AppendLine(fullHeader);
@@ -2,6 +2,7 @@ using System.IO;
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
namespace SharepointToolbox.Services.Export;
@@ -26,6 +27,8 @@ public class UserAccessHtmlExportService
return BuildConsolidatedHtml(consolidated, entries, branding);
}
var T = TranslationSource.Instance;
// Compute stats
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -41,7 +44,7 @@ public class UserAccessHtmlExportService
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -98,15 +101,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards
@@ -123,34 +126,34 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle buttons
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>");
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── BY-USER VIEW ───────────────────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -161,10 +164,10 @@ a:hover { text-decoration: underline; }
var uName = HtmlEncode(ug.First().UserDisplayName);
var uIsExt = ug.First().IsExternalUser;
var uCount = ug.Count();
var guestBadge = uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} &mdash; {uCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in ug)
@@ -173,10 +176,14 @@ a:hover { text-decoration: underline; }
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -192,12 +199,12 @@ a:hover { text-decoration: underline; }
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
sb.AppendLine("<table id=\"tbl-site\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-site\">");
@@ -210,7 +217,7 @@ a:hover { text-decoration: underline; }
var sCount = sg.Count();
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} access(es)</td>");
sb.AppendLine($" <td colspan=\"6\">{siteTitle} &mdash; {sCount} {T["report.text.access_es"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in sg)
@@ -218,12 +225,16 @@ a:hover { text-decoration: underline; }
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
var accessBadge = AccessTypeBadge(entry.AccessType);
var highIcon = entry.IsHighPrivilege ? " &#9888;" : "";
var guestBadge = entry.IsExternalUser ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = entry.IsExternalUser ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
? "&mdash;"
: HtmlEncode(entry.ObjectTitle);
sb.AppendLine($"<tr data-group=\"{groupId}\">");
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
@@ -345,6 +356,8 @@ function sortTable(view, col) {
IReadOnlyList<UserAccessEntry> entries,
ReportBranding? branding)
{
var T = TranslationSource.Instance;
// Stats computed from the original flat list for accurate counts
var totalAccesses = entries.Count;
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
@@ -360,7 +373,7 @@ function sortTable(view, col) {
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"UTF-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine("<title>User Access Audit Report</title>");
sb.AppendLine($"<title>{T["report.title.user_access_consolidated"]}</title>");
sb.AppendLine("<style>");
sb.AppendLine(@"
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -417,15 +430,15 @@ a:hover { text-decoration: underline; }
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>User Access Audit Report</h1>");
sb.AppendLine($"<h1>{T["report.title.user_access_consolidated"]}</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">{T["report.stat.total_accesses"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">{T["report.stat.users_audited"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">{T["report.stat.sites_scanned"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">{T["report.stat.high_privilege"]}</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">{T["report.stat.external_users"]}</div></div>");
sb.AppendLine("</div>");
// Per-user summary cards (from original flat entries)
@@ -442,32 +455,32 @@ a:hover { text-decoration: underline; }
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
sb.AppendLine($" <div class=\"{cardClass}\">");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses &bull; {uSites} site(s){(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} &bull; {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" &bull; <span style=\"color:#dc2626\">{uHighPriv} {T["report.text.high_priv"]}</span>" : "")}</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// View toggle — only By User (By Site is suppressed for consolidated view)
sb.AppendLine("<div class=\"view-toggle\">");
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>");
sb.AppendLine($" <button id=\"btn-user\" class=\"active\">{T["report.view.by_user"]}</button>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_results"]}\" oninput=\"filterTable()\" />");
sb.AppendLine("</div>");
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
sb.AppendLine("<table id=\"tbl-user\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>User</th>");
sb.AppendLine(" <th>Permission Level</th>");
sb.AppendLine(" <th>Access Type</th>");
sb.AppendLine(" <th>Granted Through</th>");
sb.AppendLine(" <th>Sites</th>");
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody id=\"tbody-user\">");
@@ -486,10 +499,10 @@ a:hover { text-decoration: underline; }
var cuName = HtmlEncode(cug.First().UserDisplayName);
var cuIsExt = cug.First().IsExternalUser;
var cuCount = cug.Count();
var guestBadge = cuIsExt ? " <span class=\"guest-badge\">Guest</span>" : "";
var guestBadge = cuIsExt ? $" <span class=\"guest-badge\">{T["report.badge.guest"]}</span>" : "";
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} permission(s)</td>");
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} &mdash; {cuCount} {T["report.text.permissions_parens"]}</td>");
sb.AppendLine("</tr>");
foreach (var entry in cug)
@@ -508,7 +521,7 @@ a:hover { text-decoration: underline; }
{
// Single location — inline site title + object title
var loc0 = entry.Locations[0];
var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle)
var locLabel = IsRedundantObjectTitle(loc0.SiteTitle, loc0.ObjectTitle)
? HtmlEncode(loc0.SiteTitle)
: $"{HtmlEncode(loc0.SiteTitle)} &rsaquo; {HtmlEncode(loc0.ObjectTitle)}";
sb.AppendLine($" <td>{locLabel}</td>");
@@ -524,7 +537,7 @@ a:hover { text-decoration: underline; }
// Hidden sub-rows — one per location
foreach (var loc in entry.Locations)
{
var subLabel = string.IsNullOrEmpty(loc.ObjectTitle)
var subLabel = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle)
? $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>"
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> &rsaquo; {HtmlEncode(loc.ObjectTitle)}";
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
@@ -591,13 +604,31 @@ function toggleGroup(id) {
}
/// <summary>Returns a colored badge span for the given access type.</summary>
private static string AccessTypeBadge(AccessType accessType) => accessType switch
private static string AccessTypeBadge(AccessType accessType)
{
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>",
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
var T = TranslationSource.Instance;
return accessType switch
{
AccessType.Direct => $"<span class=\"badge access-direct\">{T["report.badge.direct"]}</span>",
AccessType.Group => $"<span class=\"badge access-group\">{T["report.badge.group"]}</span>",
AccessType.Inherited => $"<span class=\"badge access-inherited\">{T["report.badge.inherited"]}</span>",
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
};
}
/// <summary>
/// Returns true when the ObjectTitle adds no information beyond the SiteTitle:
/// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate
/// of the other. Used to collapse "All Company &rsaquo; All Company" to "All Company".
/// </summary>
private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle)
{
if (string.IsNullOrWhiteSpace(objectTitle)) return true;
return string.Equals(
(siteTitle ?? string.Empty).Trim(),
objectTitle.Trim(),
StringComparison.OrdinalIgnoreCase);
}
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
private static string HtmlEncode(string value)
+118 -23
View File
@@ -15,19 +15,53 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// 1. Enumerate files from source
// 1. Enumerate files from source (unless contents are suppressed).
IReadOnlyList<string> files;
if (job.CopyFolderContents)
{
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
}
else
{
files = Array.Empty<string>();
}
if (files.Count == 0)
// When CopyFolderContents is off, the job is folder-only: ensure the
// destination folder is created below (IncludeSourceFolder branch) and
// return without iterating any files.
if (files.Count == 0 && !job.IncludeSourceFolder)
{
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
}
// 2. Build source and destination base paths
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath);
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath);
// 2. Build source and destination base paths. Resolve library roots via
// CSOM — constructing from title breaks for localized libraries whose
// URL segment differs (e.g. title "Documents" → URL "Shared Documents"),
// causing "Access denied" when CSOM tries to touch a non-existent path.
var srcBasePath = await ResolveLibraryPathAsync(
sourceCtx, job.SourceLibrary, job.SourceFolderPath, progress, ct);
var dstBasePath = await ResolveLibraryPathAsync(
destCtx, job.DestinationLibrary, job.DestinationFolderPath, progress, ct);
// When IncludeSourceFolder is set, recreate the source folder name under
// destination so dest/srcFolderName/... mirrors the source tree. When
// no SourceFolderPath is set, fall back to the source library name.
// Also pre-create the folder itself — per-file EnsureFolder only fires
// for nested paths, so flat files at the root of the source folder
// would otherwise copy into a missing parent and fail.
if (job.IncludeSourceFolder)
{
var srcFolderName = !string.IsNullOrEmpty(job.SourceFolderPath)
? Path.GetFileName(job.SourceFolderPath.TrimEnd('/'))
: job.SourceLibrary;
if (!string.IsNullOrEmpty(srcFolderName))
{
dstBasePath = $"{dstBasePath}/{srcFolderName}";
await EnsureFolderAsync(destCtx, dstBasePath, progress, ct);
}
}
// 3. Transfer each file using BulkOperationRunner
return await BulkOperationRunner.RunAsync(
@@ -68,8 +102,14 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
// not server-relative paths. Passing "/sites/..." silently fails or
// returns no error yet copies nothing — especially across site
// collections. Prefix with the owning site's scheme+host.
var srcAbs = ToAbsoluteUrl(sourceCtx, srcFileUrl);
var dstAbs = ToAbsoluteUrl(destCtx, dstFileUrl);
var srcPath = ResourcePath.FromDecodedUrl(srcAbs);
var dstPath = ResourcePath.FromDecodedUrl(dstAbs);
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
var options = new MoveCopyOptions
@@ -109,7 +149,20 @@ public class FileTransferService : IFileTransferService
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/');
var libraryRoot = rootFolder.ServerRelativeUrl.TrimEnd('/');
// Explicit per-file selection overrides folder enumeration. Paths are
// library-relative (e.g. "SubFolder/file.docx") and get resolved to
// full server-relative URLs here.
if (job.SelectedFilePaths.Count > 0)
{
return job.SelectedFilePaths
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => $"{libraryRoot}/{p.TrimStart('/')}")
.ToList();
}
var baseFolderUrl = libraryRoot;
if (!string.IsNullOrEmpty(job.SourceFolderPath))
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
@@ -152,28 +205,70 @@ public class FileTransferService : IFileTransferService
IProgress<OperationProgress> progress,
CancellationToken ct)
{
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
// Already there?
try
{
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
ctx.Load(folder, f => f.Exists);
var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
ctx.Load(existing, f => f.Exists);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (folder.Exists) return;
if (existing.Exists) return;
}
catch { /* folder doesn't exist, create it */ }
catch { /* not present — fall through to creation */ }
// Create folder using Folders.Add which creates intermediate folders
ctx.Web.Folders.Add(folderServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
// Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
// ambiguous across CSOM versions (some treat the arg as relative to Web,
// others server-relative), which produces bogus paths + "Access denied".
// Resolve the parent explicitly and add only the leaf name instead.
int slash = folderServerRelativeUrl.LastIndexOf('/');
if (slash <= 0) return;
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
var parentUrl = folderServerRelativeUrl.Substring(0, slash);
var leafName = folderServerRelativeUrl.Substring(slash + 1);
if (string.IsNullOrEmpty(leafName)) return;
// Recurse to guarantee the parent exists first.
await EnsureFolderAsync(ctx, parentUrl, progress, ct);
var parent = ctx.Web.GetFolderByServerRelativeUrl(parentUrl);
parent.Folders.Add(leafName);
try
{
// Extract site-relative URL from context URL
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("EnsureFolder failed at {Parent}/{Leaf}: {Error}",
parentUrl, leafName, ex.Message);
throw;
}
}
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
{
if (pathOrUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
pathOrUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return pathOrUrl;
var uri = new Uri(ctx.Url);
var siteRelative = uri.AbsolutePath.TrimEnd('/');
var basePath = $"{siteRelative}/{library}";
if (!string.IsNullOrEmpty(folderPath))
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
}
private static async Task<string> ResolveLibraryPathAsync(
ClientContext ctx,
string libraryTitle,
string relativeFolderPath,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var list = ctx.Web.Lists.GetByTitle(libraryTitle);
ctx.Load(list, l => l.RootFolder.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var basePath = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
if (!string.IsNullOrEmpty(relativeFolderPath))
basePath = $"{basePath}/{relativeFolderPath.TrimStart('/')}";
return basePath;
}
}
@@ -27,5 +27,6 @@ public interface IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null);
}
@@ -7,6 +7,17 @@ public class OwnershipElevationService : IOwnershipElevationService
{
public async Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct)
{
// Tenant.SetSiteAdmin requires a real claims/UPN login; an empty string
// makes the server raise "Cannot convert Org ID to Claims" and abort.
// When the caller doesn't specify a user, fall back to the signed-in
// admin (the owner of tenantAdminCtx).
if (string.IsNullOrWhiteSpace(loginName))
{
tenantAdminCtx.Load(tenantAdminCtx.Web.CurrentUser, u => u.LoginName);
await tenantAdminCtx.ExecuteQueryAsync();
loginName = tenantAdminCtx.Web.CurrentUser.LoginName;
}
var tenant = new Tenant(tenantAdminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
await tenantAdminCtx.ExecuteQueryAsync();
@@ -1,4 +1,5 @@
using Microsoft.SharePoint.Client;
using Serilog;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
@@ -10,6 +11,21 @@ namespace SharepointToolbox.Services;
/// </summary>
public class PermissionsService : IPermissionsService
{
/// <summary>
/// Detects the SharePoint server error raised when a RoleAssignment member
/// refers to a user that no longer resolves (orphaned Azure AD account).
/// Message surfaces in the user's locale — match on language-agnostic tokens.
/// </summary>
private static bool IsClaimsResolutionError(ServerException ex)
{
var msg = ex.Message ?? string.Empty;
return msg.Contains("Claims", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Revendications", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Org ID", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("ID org", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("OrgIdToClaims", StringComparison.OrdinalIgnoreCase);
}
// Port of PS lines 1914-1926: system lists excluded from permission reporting
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
{
@@ -122,7 +138,17 @@ public class PermissionsService : IPermissionsService
u => u.Title,
u => u.LoginName,
u => u.IsSiteAdmin));
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped site collection admins for {Url} — orphaned user: {Error}",
ctx.Web.Url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
var admins = ctx.Web.SiteUsers
.Where(u => u.IsSiteAdmin)
@@ -280,7 +306,23 @@ public class PermissionsService : IPermissionsService
ra => ra.Member.LoginName,
ra => ra.Member.PrincipalType,
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
// Orphaned AD users in RoleAssignments cause the server to throw
// "Cannot convert Org ID user to Claims user" during claim resolution.
// That kills the whole batch — skip this object so the scan continues.
// Only swallow the claims-resolution signature; real access-denied errors
// must bubble up so callers (e.g. PermissionsViewModel auto-elevation)
// can react to them.
try
{
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
}
catch (ServerException ex) when (IsClaimsResolutionError(ex))
{
Log.Warning("Skipped {Type} '{Title}' ({Url}) — orphaned user in permissions: {Error}",
objectType, title, url, ex.Message);
return Enumerable.Empty<PermissionEntry>();
}
// Skip inherited objects when IncludeInherited=false
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
+3 -2
View File
@@ -24,8 +24,9 @@ public class ProfileService
!Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _))
throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile));
if (string.IsNullOrWhiteSpace(profile.ClientId))
throw new ArgumentException("ClientId must not be empty.", nameof(profile));
// ClientId is optional at creation time: the user can register the app from within
// the tool, which will populate ClientId/AppId on the profile afterwards.
profile.ClientId ??= string.Empty;
var existing = (await _repository.LoadAsync()).ToList();
existing.Add(profile);
+18 -3
View File
@@ -62,10 +62,25 @@ public class SearchService : ISearchService
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
if (table == null || table.RowCount == 0) break;
foreach (System.Collections.Hashtable row in table.ResultRows)
foreach (var rawRow in table.ResultRows)
{
var dict = row.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
// CSOM has returned ResultRows as either Hashtable or
// Dictionary<string,object> across versions — accept both.
IDictionary<string, object> dict;
if (rawRow is IDictionary<string, object> generic)
{
dict = generic;
}
else if (rawRow is System.Collections.IDictionary legacy)
{
dict = new Dictionary<string, object>();
foreach (System.Collections.DictionaryEntry e in legacy)
dict[e.Key.ToString()!] = e.Value ?? string.Empty;
}
else
{
continue;
}
// Skip SharePoint version history paths
string path = Str(dict, "Path");
@@ -43,4 +43,14 @@ public class SettingsService
settings.AutoTakeOwnership = enabled;
await _repository.SaveAsync(settings);
}
public async Task SetThemeAsync(string mode)
{
if (mode is not ("System" or "Light" or "Dark"))
throw new ArgumentException($"Unsupported theme '{mode}'. Supported: System, Light, Dark.", nameof(mode));
var settings = await _repository.LoadAsync();
settings.Theme = mode;
await _repository.SaveAsync(settings);
}
}
@@ -45,10 +45,37 @@ public class SharePointGroupResolver : ISharePointGroupResolver
GraphServiceClient? graphClient = null;
// Preload the web's SiteGroups catalog once, so we can skip missing
// groups without triggering a server round-trip per name (which fills
// logs with "Could not resolve SP group" warnings for groups that
// live on other sites or were renamed/deleted).
var groupTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
ctx.Load(ctx.Web.SiteGroups, gs => gs.Include(g => g.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
foreach (var g in ctx.Web.SiteGroups)
groupTitles.Add(g.Title);
}
catch (Exception ex)
{
Log.Warning("Could not enumerate SiteGroups on {Url}: {Error}", ctx.Url, ex.Message);
}
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
ct.ThrowIfCancellationRequested();
if (!groupTitles.Contains(groupName))
{
// Group not on this web — likely scoped to another site in a
// multi-site scan. Keep quiet: log at Debug, return empty.
Log.Debug("SP group '{Group}' not present on {Url}; skipping.",
groupName, ctx.Url);
result[groupName] = Array.Empty<ResolvedMember>();
continue;
}
try
{
var group = ctx.Web.SiteGroups.GetByName(groupName);
+135
View File
@@ -0,0 +1,135 @@
using System.Windows;
using Microsoft.Win32;
using Microsoft.Extensions.Logging;
namespace SharepointToolbox.Services;
public enum ThemeMode { System, Light, Dark }
/// <summary>
/// Swaps the merged palette dictionary at runtime so all DynamicResource brush lookups retint live.
/// "System" mode reads HKCU AppsUseLightTheme (0 = dark, 1 = light) and subscribes to system theme changes.
/// </summary>
public class ThemeManager
{
private const string LightPaletteSource = "pack://application:,,,/Themes/LightPalette.xaml";
private const string DarkPaletteSource = "pack://application:,,,/Themes/DarkPalette.xaml";
private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
private readonly ILogger<ThemeManager> _logger;
private ThemeMode _mode = ThemeMode.System;
private bool _systemSubscribed;
public event EventHandler? ThemeChanged;
public ThemeMode Mode => _mode;
public bool IsDarkActive { get; private set; }
public ThemeManager(ILogger<ThemeManager> logger)
{
_logger = logger;
}
public void ApplyMode(ThemeMode mode)
{
_mode = mode;
bool dark = ResolveDark(mode);
ApplyPalette(dark);
EnsureSystemSubscription(mode);
}
public void ApplyFromString(string? value)
{
var mode = (value ?? "System") switch
{
"Light" => ThemeMode.Light,
"Dark" => ThemeMode.Dark,
_ => ThemeMode.System,
};
ApplyMode(mode);
}
private bool ResolveDark(ThemeMode mode) => mode switch
{
ThemeMode.Light => false,
ThemeMode.Dark => true,
_ => ReadSystemPrefersDark(),
};
private bool ReadSystemPrefersDark()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
if (key?.GetValue("AppsUseLightTheme") is int v)
return v == 0;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read system theme preference, defaulting to light");
}
return false;
}
private void ApplyPalette(bool dark)
{
var app = Application.Current;
if (app is null) return;
var newPalette = new ResourceDictionary
{
Source = new Uri(dark ? DarkPaletteSource : LightPaletteSource, UriKind.Absolute)
};
var dicts = app.Resources.MergedDictionaries;
int replaced = -1;
for (int i = 0; i < dicts.Count; i++)
{
var src = dicts[i].Source?.OriginalString ?? string.Empty;
if (src.EndsWith("LightPalette.xaml", StringComparison.OrdinalIgnoreCase) ||
src.EndsWith("DarkPalette.xaml", StringComparison.OrdinalIgnoreCase))
{
replaced = i;
break;
}
}
if (replaced >= 0)
dicts[replaced] = newPalette;
else
dicts.Insert(0, newPalette);
IsDarkActive = dark;
ThemeChanged?.Invoke(this, EventArgs.Empty);
}
private void EnsureSystemSubscription(ThemeMode mode)
{
if (mode == ThemeMode.System && !_systemSubscribed)
{
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
_systemSubscribed = true;
}
else if (mode != ThemeMode.System && _systemSubscribed)
{
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
_systemSubscribed = false;
}
}
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
if (e.Category != UserPreferenceCategory.General) return;
if (_mode != ThemeMode.System) return;
var app = Application.Current;
if (app is null) return;
app.Dispatcher.BeginInvoke(new Action(() =>
{
bool dark = ReadSystemPrefersDark();
if (dark != IsDarkActive)
ApplyPalette(dark);
}));
}
}
@@ -29,7 +29,8 @@ public class UserAccessAuditService : IUserAccessAuditService
IReadOnlyList<SiteInfo> sites,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
CancellationToken ct,
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
{
// Normalize target logins for case-insensitive matching.
// Users may be identified by email ("alice@contoso.com") or full claim
@@ -59,10 +60,21 @@ public class UserAccessAuditService : IUserAccessAuditService
};
var ctx = await sessionManager.GetOrCreateContextAsync(profile, ct);
var permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
IReadOnlyList<PermissionEntry> permEntries;
try
{
permEntries = await _permissionsService.ScanSiteAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (onAccessDenied != null)
{
var elevated = await onAccessDenied(site.Url, ct);
if (!elevated)
throw;
var retryCtx = await sessionManager.GetOrCreateContextAsync(profile, ct);
permEntries = await _permissionsService.ScanSiteAsync(retryCtx, options, progress, ct);
}
var userEntries = TransformEntries(permEntries, targets, site);
allEntries.AddRange(userEntries);
allEntries.AddRange(TransformEntries(permEntries, targets, site));
}
progress.Report(new OperationProgress(sites.Count, sites.Count,
+27
View File
@@ -0,0 +1,27 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Dark palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#15181F" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#1E2230" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#272C3B" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#222636" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#323849" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#3F475B" />
<SolidColorBrush x:Key="TextBrush" Color="#E7EAF1" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#9AA3B2" />
<SolidColorBrush x:Key="AccentBrush" Color="#60A5FA" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#3B82F6" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#1E3A5F" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#0B1220" />
<SolidColorBrush x:Key="DangerBrush" Color="#F87171" />
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399" />
<!-- Forced-dark text for elements painted with hardcoded light pastel backgrounds (risk tiles, colored rows). -->
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#2A4572" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#4A5366" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#0B1220" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#E7EAF1" />
</ResourceDictionary>
@@ -0,0 +1,26 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Light palette -->
<SolidColorBrush x:Key="AppBgBrush" Color="#F6F7FB" />
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SurfaceAltBrush" Color="#F3F4F8" />
<SolidColorBrush x:Key="SurfaceAltBrushAlt" Color="#FAFAFC" />
<SolidColorBrush x:Key="BorderSoftBrush" Color="#E3E6EC" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#CED2D9" />
<SolidColorBrush x:Key="TextBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#5B6472" />
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4ED8" />
<SolidColorBrush x:Key="AccentPressedBrush" Color="#1E40AF" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E8F0FE" />
<SolidColorBrush x:Key="AccentForegroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="DangerBrush" Color="#DC2626" />
<SolidColorBrush x:Key="SuccessBrush" Color="#047857" />
<SolidColorBrush x:Key="OnColoredBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="SelectionBrush" Color="#DBE7FF" />
<SolidColorBrush x:Key="ScrollThumbBrush" Color="#B8BEC7" />
<SolidColorBrush x:Key="TooltipBgBrush" Color="#1F2430" />
<SolidColorBrush x:Key="TooltipFgBrush" Color="#FFFFFF" />
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,9 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
[ObservableProperty]
private int _progressValue;
[ObservableProperty]
private bool _isIndeterminate;
/// <summary>
/// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage.
/// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl.
@@ -46,24 +49,44 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient
IsRunning = true;
StatusMessage = string.Empty;
ProgressValue = 0;
IsIndeterminate = false;
try
{
var progress = new Progress<OperationProgress>(p =>
{
// Indeterminate reports (throttle waits, inner scan steps) must not
// reset the determinate bar to 0%; only update the status message
// and flip the bar into marquee mode. The next determinate report
// restores % and clears the marquee flag.
if (p.IsIndeterminate)
{
IsIndeterminate = true;
}
else
{
IsIndeterminate = false;
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
}
StatusMessage = p.Message;
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
});
await RunOperationAsync(_cts.Token, progress);
// Success path: replace any lingering "Scanning X…" with a neutral
// completion marker so stale in-progress labels don't stick around.
StatusMessage = TranslationSource.Instance["status.complete"];
ProgressValue = 100;
IsIndeterminate = false;
}
catch (OperationCanceledException)
{
StatusMessage = TranslationSource.Instance["status.cancelled"];
IsIndeterminate = false;
_logger.LogInformation("Operation cancelled by user.");
}
catch (Exception ex)
{
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
IsIndeterminate = false;
_logger.LogError(ex, "Operation failed.");
}
finally
@@ -19,6 +19,11 @@ public partial class ProfileManagementViewModel : ObservableObject
private readonly ILogger<ProfileManagementViewModel> _logger;
private readonly IAppRegistrationService _appRegistrationService;
// Well-known public client (Microsoft Graph Command Line Tools) used as a bootstrap
// when a profile has no ClientId yet, so the user can sign in as admin and have the
// app registration created for them.
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
[ObservableProperty]
private TenantProfile? _selectedProfile;
@@ -137,7 +142,7 @@ public partial class ProfileManagementViewModel : ObservableObject
{
if (string.IsNullOrWhiteSpace(NewName)) return false;
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
if (string.IsNullOrWhiteSpace(NewClientId)) return false;
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
return true;
}
@@ -150,7 +155,7 @@ public partial class ProfileManagementViewModel : ObservableObject
{
Name = NewName.Trim(),
TenantUrl = NewTenantUrl.Trim(),
ClientId = NewClientId.Trim()
ClientId = NewClientId?.Trim() ?? string.Empty
};
await _profileService.AddProfileAsync(profile);
Profiles.Add(profile);
@@ -299,7 +304,14 @@ public partial class ProfileManagementViewModel : ObservableObject
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
try
{
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(SelectedProfile.ClientId, ct);
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
// Microsoft Graph Command Line Tools public client so a first-time profile
// (name + URL only) can still perform the admin check and registration.
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
? BootstrapClientId
: SelectedProfile.ClientId;
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct);
if (!isAdmin)
{
ShowFallbackInstructions = true;
@@ -308,11 +320,15 @@ public partial class ProfileManagementViewModel : ObservableObject
}
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
var result = await _appRegistrationService.RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct);
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct);
if (result.IsSuccess)
{
SelectedProfile.AppId = result.AppId;
// If the profile had no ClientId, adopt the freshly registered app's id
// so subsequent sign-ins use the profile's own app registration.
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
SelectedProfile.ClientId = result.AppId!;
await _profileService.UpdateProfileAsync(SelectedProfile);
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
OnPropertyChanged(nameof(HasRegisteredApp));
@@ -31,6 +31,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;
@@ -55,16 +56,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,10 +76,12 @@ 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)
@@ -152,6 +158,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_lastGroups = Array.Empty<DuplicateGroup>();
OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
@@ -184,4 +191,23 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
}
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, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
}
}
@@ -308,6 +308,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('/'));
@@ -408,30 +434,58 @@ 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)
.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 (groupsBySite.Count > 0)
{
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
StringComparer.OrdinalIgnoreCase);
foreach (var bucket in groupsBySite)
{
var distinctNames = bucket
.Select(x => x.GroupName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count > 0 && _currentProfile != null)
{
try
{
var siteProfile = new TenantProfile
{
TenantUrl = bucket.Key,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(
_currentProfile, CancellationToken.None);
groupMembers = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
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 — exporting without member expansion.");
_logger.LogWarning(ex,
"Group resolution failed for {Site} — continuing without member expansion.",
bucket.Key);
}
}
groupMembers = merged;
}
}
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
@@ -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
@@ -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)
}
};
}
@@ -15,6 +15,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 +34,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 +64,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);
@@ -108,6 +125,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 +147,33 @@ public partial class TransferViewModel : FeatureViewModelBase
var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, 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 +228,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}";
}
}
@@ -27,6 +27,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 ──────────────────────────────────────────────
@@ -163,7 +165,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 +177,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_graphUserDirectoryService = graphUserDirectoryService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
@@ -273,6 +279,35 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
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 +315,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 +343,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)
@@ -0,0 +1,26 @@
<UserControl x:Class="SharepointToolbox.Views.Common.Spinner"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="20" Height="20">
<Grid RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<RotateTransform x:Name="Rot" Angle="0" />
</Grid.RenderTransform>
<Grid.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="Rot"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Grid.Triggers>
<Ellipse Stroke="{DynamicResource BorderSoftBrush}" StrokeThickness="3" />
<Ellipse Stroke="{DynamicResource AccentBrush}" StrokeThickness="3"
StrokeDashArray="3 3" StrokeDashCap="Round" />
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using System.Windows.Controls;
namespace SharepointToolbox.Views.Common;
public partial class Spinner : UserControl
{
public Spinner()
{
InitializeComponent();
}
}
@@ -4,6 +4,9 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}"
Width="450" Height="220" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize">
<Grid Margin="20">
<Grid.RowDefinitions>
@@ -3,13 +3,23 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}"
Width="400" Height="500" WindowStartupLocation="CenterOwner"
Width="520" Height="560" WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="CanResizeWithGrip">
<DockPanel Margin="10">
<!-- Status -->
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.loading]}" />
<!-- Action bar: new folder (destination mode only) -->
<StackPanel x:Name="ActionBar" DockPanel.Dock="Top" Orientation="Horizontal"
Margin="0,0,0,6" Visibility="Collapsed">
<Button x:Name="NewFolderButton" Content="+ New Folder"
Padding="8,3" Click="NewFolder_Click" IsEnabled="False" />
</StackPanel>
<!-- Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,0">
@@ -8,13 +8,37 @@ namespace SharepointToolbox.Views.Dialogs;
public partial class FolderBrowserDialog : Window
{
private readonly ClientContext _ctx;
private readonly bool _allowFileSelection;
private readonly bool _allowFolderCreation;
public string SelectedLibrary { get; private set; } = string.Empty;
public string SelectedFolderPath { get; private set; } = string.Empty;
public FolderBrowserDialog(ClientContext ctx)
/// <summary>
/// Library-relative file paths checked by the user. Only populated when
/// <paramref name="allowFileSelection"/> was true. Empty if the user picked
/// a folder node instead.
/// </summary>
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
private readonly List<CheckBox> _fileCheckboxes = new();
/// <summary>
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
/// to show individual files (with sizes) and allow ticking them for targeted
/// transfer. Set <paramref name="allowFolderCreation"/> to expose a "New
/// Folder" button that creates a folder under the selected node.
/// </summary>
public FolderBrowserDialog(ClientContext ctx,
bool allowFileSelection = false,
bool allowFolderCreation = false)
{
InitializeComponent();
_ctx = ctx;
_allowFileSelection = allowFileSelection;
_allowFolderCreation = allowFolderCreation;
if (allowFolderCreation)
ActionBar.Visibility = Visibility.Visible;
Loaded += OnLoaded;
}
@@ -22,24 +46,19 @@ public partial class FolderBrowserDialog : Window
{
try
{
// Load libraries
var web = _ctx.Web;
var lists = _ctx.LoadQuery(web.Lists
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder)
.Include(l => l.Title, l => l.Hidden, l => l.BaseType,
l => l.RootFolder.ServerRelativeUrl)
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary));
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
foreach (var list in lists)
{
var libNode = new TreeViewItem
{
Header = list.Title,
Tag = new FolderNodeInfo(list.Title, string.Empty),
};
// Add dummy child for expand arrow
libNode.Items.Add(new TreeViewItem { Header = "Loading..." });
libNode.Expanded += LibNode_Expanded;
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
var libNode = MakeFolderNode(list.Title,
new FolderNodeInfo(list.Title, string.Empty, rootUrl));
FolderTree.Items.Add(libNode);
}
@@ -51,52 +70,116 @@ public partial class FolderBrowserDialog : Window
}
}
private async void LibNode_Expanded(object sender, RoutedEventArgs e)
private TreeViewItem MakeFolderNode(string name, FolderNodeInfo info)
{
var node = new TreeViewItem
{
Header = name,
Tag = info,
};
// Placeholder child so the expand arrow appears.
node.Items.Add(new TreeViewItem { Header = "Loading..." });
node.Expanded += FolderNode_Expanded;
return node;
}
private async void FolderNode_Expanded(object sender, RoutedEventArgs e)
{
if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info)
return;
// Only load children once
if (node.Items.Count == 1 && node.Items[0] is TreeViewItem dummy && dummy.Header?.ToString() == "Loading...")
{
// Only load children once.
if (!(node.Items.Count == 1
&& node.Items[0] is TreeViewItem dummy
&& dummy.Header?.ToString() == "Loading..."))
return;
node.Items.Clear();
try
{
var folderUrl = string.IsNullOrEmpty(info.FolderPath)
? GetLibraryRootUrl(info.LibraryTitle)
: info.FolderPath;
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl);
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
f => f.StorageMetrics.TotalFileCount,
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
sf => sf.StorageMetrics.TotalSize,
sf => sf.StorageMetrics.TotalFileCount),
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
fi => fi.ServerRelativeUrl));
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
// Annotate the parent node header with total metrics now that they loaded.
node.Header = FormatFolderHeader(info.LibraryTitle == info.RelativePath || string.IsNullOrEmpty(info.RelativePath)
? (string)node.Header!
: System.IO.Path.GetFileName(info.RelativePath),
folder.StorageMetrics.TotalFileCount,
folder.StorageMetrics.TotalSize);
// Child folders first
foreach (var subFolder in folder.Folders)
{
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
var childNode = new TreeViewItem
{
Header = subFolder.Name,
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
};
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
childNode.Expanded += LibNode_Expanded;
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? subFolder.Name
: $"{info.RelativePath}/{subFolder.Name}";
var childInfo = new FolderNodeInfo(
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
var childNode = MakeFolderNode(
FormatFolderHeader(subFolder.Name,
subFolder.StorageMetrics.TotalFileCount,
subFolder.StorageMetrics.TotalSize),
childInfo);
node.Items.Add(childNode);
}
// Files under this folder — only shown when selection is enabled.
if (_allowFileSelection)
{
foreach (var file in folder.Files)
{
// Library-relative path for the file (used by the transfer service)
var fileRel = string.IsNullOrEmpty(info.RelativePath)
? file.Name
: $"{info.RelativePath}/{file.Name}";
var cb = new CheckBox
{
Content = $"{file.Name} ({FormatSize(file.Length)})",
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
Margin = new Thickness(4, 2, 0, 2),
};
cb.Checked += FileCheckbox_Toggled;
cb.Unchecked += FileCheckbox_Toggled;
_fileCheckboxes.Add(cb);
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
node.Items.Add(fileItem);
}
}
}
catch (Exception ex)
{
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
}
}
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
{
if (fileCount <= 0) return name;
return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
}
private string GetLibraryRootUrl(string libraryTitle)
private static string FormatSize(long bytes)
{
var uri = new Uri(_ctx.Url);
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}";
if (bytes <= 0) return "0 B";
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
return $"{bytes} B";
}
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
@@ -104,13 +187,83 @@ public partial class FolderBrowserDialog : Window
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
{
SelectedLibrary = info.LibraryTitle;
SelectedFolderPath = info.FolderPath;
SelectedFolderPath = info.RelativePath;
SelectButton.IsEnabled = true;
NewFolderButton.IsEnabled = _allowFolderCreation;
}
else
{
// File nodes have CheckBox headers, not FolderNodeInfo tags.
NewFolderButton.IsEnabled = false;
}
}
private void FileCheckbox_Toggled(object sender, RoutedEventArgs e)
{
// Enable "Select" as soon as any file is checked — user can confirm
// purely via file selection without also picking a folder node.
if (_fileCheckboxes.Any(c => c.IsChecked == true))
SelectButton.IsEnabled = true;
}
private async void NewFolder_Click(object sender, RoutedEventArgs e)
{
if (FolderTree.SelectedItem is not TreeViewItem node ||
node.Tag is not FolderNodeInfo info)
return;
var dlg = new InputDialog("New folder name:", string.Empty)
{
Owner = this
};
if (dlg.ShowDialog() != true) return;
var folderName = dlg.ResponseText.Trim();
if (string.IsNullOrEmpty(folderName)) return;
try
{
var parent = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
var created = parent.Folders.Add(folderName);
_ctx.Load(created, f => f.ServerRelativeUrl, f => f.Name);
var progress = new Progress<Core.Models.OperationProgress>();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
var childRelative = string.IsNullOrEmpty(info.RelativePath)
? created.Name
: $"{info.RelativePath}/{created.Name}";
var childInfo = new FolderNodeInfo(
info.LibraryTitle, childRelative, created.ServerRelativeUrl);
var childNode = MakeFolderNode(created.Name, childInfo);
// Expand the parent so the fresh folder is visible immediately.
node.IsExpanded = true;
node.Items.Add(childNode);
StatusText.Text = $"Created: {created.ServerRelativeUrl}";
}
catch (Exception ex)
{
StatusText.Text = $"Error: {ex.Message}";
}
}
private void Select_Click(object sender, RoutedEventArgs e)
{
// Harvest checked files (library-relative paths).
SelectedFilePaths = _fileCheckboxes
.Where(c => c.IsChecked == true && c.Tag is FileNodeInfo)
.Select(c => ((FileNodeInfo)c.Tag!).RelativePath)
.ToList();
// If files were picked but no folder node was selected, borrow the
// library from the first file so the caller still has a valid target.
if (SelectedFilePaths.Count > 0 && string.IsNullOrEmpty(SelectedLibrary))
{
var firstTag = (FileNodeInfo)_fileCheckboxes
.First(c => c.IsChecked == true && c.Tag is FileNodeInfo).Tag!;
SelectedLibrary = firstTag.LibraryTitle;
SelectedFolderPath = string.Empty;
}
DialogResult = true;
Close();
}
@@ -121,5 +274,6 @@ public partial class FolderBrowserDialog : Window
Close();
}
private record FolderNodeInfo(string LibraryTitle, string FolderPath);
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
private record FileNodeInfo(string LibraryTitle, string RelativePath);
}
@@ -0,0 +1,22 @@
<Window x:Class="SharepointToolbox.Views.Dialogs.InputDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Input"
Width="340" Height="140"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize">
<DockPanel Margin="12">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Cancel" Width="70" IsCancel="True" Margin="0,0,8,0"
Click="Cancel_Click" />
<Button Content="OK" Width="70" IsDefault="True"
Click="Ok_Click" />
</StackPanel>
<TextBlock x:Name="PromptText" DockPanel.Dock="Top" Margin="0,0,0,6" />
<TextBox x:Name="ResponseBox" VerticalContentAlignment="Center" Padding="4" />
</DockPanel>
</Window>
@@ -0,0 +1,28 @@
using System.Windows;
namespace SharepointToolbox.Views.Dialogs;
public partial class InputDialog : Window
{
public string ResponseText => ResponseBox.Text;
public InputDialog(string prompt, string initialValue)
{
InitializeComponent();
PromptText.Text = prompt;
ResponseBox.Text = initialValue ?? string.Empty;
Loaded += (_, _) => { ResponseBox.Focus(); ResponseBox.SelectAll(); };
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
DialogResult = true;
Close();
}
private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
@@ -4,6 +4,9 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
Title="Manage Profiles" Width="500" Height="750"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
ResizeMode="NoResize">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
@@ -35,6 +38,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}"
Grid.Row="0" Grid.Column="0" />
@@ -48,12 +52,15 @@
Grid.Row="2" Grid.Column="0" />
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="2" Grid.Column="1" Margin="0,2" />
<TextBlock Grid.Row="3" Grid.Column="1" Margin="0,2,0,0"
FontSize="11" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
</Grid>
<!-- Client Logo -->
<StackPanel Grid.Row="3" Margin="0,8,0,8">
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.title]}" Padding="0,0,0,4" />
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4"
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
<Grid>
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
@@ -61,7 +68,7 @@
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
VerticalAlignment="Center" HorizontalAlignment="Center"
Foreground="#999999" FontStyle="Italic">
Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
@@ -83,7 +90,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
Command="{Binding AutoPullClientLogoCommand}" Width="130" />
</StackPanel>
<TextBlock Text="{Binding ValidationMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0"
<TextBlock Text="{Binding ValidationMessage}" Foreground="{DynamicResource DangerBrush}" FontSize="11" Margin="0,4,0,0"
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
@@ -103,11 +110,11 @@
<!-- Status text -->
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
Foreground="#006600"
Foreground="{DynamicResource SuccessBrush}"
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
<!-- Fallback instructions panel -->
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4" Margin="0,4,0,0"
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}"
@@ -3,6 +3,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Select Sites" Width="600" Height="500"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Background="{DynamicResource AppBgBrush}"
Foreground="{DynamicResource TextBrush}"
TextOptions.TextFormattingMode="Ideal"
Loaded="Window_Loaded">
<Grid Margin="12">
<Grid.RowDefinitions>
@@ -21,7 +24,7 @@
<!-- Site list with checkboxes -->
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
SelectionMode="Single"
BorderThickness="1" BorderBrush="#CCCCCC">
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
<ListView.View>
<GridView>
<GridViewColumn Header="" Width="32">
@@ -40,7 +43,7 @@
<!-- Status text -->
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
Foreground="#555555" FontSize="11" />
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
<!-- Button row -->
<DockPanel Grid.Row="3">
@@ -1,10 +1,13 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10">
<!-- Options Panel (Left) -->
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
@@ -19,7 +22,7 @@
Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -29,6 +32,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" />
</StackPanel>
</ScrollViewer>
<!-- Preview DataGrid (Right) -->
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
@@ -1,9 +1,12 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10">
<StackPanel DockPanel.Dock="Left" Width="240" Margin="0,0,10,0">
<ScrollViewer DockPanel.Dock="Left" Width="240" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
@@ -18,7 +21,7 @@
Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -28,6 +31,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" />
</StackPanel>
</ScrollViewer>
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
IsReadOnly="True" CanUserSortColumns="True">
@@ -18,7 +18,7 @@
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
<StackPanel Margin="4">
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,0,0,6" />
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,6" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
IsChecked="{Binding MatchSize}" Margin="0,2" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
@@ -44,16 +44,21 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.open.results]}"
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportCsv]}"
Command="{Binding ExportCsvCommand}" Height="28" Margin="0,0,0,4" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.btn.exportHtml]}"
Command="{Binding ExportHtmlCommand}" Height="28" Margin="0,0,0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel>
</ScrollViewer>
<!-- Results DataGrid -->
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8">
VirtualizingPanel.IsVirtualizing="True" Margin="4,8,8,8"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ColumnWidth="Auto">
<DataGrid.Columns>
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
@@ -65,7 +70,7 @@
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Modified" Binding="{Binding Modified, StringFormat=yyyy-MM-dd}" Width="100" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="400" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
@@ -1,9 +1,12 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10">
<StackPanel DockPanel.Dock="Left" Width="280" Margin="0,0,10,0">
<ScrollViewer DockPanel.Dock="Left" Width="280" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Library input -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
Margin="0,0,0,3" />
@@ -23,7 +26,7 @@
Command="{Binding CancelCommand}"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -31,6 +34,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
Command="{Binding ExportFailedCommand}" />
</StackPanel>
</ScrollViewer>
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
IsReadOnly="True" CanUserSortColumns="True">
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common"
xmlns:models="clr-namespace:SharepointToolbox.Core.Models"
xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters">
@@ -21,7 +22,9 @@
</Grid.RowDefinitions>
<!-- Left panel: Scan configuration -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
<ScrollViewer Grid.Column="0" Grid.Row="0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<DockPanel Margin="8">
<!-- Scan Options GroupBox -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
@@ -125,6 +128,7 @@
</StackPanel>
</DockPanel>
</ScrollViewer>
<!-- Right panel: Summary + Results -->
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8">
@@ -153,7 +157,8 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="6" Padding="14,10" Margin="0,0,10,4" MinWidth="140">
<Border CornerRadius="6" Padding="14,10" Margin="0,0,10,4" MinWidth="140"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#F3F4F6" />
@@ -182,7 +187,7 @@
</Style>
</Border.Style>
<StackPanel>
<TextBlock Text="{Binding Count}" FontSize="22" FontWeight="Bold" />
<TextBlock Text="{Binding Count}" FontSize="22" FontWeight="Bold" Foreground="#1F2430" />
<TextBlock Text="{Binding Label}" FontSize="11" Foreground="#555" />
<TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0">
<Run Text="{Binding DistinctUsers, Mode=OneWay}" />
@@ -222,19 +227,24 @@
<Style.Triggers>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
<Setter Property="Background" Value="#FEF2F2" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
<Setter Property="Background" Value="#FFFBEB" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
<Setter Property="Background" Value="#ECFDF5" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger>
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
<Setter Property="Background" Value="#EFF6FF" />
<Setter Property="Foreground" Value="#1F2430" />
</DataTrigger>
<!-- Phase 18: auto-elevated rows get amber background + tooltip -->
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
<Setter Property="Background" Value="#FFF9E6" />
<Setter Property="Foreground" Value="#1F2430" />
<Setter Property="ToolTip" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[permissions.elevated.tooltip]}" />
</DataTrigger>
</Style.Triggers>
@@ -286,9 +296,8 @@
<!-- Bottom: status bar spanning both columns -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem>
<ProgressBar Width="150" Height="14"
Value="{Binding ProgressValue}"
Minimum="0" Maximum="100" />
<common:Spinner Width="14" Height="14"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
</StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar>
+1 -1
View File
@@ -72,7 +72,7 @@
</StackPanel>
</GroupBox>
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="#555" Margin="0,4" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel>
</ScrollViewer>
+21 -4
View File
@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16">
<!-- Language -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" />
@@ -16,6 +17,21 @@
<Separator Margin="0,12" />
<!-- Theme -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme]}" />
<ComboBox Width="200" HorizontalAlignment="Left"
SelectedValue="{Binding SelectedTheme}"
SelectedValuePath="Tag">
<ComboBoxItem Tag="System"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.system]}" />
<ComboBoxItem Tag="Light"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.light]}" />
<ComboBoxItem Tag="Dark"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.theme.dark]}" />
</ComboBox>
<Separator Margin="0,12" />
<!-- Data folder -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" />
<DockPanel>
@@ -29,7 +45,7 @@
<!-- MSP Logo -->
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.title]}" />
<Border BorderBrush="#DDDDDD" BorderThickness="1" Padding="8" CornerRadius="4"
<Border BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" Padding="8" CornerRadius="4"
HorizontalAlignment="Left" MinWidth="200" MinHeight="60" Margin="0,4,0,0">
<Grid>
<Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
@@ -37,7 +53,7 @@
Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}"
VerticalAlignment="Center" HorizontalAlignment="Center"
Foreground="#999999" FontStyle="Italic">
Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
@@ -65,9 +81,10 @@
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}"
Margin="0,4,0,0" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.description]}"
Foreground="#666666" FontSize="11" TextWrapping="Wrap" Margin="20,4,0,0" />
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" TextWrapping="Wrap" Margin="20,4,0,0" />
<TextBlock Text="{Binding StatusMessage}" Foreground="#CC0000" FontSize="11" Margin="0,4,0,0"
<TextBlock Text="{Binding StatusMessage}" Foreground="{DynamicResource DangerBrush}" FontSize="11" Margin="0,4,0,0"
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -28,7 +28,7 @@
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
TextWrapping="Wrap" FontSize="11" Foreground="#888"
TextWrapping="Wrap" FontSize="11" Foreground="{DynamicResource TextMutedBrush}"
Margin="0,6,0,0" />
</StackPanel>
</GroupBox>
@@ -68,7 +68,7 @@
<!-- Status -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
FontSize="11" Foreground="#555" Margin="0,4" />
FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
</StackPanel>
</ScrollViewer>
@@ -82,7 +82,7 @@
</Grid.RowDefinitions>
<!-- Summary bar -->
<Border Grid.Row="0" Background="#F0F7FF" CornerRadius="4" Padding="12,8" Margin="0,0,0,6">
<Border Grid.Row="0" Background="{DynamicResource AccentSoftBrush}" CornerRadius="4" Padding="12,8" Margin="0,0,0,6">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Collapsed" />
@@ -147,11 +147,11 @@
<!-- Splitter between DataGrid and Chart -->
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
Background="#DDD" ResizeDirection="Rows" />
Background="{DynamicResource BorderSoftBrush}" ResizeDirection="Rows" />
<!-- Chart panel -->
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
Padding="8" Background="White">
<Border Grid.Row="3" BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" CornerRadius="4"
Padding="8" Background="{DynamicResource SurfaceBrush}">
<Grid>
<!-- Chart title -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}"
@@ -160,7 +160,7 @@
<!-- No data placeholder -->
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="#888" FontSize="12"
Foreground="{DynamicResource TextMutedBrush}" FontSize="12"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.nodata]}">
<TextBlock.Style>
<Style TargetType="TextBlock">
@@ -4,7 +4,9 @@
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<DockPanel Margin="10">
<!-- Left panel: Capture and Apply -->
<StackPanel DockPanel.Dock="Left" Width="320" Margin="0,0,10,0">
<ScrollViewer DockPanel.Dock="Left" Width="320" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Capture Section -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
Margin="0,0,0,10">
@@ -52,6 +54,7 @@
<!-- Progress -->
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" />
</StackPanel>
</ScrollViewer>
<!-- Right panel: Template list -->
<DockPanel>
+21 -6
View File
@@ -1,10 +1,13 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<DockPanel Margin="10">
<!-- Options Panel (Left) -->
<StackPanel DockPanel.Dock="Left" Width="340" Margin="0,0,10,0">
<ScrollViewer DockPanel.Dock="Left" Width="340" Margin="0,0,10,0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<!-- Source -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
Margin="0,0,0,10">
@@ -14,7 +17,19 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
Click="BrowseSource_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="Gray" />
<TextBlock Text="{Binding SourceFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Foreground="{DynamicResource AccentBrush}" FontStyle="Italic" FontSize="11">
<Run Text="{Binding SelectedFileCount, Mode=OneWay}" />
<Run Text=" file(s) selected" />
</TextBlock>
<CheckBox Content="Include source folder at destination"
IsChecked="{Binding IncludeSourceFolder}"
Margin="0,6,0,0"
ToolTip="When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder." />
<CheckBox Content="Copy folder contents"
IsChecked="{Binding CopyFolderContents}"
Margin="0,4,0,0"
ToolTip="When on (default), transfer files inside the folder. When off, only the folder is created at the destination." />
</StackPanel>
</GroupBox>
@@ -27,7 +42,7 @@
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
Click="BrowseDest_Click" Margin="0,0,0,5" />
<TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" />
<TextBlock Text="{Binding DestFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</GroupBox>
@@ -62,8 +77,7 @@
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Progress -->
<ProgressBar Height="20" Margin="0,10,0,5"
Value="{Binding ProgressValue}"
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -74,6 +88,7 @@
Command="{Binding ExportFailedCommand}"
Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
</ScrollViewer>
<!-- Right panel placeholder for future enhancements -->
<Border />
@@ -53,11 +53,15 @@ public partial class TransferView : UserControl
ClientId = _viewModel.CurrentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
var folderBrowser = new FolderBrowserDialog(ctx, allowFileSelection: true)
{
Owner = Window.GetWindow(this)
};
if (folderBrowser.ShowDialog() == true)
{
_viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
_viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
_viewModel.SetSelectedFiles(folderBrowser.SelectedFilePaths);
}
}
@@ -81,7 +85,10 @@ public partial class TransferView : UserControl
ClientId = _viewModel.CurrentProfile.ClientId,
};
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
var folderBrowser = new FolderBrowserDialog(ctx) { Owner = Window.GetWindow(this) };
var folderBrowser = new FolderBrowserDialog(ctx, allowFolderCreation: true)
{
Owner = Window.GetWindow(this)
};
if (folderBrowser.ShowDialog() == true)
{
_viewModel.DestLibrary = folderBrowser.SelectedLibrary;
@@ -1,7 +1,8 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.UserAccessAuditView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
xmlns:common="clr-namespace:SharepointToolbox.Views.Common">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="290" />
@@ -13,7 +14,9 @@
</Grid.RowDefinitions>
<!-- Left panel -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
<ScrollViewer Grid.Column="0" Grid.Row="0"
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<DockPanel Margin="8">
<!-- Mode toggle -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
@@ -41,7 +44,7 @@
</GroupBox.Style>
<StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="Gray" Margin="0,0,0,2">
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="{DynamicResource TextMutedBrush}" Margin="0,0,0,2">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
@@ -115,9 +118,9 @@
<!-- Status row: load status + user count -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="Gray" FontSize="10"
<TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10"
Margin="0,0,8,0" />
<TextBlock FontSize="10" Foreground="Gray">
<TextBlock FontSize="10" Foreground="{DynamicResource TextMutedBrush}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} {1}">
<Binding Path="DirectoryUserCount" />
@@ -130,7 +133,7 @@
<!-- Hint text -->
<TextBlock DockPanel.Dock="Bottom"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}"
FontStyle="Italic" Foreground="Gray" FontSize="10" Margin="0,4,0,0"
FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10" Margin="0,4,0,0"
TextWrapping="Wrap" />
<!-- Directory DataGrid -->
@@ -142,7 +145,7 @@
CanUserSortColumns="True"
SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
BorderThickness="1" BorderBrush="#DDDDDD">
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding DisplayName}" Width="120" />
@@ -181,19 +184,20 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="6,2" Margin="0,1">
CornerRadius="4" Padding="6,2" Margin="0,1"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<DockPanel>
<Button Content="x" DockPanel.Dock="Right" Padding="4,0"
Background="Transparent" BorderThickness="0"
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" />
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" Foreground="#1F2430" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10" />
</StackPanel>
<!-- Scan Options (always visible) -->
@@ -247,6 +251,7 @@
</StackPanel>
</DockPanel>
</ScrollViewer>
<!-- Right panel -->
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,0">
@@ -258,37 +263,40 @@
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="12,6" Margin="0,0,8,0">
CornerRadius="4" Padding="12,6" Margin="0,0,8,0"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding TotalAccessCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" />
<TextBlock Text="{Binding TotalAccessCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" Foreground="#1F2430" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.total]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" />
FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#EAFAF1" BorderBrush="#27AE60" BorderThickness="1"
CornerRadius="4" Padding="12,6" Margin="0,0,8,0">
CornerRadius="4" Padding="12,6" Margin="0,0,8,0"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding SitesCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" />
<TextBlock Text="{Binding SitesCount}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" Foreground="#1F2430" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.sites]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" />
FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel>
</Border>
<Border Background="#FDEDEC" BorderBrush="#E74C3C" BorderThickness="1"
CornerRadius="4" Padding="12,6">
CornerRadius="4" Padding="12,6"
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
<StackPanel HorizontalAlignment="Center">
<TextBlock Text="{Binding HighPrivilegeCount}" FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center" Foreground="#C0392B" />
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.highPriv]}"
FontSize="10" Foreground="Gray" HorizontalAlignment="Center" />
FontSize="10" Foreground="#5B6472" HorizontalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4">
<TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}" Width="200" Margin="0,0,8,0" />
<ToggleButton IsChecked="{Binding IsGroupByUser}" Padding="8,3">
<ToggleButton IsChecked="{Binding IsGroupByUser}">
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Style TargetType="ToggleButton" BasedOn="{StaticResource ThemeToggleButtonStyle}">
<Setter Property="Content" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.toggle.bySite]}" />
<Style.Triggers>
<Trigger Property="IsChecked" Value="True">
@@ -327,7 +335,7 @@
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="4,2">
<TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="0,0,8,0" />
<TextBlock Text="{Binding ItemCount, StringFormat=({0})}" Foreground="Gray" />
<TextBlock Text="{Binding ItemCount, StringFormat=({0})}" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DataTemplate>
</GroupStyle.HeaderTemplate>
@@ -364,7 +372,7 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#x26A0;" Foreground="#E74C3C" Margin="0,0,4,0"
<TextBlock Text="&#x26A0;" Foreground="{DynamicResource DangerBrush}" Margin="0,0,4,0"
FontSize="12" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
@@ -415,7 +423,8 @@
<!-- Status bar -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem>
<ProgressBar Width="150" Height="14" Value="{Binding ProgressValue}" Minimum="0" Maximum="100" />
<common:Spinner Width="14" Height="14"
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
</StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar>