using Microsoft.SharePoint.Client; using PnP.Framework.Sites; using Serilog; using SharepointToolbox.Web.Core.Helpers; using SharepointToolbox.Web.Core.Models; using SharepointToolbox.Web.Services.Audit; using ModelSiteTemplate = SharepointToolbox.Web.Core.Models.SiteTemplate; using SpWeb = Microsoft.SharePoint.Client.Web; namespace SharepointToolbox.Web.Services; public class TemplateService : ITemplateService { private readonly IAuditService _audit; public TemplateService(IAuditService audit) { _audit = audit; } 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, SourceUrl = ctx.Url, CapturedAt = DateTime.UtcNow, SiteType = siteType, Options = options, }; if (options.CaptureSettings) template.Settings = new TemplateSettings { Title = web.Title, Description = web.Description, Language = (int)web.Language }; if (options.CaptureLogo) template.Logo = new TemplateLogo { LogoUrl = web.SiteLogoUrl ?? string.Empty }; 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 filtered = lists.Where(l => !SystemListNames.Contains(l.Title)) .Where(l => l.BaseType == BaseType.DocumentLibrary || l.BaseType == BaseType.GenericList).ToList(); for (int i = 0; i < filtered.Count; i++) { ct.ThrowIfCancellationRequested(); var list = filtered[i]; progress.Report(new OperationProgress(i + 1, filtered.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); } } 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); var roleAssignments = web.RoleAssignments; ctx.Load(roleAssignments, ras => ras.Include(ra => ra.Member.Title, ra => ra.RoleDefinitionBindings.Include(rd => rd.Name))); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); foreach (var group in groups) { ct.ThrowIfCancellationRequested(); var roles = roleAssignments.Where(ra => ra.Member.Title == group.Title) .SelectMany(ra => ra.RoleDefinitionBindings.Select(rd => rd.Name)).ToList(); 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) { 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; } var newCtx = new ClientContext(siteUrl) { Credentials = adminCtx.Credentials }; try { 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); if (lib.Folders.Count > 0) await CreateFoldersRecursiveAsync(newCtx, newList, lib.Folders, progress, ct); } catch (Exception ex) { Log.Warning("Failed to create library {Name}: {Error}", lib.Name, ex.Message); } } 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); foreach (var roleName in group.RoleDefinitions) { try { var roleDef = newCtx.Web.RoleDefinitions.GetByName(roleName); var bindings = new RoleDefinitionBindingCollection(newCtx) { roleDef }; newCtx.Web.RoleAssignments.Add(newGroup, bindings); } catch (Exception ex) { Log.Warning("Failed to assign role {Role}: {Error}", roleName, ex.Message); } } await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct); } catch (Exception ex) { Log.Warning("Failed to create group {Name}: {Error}", group.Name, ex.Message); } } } finally { newCtx.Dispose(); } progress.Report(new OperationProgress(1, 1, $"Template applied. Site: {siteUrl}")); await _audit.LogAsync("ApplyTemplate", adminCtx.Url, new[] { siteUrl }, $"Template '{template.Name}' applied to new site '{newSiteTitle}'"); return siteUrl; } private static async Task> EnumerateLibraryFoldersAsync(ClientContext ctx, List list, CancellationToken ct) { var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/'); 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; 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("_") || name.Equals("Forms", StringComparison.OrdinalIgnoreCase)) continue; var rel = fileRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase) ? fileRef[rootUrl.Length..].TrimStart('/') : name; var parentRel = dirRef.StartsWith(rootUrl, StringComparison.OrdinalIgnoreCase) ? dirRef[rootUrl.Length..].TrimStart('/') : string.Empty; folders.Add((rel, parentRel)); } var nodes = folders.ToDictionary(f => f.Relative, f => new TemplateFolderInfo { Name = Path.GetFileName(f.Relative), RelativePath = f.Relative, Children = new List() }, StringComparer.OrdinalIgnoreCase); var roots = new List(); 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 CreateFoldersRecursiveAsync(ClientContext ctx, List list, List folders, IProgress progress, CancellationToken ct) { ctx.Load(list.RootFolder, f => f.ServerRelativeUrl); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); await CreateSubFoldersRecursiveAsync(ctx, list.RootFolder.ServerRelativeUrl.TrimEnd('/'), folders, progress, ct); } private static async Task CreateSubFoldersRecursiveAsync(ClientContext ctx, string parentUrl, List folders, IProgress progress, CancellationToken ct) { foreach (var folder in folders) { ct.ThrowIfCancellationRequested(); try { ctx.Web.Folders.Add($"{parentUrl}/{folder.Name}"); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); if (folder.Children.Count > 0) await CreateSubFoldersRecursiveAsync(ctx, $"{parentUrl}/{folder.Name}", folder.Children, progress, ct); } catch (Exception ex) { Log.Warning("Failed to create folder {Path}: {Error}", folder.RelativePath, ex.Message); } } } }