Compare commits
6 Commits
bbfb9097ce
..
v2.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| f4cc81bb71 | |||
| 8f30a60d2a | |||
| 6e05d26114 | |||
| a257fbba0a | |||
| b33c0769d4 | |||
| fec5ae26e1 |
@@ -37,4 +37,4 @@ Application pour administrer, auditer et exporter des donnees depuis des sites S
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
MVVM (CommunityToolkit) · DI via Microsoft.Extensions.Hosting · Authentification MSAL avec cache persistant et broker WAM · Microsoft Graph SDK · PnP.Framework (CSOM) · Localisation .resx (EN/FR) · Branding configurable dans les exports HTML.
|
MVVM (CommunityToolkit) · DI via Microsoft.Extensions.Hosting · Authentification MSAL avec cache persistant et broker WAM · Microsoft Graph SDK · PnP.Framework (CSOM) · Localisation .resx (EN/FR) · Branding configurable dans les exports HTML.
|
||||||
|
|||||||
@@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests
|
|||||||
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
|
public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress()
|
||||||
{
|
{
|
||||||
var vm = new TestViewModel();
|
var vm = new TestViewModel();
|
||||||
|
int midProgress = -1;
|
||||||
|
string? midStatus = null;
|
||||||
|
|
||||||
vm.OperationFunc = async (ct, progress) =>
|
vm.OperationFunc = async (ct, progress) =>
|
||||||
{
|
{
|
||||||
progress.Report(new OperationProgress(50, 100, "halfway"));
|
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);
|
await vm.RunCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
// Allow dispatcher to process
|
// Mid-operation snapshot confirms IProgress reaches bound properties.
|
||||||
await Task.Delay(20);
|
// Post-completion, FeatureViewModelBase snaps to 100% / "Complete"
|
||||||
|
// so stale "Scanning X" labels don't linger after a successful run.
|
||||||
Assert.Equal(50, vm.ProgressValue);
|
Assert.Equal(50, midProgress);
|
||||||
Assert.Equal("halfway", vm.StatusMessage);
|
Assert.Equal("halfway", midStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable
|
|||||||
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
||||||
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
|
var mockBranding = brandingService ?? new Mock<IBrandingService>().Object;
|
||||||
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
||||||
return new SettingsViewModel(settingsService, mockBranding, logger);
|
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
|
|||||||
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
||||||
var mockBranding = new Mock<IBrandingService>().Object;
|
var mockBranding = new Mock<IBrandingService>().Object;
|
||||||
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
||||||
return new SettingsViewModel(settingsService, mockBranding, logger);
|
return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger<ThemeManager>.Instance), logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable
|
|||||||
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
var settingsService = new SettingsService(new SettingsRepository(_tempFile));
|
||||||
var mockBranding = new Mock<IBrandingService>().Object;
|
var mockBranding = new Mock<IBrandingService>().Object;
|
||||||
var logger = NullLogger<FeatureViewModelBase>.Instance;
|
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();
|
await vm.LoadAsync();
|
||||||
vm.AutoTakeOwnership = true;
|
vm.AutoTakeOwnership = true;
|
||||||
|
|||||||
+18
-11
@@ -4,16 +4,23 @@
|
|||||||
xmlns:local="clr-namespace:SharepointToolbox"
|
xmlns:local="clr-namespace:SharepointToolbox"
|
||||||
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
|
xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
|
<ResourceDictionary>
|
||||||
<conv:IndentConverter x:Key="IndentConverter" />
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<conv:BytesConverter x:Key="BytesConverter" />
|
<ResourceDictionary Source="/Themes/LightPalette.xaml" />
|
||||||
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
|
<ResourceDictionary Source="/Themes/ModernTheme.xaml" />
|
||||||
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
</ResourceDictionary.MergedDictionaries>
|
||||||
<conv:StringToVisibilityConverter x:Key="StringToVisibilityConverter" />
|
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
|
||||||
<conv:ListToStringConverter x:Key="ListToStringConverter" />
|
<conv:IndentConverter x:Key="IndentConverter" />
|
||||||
<conv:Base64ToImageSourceConverter x:Key="Base64ToImageConverter" />
|
<conv:BytesConverter x:Key="BytesConverter" />
|
||||||
<Style x:Key="RightAlignStyle" TargetType="TextBlock">
|
<conv:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Right" />
|
<conv:EnumBoolConverter x:Key="EnumBoolConverter" />
|
||||||
</Style>
|
<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.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -34,9 +34,34 @@ public partial class App : Application
|
|||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
host.Start();
|
host.Start();
|
||||||
|
|
||||||
|
// Apply persisted language before any UI is created so bindings resolve to the saved culture.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = host.Services.GetRequiredService<SettingsService>().GetSettingsAsync().GetAwaiter().GetResult();
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.Lang))
|
||||||
|
Localization.TranslationSource.Instance.CurrentCulture = new System.Globalization.CultureInfo(settings.Lang);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to apply persisted language at startup");
|
||||||
|
}
|
||||||
|
|
||||||
App app = new();
|
App app = new();
|
||||||
app.InitializeComponent();
|
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>();
|
var mainWindow = host.Services.GetRequiredService<MainWindow>();
|
||||||
|
|
||||||
// Wire LogPanelSink now that we have the RichTextBox
|
// Wire LogPanelSink now that we have the RichTextBox
|
||||||
@@ -88,6 +113,7 @@ public partial class App : Application
|
|||||||
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
|
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
|
||||||
services.AddSingleton<ProfileService>();
|
services.AddSingleton<ProfileService>();
|
||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
|
services.AddSingleton<ThemeManager>();
|
||||||
services.AddSingleton<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
|
// Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory
|
||||||
services.AddTransient<ProfileManagementViewModel>();
|
services.AddTransient<ProfileManagementViewModel>();
|
||||||
@@ -112,6 +138,7 @@ public partial class App : Application
|
|||||||
// Phase 3: Duplicates
|
// Phase 3: Duplicates
|
||||||
services.AddTransient<IDuplicatesService, DuplicatesService>();
|
services.AddTransient<IDuplicatesService, DuplicatesService>();
|
||||||
services.AddTransient<DuplicatesHtmlExportService>();
|
services.AddTransient<DuplicatesHtmlExportService>();
|
||||||
|
services.AddTransient<DuplicatesCsvExportService>();
|
||||||
services.AddTransient<DuplicatesViewModel>();
|
services.AddTransient<DuplicatesViewModel>();
|
||||||
services.AddTransient<DuplicatesView>();
|
services.AddTransient<DuplicatesView>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ public class AppSettings
|
|||||||
public string DataFolder { get; set; } = string.Empty;
|
public string DataFolder { get; set; } = string.Empty;
|
||||||
public string Lang { get; set; } = "en";
|
public string Lang { get; set; } = "en";
|
||||||
public bool AutoTakeOwnership { get; set; } = false;
|
public bool AutoTakeOwnership { get; set; } = false;
|
||||||
|
public string Theme { get; set; } = "System"; // System | Light | Dark
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace SharepointToolbox.Core.Models;
|
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) =>
|
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 string DestinationFolderPath { get; set; } = string.Empty;
|
||||||
public TransferMode Mode { get; set; } = TransferMode.Copy;
|
public TransferMode Mode { get; set; } = TransferMode.Copy;
|
||||||
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
|
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">
|
<data name="settings.lang.fr" xml:space="preserve">
|
||||||
<value>Français</value>
|
<value>Français</value>
|
||||||
</data>
|
</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">
|
<data name="settings.folder" xml:space="preserve">
|
||||||
<value>Dossier de sortie des données</value>
|
<value>Dossier de sortie des données</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -124,6 +136,9 @@
|
|||||||
<data name="profile.clientid" xml:space="preserve">
|
<data name="profile.clientid" xml:space="preserve">
|
||||||
<value>ID client</value>
|
<value>ID client</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profile.clientid.hint" xml:space="preserve">
|
||||||
|
<value>Optionnel — laissez vide pour enregistrer l'application automatiquement</value>
|
||||||
|
</data>
|
||||||
<data name="profile.add" xml:space="preserve">
|
<data name="profile.add" xml:space="preserve">
|
||||||
<value>Ajouter</value>
|
<value>Ajouter</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -136,6 +151,9 @@
|
|||||||
<data name="status.ready" xml:space="preserve">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Prêt</value>
|
<value>Prêt</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="status.complete" xml:space="preserve">
|
||||||
|
<value>Terminé</value>
|
||||||
|
</data>
|
||||||
<data name="status.cancelled" xml:space="preserve">
|
<data name="status.cancelled" xml:space="preserve">
|
||||||
<value>Opération annulée</value>
|
<value>Opération annulée</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -434,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.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="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>
|
<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>
|
</root>
|
||||||
|
|||||||
@@ -109,6 +109,18 @@
|
|||||||
<data name="settings.lang.fr" xml:space="preserve">
|
<data name="settings.lang.fr" xml:space="preserve">
|
||||||
<value>French</value>
|
<value>French</value>
|
||||||
</data>
|
</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">
|
<data name="settings.folder" xml:space="preserve">
|
||||||
<value>Data output folder</value>
|
<value>Data output folder</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -124,6 +136,9 @@
|
|||||||
<data name="profile.clientid" xml:space="preserve">
|
<data name="profile.clientid" xml:space="preserve">
|
||||||
<value>Client ID</value>
|
<value>Client ID</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="profile.clientid.hint" xml:space="preserve">
|
||||||
|
<value>Optional — leave blank to register the app automatically</value>
|
||||||
|
</data>
|
||||||
<data name="profile.add" xml:space="preserve">
|
<data name="profile.add" xml:space="preserve">
|
||||||
<value>Add</value>
|
<value>Add</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -136,6 +151,9 @@
|
|||||||
<data name="status.ready" xml:space="preserve">
|
<data name="status.ready" xml:space="preserve">
|
||||||
<value>Ready</value>
|
<value>Ready</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="status.complete" xml:space="preserve">
|
||||||
|
<value>Complete</value>
|
||||||
|
</data>
|
||||||
<data name="status.cancelled" xml:space="preserve">
|
<data name="status.cancelled" xml:space="preserve">
|
||||||
<value>Operation cancelled</value>
|
<value>Operation cancelled</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -434,4 +452,89 @@
|
|||||||
<data name="settings.ownership.auto" xml:space="preserve"><value>Automatically take site collection admin ownership on access denied</value></data>
|
<data name="settings.ownership.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="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>
|
<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>
|
</root>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
|
xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}"
|
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">
|
MinWidth="900" MinHeight="600" Height="700" Width="1100">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" />
|
ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" />
|
||||||
<TextBlock Text="{Binding GlobalSitesSelectedLabel}"
|
<TextBlock Text="{Binding GlobalSitesSelectedLabel}"
|
||||||
VerticalAlignment="Center" Margin="6,0,0,0"
|
VerticalAlignment="Center" Margin="6,0,0,0"
|
||||||
Foreground="Gray" />
|
Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
</ToolBar>
|
</ToolBar>
|
||||||
|
|
||||||
<!-- StatusBar: tenant name | operation status text | progress % -->
|
<!-- StatusBar: tenant name | operation status text | progress % -->
|
||||||
|
|||||||
@@ -7,29 +7,72 @@ public static class BulkOperationRunner
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
|
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
|
||||||
/// and cancellation support. OperationCanceledException propagates immediately.
|
/// 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>
|
/// </summary>
|
||||||
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
|
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
|
||||||
IReadOnlyList<TItem> items,
|
IReadOnlyList<TItem> items,
|
||||||
Func<TItem, int, CancellationToken, Task> processItem,
|
Func<TItem, int, CancellationToken, Task> processItem,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
int maxConcurrency = 1)
|
||||||
{
|
{
|
||||||
var results = new List<BulkItemResult<TItem>>();
|
if (items.Count == 0)
|
||||||
for (int i = 0; i < items.Count; i++)
|
{
|
||||||
|
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
|
try
|
||||||
{
|
{
|
||||||
await processItem(items[i], i, ct);
|
await processItem(items[i], i, token);
|
||||||
results.Add(BulkItemResult<TItem>.Success(items[i]));
|
results[i] = BulkItemResult<TItem>.Success(items[i]);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
catch (Exception ex)
|
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."));
|
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
|
||||||
return new BulkOperationSummary<TItem>(results);
|
return new BulkOperationSummary<TItem>(results);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,10 +102,25 @@ public class DuplicatesService : IDuplicatesService
|
|||||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||||
if (table == null || table.RowCount == 0) break;
|
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>()
|
// CSOM has returned ResultRows as either Hashtable or
|
||||||
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
|
// 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");
|
string path = GetStr(dict, "Path");
|
||||||
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
|
if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -10,12 +11,13 @@ public class BulkResultCsvExportService
|
|||||||
{
|
{
|
||||||
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
|
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
|
||||||
{
|
{
|
||||||
|
var TL = TranslationSource.Instance;
|
||||||
using var writer = new StringWriter();
|
using var writer = new StringWriter();
|
||||||
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
csv.WriteHeader<T>();
|
csv.WriteHeader<T>();
|
||||||
csv.WriteField("Error");
|
csv.WriteField(TL["report.col.error"]);
|
||||||
csv.WriteField("Timestamp");
|
csv.WriteField(TL["report.col.timestamp"]);
|
||||||
csv.NextRecord();
|
csv.NextRecord();
|
||||||
|
|
||||||
foreach (var item in failedItems.Where(r => !r.IsSuccess))
|
foreach (var item in failedItems.Where(r => !r.IsSuccess))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CsvExportService
|
public class CsvExportService
|
||||||
{
|
{
|
||||||
private const string Header =
|
private static string BuildHeader()
|
||||||
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a CSV string from the supplied permission entries.
|
/// Builds a CSV string from the supplied permission entries.
|
||||||
@@ -20,7 +24,7 @@ public class CsvExportService
|
|||||||
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
|
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine(Header);
|
sb.AppendLine(BuildHeader());
|
||||||
|
|
||||||
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
|
// Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows
|
||||||
var merged = entries
|
var merged = entries
|
||||||
@@ -61,8 +65,11 @@ public class CsvExportService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
|
/// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string SimplifiedHeader =
|
private static string BuildSimplifiedHeader()
|
||||||
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Builds a CSV string from simplified permission entries.
|
/// Builds a CSV string from simplified permission entries.
|
||||||
@@ -72,7 +79,7 @@ public class CsvExportService
|
|||||||
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
public string BuildCsv(IReadOnlyList<SimplifiedPermissionEntry> entries)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine(SimplifiedHeader);
|
sb.AppendLine(BuildSimplifiedHeader());
|
||||||
|
|
||||||
var merged = entries
|
var merged = entries
|
||||||
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
|
.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.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
@@ -12,15 +13,16 @@ public class DuplicatesHtmlExportService
|
|||||||
{
|
{
|
||||||
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<DuplicateGroup> groups, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
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("""
|
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>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
h1 { color: #0078d4; }
|
h1 { color: #0078d4; }
|
||||||
@@ -54,11 +56,9 @@ public class DuplicatesHtmlExportService
|
|||||||
<body>
|
<body>
|
||||||
""");
|
""");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"<h1>{T["report.title.duplicates_short"]}</h1>");
|
||||||
<h1>Duplicate Detection Report</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++)
|
for (int i = 0; i < groups.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -70,19 +70,19 @@ public class DuplicatesHtmlExportService
|
|||||||
<div class="group-card">
|
<div class="group-card">
|
||||||
<div class="group-header" onclick="toggleGroup({i})">
|
<div class="group-header" onclick="toggleGroup({i})">
|
||||||
<span class="group-name">{H(g.Name)}</span>
|
<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>
|
||||||
<div class="group-body" id="gb-{i}">
|
<div class="group-body" id="gb-{i}">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>{T["report.col.number"]}</th>
|
||||||
<th>Name</th>
|
<th>{T["report.col.name"]}</th>
|
||||||
<th>Library</th>
|
<th>{T["report.col.library"]}</th>
|
||||||
<th>Path</th>
|
<th>{T["report.col.path"]}</th>
|
||||||
<th>Size</th>
|
<th>{T["report.col.size"]}</th>
|
||||||
<th>Created</th>
|
<th>{T["report.col.created"]}</th>
|
||||||
<th>Modified</th>
|
<th>{T["report.col.modified"]}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public class HtmlExportService
|
|||||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
|
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
// Compute stats
|
// Compute stats
|
||||||
var totalEntries = entries.Count;
|
var totalEntries = entries.Count;
|
||||||
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
|
||||||
@@ -37,7 +39,7 @@ public class HtmlExportService
|
|||||||
sb.AppendLine("<head>");
|
sb.AppendLine("<head>");
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
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("<style>");
|
||||||
sb.AppendLine(@"
|
sb.AppendLine(@"
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -78,25 +80,25 @@ a:hover { text-decoration: underline; }
|
|||||||
// ── BODY ───────────────────────────────────────────────────────────────
|
// ── BODY ───────────────────────────────────────────────────────────────
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
|
sb.AppendLine($"<h1>{T["report.title.permissions"]}</h1>");
|
||||||
|
|
||||||
// Stats cards
|
// Stats cards
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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\">{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\">Unique Permission Sets</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\">Distinct Users/Groups</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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Filter input
|
// Filter input
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Table
|
// Table
|
||||||
sb.AppendLine("<div class=\"table-wrap\">");
|
sb.AppendLine("<div class=\"table-wrap\">");
|
||||||
sb.AppendLine("<table id=\"permTable\">");
|
sb.AppendLine("<table id=\"permTable\">");
|
||||||
sb.AppendLine("<thead><tr>");
|
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("</tr></thead>");
|
||||||
sb.AppendLine("<tbody>");
|
sb.AppendLine("<tbody>");
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ a:hover { text-decoration: underline; }
|
|||||||
{
|
{
|
||||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
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)
|
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
|
||||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
@@ -136,7 +138,7 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
else
|
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>");
|
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++;
|
grpMemIdx++;
|
||||||
@@ -151,7 +153,7 @@ a:hover { text-decoration: underline; }
|
|||||||
sb.AppendLine("<tr>");
|
sb.AppendLine("<tr>");
|
||||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</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><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||||
@@ -215,6 +217,7 @@ function toggleGroup(id) {
|
|||||||
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
|
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var summaries = PermissionSummaryBuilder.Build(entries);
|
var summaries = PermissionSummaryBuilder.Build(entries);
|
||||||
|
|
||||||
var totalEntries = entries.Count;
|
var totalEntries = entries.Count;
|
||||||
@@ -233,7 +236,7 @@ function toggleGroup(id) {
|
|||||||
sb.AppendLine("<head>");
|
sb.AppendLine("<head>");
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
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("<style>");
|
||||||
sb.AppendLine(@"
|
sb.AppendLine(@"
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -276,13 +279,13 @@ function toggleGroup(id) {
|
|||||||
|
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
|
sb.AppendLine($"<h1>{T["report.title.permissions_simplified"]}</h1>");
|
||||||
|
|
||||||
// Stats cards
|
// Stats cards
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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\">{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\">Unique Permission Sets</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\">Distinct Users/Groups</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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Risk-level summary cards
|
// Risk-level summary cards
|
||||||
@@ -300,14 +303,14 @@ function toggleGroup(id) {
|
|||||||
|
|
||||||
// Filter input
|
// Filter input
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Table with simplified columns
|
// Table with simplified columns
|
||||||
sb.AppendLine("<div class=\"table-wrap\">");
|
sb.AppendLine("<div class=\"table-wrap\">");
|
||||||
sb.AppendLine("<table id=\"permTable\">");
|
sb.AppendLine("<table id=\"permTable\">");
|
||||||
sb.AppendLine("<thead><tr>");
|
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("</tr></thead>");
|
||||||
sb.AppendLine("<tbody>");
|
sb.AppendLine("<tbody>");
|
||||||
|
|
||||||
@@ -316,7 +319,7 @@ function toggleGroup(id) {
|
|||||||
{
|
{
|
||||||
var typeCss = ObjectTypeCss(entry.ObjectType);
|
var typeCss = ObjectTypeCss(entry.ObjectType);
|
||||||
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
|
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 (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
|
||||||
|
|
||||||
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||||
@@ -347,7 +350,7 @@ function toggleGroup(id) {
|
|||||||
}
|
}
|
||||||
else
|
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>");
|
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++;
|
grpMemIdx++;
|
||||||
@@ -362,7 +365,7 @@ function toggleGroup(id) {
|
|||||||
sb.AppendLine("<tr>");
|
sb.AppendLine("<tr>");
|
||||||
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</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><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
|
||||||
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
sb.AppendLine($" <td>{pillsBuilder}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -12,10 +13,11 @@ public class SearchCsvExportService
|
|||||||
{
|
{
|
||||||
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
public string BuildCsv(IReadOnlyList<SearchResult> results)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
// Header
|
// 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)
|
foreach (var r in results)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -13,15 +14,16 @@ public class SearchHtmlExportService
|
|||||||
{
|
{
|
||||||
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<SearchResult> results, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
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("""
|
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>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
h1 { color: #0078d4; }
|
h1 { color: #0078d4; }
|
||||||
@@ -45,27 +47,27 @@ public class SearchHtmlExportService
|
|||||||
<body>
|
<body>
|
||||||
""");
|
""");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"""
|
||||||
<h1>File Search Results</h1>
|
<h1>{T["report.title.search_short"]}</h1>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<label for="filterInput">Filter:</label>
|
<label for="filterInput">{T["report.filter.label"]}</label>
|
||||||
<input id="filterInput" type="text" placeholder="Filter rows…" oninput="filterTable()" />
|
<input id="filterInput" type="text" placeholder="{T["report.filter.placeholder_rows"]}" oninput="filterTable()" />
|
||||||
<span id="resultCount"></span>
|
<span id="resultCount"></span>
|
||||||
</div>
|
</div>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"""
|
||||||
<table id="resultsTable">
|
<table id="resultsTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th onclick="sortTable(0)">File Name</th>
|
<th onclick="sortTable(0)">{T["report.col.file_name"]}</th>
|
||||||
<th onclick="sortTable(1)">Extension</th>
|
<th onclick="sortTable(1)">{T["report.col.extension"]}</th>
|
||||||
<th onclick="sortTable(2)">Path</th>
|
<th onclick="sortTable(2)">{T["report.col.path"]}</th>
|
||||||
<th onclick="sortTable(3)">Created</th>
|
<th onclick="sortTable(3)">{T["report.col.created"]}</th>
|
||||||
<th onclick="sortTable(4)">Created By</th>
|
<th onclick="sortTable(4)">{T["report.col.created_by"]}</th>
|
||||||
<th onclick="sortTable(5)">Modified</th>
|
<th onclick="sortTable(5)">{T["report.col.modified"]}</th>
|
||||||
<th onclick="sortTable(6)">Modified By</th>
|
<th onclick="sortTable(6)">{T["report.col.modified_by"]}</th>
|
||||||
<th class="num" onclick="sortTable(7)">Size</th>
|
<th class="num" onclick="sortTable(7)">{T["report.col.size"]}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -93,7 +95,7 @@ public class SearchHtmlExportService
|
|||||||
sb.AppendLine(" </tbody>\n</table>");
|
sb.AppendLine(" </tbody>\n</table>");
|
||||||
|
|
||||||
int count = results.Count;
|
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($$"""
|
sb.AppendLine($$"""
|
||||||
<script>
|
<script>
|
||||||
@@ -126,10 +128,10 @@ public class SearchHtmlExportService
|
|||||||
rows[i].className = match ? '' : 'hidden';
|
rows[i].className = match ? '' : 'hidden';
|
||||||
if (match) visible++;
|
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() {
|
window.onload = function() {
|
||||||
document.getElementById('resultCount').innerText = '{{count:N0}} result(s)';
|
document.getElementById('resultCount').innerText = '{{count:N0}} {{T["report.text.results_parens"]}}';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -12,10 +13,11 @@ public class StorageCsvExportService
|
|||||||
{
|
{
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
// Header
|
// 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)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
@@ -44,10 +46,11 @@ public class StorageCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
public string BuildCsv(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
// Library details
|
// 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)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",",
|
sb.AppendLine(string.Join(",",
|
||||||
@@ -65,10 +68,10 @@ public class StorageCsvExportService
|
|||||||
if (fileTypeMetrics.Count > 0)
|
if (fileTypeMetrics.Count > 0)
|
||||||
{
|
{
|
||||||
sb.AppendLine();
|
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)
|
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()));
|
sb.AppendLine(string.Join(",", Csv(label), FormatMb(m.TotalSizeBytes), m.FileCount.ToString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -15,16 +16,17 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
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("""
|
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>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
h1 { color: #0078d4; }
|
h1 { color: #0078d4; }
|
||||||
@@ -50,9 +52,7 @@ public class StorageHtmlExportService
|
|||||||
<body>
|
<body>
|
||||||
""");
|
""");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
<h1>SharePoint Storage Metrics</h1>
|
|
||||||
""");
|
|
||||||
|
|
||||||
// Summary cards
|
// Summary cards
|
||||||
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
var rootNodes0 = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||||
@@ -62,22 +62,22 @@ public class StorageHtmlExportService
|
|||||||
|
|
||||||
sb.AppendLine($"""
|
sb.AppendLine($"""
|
||||||
<div style="display:flex;gap:16px;margin:16px 0;flex-wrap:wrap">
|
<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(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">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">{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">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">{fileTotal0:N0}</div><div style="font-size:.8rem;color:#666">{T["report.stat.files"]}</div></div>
|
||||||
</div>
|
</div>
|
||||||
""");
|
""");
|
||||||
|
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"""
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Library / Folder</th>
|
<th>{T["report.col.library_folder"]}</th>
|
||||||
<th>Site</th>
|
<th>{T["report.col.site"]}</th>
|
||||||
<th class="num">Files</th>
|
<th class="num">{T["report.stat.files"]}</th>
|
||||||
<th class="num">Total Size</th>
|
<th class="num">{T["report.stat.total_size"]}</th>
|
||||||
<th class="num">Version Size</th>
|
<th class="num">{T["report.stat.version_size"]}</th>
|
||||||
<th>Last Modified</th>
|
<th>{T["report.col.last_modified"]}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -93,7 +93,7 @@ public class StorageHtmlExportService
|
|||||||
</table>
|
</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>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -104,16 +104,17 @@ public class StorageHtmlExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
public string BuildHtml(IReadOnlyList<StorageNode> nodes, IReadOnlyList<FileTypeMetric> fileTypeMetrics, ReportBranding? branding = null)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
_togIdx = 0;
|
_togIdx = 0;
|
||||||
var sb = new StringBuilder();
|
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("""
|
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>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; margin: 20px; background: #f5f5f5; }
|
||||||
h1 { color: #0078d4; }
|
h1 { color: #0078d4; }
|
||||||
@@ -150,9 +151,7 @@ public class StorageHtmlExportService
|
|||||||
<body>
|
<body>
|
||||||
""");
|
""");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"<h1>{T["report.title.storage"]}</h1>");
|
||||||
<h1>SharePoint Storage Metrics</h1>
|
|
||||||
""");
|
|
||||||
|
|
||||||
// ── Summary cards ──
|
// ── Summary cards ──
|
||||||
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
var rootNodes = nodes.Where(n => n.IndentLevel == 0).ToList();
|
||||||
@@ -161,10 +160,10 @@ public class StorageHtmlExportService
|
|||||||
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
long fileTotal = rootNodes.Sum(n => n.TotalFileCount);
|
||||||
|
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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(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\">Version 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\">Files</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\">Libraries</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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// ── File type chart section ──
|
// ── File type chart section ──
|
||||||
@@ -175,7 +174,7 @@ public class StorageHtmlExportService
|
|||||||
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
var totalFiles = fileTypeMetrics.Sum(m => m.FileCount);
|
||||||
|
|
||||||
sb.AppendLine("<div class=\"chart-section\">");
|
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",
|
var colors = new[] { "#0078d4", "#2b88d8", "#106ebe", "#005a9e", "#004578",
|
||||||
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
"#00bcf2", "#009e49", "#8cbd18", "#ffb900", "#d83b01" };
|
||||||
@@ -185,7 +184,7 @@ public class StorageHtmlExportService
|
|||||||
{
|
{
|
||||||
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
|
double pct = maxSize > 0 ? m.TotalSizeBytes * 100.0 / maxSize : 0;
|
||||||
string color = colors[idx % colors.Length];
|
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($"""
|
sb.AppendLine($"""
|
||||||
<div class="bar-row">
|
<div class="bar-row">
|
||||||
@@ -201,17 +200,17 @@ public class StorageHtmlExportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Storage table ──
|
// ── Storage table ──
|
||||||
sb.AppendLine("<h2>Library Details</h2>");
|
sb.AppendLine($"<h2>{T["report.section.library_details"]}</h2>");
|
||||||
sb.AppendLine("""
|
sb.AppendLine($"""
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Library / Folder</th>
|
<th>{T["report.col.library_folder"]}</th>
|
||||||
<th>Site</th>
|
<th>{T["report.col.site"]}</th>
|
||||||
<th class="num">Files</th>
|
<th class="num">{T["report.stat.files"]}</th>
|
||||||
<th class="num">Total Size</th>
|
<th class="num">{T["report.stat.total_size"]}</th>
|
||||||
<th class="num">Version Size</th>
|
<th class="num">{T["report.stat.version_size"]}</th>
|
||||||
<th>Last Modified</th>
|
<th>{T["report.col.last_modified"]}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -227,7 +226,7 @@ public class StorageHtmlExportService
|
|||||||
</table>
|
</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>");
|
sb.AppendLine("</body></html>");
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.IO;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Helpers;
|
using SharepointToolbox.Core.Helpers;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ namespace SharepointToolbox.Services.Export;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserAccessCsvExportService
|
public class UserAccessCsvExportService
|
||||||
{
|
{
|
||||||
private const string DataHeader =
|
private static string BuildDataHeader()
|
||||||
"\"Site\",\"Object Type\",\"Object\",\"URL\",\"Permission Level\",\"Access Type\",\"Granted Through\"";
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Builds a CSV string for a single user's access entries.
|
/// Builds a CSV string for a single user's access entries.
|
||||||
@@ -20,22 +24,23 @@ public class UserAccessCsvExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
|
public string BuildCsv(string userDisplayName, string userLogin, IReadOnlyList<UserAccessEntry> entries)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
// Summary section
|
// Summary section
|
||||||
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
|
var sitesCount = entries.Select(e => e.SiteUrl).Distinct().Count();
|
||||||
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
|
var highPrivCount = entries.Count(e => e.IsHighPrivilege);
|
||||||
|
|
||||||
sb.AppendLine($"\"User Access Audit Report\"");
|
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
|
||||||
sb.AppendLine($"\"User\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
|
sb.AppendLine($"\"{T["report.col.user"]}\",\"{Csv(userDisplayName)} ({Csv(userLogin)})\"");
|
||||||
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
|
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
|
||||||
sb.AppendLine($"\"Sites\",\"{sitesCount}\"");
|
sb.AppendLine($"\"{T["report.col.sites"]}\",\"{sitesCount}\"");
|
||||||
sb.AppendLine($"\"High Privilege\",\"{highPrivCount}\"");
|
sb.AppendLine($"\"{T["report.stat.high_privilege"]}\",\"{highPrivCount}\"");
|
||||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||||
sb.AppendLine(); // Blank line separating summary from data
|
sb.AppendLine(); // Blank line separating summary from data
|
||||||
|
|
||||||
// Data rows
|
// Data rows
|
||||||
sb.AppendLine(DataHeader);
|
sb.AppendLine(BuildDataHeader());
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
{
|
{
|
||||||
sb.AppendLine(string.Join(",", new[]
|
sb.AppendLine(string.Join(",", new[]
|
||||||
@@ -99,20 +104,21 @@ public class UserAccessCsvExportService
|
|||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
bool mergePermissions = false)
|
bool mergePermissions = false)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
if (mergePermissions)
|
if (mergePermissions)
|
||||||
{
|
{
|
||||||
var consolidated = PermissionConsolidator.Consolidate(entries);
|
var consolidated = PermissionConsolidator.Consolidate(entries);
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
// Summary section
|
// Summary section
|
||||||
sb.AppendLine("\"User Access Audit Report (Consolidated)\"");
|
sb.AppendLine($"\"{T["report.title.user_access_consolidated"]}\"");
|
||||||
sb.AppendLine($"\"Users Audited\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
|
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{consolidated.Select(e => e.UserLogin).Distinct().Count()}\"");
|
||||||
sb.AppendLine($"\"Total Entries\",\"{consolidated.Count}\"");
|
sb.AppendLine($"\"{T["report.stat.total_entries"]}\",\"{consolidated.Count}\"");
|
||||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
// Header
|
// 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
|
// Data rows
|
||||||
foreach (var entry in consolidated)
|
foreach (var entry in consolidated)
|
||||||
@@ -136,14 +142,14 @@ public class UserAccessCsvExportService
|
|||||||
|
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var fullHeader = "\"User\",\"User Login\"," + DataHeader;
|
var fullHeader = $"\"{T["report.col.user"]}\",\"User Login\"," + BuildDataHeader();
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
|
var users = entries.Select(e => e.UserLogin).Distinct().ToList();
|
||||||
sb.AppendLine($"\"User Access Audit Report\"");
|
sb.AppendLine($"\"{T["report.title.user_access"]}\"");
|
||||||
sb.AppendLine($"\"Users Audited\",\"{users.Count}\"");
|
sb.AppendLine($"\"{T["report.stat.users_audited"]}\",\"{users.Count}\"");
|
||||||
sb.AppendLine($"\"Total Accesses\",\"{entries.Count}\"");
|
sb.AppendLine($"\"{T["report.stat.total_accesses"]}\",\"{entries.Count}\"");
|
||||||
sb.AppendLine($"\"Generated\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\"");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
sb.AppendLine(fullHeader);
|
sb.AppendLine(fullHeader);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.IO;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using SharepointToolbox.Core.Helpers;
|
using SharepointToolbox.Core.Helpers;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Localization;
|
||||||
|
|
||||||
namespace SharepointToolbox.Services.Export;
|
namespace SharepointToolbox.Services.Export;
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ public class UserAccessHtmlExportService
|
|||||||
return BuildConsolidatedHtml(consolidated, entries, branding);
|
return BuildConsolidatedHtml(consolidated, entries, branding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
|
||||||
// Compute stats
|
// Compute stats
|
||||||
var totalAccesses = entries.Count;
|
var totalAccesses = entries.Count;
|
||||||
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
||||||
@@ -41,7 +44,7 @@ public class UserAccessHtmlExportService
|
|||||||
sb.AppendLine("<head>");
|
sb.AppendLine("<head>");
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
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("<style>");
|
||||||
sb.AppendLine(@"
|
sb.AppendLine(@"
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -98,15 +101,15 @@ a:hover { text-decoration: underline; }
|
|||||||
// ── BODY ───────────────────────────────────────────────────────────────
|
// ── BODY ───────────────────────────────────────────────────────────────
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
||||||
sb.AppendLine("<h1>User Access Audit Report</h1>");
|
sb.AppendLine($"<h1>{T["report.title.user_access"]}</h1>");
|
||||||
|
|
||||||
// Stats cards
|
// Stats cards
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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\">{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\">Users Audited</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\">Sites Scanned</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\">High Privilege</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\">External Users</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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Per-user summary cards
|
// Per-user summary cards
|
||||||
@@ -123,34 +126,34 @@ a:hover { text-decoration: underline; }
|
|||||||
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
|
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
|
||||||
|
|
||||||
sb.AppendLine($" <div class=\"{cardClass}\">");
|
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\">{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>");
|
||||||
}
|
}
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// View toggle buttons
|
// View toggle buttons
|
||||||
sb.AppendLine("<div class=\"view-toggle\">");
|
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-user\" class=\"active\" onclick=\"toggleView('user')\">{T["report.view.by_user"]}</button>");
|
||||||
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
|
sb.AppendLine($" <button id=\"btn-site\" onclick=\"toggleView('site')\">{T["report.view.by_site"]}</button>");
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Filter input
|
// Filter input
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// ── BY-USER VIEW ───────────────────────────────────────────────────────
|
// ── BY-USER VIEW ───────────────────────────────────────────────────────
|
||||||
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
||||||
sb.AppendLine("<table id=\"tbl-user\">");
|
sb.AppendLine("<table id=\"tbl-user\">");
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',0)\">{T["report.col.site"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',1)\">{T["report.col.object_type"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',2)\">{T["report.col.object"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',3)\">{T["report.col.permission_level"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',4)\">{T["report.col.access_type"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('user',5)\">{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
sb.AppendLine("<tbody id=\"tbody-user\">");
|
sb.AppendLine("<tbody id=\"tbody-user\">");
|
||||||
|
|
||||||
@@ -161,10 +164,10 @@ a:hover { text-decoration: underline; }
|
|||||||
var uName = HtmlEncode(ug.First().UserDisplayName);
|
var uName = HtmlEncode(ug.First().UserDisplayName);
|
||||||
var uIsExt = ug.First().IsExternalUser;
|
var uIsExt = ug.First().IsExternalUser;
|
||||||
var uCount = ug.Count();
|
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($"<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>");
|
sb.AppendLine("</tr>");
|
||||||
|
|
||||||
foreach (var entry in ug)
|
foreach (var entry in ug)
|
||||||
@@ -173,10 +176,14 @@ a:hover { text-decoration: underline; }
|
|||||||
var accessBadge = AccessTypeBadge(entry.AccessType);
|
var accessBadge = AccessTypeBadge(entry.AccessType);
|
||||||
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
||||||
|
|
||||||
|
var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle)
|
||||||
|
? "—"
|
||||||
|
: HtmlEncode(entry.ObjectTitle);
|
||||||
|
|
||||||
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
||||||
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</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{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</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("<div id=\"view-site\" class=\"table-wrap hidden\">");
|
||||||
sb.AppendLine("<table id=\"tbl-site\">");
|
sb.AppendLine("<table id=\"tbl-site\">");
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',0)\">{T["report.col.user"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',1)\">{T["report.col.object_type"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',2)\">{T["report.col.object"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',3)\">{T["report.col.permission_level"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',4)\">{T["report.col.access_type"]}</th>");
|
||||||
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
|
sb.AppendLine($" <th onclick=\"sortTable('site',5)\">{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
sb.AppendLine("<tbody id=\"tbody-site\">");
|
sb.AppendLine("<tbody id=\"tbody-site\">");
|
||||||
|
|
||||||
@@ -210,7 +217,7 @@ a:hover { text-decoration: underline; }
|
|||||||
var sCount = sg.Count();
|
var sCount = sg.Count();
|
||||||
|
|
||||||
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
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>");
|
sb.AppendLine("</tr>");
|
||||||
|
|
||||||
foreach (var entry in sg)
|
foreach (var entry in sg)
|
||||||
@@ -218,12 +225,16 @@ a:hover { text-decoration: underline; }
|
|||||||
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
|
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
|
||||||
var accessBadge = AccessTypeBadge(entry.AccessType);
|
var accessBadge = AccessTypeBadge(entry.AccessType);
|
||||||
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
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($"<tr data-group=\"{groupId}\">");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</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{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
||||||
sb.AppendLine($" <td>{accessBadge}</td>");
|
sb.AppendLine($" <td>{accessBadge}</td>");
|
||||||
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
||||||
@@ -345,6 +356,8 @@ function sortTable(view, col) {
|
|||||||
IReadOnlyList<UserAccessEntry> entries,
|
IReadOnlyList<UserAccessEntry> entries,
|
||||||
ReportBranding? branding)
|
ReportBranding? branding)
|
||||||
{
|
{
|
||||||
|
var T = TranslationSource.Instance;
|
||||||
|
|
||||||
// Stats computed from the original flat list for accurate counts
|
// Stats computed from the original flat list for accurate counts
|
||||||
var totalAccesses = entries.Count;
|
var totalAccesses = entries.Count;
|
||||||
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count();
|
||||||
@@ -360,7 +373,7 @@ function sortTable(view, col) {
|
|||||||
sb.AppendLine("<head>");
|
sb.AppendLine("<head>");
|
||||||
sb.AppendLine("<meta charset=\"UTF-8\">");
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
||||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
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("<style>");
|
||||||
sb.AppendLine(@"
|
sb.AppendLine(@"
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -417,15 +430,15 @@ a:hover { text-decoration: underline; }
|
|||||||
// ── BODY ───────────────────────────────────────────────────────────────
|
// ── BODY ───────────────────────────────────────────────────────────────
|
||||||
sb.AppendLine("<body>");
|
sb.AppendLine("<body>");
|
||||||
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
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
|
// Stats cards
|
||||||
sb.AppendLine("<div class=\"stats\">");
|
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\">{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\">Users Audited</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\">Sites Scanned</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\">High Privilege</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\">External Users</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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Per-user summary cards (from original flat entries)
|
// 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";
|
var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card";
|
||||||
|
|
||||||
sb.AppendLine($" <div class=\"{cardClass}\">");
|
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\">{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>");
|
||||||
}
|
}
|
||||||
sb.AppendLine("</div>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// View toggle — only By User (By Site is suppressed for consolidated view)
|
// View toggle — only By User (By Site is suppressed for consolidated view)
|
||||||
sb.AppendLine("<div class=\"view-toggle\">");
|
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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// Filter input
|
// Filter input
|
||||||
sb.AppendLine("<div class=\"filter-wrap\">");
|
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>");
|
sb.AppendLine("</div>");
|
||||||
|
|
||||||
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
|
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
|
||||||
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
||||||
sb.AppendLine("<table id=\"tbl-user\">");
|
sb.AppendLine("<table id=\"tbl-user\">");
|
||||||
sb.AppendLine("<thead><tr>");
|
sb.AppendLine("<thead><tr>");
|
||||||
sb.AppendLine(" <th>User</th>");
|
sb.AppendLine($" <th>{T["report.col.user"]}</th>");
|
||||||
sb.AppendLine(" <th>Permission Level</th>");
|
sb.AppendLine($" <th>{T["report.col.permission_level"]}</th>");
|
||||||
sb.AppendLine(" <th>Access Type</th>");
|
sb.AppendLine($" <th>{T["report.col.access_type"]}</th>");
|
||||||
sb.AppendLine(" <th>Granted Through</th>");
|
sb.AppendLine($" <th>{T["report.col.granted_through"]}</th>");
|
||||||
sb.AppendLine(" <th>Sites</th>");
|
sb.AppendLine($" <th>{T["report.col.sites"]}</th>");
|
||||||
sb.AppendLine("</tr></thead>");
|
sb.AppendLine("</tr></thead>");
|
||||||
sb.AppendLine("<tbody id=\"tbody-user\">");
|
sb.AppendLine("<tbody id=\"tbody-user\">");
|
||||||
|
|
||||||
@@ -486,10 +499,10 @@ a:hover { text-decoration: underline; }
|
|||||||
var cuName = HtmlEncode(cug.First().UserDisplayName);
|
var cuName = HtmlEncode(cug.First().UserDisplayName);
|
||||||
var cuIsExt = cug.First().IsExternalUser;
|
var cuIsExt = cug.First().IsExternalUser;
|
||||||
var cuCount = cug.Count();
|
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($"<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>");
|
sb.AppendLine("</tr>");
|
||||||
|
|
||||||
foreach (var entry in cug)
|
foreach (var entry in cug)
|
||||||
@@ -508,7 +521,7 @@ a:hover { text-decoration: underline; }
|
|||||||
{
|
{
|
||||||
// Single location — inline site title + object title
|
// Single location — inline site title + object title
|
||||||
var loc0 = entry.Locations[0];
|
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.SiteTitle)} › {HtmlEncode(loc0.ObjectTitle)}";
|
: $"{HtmlEncode(loc0.SiteTitle)} › {HtmlEncode(loc0.ObjectTitle)}";
|
||||||
sb.AppendLine($" <td>{locLabel}</td>");
|
sb.AppendLine($" <td>{locLabel}</td>");
|
||||||
@@ -524,7 +537,7 @@ a:hover { text-decoration: underline; }
|
|||||||
// Hidden sub-rows — one per location
|
// Hidden sub-rows — one per location
|
||||||
foreach (var loc in entry.Locations)
|
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>"
|
||||||
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> › {HtmlEncode(loc.ObjectTitle)}";
|
: $"<a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a> › {HtmlEncode(loc.ObjectTitle)}";
|
||||||
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
|
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>
|
/// <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>",
|
var T = TranslationSource.Instance;
|
||||||
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
|
return accessType switch
|
||||||
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
|
{
|
||||||
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
|
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>
|
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
||||||
private static string HtmlEncode(string value)
|
private static string HtmlEncode(string value)
|
||||||
|
|||||||
@@ -15,19 +15,53 @@ public class FileTransferService : IFileTransferService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 1. Enumerate files from source
|
// 1. Enumerate files from source (unless contents are suppressed).
|
||||||
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
IReadOnlyList<string> files;
|
||||||
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
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."));
|
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
|
||||||
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build source and destination base paths
|
// 2. Build source and destination base paths. Resolve library roots via
|
||||||
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath);
|
// CSOM — constructing from title breaks for localized libraries whose
|
||||||
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath);
|
// 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
|
// 3. Transfer each file using BulkOperationRunner
|
||||||
return await BulkOperationRunner.RunAsync(
|
return await BulkOperationRunner.RunAsync(
|
||||||
@@ -68,8 +102,14 @@ public class FileTransferService : IFileTransferService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
|
// MoveCopyUtil.CopyFileByPath expects absolute URLs (scheme + host),
|
||||||
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
|
// 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;
|
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||||
var options = new MoveCopyOptions
|
var options = new MoveCopyOptions
|
||||||
@@ -109,7 +149,20 @@ public class FileTransferService : IFileTransferService
|
|||||||
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
|
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
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))
|
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
||||||
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
||||||
|
|
||||||
@@ -152,28 +205,70 @@ public class FileTransferService : IFileTransferService
|
|||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
folderServerRelativeUrl = folderServerRelativeUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
// Already there?
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
var existing = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||||
ctx.Load(folder, f => f.Exists);
|
ctx.Load(existing, f => f.Exists);
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
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
|
// Walk the path, creating each missing segment. `Web.Folders.Add(url)` is
|
||||||
ctx.Web.Folders.Add(folderServerRelativeUrl);
|
// ambiguous across CSOM versions (some treat the arg as relative to Web,
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
// 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 uri = new Uri(ctx.Url);
|
||||||
var siteRelative = uri.AbsolutePath.TrimEnd('/');
|
return $"{uri.Scheme}://{uri.Host}{(pathOrUrl.StartsWith("/") ? "" : "/")}{pathOrUrl}";
|
||||||
var basePath = $"{siteRelative}/{library}";
|
}
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
|
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;
|
return basePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ public interface IUserAccessAuditService
|
|||||||
IReadOnlyList<SiteInfo> sites,
|
IReadOnlyList<SiteInfo> sites,
|
||||||
ScanOptions options,
|
ScanOptions options,
|
||||||
IProgress<OperationProgress> progress,
|
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)
|
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);
|
var tenant = new Tenant(tenantAdminCtx);
|
||||||
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
|
tenant.SetSiteAdmin(siteUrl, loginName, isSiteAdmin: true);
|
||||||
await tenantAdminCtx.ExecuteQueryAsync();
|
await tenantAdminCtx.ExecuteQueryAsync();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.SharePoint.Client;
|
using Microsoft.SharePoint.Client;
|
||||||
|
using Serilog;
|
||||||
using SharepointToolbox.Core.Helpers;
|
using SharepointToolbox.Core.Helpers;
|
||||||
using SharepointToolbox.Core.Models;
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
@@ -10,6 +11,21 @@ namespace SharepointToolbox.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PermissionsService : IPermissionsService
|
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
|
// Port of PS lines 1914-1926: system lists excluded from permission reporting
|
||||||
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -122,7 +138,17 @@ public class PermissionsService : IPermissionsService
|
|||||||
u => u.Title,
|
u => u.Title,
|
||||||
u => u.LoginName,
|
u => u.LoginName,
|
||||||
u => u.IsSiteAdmin));
|
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
|
var admins = ctx.Web.SiteUsers
|
||||||
.Where(u => u.IsSiteAdmin)
|
.Where(u => u.IsSiteAdmin)
|
||||||
@@ -280,7 +306,23 @@ public class PermissionsService : IPermissionsService
|
|||||||
ra => ra.Member.LoginName,
|
ra => ra.Member.LoginName,
|
||||||
ra => ra.Member.PrincipalType,
|
ra => ra.Member.PrincipalType,
|
||||||
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
|
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
|
// Skip inherited objects when IncludeInherited=false
|
||||||
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
|
if (!options.IncludeInherited && !obj.HasUniqueRoleAssignments)
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ public class ProfileService
|
|||||||
!Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _))
|
!Uri.TryCreate(profile.TenantUrl, UriKind.Absolute, out _))
|
||||||
throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile));
|
throw new ArgumentException("TenantUrl must be a valid absolute URL.", nameof(profile));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(profile.ClientId))
|
// ClientId is optional at creation time: the user can register the app from within
|
||||||
throw new ArgumentException("ClientId must not be empty.", nameof(profile));
|
// the tool, which will populate ClientId/AppId on the profile afterwards.
|
||||||
|
profile.ClientId ??= string.Empty;
|
||||||
|
|
||||||
var existing = (await _repository.LoadAsync()).ToList();
|
var existing = (await _repository.LoadAsync()).ToList();
|
||||||
existing.Add(profile);
|
existing.Add(profile);
|
||||||
|
|||||||
@@ -62,10 +62,25 @@ public class SearchService : ISearchService
|
|||||||
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
.FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults);
|
||||||
if (table == null || table.RowCount == 0) break;
|
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>()
|
// CSOM has returned ResultRows as either Hashtable or
|
||||||
.ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty);
|
// 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
|
// Skip SharePoint version history paths
|
||||||
string path = Str(dict, "Path");
|
string path = Str(dict, "Path");
|
||||||
|
|||||||
@@ -43,4 +43,14 @@ public class SettingsService
|
|||||||
settings.AutoTakeOwnership = enabled;
|
settings.AutoTakeOwnership = enabled;
|
||||||
await _repository.SaveAsync(settings);
|
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;
|
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))
|
foreach (var groupName in groupNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
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
|
try
|
||||||
{
|
{
|
||||||
var group = ctx.Web.SiteGroups.GetByName(groupName);
|
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,
|
IReadOnlyList<SiteInfo> sites,
|
||||||
ScanOptions options,
|
ScanOptions options,
|
||||||
IProgress<OperationProgress> progress,
|
IProgress<OperationProgress> progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null)
|
||||||
{
|
{
|
||||||
// Normalize target logins for case-insensitive matching.
|
// Normalize target logins for case-insensitive matching.
|
||||||
// Users may be identified by email ("alice@contoso.com") or full claim
|
// 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 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(TransformEntries(permEntries, targets, site));
|
||||||
allEntries.AddRange(userEntries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(new OperationProgress(sites.Count, sites.Count,
|
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]
|
[ObservableProperty]
|
||||||
private int _progressValue;
|
private int _progressValue;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isIndeterminate;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage.
|
/// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage.
|
||||||
/// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl.
|
/// 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;
|
IsRunning = true;
|
||||||
StatusMessage = string.Empty;
|
StatusMessage = string.Empty;
|
||||||
ProgressValue = 0;
|
ProgressValue = 0;
|
||||||
|
IsIndeterminate = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var progress = new Progress<OperationProgress>(p =>
|
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;
|
StatusMessage = p.Message;
|
||||||
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
|
WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p));
|
||||||
});
|
});
|
||||||
await RunOperationAsync(_cts.Token, progress);
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
StatusMessage = TranslationSource.Instance["status.cancelled"];
|
StatusMessage = TranslationSource.Instance["status.cancelled"];
|
||||||
|
IsIndeterminate = false;
|
||||||
_logger.LogInformation("Operation cancelled by user.");
|
_logger.LogInformation("Operation cancelled by user.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
|
StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}";
|
||||||
|
IsIndeterminate = false;
|
||||||
_logger.LogError(ex, "Operation failed.");
|
_logger.LogError(ex, "Operation failed.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
private readonly ILogger<ProfileManagementViewModel> _logger;
|
private readonly ILogger<ProfileManagementViewModel> _logger;
|
||||||
private readonly IAppRegistrationService _appRegistrationService;
|
private readonly IAppRegistrationService _appRegistrationService;
|
||||||
|
|
||||||
|
// Well-known public client (Microsoft Graph Command Line Tools) used as a bootstrap
|
||||||
|
// when a profile has no ClientId yet, so the user can sign in as admin and have the
|
||||||
|
// app registration created for them.
|
||||||
|
private const string BootstrapClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TenantProfile? _selectedProfile;
|
private TenantProfile? _selectedProfile;
|
||||||
|
|
||||||
@@ -137,7 +142,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
if (string.IsNullOrWhiteSpace(NewName)) return false;
|
||||||
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false;
|
||||||
if (string.IsNullOrWhiteSpace(NewClientId)) return false;
|
// ClientId is optional — leaving it blank lets the user register the app from within the tool.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +155,7 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Name = NewName.Trim(),
|
Name = NewName.Trim(),
|
||||||
TenantUrl = NewTenantUrl.Trim(),
|
TenantUrl = NewTenantUrl.Trim(),
|
||||||
ClientId = NewClientId.Trim()
|
ClientId = NewClientId?.Trim() ?? string.Empty
|
||||||
};
|
};
|
||||||
await _profileService.AddProfileAsync(profile);
|
await _profileService.AddProfileAsync(profile);
|
||||||
Profiles.Add(profile);
|
Profiles.Add(profile);
|
||||||
@@ -299,7 +304,14 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.checking"];
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(SelectedProfile.ClientId, ct);
|
// Use the profile's own ClientId if it has one; otherwise bootstrap with the
|
||||||
|
// Microsoft Graph Command Line Tools public client so a first-time profile
|
||||||
|
// (name + URL only) can still perform the admin check and registration.
|
||||||
|
var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId)
|
||||||
|
? BootstrapClientId
|
||||||
|
: SelectedProfile.ClientId;
|
||||||
|
|
||||||
|
var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct);
|
||||||
if (!isAdmin)
|
if (!isAdmin)
|
||||||
{
|
{
|
||||||
ShowFallbackInstructions = true;
|
ShowFallbackInstructions = true;
|
||||||
@@ -308,11 +320,15 @@ public partial class ProfileManagementViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.registering"];
|
||||||
var result = await _appRegistrationService.RegisterAsync(SelectedProfile.ClientId, SelectedProfile.Name, ct);
|
var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
SelectedProfile.AppId = result.AppId;
|
SelectedProfile.AppId = result.AppId;
|
||||||
|
// If the profile had no ClientId, adopt the freshly registered app's id
|
||||||
|
// so subsequent sign-ins use the profile's own app registration.
|
||||||
|
if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId))
|
||||||
|
SelectedProfile.ClientId = result.AppId!;
|
||||||
await _profileService.UpdateProfileAsync(SelectedProfile);
|
await _profileService.UpdateProfileAsync(SelectedProfile);
|
||||||
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
|
RegistrationStatus = TranslationSource.Instance["profile.register.success"];
|
||||||
OnPropertyChanged(nameof(HasRegisteredApp));
|
OnPropertyChanged(nameof(HasRegisteredApp));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
private readonly IDuplicatesService _duplicatesService;
|
private readonly IDuplicatesService _duplicatesService;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly DuplicatesHtmlExportService _htmlExportService;
|
private readonly DuplicatesHtmlExportService _htmlExportService;
|
||||||
|
private readonly DuplicatesCsvExportService _csvExportService;
|
||||||
private readonly IBrandingService _brandingService;
|
private readonly IBrandingService _brandingService;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
private TenantProfile? _currentProfile;
|
private TenantProfile? _currentProfile;
|
||||||
@@ -55,16 +56,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
_results = value;
|
_results = value;
|
||||||
OnPropertyChanged();
|
OnPropertyChanged();
|
||||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
||||||
public TenantProfile? CurrentProfile => _currentProfile;
|
public TenantProfile? CurrentProfile => _currentProfile;
|
||||||
|
|
||||||
public DuplicatesViewModel(
|
public DuplicatesViewModel(
|
||||||
IDuplicatesService duplicatesService,
|
IDuplicatesService duplicatesService,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
DuplicatesHtmlExportService htmlExportService,
|
DuplicatesHtmlExportService htmlExportService,
|
||||||
|
DuplicatesCsvExportService csvExportService,
|
||||||
IBrandingService brandingService,
|
IBrandingService brandingService,
|
||||||
ILogger<FeatureViewModelBase> logger)
|
ILogger<FeatureViewModelBase> logger)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
@@ -72,10 +76,12 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
_duplicatesService = duplicatesService;
|
_duplicatesService = duplicatesService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_htmlExportService = htmlExportService;
|
_htmlExportService = htmlExportService;
|
||||||
|
_csvExportService = csvExportService;
|
||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
@@ -152,6 +158,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
|||||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||||
OnPropertyChanged(nameof(CurrentProfile));
|
OnPropertyChanged(nameof(CurrentProfile));
|
||||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
||||||
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
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."); }
|
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.
|
/// Derives the tenant admin URL from a standard tenant URL.
|
||||||
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
||||||
/// </summary>
|
/// </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)
|
internal static string DeriveAdminUrl(string tenantUrl)
|
||||||
{
|
{
|
||||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||||
@@ -408,29 +434,57 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
|
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")
|
.Where(r => r.PrincipalType == "SharePointGroup")
|
||||||
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
.SelectMany(r => r.Users
|
||||||
.Select(n => n.Trim())
|
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Where(n => n.Length > 0)
|
.Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Where(x => x.GroupName.Length > 0)
|
||||||
|
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.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(
|
var distinctNames = bucket
|
||||||
_currentProfile, CancellationToken.None);
|
.Select(x => x.GroupName)
|
||||||
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
.ToList();
|
||||||
}
|
|
||||||
catch (Exception ex)
|
try
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
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 SettingsService _settingsService;
|
||||||
private readonly IBrandingService _brandingService;
|
private readonly IBrandingService _brandingService;
|
||||||
|
private readonly ThemeManager _themeManager;
|
||||||
|
|
||||||
private string _selectedLanguage = "en";
|
private string _selectedLanguage = "en";
|
||||||
public string SelectedLanguage
|
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;
|
private bool _autoTakeOwnership;
|
||||||
public bool AutoTakeOwnership
|
public bool AutoTakeOwnership
|
||||||
{
|
{
|
||||||
@@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
|||||||
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
|
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
|
||||||
public IAsyncRelayCommand ClearMspLogoCommand { 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)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
|
_themeManager = themeManager;
|
||||||
BrowseFolderCommand = new RelayCommand(BrowseFolder);
|
BrowseFolderCommand = new RelayCommand(BrowseFolder);
|
||||||
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
|
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
|
||||||
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
|
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
|
||||||
@@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase
|
|||||||
_selectedLanguage = settings.Lang;
|
_selectedLanguage = settings.Lang;
|
||||||
_dataFolder = settings.DataFolder;
|
_dataFolder = settings.DataFolder;
|
||||||
_autoTakeOwnership = settings.AutoTakeOwnership;
|
_autoTakeOwnership = settings.AutoTakeOwnership;
|
||||||
|
_selectedTheme = settings.Theme;
|
||||||
OnPropertyChanged(nameof(SelectedLanguage));
|
OnPropertyChanged(nameof(SelectedLanguage));
|
||||||
OnPropertyChanged(nameof(DataFolder));
|
OnPropertyChanged(nameof(DataFolder));
|
||||||
OnPropertyChanged(nameof(AutoTakeOwnership));
|
OnPropertyChanged(nameof(AutoTakeOwnership));
|
||||||
|
OnPropertyChanged(nameof(SelectedTheme));
|
||||||
|
|
||||||
var mspLogo = await _brandingService.GetMspLogoAsync();
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
||||||
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
|
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)
|
private async Task ApplyLanguageAsync(string code)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
private readonly StorageCsvExportService _csvExportService;
|
private readonly StorageCsvExportService _csvExportService;
|
||||||
private readonly StorageHtmlExportService _htmlExportService;
|
private readonly StorageHtmlExportService _htmlExportService;
|
||||||
private readonly IBrandingService? _brandingService;
|
private readonly IBrandingService? _brandingService;
|
||||||
|
private readonly IOwnershipElevationService? _ownershipService;
|
||||||
|
private readonly SettingsService? _settingsService;
|
||||||
|
private readonly ThemeManager? _themeManager;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
private TenantProfile? _currentProfile;
|
private TenantProfile? _currentProfile;
|
||||||
|
|
||||||
@@ -136,7 +139,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
StorageCsvExportService csvExportService,
|
StorageCsvExportService csvExportService,
|
||||||
StorageHtmlExportService htmlExportService,
|
StorageHtmlExportService htmlExportService,
|
||||||
IBrandingService brandingService,
|
IBrandingService brandingService,
|
||||||
ILogger<FeatureViewModelBase> logger)
|
ILogger<FeatureViewModelBase> logger,
|
||||||
|
IOwnershipElevationService? ownershipService = null,
|
||||||
|
SettingsService? settingsService = null,
|
||||||
|
ThemeManager? themeManager = null)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_storageService = storageService;
|
_storageService = storageService;
|
||||||
@@ -144,10 +150,16 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
_csvExportService = csvExportService;
|
_csvExportService = csvExportService;
|
||||||
_htmlExportService = htmlExportService;
|
_htmlExportService = htmlExportService;
|
||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
|
_ownershipService = ownershipService;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_themeManager = themeManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
|
|
||||||
|
if (_themeManager is not null)
|
||||||
|
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Test constructor — omits export services.</summary>
|
/// <summary>Test constructor — omits export services.</summary>
|
||||||
@@ -194,6 +206,8 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
var allNodes = new List<StorageNode>();
|
var allNodes = new List<StorageNode>();
|
||||||
var allTypeMetrics = new List<FileTypeMetric>();
|
var allTypeMetrics = new List<FileTypeMetric>();
|
||||||
|
|
||||||
|
var autoOwnership = await IsAutoTakeOwnershipEnabled();
|
||||||
|
|
||||||
int i = 0;
|
int i = 0;
|
||||||
foreach (var url in nonEmpty)
|
foreach (var url in nonEmpty)
|
||||||
{
|
{
|
||||||
@@ -207,9 +221,30 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
ClientId = _currentProfile.ClientId,
|
ClientId = _currentProfile.ClientId,
|
||||||
Name = _currentProfile.Name
|
Name = _currentProfile.Name
|
||||||
};
|
};
|
||||||
|
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
|
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
|
// Backfill any libraries where StorageMetrics returned zeros
|
||||||
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
|
||||||
@@ -258,6 +293,24 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
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 void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
||||||
|
|
||||||
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||||
@@ -324,6 +377,9 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
UpdateChartSeries();
|
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()
|
private void UpdateChartSeries()
|
||||||
{
|
{
|
||||||
var metrics = FileTypeMetrics.ToList();
|
var metrics = FileTypeMetrics.ToList();
|
||||||
@@ -361,6 +417,7 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
HoverPushout = 8,
|
HoverPushout = 8,
|
||||||
MaxRadialColumnWidth = 60,
|
MaxRadialColumnWidth = 60,
|
||||||
DataLabelsFormatter = _ => m.DisplayLabel,
|
DataLabelsFormatter = _ => m.DisplayLabel,
|
||||||
|
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
|
||||||
ToolTipLabelFormatter = _ =>
|
ToolTipLabelFormatter = _ =>
|
||||||
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
|
||||||
IsVisibleAtLegend = true,
|
IsVisibleAtLegend = true,
|
||||||
@@ -379,7 +436,8 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
{
|
{
|
||||||
int idx = (int)point.Index;
|
int idx = (int)point.Index;
|
||||||
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
|
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
|
||||||
}
|
},
|
||||||
|
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -388,7 +446,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
|||||||
new Axis
|
new Axis
|
||||||
{
|
{
|
||||||
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
|
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
|
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 IFileTransferService _transferService;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly BulkResultCsvExportService _exportService;
|
private readonly BulkResultCsvExportService _exportService;
|
||||||
|
private readonly IOwnershipElevationService? _ownershipService;
|
||||||
|
private readonly SettingsService? _settingsService;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
private TenantProfile? _currentProfile;
|
private TenantProfile? _currentProfile;
|
||||||
private bool _hasLocalSourceSiteOverride;
|
private bool _hasLocalSourceSiteOverride;
|
||||||
@@ -32,6 +34,17 @@ public partial class TransferViewModel : FeatureViewModelBase
|
|||||||
// Transfer options
|
// Transfer options
|
||||||
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
|
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
|
||||||
[ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;
|
[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
|
// Results
|
||||||
[ObservableProperty] private string _resultSummary = string.Empty;
|
[ObservableProperty] private string _resultSummary = string.Empty;
|
||||||
@@ -51,12 +64,16 @@ public partial class TransferViewModel : FeatureViewModelBase
|
|||||||
IFileTransferService transferService,
|
IFileTransferService transferService,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
BulkResultCsvExportService exportService,
|
BulkResultCsvExportService exportService,
|
||||||
ILogger<FeatureViewModelBase> logger)
|
ILogger<FeatureViewModelBase> logger,
|
||||||
|
IOwnershipElevationService? ownershipService = null,
|
||||||
|
SettingsService? settingsService = null)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_transferService = transferService;
|
_transferService = transferService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_exportService = exportService;
|
_exportService = exportService;
|
||||||
|
_ownershipService = ownershipService;
|
||||||
|
_settingsService = settingsService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
|
||||||
@@ -108,6 +125,9 @@ public partial class TransferViewModel : FeatureViewModelBase
|
|||||||
DestinationFolderPath = DestFolderPath,
|
DestinationFolderPath = DestFolderPath,
|
||||||
Mode = TransferMode,
|
Mode = TransferMode,
|
||||||
ConflictPolicy = ConflictPolicy,
|
ConflictPolicy = ConflictPolicy,
|
||||||
|
SelectedFilePaths = SelectedFilePaths.ToList(),
|
||||||
|
IncludeSourceFolder = IncludeSourceFolder,
|
||||||
|
CopyFolderContents = CopyFolderContents,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build per-site profiles so SessionManager can resolve contexts
|
// 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 srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
|
||||||
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, 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
|
// Update UI on dispatcher
|
||||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
@@ -182,6 +228,34 @@ public partial class TransferViewModel : FeatureViewModelBase
|
|||||||
DestFolderPath = string.Empty;
|
DestFolderPath = string.Empty;
|
||||||
ResultSummary = string.Empty;
|
ResultSummary = string.Empty;
|
||||||
HasFailures = false;
|
HasFailures = false;
|
||||||
|
SelectedFilePaths.Clear();
|
||||||
|
OnPropertyChanged(nameof(SelectedFileCount));
|
||||||
_lastResult = null;
|
_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 UserAccessHtmlExportService? _htmlExportService;
|
||||||
private readonly IBrandingService? _brandingService;
|
private readonly IBrandingService? _brandingService;
|
||||||
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
|
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
|
||||||
|
private readonly IOwnershipElevationService? _ownershipService;
|
||||||
|
private readonly SettingsService? _settingsService;
|
||||||
private readonly ILogger<FeatureViewModelBase> _logger;
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
||||||
|
|
||||||
// ── People picker debounce ──────────────────────────────────────────────
|
// ── People picker debounce ──────────────────────────────────────────────
|
||||||
@@ -163,7 +165,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
UserAccessHtmlExportService htmlExportService,
|
UserAccessHtmlExportService htmlExportService,
|
||||||
IBrandingService brandingService,
|
IBrandingService brandingService,
|
||||||
IGraphUserDirectoryService graphUserDirectoryService,
|
IGraphUserDirectoryService graphUserDirectoryService,
|
||||||
ILogger<FeatureViewModelBase> logger)
|
ILogger<FeatureViewModelBase> logger,
|
||||||
|
IOwnershipElevationService? ownershipService = null,
|
||||||
|
SettingsService? settingsService = null)
|
||||||
: base(logger)
|
: base(logger)
|
||||||
{
|
{
|
||||||
_auditService = auditService;
|
_auditService = auditService;
|
||||||
@@ -173,6 +177,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
_htmlExportService = htmlExportService;
|
_htmlExportService = htmlExportService;
|
||||||
_brandingService = brandingService;
|
_brandingService = brandingService;
|
||||||
_graphUserDirectoryService = graphUserDirectoryService;
|
_graphUserDirectoryService = graphUserDirectoryService;
|
||||||
|
_ownershipService = ownershipService;
|
||||||
|
_settingsService = settingsService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
||||||
@@ -273,6 +279,35 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
return;
|
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(
|
var entries = await _auditService.AuditUsersAsync(
|
||||||
_sessionManager,
|
_sessionManager,
|
||||||
_currentProfile,
|
_currentProfile,
|
||||||
@@ -280,7 +315,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
effectiveSites,
|
effectiveSites,
|
||||||
scanOptions,
|
scanOptions,
|
||||||
progress,
|
progress,
|
||||||
ct);
|
ct,
|
||||||
|
onAccessDenied);
|
||||||
|
|
||||||
// Update Results on the UI thread — clear + repopulate (not replace)
|
// Update Results on the UI thread — clear + repopulate (not replace)
|
||||||
// so the CollectionViewSource bound to ResultsView stays connected.
|
// so the CollectionViewSource bound to ResultsView stays connected.
|
||||||
@@ -307,6 +343,26 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
ExportHtmlCommand.NotifyCanExecuteChanged();
|
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 ─────────────────────────────────────────────────────
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
protected override void OnTenantSwitched(TenantProfile profile)
|
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"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}"
|
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}"
|
||||||
Width="450" Height="220" WindowStartupLocation="CenterOwner"
|
Width="450" Height="220" WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource AppBgBrush}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
ResizeMode="NoResize">
|
ResizeMode="NoResize">
|
||||||
<Grid Margin="20">
|
<Grid Margin="20">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
|||||||
@@ -3,13 +3,23 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}"
|
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">
|
ResizeMode="CanResizeWithGrip">
|
||||||
<DockPanel Margin="10">
|
<DockPanel Margin="10">
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10"
|
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,10"
|
||||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.loading]}" />
|
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 -->
|
<!-- Buttons -->
|
||||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Right" Margin="0,10,0,0">
|
HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
|
|||||||
@@ -8,13 +8,37 @@ namespace SharepointToolbox.Views.Dialogs;
|
|||||||
public partial class FolderBrowserDialog : Window
|
public partial class FolderBrowserDialog : Window
|
||||||
{
|
{
|
||||||
private readonly ClientContext _ctx;
|
private readonly ClientContext _ctx;
|
||||||
|
private readonly bool _allowFileSelection;
|
||||||
|
private readonly bool _allowFolderCreation;
|
||||||
|
|
||||||
public string SelectedLibrary { get; private set; } = string.Empty;
|
public string SelectedLibrary { get; private set; } = string.Empty;
|
||||||
public string SelectedFolderPath { 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();
|
InitializeComponent();
|
||||||
_ctx = ctx;
|
_ctx = ctx;
|
||||||
|
_allowFileSelection = allowFileSelection;
|
||||||
|
_allowFolderCreation = allowFolderCreation;
|
||||||
|
if (allowFolderCreation)
|
||||||
|
ActionBar.Visibility = Visibility.Visible;
|
||||||
Loaded += OnLoaded;
|
Loaded += OnLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,24 +46,19 @@ public partial class FolderBrowserDialog : Window
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Load libraries
|
|
||||||
var web = _ctx.Web;
|
var web = _ctx.Web;
|
||||||
var lists = _ctx.LoadQuery(web.Lists
|
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));
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary));
|
||||||
var progress = new Progress<Core.Models.OperationProgress>();
|
var progress = new Progress<Core.Models.OperationProgress>();
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||||
|
|
||||||
foreach (var list in lists)
|
foreach (var list in lists)
|
||||||
{
|
{
|
||||||
var libNode = new TreeViewItem
|
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||||
{
|
var libNode = MakeFolderNode(list.Title,
|
||||||
Header = list.Title,
|
new FolderNodeInfo(list.Title, string.Empty, rootUrl));
|
||||||
Tag = new FolderNodeInfo(list.Title, string.Empty),
|
|
||||||
};
|
|
||||||
// Add dummy child for expand arrow
|
|
||||||
libNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
|
||||||
libNode.Expanded += LibNode_Expanded;
|
|
||||||
FolderTree.Items.Add(libNode);
|
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)
|
if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only load children once
|
// Only load children once.
|
||||||
if (node.Items.Count == 1 && node.Items[0] is TreeViewItem dummy && dummy.Header?.ToString() == "Loading...")
|
if (!(node.Items.Count == 1
|
||||||
|
&& node.Items[0] is TreeViewItem dummy
|
||||||
|
&& dummy.Header?.ToString() == "Loading..."))
|
||||||
|
return;
|
||||||
|
|
||||||
|
node.Items.Clear();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
node.Items.Clear();
|
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
||||||
try
|
_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)
|
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||||
? GetLibraryRootUrl(info.LibraryTitle)
|
continue;
|
||||||
: info.FolderPath;
|
|
||||||
|
|
||||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl);
|
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||||
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
? subFolder.Name
|
||||||
var progress = new Progress<Core.Models.OperationProgress>();
|
: $"{info.RelativePath}/{subFolder.Name}";
|
||||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
|
||||||
|
|
||||||
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")
|
// Library-relative path for the file (used by the transfer service)
|
||||||
continue;
|
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||||
|
? file.Name
|
||||||
|
: $"{info.RelativePath}/{file.Name}";
|
||||||
|
|
||||||
var childNode = new TreeViewItem
|
var cb = new CheckBox
|
||||||
{
|
{
|
||||||
Header = subFolder.Name,
|
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
||||||
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
|
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||||
|
Margin = new Thickness(4, 2, 0, 2),
|
||||||
};
|
};
|
||||||
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
cb.Checked += FileCheckbox_Toggled;
|
||||||
childNode.Expanded += LibNode_Expanded;
|
cb.Unchecked += FileCheckbox_Toggled;
|
||||||
node.Items.Add(childNode);
|
_fileCheckboxes.Add(cb);
|
||||||
|
|
||||||
|
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
|
||||||
|
node.Items.Add(fileItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
}
|
||||||
{
|
catch (Exception ex)
|
||||||
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
{
|
||||||
}
|
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);
|
if (fileCount <= 0) return name;
|
||||||
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}";
|
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)
|
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)
|
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
|
||||||
{
|
{
|
||||||
SelectedLibrary = info.LibraryTitle;
|
SelectedLibrary = info.LibraryTitle;
|
||||||
SelectedFolderPath = info.FolderPath;
|
SelectedFolderPath = info.RelativePath;
|
||||||
SelectButton.IsEnabled = true;
|
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)
|
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;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
@@ -121,5 +274,6 @@ public partial class FolderBrowserDialog : Window
|
|||||||
Close();
|
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"
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization"
|
||||||
Title="Manage Profiles" Width="500" Height="750"
|
Title="Manage Profiles" Width="500" Height="750"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
|
Background="{DynamicResource AppBgBrush}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
ResizeMode="NoResize">
|
ResizeMode="NoResize">
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
|
||||||
@@ -35,6 +38,7 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}"
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.name]}"
|
||||||
Grid.Row="0" Grid.Column="0" />
|
Grid.Row="0" Grid.Column="0" />
|
||||||
@@ -48,12 +52,15 @@
|
|||||||
Grid.Row="2" Grid.Column="0" />
|
Grid.Row="2" Grid.Column="0" />
|
||||||
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Text="{Binding NewClientId, UpdateSourceTrigger=PropertyChanged}"
|
||||||
Grid.Row="2" Grid.Column="1" Margin="0,2" />
|
Grid.Row="2" Grid.Column="1" Margin="0,2" />
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1" Margin="0,2,0,0"
|
||||||
|
FontSize="11" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap"
|
||||||
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.clientid.hint]}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Client Logo -->
|
<!-- Client Logo -->
|
||||||
<StackPanel Grid.Row="3" Margin="0,8,0,8">
|
<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" />
|
<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">
|
HorizontalAlignment="Left" MinWidth="200" MinHeight="50">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
<Image Source="{Binding ClientLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
||||||
@@ -61,7 +68,7 @@
|
|||||||
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
Visibility="{Binding ClientLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.nopreview]}"
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
Foreground="#999999" FontStyle="Italic">
|
Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Setter Property="Visibility" Value="Visible" />
|
<Setter Property="Visibility" Value="Visible" />
|
||||||
@@ -83,7 +90,7 @@
|
|||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.logo.autopull]}"
|
||||||
Command="{Binding AutoPullClientLogoCommand}" Width="130" />
|
Command="{Binding AutoPullClientLogoCommand}" Width="130" />
|
||||||
</StackPanel>
|
</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}}" />
|
Visibility="{Binding ValidationMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -103,11 +110,11 @@
|
|||||||
|
|
||||||
<!-- Status text -->
|
<!-- Status text -->
|
||||||
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
|
<TextBlock Text="{Binding RegistrationStatus}" FontSize="11" Margin="0,2,0,0"
|
||||||
Foreground="#006600"
|
Foreground="{DynamicResource SuccessBrush}"
|
||||||
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
|
Visibility="{Binding RegistrationStatus, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
|
|
||||||
<!-- Fallback instructions panel -->
|
<!-- 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}}">
|
Visibility="{Binding ShowFallbackInstructions, Converter={StaticResource BooleanToVisibilityConverter}}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[profile.fallback.step1]}"
|
<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"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Select Sites" Width="600" Height="500"
|
Title="Select Sites" Width="600" Height="500"
|
||||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
|
Background="{DynamicResource AppBgBrush}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextOptions.TextFormattingMode="Ideal"
|
||||||
Loaded="Window_Loaded">
|
Loaded="Window_Loaded">
|
||||||
<Grid Margin="12">
|
<Grid Margin="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
<!-- Site list with checkboxes -->
|
<!-- Site list with checkboxes -->
|
||||||
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
|
<ListView x:Name="SiteList" Grid.Row="1" Margin="0,0,0,8"
|
||||||
SelectionMode="Single"
|
SelectionMode="Single"
|
||||||
BorderThickness="1" BorderBrush="#CCCCCC">
|
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
||||||
<ListView.View>
|
<ListView.View>
|
||||||
<GridView>
|
<GridView>
|
||||||
<GridViewColumn Header="" Width="32">
|
<GridViewColumn Header="" Width="32">
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
|
|
||||||
<!-- Status text -->
|
<!-- Status text -->
|
||||||
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
<TextBlock x:Name="StatusText" Grid.Row="2" Margin="0,0,0,8"
|
||||||
Foreground="#555555" FontSize="11" />
|
Foreground="{DynamicResource TextMutedBrush}" FontSize="11" />
|
||||||
|
|
||||||
<!-- Button row -->
|
<!-- Button row -->
|
||||||
<DockPanel Grid.Row="3">
|
<DockPanel Grid.Row="3">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkMembersView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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">
|
<DockPanel Margin="10">
|
||||||
<!-- Options Panel (Left) -->
|
<!-- 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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.import]}"
|
||||||
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulkmembers.example]}"
|
||||||
@@ -19,8 +22,8 @@
|
|||||||
Command="{Binding CancelCommand}"
|
Command="{Binding CancelCommand}"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
|
|
||||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
<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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
||||||
Command="{Binding ExportFailedCommand}" />
|
Command="{Binding ExportFailedCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Preview DataGrid (Right) -->
|
<!-- Preview DataGrid (Right) -->
|
||||||
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.BulkSitesView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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">
|
<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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.import]}"
|
||||||
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
Command="{Binding ImportCsvCommand}" Margin="0,0,0,5" />
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulksites.example]}"
|
||||||
@@ -18,8 +21,8 @@
|
|||||||
Command="{Binding CancelCommand}"
|
Command="{Binding CancelCommand}"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
|
|
||||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
<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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
||||||
Command="{Binding ExportFailedCommand}" />
|
Command="{Binding ExportFailedCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
||||||
IsReadOnly="True" CanUserSortColumns="True">
|
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">
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.dup.criteria]}" Margin="0,0,0,8">
|
||||||
<StackPanel Margin="4">
|
<StackPanel Margin="4">
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.dup.note]}"
|
<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]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.size]}"
|
||||||
IsChecked="{Binding MatchSize}" Margin="0,2" />
|
IsChecked="{Binding MatchSize}" Margin="0,2" />
|
||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.dup.created]}"
|
<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]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
|
||||||
Command="{Binding CancelCommand}" Height="28" Margin="0,0,0,8" />
|
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" />
|
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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Results DataGrid -->
|
<!-- Results DataGrid -->
|
||||||
<DataGrid ItemsSource="{Binding Results}" IsReadOnly="True" AutoGenerateColumns="False"
|
<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>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
|
<DataGridTextColumn Header="Group" Binding="{Binding GroupName}" Width="160" />
|
||||||
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
|
<DataGridTextColumn Header="Copies" Binding="{Binding GroupSize}" Width="60"
|
||||||
@@ -65,7 +70,7 @@
|
|||||||
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
Width="90" ElementStyle="{StaticResource RightAlignStyle}" />
|
||||||
<DataGridTextColumn Header="Created" Binding="{Binding Created, StringFormat=yyyy-MM-dd}" Width="100" />
|
<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="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.Columns>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.FolderStructureView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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">
|
<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 -->
|
<!-- Library input -->
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderstruct.library]}"
|
||||||
Margin="0,0,0,3" />
|
Margin="0,0,0,3" />
|
||||||
@@ -23,14 +26,15 @@
|
|||||||
Command="{Binding CancelCommand}"
|
Command="{Binding CancelCommand}"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
|
|
||||||
<ProgressBar Height="20" Margin="0,10,0,5" Value="{Binding ProgressValue}"
|
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
<TextBlock Text="{Binding ResultSummary}" FontWeight="Bold" Margin="0,10,0,5" />
|
||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.exportfailed]}"
|
||||||
Command="{Binding ExportFailedCommand}" />
|
Command="{Binding ExportFailedCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
<DataGrid ItemsSource="{Binding PreviewRows}" AutoGenerateColumns="False"
|
||||||
IsReadOnly="True" CanUserSortColumns="True">
|
IsReadOnly="True" CanUserSortColumns="True">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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"
|
||||||
xmlns:models="clr-namespace:SharepointToolbox.Core.Models"
|
xmlns:models="clr-namespace:SharepointToolbox.Core.Models"
|
||||||
xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters">
|
xmlns:converters="clr-namespace:SharepointToolbox.Core.Converters">
|
||||||
|
|
||||||
@@ -21,7 +22,9 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Left panel: Scan configuration -->
|
<!-- 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 -->
|
<!-- Scan Options GroupBox -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
|
||||||
@@ -125,6 +128,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Right panel: Summary + Results -->
|
<!-- Right panel: Summary + Results -->
|
||||||
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8">
|
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,8">
|
||||||
@@ -153,7 +157,8 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<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>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
<Setter Property="Background" Value="#F3F4F6" />
|
<Setter Property="Background" Value="#F3F4F6" />
|
||||||
@@ -182,7 +187,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Border.Style>
|
</Border.Style>
|
||||||
<StackPanel>
|
<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 Text="{Binding Label}" FontSize="11" Foreground="#555" />
|
||||||
<TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0">
|
<TextBlock FontSize="10" Foreground="#888" Margin="0,2,0,0">
|
||||||
<Run Text="{Binding DistinctUsers, Mode=OneWay}" />
|
<Run Text="{Binding DistinctUsers, Mode=OneWay}" />
|
||||||
@@ -222,19 +227,24 @@
|
|||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
|
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.High}">
|
||||||
<Setter Property="Background" Value="#FEF2F2" />
|
<Setter Property="Background" Value="#FEF2F2" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
|
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Medium}">
|
||||||
<Setter Property="Background" Value="#FFFBEB" />
|
<Setter Property="Background" Value="#FFFBEB" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
|
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.Low}">
|
||||||
<Setter Property="Background" Value="#ECFDF5" />
|
<Setter Property="Background" Value="#ECFDF5" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
|
<DataTrigger Binding="{Binding RiskLevel}" Value="{x:Static models:RiskLevel.ReadOnly}">
|
||||||
<Setter Property="Background" Value="#EFF6FF" />
|
<Setter Property="Background" Value="#EFF6FF" />
|
||||||
|
<Setter Property="Foreground" Value="#1F2430" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<!-- Phase 18: auto-elevated rows get amber background + tooltip -->
|
<!-- Phase 18: auto-elevated rows get amber background + tooltip -->
|
||||||
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
|
<DataTrigger Binding="{Binding WasAutoElevated}" Value="True">
|
||||||
<Setter Property="Background" Value="#FFF9E6" />
|
<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]}" />
|
<Setter Property="ToolTip" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[permissions.elevated.tooltip]}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
@@ -286,9 +296,8 @@
|
|||||||
<!-- Bottom: status bar spanning both columns -->
|
<!-- Bottom: status bar spanning both columns -->
|
||||||
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
|
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
|
||||||
<StatusBarItem>
|
<StatusBarItem>
|
||||||
<ProgressBar Width="150" Height="14"
|
<common:Spinner Width="14" Height="14"
|
||||||
Value="{Binding ProgressValue}"
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
Minimum="0" Maximum="100" />
|
|
||||||
</StatusBarItem>
|
</StatusBarItem>
|
||||||
<StatusBarItem Content="{Binding StatusMessage}" />
|
<StatusBarItem Content="{Binding StatusMessage}" />
|
||||||
</StatusBar>
|
</StatusBar>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel Margin="16">
|
<StackPanel Margin="16">
|
||||||
<!-- Language -->
|
<!-- Language -->
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" />
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.language]}" />
|
||||||
@@ -16,6 +17,21 @@
|
|||||||
|
|
||||||
<Separator Margin="0,12" />
|
<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 -->
|
<!-- Data folder -->
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" />
|
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.folder]}" />
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@@ -29,7 +45,7 @@
|
|||||||
|
|
||||||
<!-- MSP Logo -->
|
<!-- MSP Logo -->
|
||||||
<Label Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.title]}" />
|
<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">
|
HorizontalAlignment="Left" MinWidth="200" MinHeight="60" Margin="0,4,0,0">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
<Image Source="{Binding MspLogoPreview, Converter={StaticResource Base64ToImageConverter}}"
|
||||||
@@ -37,7 +53,7 @@
|
|||||||
Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
Visibility="{Binding MspLogoPreview, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.logo.nopreview]}"
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||||
Foreground="#999999" FontStyle="Italic">
|
Foreground="{DynamicResource TextMutedBrush}" FontStyle="Italic">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Setter Property="Visibility" Value="Visible" />
|
<Setter Property="Visibility" Value="Visible" />
|
||||||
@@ -65,9 +81,10 @@
|
|||||||
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}"
|
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.auto]}"
|
||||||
Margin="0,4,0,0" />
|
Margin="0,4,0,0" />
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[settings.ownership.description]}"
|
<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}}" />
|
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisibilityConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
|
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
|
||||||
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
|
IsChecked="{Binding IsMaxDepth}" Margin="0,2" />
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.note]}"
|
<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" />
|
Margin="0,6,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap"
|
||||||
FontSize="11" Foreground="#555" Margin="0,4" />
|
FontSize="11" Foreground="{DynamicResource TextMutedBrush}" Margin="0,4" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Summary bar -->
|
<!-- 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>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
<Setter Property="Visibility" Value="Collapsed" />
|
<Setter Property="Visibility" Value="Collapsed" />
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
|
|
||||||
<!-- Splitter between DataGrid and Chart -->
|
<!-- Splitter between DataGrid and Chart -->
|
||||||
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
|
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch"
|
||||||
Background="#DDD" ResizeDirection="Rows" />
|
Background="{DynamicResource BorderSoftBrush}" ResizeDirection="Rows" />
|
||||||
|
|
||||||
<!-- Chart panel -->
|
<!-- Chart panel -->
|
||||||
<Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4"
|
<Border Grid.Row="3" BorderBrush="{DynamicResource BorderSoftBrush}" BorderThickness="1" CornerRadius="4"
|
||||||
Padding="8" Background="White">
|
Padding="8" Background="{DynamicResource SurfaceBrush}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<!-- Chart title -->
|
<!-- Chart title -->
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}"
|
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.title]}"
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
<!-- No data placeholder -->
|
<!-- No data placeholder -->
|
||||||
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"
|
<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]}">
|
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[stor.chart.nodata]}">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
|
||||||
<DockPanel Margin="10">
|
<DockPanel Margin="10">
|
||||||
<!-- Left panel: Capture and Apply -->
|
<!-- 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 -->
|
<!-- Capture Section -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[templates.capture]}"
|
||||||
Margin="0,0,0,10">
|
Margin="0,0,0,10">
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" />
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" Margin="0,5,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Right panel: Template list -->
|
<!-- Right panel: Template list -->
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.TransferView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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">
|
<DockPanel Margin="10">
|
||||||
<!-- Options Panel (Left) -->
|
<!-- 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 -->
|
<!-- Source -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.sourcesite]}"
|
||||||
Margin="0,0,0,10">
|
Margin="0,0,0,10">
|
||||||
@@ -14,7 +17,19 @@
|
|||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
|
||||||
Click="BrowseSource_Click" Margin="0,0,0,5" />
|
Click="BrowseSource_Click" Margin="0,0,0,5" />
|
||||||
<TextBlock Text="{Binding SourceLibrary}" FontWeight="SemiBold" />
|
<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>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
@@ -27,7 +42,7 @@
|
|||||||
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
|
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[transfer.browse]}"
|
||||||
Click="BrowseDest_Click" Margin="0,0,0,5" />
|
Click="BrowseDest_Click" Margin="0,0,0,5" />
|
||||||
<TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
|
<TextBlock Text="{Binding DestLibrary}" FontWeight="SemiBold" />
|
||||||
<TextBlock Text="{Binding DestFolderPath}" Foreground="Gray" />
|
<TextBlock Text="{Binding DestFolderPath}" Foreground="{DynamicResource TextMutedBrush}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
@@ -62,9 +77,8 @@
|
|||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
<ProgressBar Height="20" Margin="0,10,0,5"
|
<common:Spinner Width="20" Height="20" HorizontalAlignment="Left" Margin="0,10,0,5"
|
||||||
Value="{Binding ProgressValue}"
|
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
Visibility="{Binding IsRunning, Converter={StaticResource BoolToVisibilityConverter}}" />
|
|
||||||
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
@@ -74,6 +88,7 @@
|
|||||||
Command="{Binding ExportFailedCommand}"
|
Command="{Binding ExportFailedCommand}"
|
||||||
Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
|
Visibility="{Binding HasFailures, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Right panel placeholder for future enhancements -->
|
<!-- Right panel placeholder for future enhancements -->
|
||||||
<Border />
|
<Border />
|
||||||
|
|||||||
@@ -53,11 +53,15 @@ public partial class TransferView : UserControl
|
|||||||
ClientId = _viewModel.CurrentProfile.ClientId,
|
ClientId = _viewModel.CurrentProfile.ClientId,
|
||||||
};
|
};
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
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)
|
if (folderBrowser.ShowDialog() == true)
|
||||||
{
|
{
|
||||||
_viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
|
_viewModel.SourceLibrary = folderBrowser.SelectedLibrary;
|
||||||
_viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
|
_viewModel.SourceFolderPath = folderBrowser.SelectedFolderPath;
|
||||||
|
_viewModel.SetSelectedFiles(folderBrowser.SelectedFilePaths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +85,10 @@ public partial class TransferView : UserControl
|
|||||||
ClientId = _viewModel.CurrentProfile.ClientId,
|
ClientId = _viewModel.CurrentProfile.ClientId,
|
||||||
};
|
};
|
||||||
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, CancellationToken.None);
|
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)
|
if (folderBrowser.ShowDialog() == true)
|
||||||
{
|
{
|
||||||
_viewModel.DestLibrary = folderBrowser.SelectedLibrary;
|
_viewModel.DestLibrary = folderBrowser.SelectedLibrary;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<UserControl x:Class="SharepointToolbox.Views.Tabs.UserAccessAuditView"
|
<UserControl x:Class="SharepointToolbox.Views.Tabs.UserAccessAuditView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="290" />
|
<ColumnDefinition Width="290" />
|
||||||
@@ -13,7 +14,9 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Left panel -->
|
<!-- 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 -->
|
<!-- Mode toggle -->
|
||||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
</GroupBox.Style>
|
</GroupBox.Style>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
|
<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>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
<Setter Property="Visibility" Value="Collapsed" />
|
<Setter Property="Visibility" Value="Collapsed" />
|
||||||
@@ -115,9 +118,9 @@
|
|||||||
|
|
||||||
<!-- Status row: load status + user count -->
|
<!-- Status row: load status + user count -->
|
||||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4">
|
<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" />
|
Margin="0,0,8,0" />
|
||||||
<TextBlock FontSize="10" Foreground="Gray">
|
<TextBlock FontSize="10" Foreground="{DynamicResource TextMutedBrush}">
|
||||||
<TextBlock.Text>
|
<TextBlock.Text>
|
||||||
<MultiBinding StringFormat="{}{0} {1}">
|
<MultiBinding StringFormat="{}{0} {1}">
|
||||||
<Binding Path="DirectoryUserCount" />
|
<Binding Path="DirectoryUserCount" />
|
||||||
@@ -130,7 +133,7 @@
|
|||||||
<!-- Hint text -->
|
<!-- Hint text -->
|
||||||
<TextBlock DockPanel.Dock="Bottom"
|
<TextBlock DockPanel.Dock="Bottom"
|
||||||
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}"
|
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" />
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
<!-- Directory DataGrid -->
|
<!-- Directory DataGrid -->
|
||||||
@@ -142,7 +145,7 @@
|
|||||||
CanUserSortColumns="True"
|
CanUserSortColumns="True"
|
||||||
SelectionMode="Single" SelectionUnit="FullRow"
|
SelectionMode="Single" SelectionUnit="FullRow"
|
||||||
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
|
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
|
||||||
BorderThickness="1" BorderBrush="#DDDDDD">
|
BorderThickness="1" BorderBrush="{DynamicResource BorderSoftBrush}">
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Name"
|
<DataGridTextColumn Header="Name"
|
||||||
Binding="{Binding DisplayName}" Width="120" />
|
Binding="{Binding DisplayName}" Width="120" />
|
||||||
@@ -181,19 +184,20 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
|
<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>
|
<DockPanel>
|
||||||
<Button Content="x" DockPanel.Dock="Right" Padding="4,0"
|
<Button Content="x" DockPanel.Dock="Right" Padding="4,0"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
CommandParameter="{Binding}" />
|
CommandParameter="{Binding}" />
|
||||||
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" />
|
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" Foreground="#1F2430" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
|
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="{DynamicResource TextMutedBrush}" FontSize="10" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Scan Options (always visible) -->
|
<!-- Scan Options (always visible) -->
|
||||||
@@ -247,6 +251,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Right panel -->
|
<!-- Right panel -->
|
||||||
<Grid Grid.Column="1" Grid.Row="0" Margin="0,8,8,0">
|
<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">
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
|
||||||
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
|
<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">
|
<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]}"
|
<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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Background="#EAFAF1" BorderBrush="#27AE60" BorderThickness="1"
|
<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">
|
<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]}"
|
<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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Background="#FDEDEC" BorderBrush="#E74C3C" BorderThickness="1"
|
<Border Background="#FDEDEC" BorderBrush="#E74C3C" BorderThickness="1"
|
||||||
CornerRadius="4" Padding="12,6">
|
CornerRadius="4" Padding="12,6"
|
||||||
|
TextElement.Foreground="{DynamicResource OnColoredBgBrush}">
|
||||||
<StackPanel HorizontalAlignment="Center">
|
<StackPanel HorizontalAlignment="Center">
|
||||||
<TextBlock Text="{Binding HighPrivilegeCount}" FontSize="20" FontWeight="Bold"
|
<TextBlock Text="{Binding HighPrivilegeCount}" FontSize="20" FontWeight="Bold"
|
||||||
HorizontalAlignment="Center" Foreground="#C0392B" />
|
HorizontalAlignment="Center" Foreground="#C0392B" />
|
||||||
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.summary.highPriv]}"
|
<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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,4">
|
||||||
<TextBox Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}" Width="200" Margin="0,0,8,0" />
|
<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>
|
<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]}" />
|
<Setter Property="Content" Value="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.toggle.bySite]}" />
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<Trigger Property="IsChecked" Value="True">
|
<Trigger Property="IsChecked" Value="True">
|
||||||
@@ -327,7 +335,7 @@
|
|||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal" Margin="4,2">
|
<StackPanel Orientation="Horizontal" Margin="4,2">
|
||||||
<TextBlock Text="{Binding Name}" FontWeight="Bold" Margin="0,0,8,0" />
|
<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>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</GroupStyle.HeaderTemplate>
|
</GroupStyle.HeaderTemplate>
|
||||||
@@ -364,7 +372,7 @@
|
|||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Orientation="Horizontal">
|
<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">
|
FontSize="12" VerticalAlignment="Center">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock">
|
<Style TargetType="TextBlock">
|
||||||
@@ -415,7 +423,8 @@
|
|||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
|
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
|
||||||
<StatusBarItem>
|
<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>
|
||||||
<StatusBarItem Content="{Binding StatusMessage}" />
|
<StatusBarItem Content="{Binding StatusMessage}" />
|
||||||
</StatusBar>
|
</StatusBar>
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>SharePoint Toolbox — Wiki</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0f172a;--panel:#1e293b;--text:#e2e8f0;--muted:#94a3b8;--accent:#38bdf8;--border:#334155;--code:#0b1220}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.6}
|
||||||
|
.layout{display:grid;grid-template-columns:260px 1fr;min-height:100vh}
|
||||||
|
nav{background:var(--panel);border-right:1px solid var(--border);padding:24px 16px;position:sticky;top:0;height:100vh;overflow-y:auto}
|
||||||
|
nav h2{font-size:14px;text-transform:uppercase;color:var(--muted);letter-spacing:1px;margin:16px 0 8px}
|
||||||
|
nav a{display:block;color:var(--text);text-decoration:none;padding:6px 10px;border-radius:6px;font-size:14px}
|
||||||
|
nav a:hover{background:var(--bg);color:var(--accent)}
|
||||||
|
main{padding:40px 56px;max-width:960px}
|
||||||
|
h1{font-size:32px;border-bottom:2px solid var(--accent);padding-bottom:12px;margin-top:0}
|
||||||
|
h2{color:var(--accent);margin-top:40px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
||||||
|
h3{color:#7dd3fc;margin-top:24px}
|
||||||
|
code{background:var(--code);padding:2px 6px;border-radius:4px;font-size:.9em;color:#fbbf24}
|
||||||
|
pre{background:var(--code);padding:16px;border-radius:8px;overflow-x:auto;border:1px solid var(--border)}
|
||||||
|
table{border-collapse:collapse;width:100%;margin:16px 0}
|
||||||
|
th,td{border:1px solid var(--border);padding:8px 12px;text-align:left}
|
||||||
|
th{background:var(--panel);color:var(--accent)}
|
||||||
|
tr:nth-child(even) td{background:rgba(30,41,59,.5)}
|
||||||
|
.badge{display:inline-block;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:2px 10px;font-size:12px;color:var(--accent);margin:2px}
|
||||||
|
.hero{background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px}
|
||||||
|
.hero p{color:var(--muted);margin:8px 0 0}
|
||||||
|
section{scroll-margin-top:20px}
|
||||||
|
ul li{margin:4px 0}
|
||||||
|
@media(max-width:800px){.layout{grid-template-columns:1fr}nav{position:static;height:auto}main{padding:24px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<nav>
|
||||||
|
<h2>SP Toolbox</h2>
|
||||||
|
<a href="#apercu">Apercu</a>
|
||||||
|
<a href="#install">Installation</a>
|
||||||
|
<a href="#prerequis">Prerequis</a>
|
||||||
|
<a href="#connexion">Connexion & profils</a>
|
||||||
|
<a href="#permissions">Rapport de permissions</a>
|
||||||
|
<a href="#stockage">Metriques de stockage</a>
|
||||||
|
<a href="#utilisateurs">Annuaire utilisateurs</a>
|
||||||
|
<a href="#recherche">Recherche de fichiers</a>
|
||||||
|
<a href="#doublons">Doublons</a>
|
||||||
|
<a href="#architecture">Architecture</a>
|
||||||
|
<a href="#dependances">Dependances</a>
|
||||||
|
<a href="#azure">Configuration Azure AD</a>
|
||||||
|
<a href="#depannage">Depannage</a>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<div class="hero">
|
||||||
|
<h1 style="border:none;margin:0">SharePoint Toolbox — Wiki</h1>
|
||||||
|
<p>Application WPF (.NET 10) pour administrer, auditer et exporter des donnees depuis un tenant SharePoint Online.</p>
|
||||||
|
<div>
|
||||||
|
<span class="badge">.NET 10</span>
|
||||||
|
<span class="badge">WPF</span>
|
||||||
|
<span class="badge">MVVM</span>
|
||||||
|
<span class="badge">MSAL</span>
|
||||||
|
<span class="badge">Graph</span>
|
||||||
|
<span class="badge">PnP.Framework</span>
|
||||||
|
<span class="badge">EN / FR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="apercu">
|
||||||
|
<h2>Apercu</h2>
|
||||||
|
<p>SharePoint Toolbox centralise les taches recurrentes d'un administrateur SharePoint Online : audit des permissions, mesure du stockage, annuaire des utilisateurs, recherche de fichiers et detection de doublons. Tous les rapports peuvent etre exportes en CSV ou en HTML interactif avec un branding configurable.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="install">
|
||||||
|
<h2>Installation</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Telecharger le dernier zip depuis la page Releases.</li>
|
||||||
|
<li>Extraire l'archive dans un dossier de votre choix.</li>
|
||||||
|
<li>Lancer <code>SharepointToolbox.exe</code>.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="prerequis">
|
||||||
|
<h2>Prerequis</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Windows 10 ou superieur</li>
|
||||||
|
<li>Runtime .NET 10 Desktop</li>
|
||||||
|
<li>Une Azure AD App Registration (Client ID) avec les permissions deleguees</li>
|
||||||
|
<li>Des identifiants ayant acces au tenant SharePoint cible</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="connexion">
|
||||||
|
<h2>Connexion & profils</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Saisir le <strong>Tenant URL</strong> et le <strong>Client ID</strong>.</li>
|
||||||
|
<li>Enregistrer des profils reutilisables (creation, renommage, suppression, rechargement).</li>
|
||||||
|
<li>Parcourir le tenant et cocher plusieurs sites depuis le selecteur.</li>
|
||||||
|
<li><strong>Enregistrement Azure AD</strong> automatique ou guide depuis le profil.</li>
|
||||||
|
<li>Branding multi-tenant avec logos client dans les exports.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="permissions">
|
||||||
|
<h2>Rapport de permissions</h2>
|
||||||
|
<p>Audit complet des permissions sur un ou plusieurs sites.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Scan des <strong>bibliotheques, listes et dossiers</strong> avec profondeur configurable.</li>
|
||||||
|
<li>Inclusion optionnelle des permissions heritees et des sous-sites.</li>
|
||||||
|
<li><strong>Mode consolidation</strong> : fusion des permissions identiques avec liste des sites/bibliotheques concernes.</li>
|
||||||
|
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (rapport interactif avec filtrage, tri et regroupement par utilisateur/site).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="stockage">
|
||||||
|
<h2>Metriques de stockage</h2>
|
||||||
|
<p>Analyse de l'occupation du stockage SharePoint.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Repartition par bibliotheque avec profondeur de dossiers configurable.</li>
|
||||||
|
<li>Metriques : taille totale, taille des versions, nombre d'elements, derniere modification.</li>
|
||||||
|
<li><strong>Visualisation 3D</strong> interactive du stockage.</li>
|
||||||
|
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (rapport avec graphiques de repartition).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="utilisateurs">
|
||||||
|
<h2>Annuaire utilisateurs</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Liste complete des utilisateurs du tenant via Microsoft Graph.</li>
|
||||||
|
<li>Filtrage et recherche textuelle.</li>
|
||||||
|
<li>Export en <strong>HTML</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="recherche">
|
||||||
|
<h2>Recherche de fichiers</h2>
|
||||||
|
<p>Recherche avancee de fichiers a travers les bibliotheques d'un site, utilisant la <strong>Search API SharePoint (KQL)</strong> avec pagination automatique.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Filtre</th><th>Description</th></tr>
|
||||||
|
<tr><td>Extension(s)</td><td>ex. <code>docx pdf xlsx</code></td></tr>
|
||||||
|
<tr><td>Nom / Regex</td><td>Expression reguliere sur le chemin du fichier</td></tr>
|
||||||
|
<tr><td>Cree apres / avant</td><td>Plage de dates de creation</td></tr>
|
||||||
|
<tr><td>Modifie apres / avant</td><td>Plage de dates de modification</td></tr>
|
||||||
|
<tr><td>Cree par</td><td>Nom ou email de l'auteur</td></tr>
|
||||||
|
<tr><td>Modifie par</td><td>Nom ou email du dernier editeur</td></tr>
|
||||||
|
<tr><td>Bibliotheque</td><td>Limite la recherche a un chemin relatif</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="doublons">
|
||||||
|
<h2>Doublons</h2>
|
||||||
|
<p>Detection de fichiers ou dossiers en double au sein d'un ou plusieurs sites.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Type de scan :</strong> Fichiers (Search API) ou Dossiers (enumeration CAML).</li>
|
||||||
|
<li><strong>Criteres de comparaison (combinables) :</strong> Nom, Taille, Date de creation, Date de modification, Nombre de sous-dossiers, Nombre de fichiers.</li>
|
||||||
|
<li>Export en <strong>CSV</strong> ou en <strong>HTML</strong> (cartes depliables avec mise en evidence des valeurs identiques/differentes).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="architecture">
|
||||||
|
<h2>Architecture</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Interface <strong>WPF</strong> avec pattern <strong>MVVM</strong> (generateurs CommunityToolkit.Mvvm).</li>
|
||||||
|
<li>Injection de dependances via <code>Microsoft.Extensions.Hosting</code>.</li>
|
||||||
|
<li>Authentification <strong>MSAL</strong> avec cache persistant et support broker WAM.</li>
|
||||||
|
<li><strong>Microsoft Graph SDK</strong> pour les operations tenant/utilisateurs.</li>
|
||||||
|
<li><strong>PnP.Framework</strong> (CSOM) pour les operations SharePoint.</li>
|
||||||
|
<li>Localisation complete <strong>EN/FR</strong> via fichiers <code>.resx</code>.</li>
|
||||||
|
<li>Branding configurable (logos MSP et client) dans les exports HTML.</li>
|
||||||
|
<li>Journalisation structuree via <strong>Serilog</strong> (sink fichier).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="dependances">
|
||||||
|
<h2>Dependances</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Paquet</th><th>Version</th><th>Role</th></tr>
|
||||||
|
<tr><td>CommunityToolkit.Mvvm</td><td>8.4.2</td><td>Generateurs MVVM</td></tr>
|
||||||
|
<tr><td>CsvHelper</td><td>33.1.0</td><td>Lecture/ecriture CSV</td></tr>
|
||||||
|
<tr><td>LiveChartsCore.SkiaSharpView.WPF</td><td>2.0.0-rc5.4</td><td>Graphiques / visualisation 3D</td></tr>
|
||||||
|
<tr><td>Microsoft.Extensions.Hosting</td><td>10.0.0</td><td>Host generique & DI</td></tr>
|
||||||
|
<tr><td>Microsoft.Graph</td><td>5.74.0</td><td>SDK Graph (tenant/utilisateurs)</td></tr>
|
||||||
|
<tr><td>Microsoft.Identity.Client</td><td>4.83.3</td><td>Authentification MSAL</td></tr>
|
||||||
|
<tr><td>Microsoft.Identity.Client.Broker</td><td>4.82.1</td><td>Support broker WAM</td></tr>
|
||||||
|
<tr><td>Microsoft.Identity.Client.Extensions.Msal</td><td>4.83.3</td><td>Cache de tokens persistant</td></tr>
|
||||||
|
<tr><td>PnP.Framework</td><td>1.18.0</td><td>Operations SharePoint CSOM</td></tr>
|
||||||
|
<tr><td>Serilog</td><td>4.3.1</td><td>Journalisation structuree</td></tr>
|
||||||
|
<tr><td>Serilog.Extensions.Hosting</td><td>10.0.0</td><td>Integration host</td></tr>
|
||||||
|
<tr><td>Serilog.Sinks.File</td><td>7.0.0</td><td>Sink fichier</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="azure">
|
||||||
|
<h2>Configuration Azure AD</h2>
|
||||||
|
<p>L'application peut enregistrer l'app Azure AD automatiquement, ou vous pouvez la creer manuellement avec les permissions deleguees suivantes :</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>Sites.FullControl.All</code> (SharePoint)</li>
|
||||||
|
<li><code>User.Read.All</code> (Microsoft Graph)</li>
|
||||||
|
<li><code>Directory.Read.All</code> (Microsoft Graph)</li>
|
||||||
|
</ul>
|
||||||
|
<p>L'URI de redirection doit etre definie sur la valeur par defaut MSAL public client (<code>http://localhost</code>) pour la connexion interactive.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="depannage">
|
||||||
|
<h2>Depannage</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Boucle de connexion / erreurs AADSTS :</strong> verifier le Client ID, le tenant URL et le consentement administrateur.</li>
|
||||||
|
<li><strong>Recherche vide :</strong> l'indexation SharePoint Search peut prendre du temps ; reessayer plus tard.</li>
|
||||||
|
<li><strong>Timeouts de scan de permissions :</strong> reduire la profondeur de dossiers ou scanner moins de sites a la fois.</li>
|
||||||
|
<li><strong>Logs :</strong> ecrits par Serilog dans le dossier local de l'application — les joindre en cas de probleme.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user