12dd1de9f2
- 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>
405 lines
16 KiB
C#
405 lines
16 KiB
C#
using Microsoft.SharePoint.Client;
|
|
using PnP.Framework.Sites;
|
|
using Serilog;
|
|
using SharepointToolbox.Core.Helpers;
|
|
using SharepointToolbox.Core.Models;
|
|
using ModelSiteTemplate = SharepointToolbox.Core.Models.SiteTemplate;
|
|
|
|
namespace SharepointToolbox.Services;
|
|
|
|
public class TemplateService : ITemplateService
|
|
{
|
|
private static readonly HashSet<string> SystemListNames = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"Style Library", "Form Templates", "Site Assets", "Site Pages",
|
|
"Composed Looks", "Master Page Gallery", "Web Part Gallery",
|
|
"Theme Gallery", "Solution Gallery", "List Template Gallery",
|
|
"Converted Forms", "Customized Reports", "Content type publishing error log",
|
|
"TaxonomyHiddenList", "appdata", "appfiles"
|
|
};
|
|
|
|
public async Task<ModelSiteTemplate> CaptureTemplateAsync(
|
|
ClientContext ctx,
|
|
SiteTemplateOptions options,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
progress.Report(new OperationProgress(0, 0, "Loading site properties..."));
|
|
|
|
var web = ctx.Web;
|
|
ctx.Load(web, w => w.Title, w => w.Description, w => w.Language,
|
|
w => w.SiteLogoUrl, w => w.WebTemplate, w => w.Configuration,
|
|
w => w.ServerRelativeUrl);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
|
|
var siteType = (web.WebTemplate == "GROUP" || web.WebTemplate == "GROUP#0")
|
|
? "Team" : "Communication";
|
|
|
|
var template = new ModelSiteTemplate
|
|
{
|
|
Name = string.Empty, // caller sets this
|
|
SourceUrl = ctx.Url,
|
|
CapturedAt = DateTime.UtcNow,
|
|
SiteType = siteType,
|
|
Options = options,
|
|
};
|
|
|
|
// Capture settings
|
|
if (options.CaptureSettings)
|
|
{
|
|
template.Settings = new TemplateSettings
|
|
{
|
|
Title = web.Title,
|
|
Description = web.Description,
|
|
Language = (int)web.Language,
|
|
};
|
|
}
|
|
|
|
// Capture logo
|
|
if (options.CaptureLogo)
|
|
{
|
|
template.Logo = new TemplateLogo { LogoUrl = web.SiteLogoUrl ?? string.Empty };
|
|
}
|
|
|
|
// Capture libraries and folders
|
|
if (options.CaptureLibraries || options.CaptureFolders)
|
|
{
|
|
progress.Report(new OperationProgress(0, 0, "Enumerating libraries..."));
|
|
var lists = ctx.LoadQuery(web.Lists
|
|
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.BaseTemplate, l => l.RootFolder)
|
|
.Where(l => !l.Hidden));
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
|
|
var filteredLists = lists
|
|
.Where(l => !SystemListNames.Contains(l.Title))
|
|
.Where(l => l.BaseType == BaseType.DocumentLibrary || l.BaseType == BaseType.GenericList)
|
|
.ToList();
|
|
|
|
for (int i = 0; i < filteredLists.Count; i++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var list = filteredLists[i];
|
|
progress.Report(new OperationProgress(i + 1, filteredLists.Count,
|
|
$"Capturing library: {list.Title}"));
|
|
|
|
var libInfo = new TemplateLibraryInfo
|
|
{
|
|
Name = list.Title,
|
|
BaseType = list.BaseType.ToString(),
|
|
BaseTemplate = (int)list.BaseTemplate,
|
|
};
|
|
|
|
if (options.CaptureFolders)
|
|
{
|
|
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
libInfo.Folders = await EnumerateLibraryFoldersAsync(ctx, list, ct);
|
|
}
|
|
|
|
template.Libraries.Add(libInfo);
|
|
}
|
|
}
|
|
|
|
// Capture permission groups
|
|
if (options.CapturePermissionGroups)
|
|
{
|
|
progress.Report(new OperationProgress(0, 0, "Capturing permission groups..."));
|
|
var groups = web.SiteGroups;
|
|
ctx.Load(groups, gs => gs.Include(
|
|
g => g.Title, g => g.Description));
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// Load role definitions for this group
|
|
var roleAssignments = web.RoleAssignments;
|
|
ctx.Load(roleAssignments, ras => ras.Include(
|
|
ra => ra.Member.LoginName,
|
|
ra => ra.Member.Title,
|
|
ra => ra.RoleDefinitionBindings.Include(rd => rd.Name)));
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
|
|
var roles = new List<string>();
|
|
foreach (var ra in roleAssignments)
|
|
{
|
|
if (ra.Member.Title == group.Title)
|
|
{
|
|
foreach (var rd in ra.RoleDefinitionBindings)
|
|
{
|
|
roles.Add(rd.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
template.PermissionGroups.Add(new TemplatePermissionGroup
|
|
{
|
|
Name = group.Title,
|
|
Description = group.Description ?? string.Empty,
|
|
RoleDefinitions = roles,
|
|
});
|
|
}
|
|
}
|
|
|
|
progress.Report(new OperationProgress(1, 1, "Template capture complete."));
|
|
return template;
|
|
}
|
|
|
|
public async Task<string> ApplyTemplateAsync(
|
|
ClientContext adminCtx,
|
|
ModelSiteTemplate template,
|
|
string newSiteTitle,
|
|
string newSiteAlias,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
// 1. Create the site
|
|
progress.Report(new OperationProgress(0, 0, $"Creating {template.SiteType} site: {newSiteTitle}..."));
|
|
string siteUrl;
|
|
|
|
if (template.SiteType.Equals("Team", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var info = new TeamSiteCollectionCreationInformation
|
|
{
|
|
DisplayName = newSiteTitle,
|
|
Alias = newSiteAlias,
|
|
Description = template.Settings?.Description ?? string.Empty,
|
|
IsPublic = false,
|
|
};
|
|
using var siteCtx = await adminCtx.CreateSiteAsync(info);
|
|
siteCtx.Load(siteCtx.Web, w => w.Url);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
|
siteUrl = siteCtx.Web.Url;
|
|
}
|
|
else
|
|
{
|
|
var tenantHost = new Uri(adminCtx.Url).Host;
|
|
var info = new CommunicationSiteCollectionCreationInformation
|
|
{
|
|
Title = newSiteTitle,
|
|
Url = $"https://{tenantHost}/sites/{newSiteAlias}",
|
|
Description = template.Settings?.Description ?? string.Empty,
|
|
};
|
|
using var siteCtx = await adminCtx.CreateSiteAsync(info);
|
|
siteCtx.Load(siteCtx.Web, w => w.Url);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
|
siteUrl = siteCtx.Web.Url;
|
|
}
|
|
|
|
// 2. Connect to the new site and apply template structure
|
|
// Need a new context for the created site
|
|
var newCtx = new ClientContext(siteUrl);
|
|
// Copy auth cookies/token from admin context
|
|
newCtx.Credentials = adminCtx.Credentials;
|
|
|
|
try
|
|
{
|
|
// Apply libraries
|
|
if (template.Libraries.Count > 0)
|
|
{
|
|
for (int i = 0; i < template.Libraries.Count; i++)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var lib = template.Libraries[i];
|
|
progress.Report(new OperationProgress(i + 1, template.Libraries.Count,
|
|
$"Creating library: {lib.Name}"));
|
|
|
|
try
|
|
{
|
|
var listInfo = new ListCreationInformation
|
|
{
|
|
Title = lib.Name,
|
|
TemplateType = lib.BaseTemplate,
|
|
};
|
|
var newList = newCtx.Web.Lists.Add(listInfo);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
|
|
|
|
// Create folders in the library
|
|
if (lib.Folders.Count > 0)
|
|
{
|
|
await CreateFoldersFromTemplateAsync(newCtx, newList, lib.Folders, progress, ct);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning("Failed to create library {Name}: {Error}", lib.Name, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply permission groups
|
|
if (template.PermissionGroups.Count > 0)
|
|
{
|
|
progress.Report(new OperationProgress(0, 0, "Creating permission groups..."));
|
|
foreach (var group in template.PermissionGroups)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
var groupInfo = new GroupCreationInformation
|
|
{
|
|
Title = group.Name,
|
|
Description = group.Description,
|
|
};
|
|
var newGroup = newCtx.Web.SiteGroups.Add(groupInfo);
|
|
|
|
// Assign role definitions
|
|
foreach (var roleName in group.RoleDefinitions)
|
|
{
|
|
try
|
|
{
|
|
var roleDef = newCtx.Web.RoleDefinitions.GetByName(roleName);
|
|
var roleBindings = new RoleDefinitionBindingCollection(newCtx) { roleDef };
|
|
newCtx.Web.RoleAssignments.Add(newGroup, roleBindings);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning("Failed to assign role {Role} to group {Group}: {Error}",
|
|
roleName, group.Name, ex.Message);
|
|
}
|
|
}
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning("Failed to create group {Name}: {Error}", group.Name, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply logo
|
|
if (template.Logo != null && !string.IsNullOrEmpty(template.Logo.LogoUrl))
|
|
{
|
|
try
|
|
{
|
|
newCtx.Web.SiteLogoUrl = template.Logo.LogoUrl;
|
|
newCtx.Web.Update();
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning("Failed to set site logo: {Error}", ex.Message);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
newCtx.Dispose();
|
|
}
|
|
|
|
progress.Report(new OperationProgress(1, 1, $"Template applied. Site created at: {siteUrl}"));
|
|
return siteUrl;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enumerates every folder in a library via one paginated CAML scan, then
|
|
/// reconstructs the hierarchy from the server-relative paths. Replaces the
|
|
/// former per-level Folder.Folders lazy loading, which hits the list-view
|
|
/// threshold on libraries above 5,000 items.
|
|
/// </summary>
|
|
private static async Task<List<TemplateFolderInfo>> EnumerateLibraryFoldersAsync(
|
|
ClientContext ctx,
|
|
List list,
|
|
CancellationToken ct)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
|
|
|
// Collect all folders flat: (relativePath, parentRelativePath).
|
|
var folders = new List<(string Relative, string Parent)>();
|
|
|
|
await foreach (var item in SharePointPaginationHelper.GetItemsInFolderAsync(
|
|
ctx, list, rootUrl, recursive: true,
|
|
viewFields: new[] { "FSObjType", "FileLeafRef", "FileRef", "FileDirRef" },
|
|
ct: ct))
|
|
{
|
|
if (item["FSObjType"]?.ToString() != "1") continue; // folders only
|
|
|
|
var name = item["FileLeafRef"]?.ToString() ?? string.Empty;
|
|
var fileRef = (item["FileRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
|
var dirRef = (item["FileDirRef"]?.ToString() ?? string.Empty).TrimEnd('/');
|
|
|
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(fileRef)) continue;
|
|
if (name.StartsWith("_", StringComparison.Ordinal) ||
|
|
name.Equals("Forms", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
// Paths relative to the library root.
|
|
var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
|
? fileRef.Substring(rootUrl.Length).TrimStart('/')
|
|
: name;
|
|
var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase)
|
|
? dirRef.Substring(rootUrl.Length).TrimStart('/')
|
|
: string.Empty;
|
|
|
|
folders.Add((rel, parentRel));
|
|
}
|
|
|
|
// Build tree keyed by relative path.
|
|
var nodes = folders.ToDictionary(
|
|
f => f.Relative,
|
|
f => new TemplateFolderInfo
|
|
{
|
|
Name = System.IO.Path.GetFileName(f.Relative),
|
|
RelativePath = f.Relative,
|
|
Children = new List<TemplateFolderInfo>(),
|
|
},
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var roots = new List<TemplateFolderInfo>();
|
|
foreach (var (rel, parent) in folders)
|
|
{
|
|
if (!nodes.TryGetValue(rel, out var node)) continue;
|
|
if (!string.IsNullOrEmpty(parent) && nodes.TryGetValue(parent, out var p))
|
|
p.Children.Add(node);
|
|
else
|
|
roots.Add(node);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
private static async Task CreateFoldersFromTemplateAsync(
|
|
ClientContext ctx,
|
|
List list,
|
|
List<TemplateFolderInfo> folders,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
var baseUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
|
|
|
await CreateFoldersRecursiveAsync(ctx, baseUrl, folders, progress, ct);
|
|
}
|
|
|
|
private static async Task CreateFoldersRecursiveAsync(
|
|
ClientContext ctx,
|
|
string parentUrl,
|
|
List<TemplateFolderInfo> folders,
|
|
IProgress<OperationProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
foreach (var folder in folders)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
try
|
|
{
|
|
var folderUrl = $"{parentUrl}/{folder.Name}";
|
|
ctx.Web.Folders.Add(folderUrl);
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
|
|
if (folder.Children.Count > 0)
|
|
{
|
|
await CreateFoldersRecursiveAsync(ctx, folderUrl, folder.Children, progress, ct);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning("Failed to create folder {Path}: {Error}", folder.RelativePath, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|