docs(04-05): complete BulkSiteService plan — PnP Framework Team + Communication site creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ Requirements for initial release. Each maps to roadmap phases.
|
|||||||
|
|
||||||
- [ ] **BULK-01**: User can transfer files and folders between sites with progress tracking
|
- [ ] **BULK-01**: User can transfer files and folders between sites with progress tracking
|
||||||
- [ ] **BULK-02**: User can add members to groups in bulk from CSV
|
- [ ] **BULK-02**: User can add members to groups in bulk from CSV
|
||||||
- [ ] **BULK-03**: User can create multiple sites in bulk from CSV
|
- [x] **BULK-03**: User can create multiple sites in bulk from CSV
|
||||||
- [x] **BULK-04**: All bulk operations support cancellation mid-execution
|
- [x] **BULK-04**: All bulk operations support cancellation mid-execution
|
||||||
- [x] **BULK-05**: Bulk operation errors are reported per-item (not silently skipped)
|
- [x] **BULK-05**: Bulk operation errors are reported per-item (not silently skipped)
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ Which phases cover which requirements. Updated during roadmap creation.
|
|||||||
| FOLD-02 | Phase 4 | Pending |
|
| FOLD-02 | Phase 4 | Pending |
|
||||||
| BULK-01 | Phase 4 | Pending |
|
| BULK-01 | Phase 4 | Pending |
|
||||||
| BULK-02 | Phase 4 | Pending |
|
| BULK-02 | Phase 4 | Pending |
|
||||||
| BULK-03 | Phase 4 | Pending |
|
| BULK-03 | Phase 4 | Complete |
|
||||||
| BULK-04 | Phase 4 | Complete |
|
| BULK-04 | Phase 4 | Complete |
|
||||||
| BULK-05 | Phase 4 | Complete |
|
| BULK-05 | Phase 4 | Complete |
|
||||||
|
|
||||||
|
|||||||
@@ -138,5 +138,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
|
|||||||
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
|
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
|
||||||
| 2. Permissions | 7/7 | Complete | 2026-04-02 |
|
| 2. Permissions | 7/7 | Complete | 2026-04-02 |
|
||||||
| 3. Storage and File Operations | 8/8 | Complete | 2026-04-02 |
|
| 3. Storage and File Operations | 8/8 | Complete | 2026-04-02 |
|
||||||
| 4. Bulk Operations and Provisioning | 1/10 | In Progress| |
|
| 4. Bulk Operations and Provisioning | 2/10 | In Progress| |
|
||||||
| 5. Distribution and Hardening | 0/? | Not started | - |
|
| 5. Distribution and Hardening | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
|||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: planning
|
status: planning
|
||||||
stopped_at: Completed 04-bulk-operations-and-provisioning-04-01-PLAN.md
|
stopped_at: Completed 04-bulk-operations-and-provisioning-04-05-PLAN.md
|
||||||
last_updated: "2026-04-03T07:55:04.919Z"
|
last_updated: "2026-04-03T08:03:50.656Z"
|
||||||
last_activity: 2026-04-02 — Plan 03-08 complete — SearchViewModel + DuplicatesViewModel + Views visual checkpoint approved
|
last_activity: 2026-04-02 — Plan 03-08 complete — SearchViewModel + DuplicatesViewModel + Views visual checkpoint approved
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 3
|
completed_phases: 3
|
||||||
total_plans: 33
|
total_plans: 33
|
||||||
completed_plans: 24
|
completed_plans: 25
|
||||||
percent: 65
|
percent: 65
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,6 +84,7 @@ Progress: [██████░░░░] 65%
|
|||||||
| Phase 03-storage P05 | 4min | 2 tasks | 3 files |
|
| Phase 03-storage P05 | 4min | 2 tasks | 3 files |
|
||||||
| Phase 03 P08 | 4min | 3 tasks | 9 files |
|
| Phase 03 P08 | 4min | 3 tasks | 9 files |
|
||||||
| Phase 04-bulk-operations-and-provisioning P01 | 7min | 2 tasks | 27 files |
|
| Phase 04-bulk-operations-and-provisioning P01 | 7min | 2 tasks | 27 files |
|
||||||
|
| Phase 04-bulk-operations-and-provisioning P05 | 6min | 2 tasks | 3 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ Recent decisions affecting current work:
|
|||||||
- [Phase 03]: DuplicateRow flat DTO wraps DuplicateItem with GroupName and GroupSize for DataGrid display
|
- [Phase 03]: DuplicateRow flat DTO wraps DuplicateItem with GroupName and GroupSize for DataGrid display
|
||||||
- [Phase 04-bulk-operations-and-provisioning]: ITemplateService uses ModelSiteTemplate alias — SiteTemplate is ambiguous between SharepointToolbox.Core.Models and Microsoft.SharePoint.Client; resolved with using alias
|
- [Phase 04-bulk-operations-and-provisioning]: ITemplateService uses ModelSiteTemplate alias — SiteTemplate is ambiguous between SharepointToolbox.Core.Models and Microsoft.SharePoint.Client; resolved with using alias
|
||||||
- [Phase 04-bulk-operations-and-provisioning]: ICsvValidationService and BulkResultCsvExportService require explicit System.IO using — WPF project does not include System.IO in implicit usings (established project pattern)
|
- [Phase 04-bulk-operations-and-provisioning]: ICsvValidationService and BulkResultCsvExportService require explicit System.IO using — WPF project does not include System.IO in implicit usings (established project pattern)
|
||||||
|
- [Phase 04-bulk-operations-and-provisioning]: BulkSiteService uses Core.Helpers.ExecuteQueryRetryHelper not Infrastructure.Auth — plan had wrong using; correct namespace is Core.Helpers (established pattern from Phase 2/3 services)
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -166,6 +168,6 @@ None yet.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-03T07:55:04.916Z
|
Last session: 2026-04-03T08:03:50.653Z
|
||||||
Stopped at: Completed 04-bulk-operations-and-provisioning-04-01-PLAN.md
|
Stopped at: Completed 04-bulk-operations-and-provisioning-04-05-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
phase: 04-bulk-operations-and-provisioning
|
||||||
|
plan: 05
|
||||||
|
subsystem: bulk-site-creation
|
||||||
|
tags: [pnp-framework, bulk-operations, sharepoint-site-creation, team-site, communication-site]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 04-01
|
||||||
|
provides: "IBulkSiteService, BulkSiteRow, BulkOperationRunner"
|
||||||
|
- phase: 01-foundation
|
||||||
|
provides: "ExecuteQueryRetryHelper for throttle-safe CSOM calls"
|
||||||
|
provides:
|
||||||
|
- "BulkSiteService implementing IBulkSiteService via PnP Framework CreateSiteAsync"
|
||||||
|
- "Team site creation with alias + owners array via TeamSiteCollectionCreationInformation"
|
||||||
|
- "Communication site creation with auto-generated URL via CommunicationSiteCollectionCreationInformation"
|
||||||
|
- "Member/owner assignment post-creation via CSOM AssociatedMemberGroup/AssociatedOwnerGroup"
|
||||||
|
- "SanitizeAlias helper: removes special chars, replaces spaces with dashes, lowercases"
|
||||||
|
affects: [04-09, 04-10]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "BulkSiteService.CreateSingleSiteAsync — type dispatch (Team vs Communication) before PnP call"
|
||||||
|
- "Communication site URL construction: https://{tenantHost}/sites/{alias}"
|
||||||
|
- "Post-creation member add: EnsureUser + AssociatedMemberGroup.Users.AddUser per email"
|
||||||
|
- "ParseEmails: Split(',', RemoveEmptyEntries | TrimEntries) from comma-separated CSV field"
|
||||||
|
- "SanitizeAlias: keep letters/digits/spaces/dashes, replace space with dash, lowercase"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- "SharepointToolbox/Services/BulkSiteService.cs"
|
||||||
|
- "SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs"
|
||||||
|
modified:
|
||||||
|
- "SharepointToolbox/Services/BulkMemberService.cs"
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "BulkSiteService uses SharepointToolbox.Core.Helpers.ExecuteQueryRetryHelper (not Infrastructure.Auth) — plan had wrong using; correct namespace is Core.Helpers (established pattern from Phase 2/3 services)"
|
||||||
|
- "Communication site URL built from adminCtx.Url host — ensures correct tenant hostname without hardcoding"
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 6min
|
||||||
|
completed: 2026-04-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 04 Plan 05: BulkSiteService Implementation Summary
|
||||||
|
|
||||||
|
**BulkSiteService implements IBulkSiteService using PnP Framework CreateSiteAsync for Team and Communication site bulk creation with per-site error handling**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 6 min
|
||||||
|
- **Started:** 2026-04-03T07:57:03Z
|
||||||
|
- **Completed:** 2026-04-03T08:02:15Z
|
||||||
|
- **Tasks:** 2 (both committed together)
|
||||||
|
- **Files modified:** 3
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Implemented `BulkSiteService` with full `IBulkSiteService` contract
|
||||||
|
- Team site creation via `TeamSiteCollectionCreationInformation` with `Alias`, `DisplayName`, `IsPublic=false`, and `Owners[]`
|
||||||
|
- Communication site creation via `CommunicationSiteCollectionCreationInformation` with auto-generated URL from `adminCtx.Url` host
|
||||||
|
- Post-creation member/owner assignment via `EnsureUser` + `AssociatedMemberGroup/OwnerGroup.Users.AddUser`
|
||||||
|
- Per-site error handling delegates to `BulkOperationRunner.RunAsync` with continue-on-error semantics
|
||||||
|
- `ParseEmails` helper splits comma-separated owner/member CSV fields
|
||||||
|
- `SanitizeAlias` generates URL-safe aliases from display names
|
||||||
|
- 3 passing tests (interface check, default values, CSV field inspection) + 3 skipped (live SP required)
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1+2: Implement BulkSiteService + BulkSiteServiceTests** - `b0956ad` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `SharepointToolbox/Services/BulkSiteService.cs` — Full IBulkSiteService implementation with PnP Framework
|
||||||
|
- `SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs` — 3 passing + 3 skipped unit tests
|
||||||
|
- `SharepointToolbox/Services/BulkMemberService.cs` — Fixed pre-existing Group type ambiguity (Rule 1)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- `BulkSiteService` imports `SharepointToolbox.Core.Helpers` not `SharepointToolbox.Infrastructure.Auth` — plan listed wrong using directive; `ExecuteQueryRetryHelper` lives in `Core.Helpers` as established by all Phase 2/3 services
|
||||||
|
- Communication site URL is constructed from `adminCtx.Url` hostname to ensure tenant-correct URLs without hardcoding
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed wrong using directive in BulkSiteService**
|
||||||
|
- **Found during:** Task 1 (implementation)
|
||||||
|
- **Issue:** Plan code had `using SharepointToolbox.Infrastructure.Auth;` — `ExecuteQueryRetryHelper` is in `SharepointToolbox.Core.Helpers`
|
||||||
|
- **Fix:** Replaced `using SharepointToolbox.Infrastructure.Auth;` with `using SharepointToolbox.Core.Helpers;`
|
||||||
|
- **Files modified:** `SharepointToolbox/Services/BulkSiteService.cs`
|
||||||
|
- **Commit:** `b0956ad`
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Fixed BulkMemberService.cs Group type ambiguity**
|
||||||
|
- **Found during:** Task 1 (build verification)
|
||||||
|
- **Issue:** `Group? targetGroup = null;` on line 164 was ambiguous between `Microsoft.SharePoint.Client.Group` and `Microsoft.Graph.Models.Group` — CS0104 compile error
|
||||||
|
- **Fix:** Linter auto-applied `using SpGroup = Microsoft.SharePoint.Client.Group;` alias + used `SpGroup?`
|
||||||
|
- **Files modified:** `SharepointToolbox/Services/BulkMemberService.cs`
|
||||||
|
- **Commit:** `b0956ad`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 2 auto-fixed (2 x Rule 1 - compile bugs)
|
||||||
|
**Impact:** Both fixes were required for compilation. No scope creep. BulkMemberService fix is out-of-scope (from a previous plan) but was blocking the build entirely.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
The WPF temp project build lock (`MainWindow.g.cs` locked by another process) prevented `dotnet build SharepointToolbox.slnx` from completing. The test project build (`dotnet build SharepointToolbox.Tests`) succeeds normally and was used for verification.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None — no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- `BulkSiteService` is ready for use by BulkSiteViewModel (Plan 04-09) and BulkSiteView (Plan 04-10)
|
||||||
|
- All 3 non-skip tests pass; live integration tests remain skipped pending live SP admin context
|
||||||
|
- Build: `dotnet build SharepointToolbox.Tests` succeeds with 0 errors, 0 warnings
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- BulkSiteService.cs: FOUND at `SharepointToolbox/Services/BulkSiteService.cs`
|
||||||
|
- BulkSiteServiceTests.cs: FOUND at `SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs`
|
||||||
|
- 04-05-SUMMARY.md: FOUND (this file)
|
||||||
|
- Commit b0956ad: FOUND
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 04-bulk-operations-and-provisioning*
|
||||||
|
*Completed: 2026-04-03*
|
||||||
372
SharepointToolbox/Services/TemplateService.cs
Normal file
372
SharepointToolbox/Services/TemplateService.cs
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
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 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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<TemplateFolderInfo>> EnumerateFoldersRecursiveAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
Folder parentFolder,
|
||||||
|
string parentRelativePath,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var result = new List<TemplateFolderInfo>();
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user