chore: release v2.4
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using SharepointToolbox.Core.Messages;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Localization;
|
||||
using SharepointToolbox.Services;
|
||||
using SharepointToolbox.Core.Helpers;
|
||||
using SharepointToolbox.Services.Export;
|
||||
@@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _isDetailView = true;
|
||||
|
||||
/// <summary>0 = Single file, 1 = Split by site.</summary>
|
||||
[ObservableProperty] private int _splitModeIndex;
|
||||
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
|
||||
[ObservableProperty] private int _htmlLayoutIndex;
|
||||
|
||||
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
|
||||
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
|
||||
|
||||
/// <summary>
|
||||
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
|
||||
/// </summary>
|
||||
@@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
||||
if (urls.Count == 0)
|
||||
{
|
||||
StatusMessage = "Select at least one site from the toolbar.";
|
||||
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,6 +317,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
/// Derives the tenant admin URL from a standard tenant URL.
|
||||
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
|
||||
/// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr
|
||||
/// Falls back to scheme+host for root-collection URLs.
|
||||
/// </summary>
|
||||
internal static string DeriveSiteCollectionUrl(string objectUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
|
||||
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
|
||||
return objectUrl.TrimEnd('/');
|
||||
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}";
|
||||
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Managed paths: /sites/<name> or /teams/<name>
|
||||
if (segments.Length >= 2 &&
|
||||
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
|
||||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return $"{baseUrl}/{segments[0]}/{segments[1]}";
|
||||
}
|
||||
|
||||
// Root site collection
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
internal static string DeriveAdminUrl(string tenantUrl)
|
||||
{
|
||||
var uri = new Uri(tenantUrl.TrimEnd('/'));
|
||||
@@ -374,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
try
|
||||
{
|
||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
else
|
||||
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
||||
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -408,36 +443,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
|
||||
if (_groupResolver != null && Results.Count > 0)
|
||||
if (_groupResolver != null && Results.Count > 0 && _currentProfile != null)
|
||||
{
|
||||
var groupNames = Results
|
||||
// SharePoint groups live per site collection. Bucket each group
|
||||
// by the site it was observed on, then resolve against that
|
||||
// site's context. Using the root tenant ctx for a group that
|
||||
// lives on a sub-site makes CSOM fail with "Group not found".
|
||||
var groupsBySite = Results
|
||||
.Where(r => r.PrincipalType == "SharePointGroup")
|
||||
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(n => n.Trim())
|
||||
.Where(n => n.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(r => r.Users
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
|
||||
.Where(x => x.GroupName.Length > 0)
|
||||
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (groupNames.Count > 0 && _currentProfile != null)
|
||||
if (groupsBySite.Count > 0)
|
||||
{
|
||||
try
|
||||
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var bucket in groupsBySite)
|
||||
{
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
_currentProfile, CancellationToken.None);
|
||||
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
||||
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
||||
var distinctNames = bucket
|
||||
.Select(x => x.GroupName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var siteProfile = new TenantProfile
|
||||
{
|
||||
TenantUrl = bucket.Key,
|
||||
ClientId = _currentProfile.ClientId,
|
||||
Name = _currentProfile.Name
|
||||
};
|
||||
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||
siteProfile, CancellationToken.None);
|
||||
var resolved = await _groupResolver.ResolveGroupsAsync(
|
||||
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
|
||||
foreach (var kv in resolved)
|
||||
merged[kv.Key] = kv.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Group resolution failed for {Site} — continuing without member expansion.",
|
||||
bucket.Key);
|
||||
}
|
||||
}
|
||||
|
||||
groupMembers = merged;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
else
|
||||
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
|
||||
OpenFile(dialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user