chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
+18
-11
@@ -4,16 +4,23 @@
|
||||
xmlns:local="clr-namespace:SharepointToolbox"
|
||||
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
|
||||
<Application.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
|
||||
<conv:IndentConverter x:Key="IndentConverter" />
|
||||
<conv:BytesConverter x:Key="BytesConverter" />
|
||||
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||
<conv:ListToStringConverter x:Key="ListToStringConverter" />
|
||||
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
|
||||
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
|
||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
||||
</Style>
|
||||
<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" />
|
||||
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
||||
<conv:ListToStringConverter x:Key="ListToStringConverter" />
|
||||
<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>
|
||||
|
||||
@@ -50,6 +50,18 @@ public partial class App : Application
|
||||
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
|
||||
@@ -101,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>();
|
||||
@@ -125,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>
|
||||
@@ -139,6 +151,9 @@
|
||||
<data name="status.ready" xml:space="preserve">
|
||||
<value>Prêt</value>
|
||||
</data>
|
||||
<data name="status.complete" xml:space="preserve">
|
||||
<value>Terminé</value>
|
||||
</data>
|
||||
<data name="status.cancelled" xml:space="preserve">
|
||||
<value>Opération annulée</value>
|
||||
</data>
|
||||
@@ -437,4 +452,89 @@
|
||||
<data name="settings.ownership.auto" xml:space="preserve"><value>Prendre automatiquement la propriété d'administrateur de collection de sites en cas de refus d'accès</value></data>
|
||||
<data name="settings.ownership.description" xml:space="preserve"><value>Lorsqu'activé, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'accès. Nécessite les permissions d'administrateur de tenant.</value></data>
|
||||
<data name="permissions.elevated.tooltip" xml:space="preserve"><value>Ce site a été élevé automatiquement — la propriété a été prise pour compléter le scan</value></data>
|
||||
<!-- Report export localization -->
|
||||
<data name="report.title.user_access" xml:space="preserve"><value>Rapport d'audit des accès utilisateurs</value></data>
|
||||
<data name="report.title.user_access_consolidated" xml:space="preserve"><value>Rapport d'audit des accès utilisateurs (consolidé)</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é)</value></data>
|
||||
<data name="report.title.storage" xml:space="preserve"><value>Métriques de stockage SharePoint</value></data>
|
||||
<data name="report.title.duplicates" xml:space="preserve"><value>Rapport de détection de doublons SharePoint</value></data>
|
||||
<data name="report.title.duplicates_short" xml:space="preserve"><value>Rapport de détection de doublons</value></data>
|
||||
<data name="report.title.search" xml:space="preserve"><value>Résultats de recherche de fichiers SharePoint</value></data>
|
||||
<data name="report.title.search_short" xml:space="preserve"><value>Résultats de recherche de fichiers</value></data>
|
||||
<data name="report.stat.total_accesses" xml:space="preserve"><value>Accès totaux</value></data>
|
||||
<data name="report.stat.users_audited" xml:space="preserve"><value>Utilisateurs audités</value></data>
|
||||
<data name="report.stat.sites_scanned" xml:space="preserve"><value>Sites analysés</value></data>
|
||||
<data name="report.stat.high_privilege" xml:space="preserve"><value>Privilège élevé</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é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è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é</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érité</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é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ès</value></data>
|
||||
<data name="report.col.granted_through" xml:space="preserve"><value>Accordé 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é</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èque / Dossier</value></data>
|
||||
<data name="report.col.last_modified" xml:space="preserve"><value>Derniè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è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éé</value></data>
|
||||
<data name="report.col.modified" xml:space="preserve"><value>Modifié</value></data>
|
||||
<data name="report.col.created_by" xml:space="preserve"><value>Créé par</value></data>
|
||||
<data name="report.col.modified_by" xml:space="preserve"><value>Modifié 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ès</value></data>
|
||||
<data name="report.text.access_es" xml:space="preserve"><value>accè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é(s).</value></data>
|
||||
<data name="report.text.results_parens" xml:space="preserve"><value>ré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é(s)</value></data>
|
||||
<data name="report.text.generated" xml:space="preserve"><value>Généré</value></data>
|
||||
<data name="report.text.generated_colon" xml:space="preserve"><value>Généré :</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. élevé</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étails des bibliothèques</value></data>
|
||||
</root>
|
||||
|
||||
@@ -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>
|
||||
@@ -139,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>
|
||||
@@ -437,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>
|
||||
|
||||
@@ -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 • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <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} — {uCount} access(es)</td>");
|
||||
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} — {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 ? " ⚠" : "";
|
||||
|
||||
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||
? "—"
|
||||
: 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} — {sCount} access(es)</td>");
|
||||
sb.AppendLine($" <td colspan=\"6\">{siteTitle} — {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 ? " ⚠" : "";
|
||||
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)
|
||||
? "—"
|
||||
: 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 • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
||||
sb.AppendLine($" <div class=\"user-stats\">{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • <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} — {cuCount} permission(s)</td>");
|
||||
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} — {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)} › {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> › {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>",
|
||||
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</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 › 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)
|
||||
|
||||
@@ -15,19 +15,53 @@ public class FileTransferService : IFileTransferService
|
||||
IProgress<OperationProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Enumerate files from source
|
||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
||||
// 1. Enumerate files from source (unless contents are suppressed).
|
||||
IReadOnlyList<string> files;
|
||||
if (job.CopyFolderContents)
|
||||
{
|
||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||
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;
|
||||
|
||||
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
|
||||
{
|
||||
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 BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
|
||||
private static string ToAbsoluteUrl(ClientContext ctx, string pathOrUrl)
|
||||
{
|
||||
// Extract site-relative URL from context URL
|
||||
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));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
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)));
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0;
|
||||
// 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
|
||||
|
||||
@@ -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,29 +434,57 @@ 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)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.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 (groupNames.Count > 0 && _currentProfile != null)
|
||||
if (groupsBySite.Count > 0)
|
||||
{
|
||||
try
|
||||
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var bucket in groupsBySite)
|
||||
{
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
_currentProfile, CancellationToken.None);
|
||||
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
||||
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
||||
var distinctNames = bucket
|
||||
.Select(x => x.GroupName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = bucket.Key,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
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 for {Site} — continuing without member expansion.",
|
||||
bucket.Key);
|
||||
}
|
||||
}
|
||||
|
||||
groupMembers = merged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, 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
|
||||
{
|
||||
node.Items.Clear();
|
||||
try
|
||||
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)
|
||||
{
|
||||
var folderUrl = string.IsNullOrEmpty(info.FolderPath)
|
||||
? GetLibraryRootUrl(info.LibraryTitle)
|
||||
: info.FolderPath;
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
|
||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl);
|
||||
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||
? subFolder.Name
|
||||
: $"{info.RelativePath}/{subFolder.Name}";
|
||||
|
||||
foreach (var subFolder in folder.Folders)
|
||||
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)
|
||||
{
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
// Library-relative path for the file (used by the transfer service)
|
||||
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||
? file.Name
|
||||
: $"{info.RelativePath}/{file.Name}";
|
||||
|
||||
var childNode = new TreeViewItem
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Header = subFolder.Name,
|
||||
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
|
||||
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
||||
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||
Margin = new Thickness(4, 2, 0, 2),
|
||||
};
|
||||
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
childNode.Expanded += LibNode_Expanded;
|
||||
node.Items.Add(childNode);
|
||||
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}" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLibraryRootUrl(string libraryTitle)
|
||||
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
|
||||
{
|
||||
var uri = new Uri(_ctx.Url);
|
||||
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}";
|
||||
if (fileCount <= 0) return name;
|
||||
return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
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" />
|
||||
@@ -50,14 +53,14 @@
|
||||
<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="#666666" TextWrapping="Wrap"
|
||||
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}}"
|
||||
@@ -65,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" />
|
||||
@@ -87,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>
|
||||
|
||||
@@ -107,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,8 +22,8 @@
|
||||
Command="{Binding CancelCommand}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<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" />
|
||||
|
||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
||||
@@ -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,8 +21,8 @@
|
||||
Command="{Binding CancelCommand}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<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" />
|
||||
|
||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
||||
@@ -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,14 +26,15 @@
|
||||
Command="{Binding CancelCommand}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<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" />
|
||||
|
||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,9 +77,8 @@
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<!-- Progress -->
|
||||
<ProgressBar Height="20" Margin="0,10,0,5"
|
||||
Value="{Binding ProgressValue}"
|
||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<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" />
|
||||
|
||||
<!-- Results -->
|
||||
@@ -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="⚠" Foreground="#E74C3C" Margin="0,0,4,0"
|
||||
<TextBlock Text="⚠" 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>
|
||||
|
||||
Reference in New Issue
Block a user