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 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 CaptureTemplateAsync( ClientContext ctx, SiteTemplateOptions options, IProgress 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 EnumerateFoldersRecursiveAsync( ctx, list.RootFolder, string.Empty, progress, 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(); 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 ApplyTemplateAsync( ClientContext adminCtx, ModelSiteTemplate template, string newSiteTitle, string newSiteAlias, IProgress 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; } private async Task> EnumerateFoldersRecursiveAsync( ClientContext ctx, Folder parentFolder, string parentRelativePath, IProgress progress, CancellationToken ct) { ct.ThrowIfCancellationRequested(); var result = new List(); ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl)); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); foreach (var subFolder in parentFolder.Folders) { // Skip system folders if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms") continue; var relativePath = string.IsNullOrEmpty(parentRelativePath) ? subFolder.Name : $"{parentRelativePath}/{subFolder.Name}"; var folderInfo = new TemplateFolderInfo { Name = subFolder.Name, RelativePath = relativePath, Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct), }; result.Add(folderInfo); } return result; } private static async Task CreateFoldersFromTemplateAsync( ClientContext ctx, List list, List folders, IProgress 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 folders, IProgress 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); } } } }