From 8ee4b0500cb856f147c2097ce9907a7a27e99915 Mon Sep 17 00:00:00 2001 From: TaoziZ03 <106975749+TaoziZ03@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:38:34 +0800 Subject: [PATCH 1/3] feat: add Enterprise Wiki migration cmdlets --- CHANGELOG.md | 2 + documentation/Copy-PnPEnterpriseWiki.md | 174 +++++++++++++ documentation/Get-PnPEnterpriseWiki.md | 296 +++++++++++++++++++++++ src/Commands/Pages/CopyEnterpriseWiki.cs | 78 ++++++ src/Commands/Pages/GetEnterpriseWiki.cs | 164 +++++++++++++ 5 files changed, 714 insertions(+) create mode 100644 documentation/Copy-PnPEnterpriseWiki.md create mode 100644 documentation/Get-PnPEnterpriseWiki.md create mode 100644 src/Commands/Pages/CopyEnterpriseWiki.cs create mode 100644 src/Commands/Pages/GetEnterpriseWiki.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4451251..5f3c40da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added +- Added `Get-PnPEnterpriseWiki` and `Copy-PnPEnterpriseWiki` to capture an Enterprise Wiki into a sealed, digest-approved migration package and create it with fresh target preflight/readback. + ### Changed ### Fixed diff --git a/documentation/Copy-PnPEnterpriseWiki.md b/documentation/Copy-PnPEnterpriseWiki.md new file mode 100644 index 000000000..c4971d5f8 --- /dev/null +++ b/documentation/Copy-PnPEnterpriseWiki.md @@ -0,0 +1,174 @@ +--- +Module Name: PnP.PowerShell +title: Copy-PnPEnterpriseWiki +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Copy-PnPEnterpriseWiki.html +--- + +# Copy-PnPEnterpriseWiki + +## SYNOPSIS + +**Required Permissions** + +* SharePoint: Sites.FullControl.All (application) or AllSites.FullControl (delegated) + +Creates an Enterprise Wiki page from an approved sealed migration package. + +## SYNTAX + +### Approved + +```powershell +Copy-PnPEnterpriseWiki [-PackagePath] -ApprovedPlanDigest ` + [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] +``` + +### AutoApprove + +```powershell +Copy-PnPEnterpriseWiki [-PackagePath] -AutoApprove ` + [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +Validates the snapshot and plan SHA-256 digests, performs a fresh target preflight, and creates the target page using only the approved package. It does not reread or replan from the source. + +The copy is create-only: an existing target page or planned dependency path blocks execution. Captured SharePoint resources are materialized, source web and tenant references are rewritten to the approved target, selected publishing metadata is applied, and shared Web Parts are imported at their captured zone positions. The page is published unless the package was captured with `Get-PnPEnterpriseWiki -Draft`. + +After writing, the command creates a new target context and independently reads back the file identity, Enterprise Wiki content type, version, page content hash, and Web Part count. SharePoint may normalize `PublishingPageContent` storage bytes; browser DOM and screenshot acceptance remain a separate required fidelity gate. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$package = Get-Content .\enterprise-wiki\architecture\enterprise-wiki-package.json -Raw | ConvertFrom-Json + +Copy-PnPEnterpriseWiki ` + -PackagePath .\enterprise-wiki\architecture ` + -ApprovedPlanDigest $package.planDigest ` + -Connection $target +``` + +Copies the page only when the supplied digest exactly matches the sealed migration plan. + +### EXAMPLE 2 + +```powershell +Copy-PnPEnterpriseWiki ` + -PackagePath .\enterprise-wiki\architecture ` + -AutoApprove ` + -WhatIf ` + -Connection $target +``` + +Shows the create operation that would be performed. `-AutoApprove` is explicit and uses the digest embedded in the validated package. + +### EXAMPLE 3 + +```powershell +Copy-PnPEnterpriseWiki ` + -PackagePath .\enterprise-wiki\architecture ` + -ApprovedPlanDigest $approvedDigest ` + -ReceiptPath .\evidence\architecture-copy.json ` + -Connection $target +``` + +Creates the page and writes the fresh-readback receipt to the requested local path. + +## PARAMETERS + +### -ApprovedPlanDigest + +SHA-256 digest reviewed and approved from the package's `planDigest` property. + +```yaml +Type: String +Parameter Sets: Approved +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AutoApprove + +Explicitly approves the plan digest embedded in a valid package. Omit this switch when approval is performed out of band and supply `-ApprovedPlanDigest` instead. + +```yaml +Type: SwitchParameter +Parameter Sets: AutoApprove +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Connection to the exact target web recorded in the approved plan. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Required: False +Position: Named +Default value: Current connection +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrites an existing local receipt file. It never permits overwriting a target SharePoint page or dependency. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackagePath + +Path to `enterprise-wiki-package.json` or its containing directory. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: Path +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -ReceiptPath + +Local receipt file or directory. Defaults to `enterprise-wiki-copy-receipt.json` beside the package. + +```yaml +Type: String +Parameter Sets: (All) +Required: False +Position: Named +Default value: Package directory +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Get-PnPEnterpriseWiki](Get-PnPEnterpriseWiki.md) + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Get-PnPEnterpriseWiki.md b/documentation/Get-PnPEnterpriseWiki.md new file mode 100644 index 000000000..b64ea8839 --- /dev/null +++ b/documentation/Get-PnPEnterpriseWiki.md @@ -0,0 +1,296 @@ +--- +Module Name: PnP.PowerShell +title: Get-PnPEnterpriseWiki +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPEnterpriseWiki.html +--- + +# Get-PnPEnterpriseWiki + +## SYNOPSIS + +**Required Permissions** + +* SharePoint: Sites.Read.All (application) or AllSites.Read (delegated) + +Captures an Enterprise Wiki page into a sealed, approval-ready migration package. + +## SYNTAX + +### Identity + +```powershell +Get-PnPEnterpriseWiki [-Identity] -TargetConnection -OutputPath ` + [-TargetPageName ] [-Draft] [-NoWebParts] [-AllowUniquePermissions] ` + [-AllowManagedMetadataSubstitution] [-BlockExternalResources] [-MaximumDependencyBytes ] ` + [-Force] [-Connection ] +``` + +### All + +```powershell +Get-PnPEnterpriseWiki -All -TargetConnection -OutputPath ` + [-TargetPagePrefix ] [-Draft] [-NoWebParts] [-AllowUniquePermissions] ` + [-AllowManagedMetadataSubstitution] [-BlockExternalResources] [-MaximumDependencyBytes ] ` + [-Force] [-Connection ] +``` + +## DESCRIPTION + +Captures the source page, analyzes its Enterprise Wiki ingredients, probes the target publishing environment, and writes a deterministic package containing a sealed source snapshot and migration plan. The command does not write to SharePoint. + +The source must have an Enterprise Wiki Page content type. Project Page content types are deliberately excluded. The default exact profile requires the stock `EnterpriseWiki.aspx` layout, inherited page permissions, and no unresolved managed metadata mapping. Shared Web Parts and authored resource dependencies are captured when possible. Source-list-bound Web Parts, source `ErrorWebPart` instances, and legacy RSS Aggregator Web Parts are sealed as review evidence but block the v1 plan until they have an explicit replacement or target mapping. A source stability fence rejects a page that changes during capture. + +The resulting `planDigest` must be explicitly supplied to `Copy-PnPEnterpriseWiki`, unless that command is invoked with `-AutoApprove`. A package with blockers is still written for review but cannot be copied. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$source = Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/source -Interactive -ReturnConnection +$target = Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/communication -Interactive -ReturnConnection + +$package = Get-PnPEnterpriseWiki ` + -Identity "/sites/source/Pages/Architecture.aspx" ` + -TargetConnection $target ` + -TargetPageName "Architecture-copy.aspx" ` + -OutputPath ".\enterprise-wiki\architecture" ` + -Connection $source +``` + +Captures one Enterprise Wiki page, performs target preflight, and writes `enterprise-wiki-package.json` plus a Markdown review report. + +### EXAMPLE 2 + +```powershell +Get-PnPEnterpriseWiki ` + -All ` + -TargetConnection $target ` + -TargetPagePrefix "migration-2026" ` + -OutputPath ".\enterprise-wiki\batch" ` + -Connection $source +``` + +Captures every Enterprise Wiki page in the current web. Each page receives its own package directory and create-only target page name. + +### EXAMPLE 3 + +```powershell +Get-PnPEnterpriseWiki ` + -Identity "Pages/Legacy.aspx" ` + -TargetConnection $target ` + -OutputPath ".\enterprise-wiki\legacy" ` + -AllowManagedMetadataSubstitution ` + -AllowUniquePermissions ` + -Connection $source +``` + +Captures a page while recording managed metadata and unique permissions as reviewed substitutions instead of blockers. These values are evidence-only in the v1 profile and are not applied by the copy command. + +## PARAMETERS + +### -All + +Captures all Enterprise Wiki pages in the current web's publishing Pages library. + +```yaml +Type: SwitchParameter +Parameter Sets: All +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AllowManagedMetadataSubstitution + +Allows non-empty managed metadata to be recorded as an explicit substitution instead of blocking the plan. The v1 copy profile does not apply those values without a reviewed term mapping. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AllowUniquePermissions + +Allows a source page with unique role assignments to produce an executable plan. Security is still captured as evidence; the v1 copy profile does not reproduce the unique assignments. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BlockExternalResources + +Treats externally hosted renderable resources as blockers instead of preserving their URLs. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Optional connection used to read the source web. Retrieve it with `Connect-PnPOnline -ReturnConnection` or `Get-PnPConnection`. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Required: False +Position: Named +Default value: Current connection +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Draft + +Plans the target page as a draft instead of publishing it after copy. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrites an existing local package and report. It never permits overwriting a target SharePoint page or dependency. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity + +Source page name, web-relative path, server-relative path, or absolute URL. + +```yaml +Type: String +Parameter Sets: Identity +Aliases: ServerRelativeUrl +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -MaximumDependencyBytes + +Maximum size in bytes of each authored SharePoint file dependency captured into the sealed package. + +```yaml +Type: Int64 +Parameter Sets: (All) +Required: False +Position: Named +Default value: 10485760 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -NoWebParts + +Skips shared Web Part export. Use only for an explicitly reviewed profile; the package cannot claim Web Part fidelity for skipped parts. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -OutputPath + +Local package file or directory. With `-All`, this is the parent directory for one package directory per page. + +```yaml +Type: String +Parameter Sets: (All) +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetConnection + +Connection used only for read-only target preflight. Capture requires a target so the sealed plan records the actual Pages library, Enterprise Wiki content type, stock layout, and create-only collision state. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetPageName + +Target file name for a single page. Defaults to the source file name. + +```yaml +Type: String +Parameter Sets: Identity +Required: False +Position: Named +Default value: Source file name +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetPagePrefix + +Prefix used to generate create-only target file names when capturing with `-All`. + +```yaml +Type: String +Parameter Sets: All +Required: False +Position: Named +Default value: pnp-ewiki +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Copy-PnPEnterpriseWiki](Copy-PnPEnterpriseWiki.md) + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Pages/CopyEnterpriseWiki.cs b/src/Commands/Pages/CopyEnterpriseWiki.cs new file mode 100644 index 000000000..70d50e503 --- /dev/null +++ b/src/Commands/Pages/CopyEnterpriseWiki.cs @@ -0,0 +1,78 @@ +using PnP.Framework.EnterpriseWiki; +using PnP.PowerShell.Commands.Attributes; +using System; +using System.IO; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Pages +{ + [Cmdlet(VerbsCommon.Copy, "PnPEnterpriseWiki", DefaultParameterSetName = ParameterSetApproved, SupportsShouldProcess = true)] + [OutputType(typeof(EnterpriseWikiCopyReceipt))] + [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] + public class CopyEnterpriseWiki : PnPWebCmdlet + { + private const string ParameterSetApproved = "Approved"; + private const string ParameterSetAutoApprove = "AutoApprove"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + [Alias("Path")] + [ValidateNotNullOrEmpty] + public string PackagePath { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetApproved)] + [ValidateNotNullOrEmpty] + public string ApprovedPlanDigest { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetAutoApprove)] + public SwitchParameter AutoApprove { get; set; } + + [Parameter(Mandatory = false)] + public string ReceiptPath { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Force { get; set; } + + protected override void ExecuteCmdlet() + { + var resolvedPackagePath = ResolveLocalPath(PackagePath); + var package = EnterpriseWikiPackageSerializer.Load(resolvedPackagePath); + var approvedDigest = ParameterSetName == ParameterSetAutoApprove + ? package.PlanDigest + : ApprovedPlanDigest; + if (!ShouldProcess( + package.Plan.TargetPageServerRelativeUrl, + $"Create Enterprise Wiki page from approved plan {approvedDigest}")) + { + return; + } + + var service = new EnterpriseWikiMigrationService(); + var receipt = service.Copy(Connection.Context, package, approvedDigest); + var receiptPath = string.IsNullOrWhiteSpace(ReceiptPath) + ? Path.GetDirectoryName(ResolvePackageFile(resolvedPackagePath)) + : ResolveLocalPath(ReceiptPath); + var savedReceiptPath = EnterpriseWikiPackageSerializer.SaveReceipt(receiptPath, receipt, Force); + WriteVerbose($"Enterprise Wiki copy receipt written to '{savedReceiptPath}'."); + foreach (var warning in receipt.Warnings) + { + WriteWarning(warning); + } + WriteObject(receipt); + } + + private string ResolveLocalPath(string value) + { + return Path.IsPathRooted(value) + ? Path.GetFullPath(value) + : Path.GetFullPath(Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, value)); + } + + private static string ResolvePackageFile(string value) + { + return Directory.Exists(value) || string.IsNullOrEmpty(Path.GetExtension(value)) + ? Path.Combine(value, EnterpriseWikiPackageSerializer.DefaultPackageFileName) + : value; + } + } +} diff --git a/src/Commands/Pages/GetEnterpriseWiki.cs b/src/Commands/Pages/GetEnterpriseWiki.cs new file mode 100644 index 000000000..9cadd4ad2 --- /dev/null +++ b/src/Commands/Pages/GetEnterpriseWiki.cs @@ -0,0 +1,164 @@ +using Microsoft.SharePoint.Client; +using PnP.Framework.EnterpriseWiki; +using PnP.Framework.Utilities; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Pages +{ + [Cmdlet(VerbsCommon.Get, "PnPEnterpriseWiki", DefaultParameterSetName = ParameterSetIdentity)] + [OutputType(typeof(EnterpriseWikiMigrationPackage))] + [RequiredApiApplicationPermissions("sharepoint/Sites.Read.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.Read")] + public class GetEnterpriseWiki : PnPWebCmdlet + { + private const string ParameterSetIdentity = "Identity"; + private const string ParameterSetAll = "All"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ParameterSetName = ParameterSetIdentity)] + [Alias("ServerRelativeUrl")] + [ValidateNotNullOrEmpty] + public string Identity { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetAll)] + public SwitchParameter All { get; set; } + + [Parameter(Mandatory = true)] + [ValidateNotNull] + public PnPConnection TargetConnection { get; set; } + + [Parameter(Mandatory = true)] + [ValidateNotNullOrEmpty] + public string OutputPath { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetIdentity)] + public string TargetPageName { get; set; } + + [Parameter(Mandatory = false, ParameterSetName = ParameterSetAll)] + public string TargetPagePrefix { get; set; } = "pnp-ewiki"; + + [Parameter(Mandatory = false)] + public SwitchParameter Draft { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter NoWebParts { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowUniquePermissions { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowManagedMetadataSubstitution { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter BlockExternalResources { get; set; } + + [Parameter(Mandatory = false)] + [ValidateRange(1, long.MaxValue)] + public long MaximumDependencyBytes { get; set; } = 10 * 1024 * 1024; + + [Parameter(Mandatory = false)] + public SwitchParameter Force { get; set; } + + protected override void ExecuteCmdlet() + { + var service = new EnterpriseWikiMigrationService(); + var sourceContext = Connection.Context; + var targetContext = TargetConnection.Context; + var targetPages = targetContext.Web.GetPagesLibrary(); + targetContext.Load(targetPages.RootFolder, folder => folder.ServerRelativeUrl); + targetContext.ExecuteQueryRetry(); + + IReadOnlyList sourcePages = ParameterSetName == ParameterSetAll + ? service.Discover(sourceContext) + : new[] { Identity }; + if (sourcePages.Count == 0) + { + WriteVerbose("No Enterprise Wiki pages were found in the current web."); + return; + } + + var outputRoot = ResolveLocalPath(OutputPath); + for (var index = 0; index < sourcePages.Count; index++) + { + var sourcePage = sourcePages[index]; + var targetName = ParameterSetName == ParameterSetAll + ? $"{TargetPagePrefix}-{index + 1:D3}-{GetLeafName(sourcePage)}" + : string.IsNullOrWhiteSpace(TargetPageName) ? GetLeafName(sourcePage) : TargetPageName; + targetName = EnsureAspx(targetName.ReplaceInvalidUrlChars("-")); + var targetPagePath = $"{targetPages.RootFolder.ServerRelativeUrl.TrimEnd('/')}/{targetName}"; + var itemOutputPath = ParameterSetName == ParameterSetAll + ? Path.Combine(outputRoot, $"{index + 1:D3}-{MakeSafeDirectoryName(Path.GetFileNameWithoutExtension(targetName))}") + : outputRoot; + + WriteProgress(new ProgressRecord( + 181, + "Capture Enterprise Wiki migration package", + $"{index + 1}/{sourcePages.Count}: {sourcePage}") + { + PercentComplete = (index * 100) / sourcePages.Count + }); + + var package = service.Capture(sourceContext, targetContext, new EnterpriseWikiCaptureOptions + { + SourcePageServerRelativeUrl = sourcePage, + TargetPageServerRelativeUrl = targetPagePath, + IncludeWebParts = !NoWebParts, + Publish = !Draft, + RequireInheritedPermissions = !AllowUniquePermissions, + BlockOnManagedMetadata = !AllowManagedMetadataSubstitution, + AllowExternalResourceReferences = !BlockExternalResources, + MaximumDependencyBytes = MaximumDependencyBytes + }); + var packagePath = EnterpriseWikiPackageSerializer.Save(itemOutputPath, package, Force); + WriteVerbose($"Enterprise Wiki package written to '{packagePath}'. Plan digest: {package.PlanDigest}"); + foreach (var warning in package.Plan.Warnings) + { + WriteWarning(warning); + } + foreach (var blocker in package.Plan.Blockers) + { + WriteWarning($"BLOCKER: {blocker}"); + } + WriteObject(package); + } + + WriteProgress(new ProgressRecord(181, "Capture Enterprise Wiki migration package", "Completed") + { + RecordType = ProgressRecordType.Completed + }); + } + + private string ResolveLocalPath(string value) + { + return Path.IsPathRooted(value) + ? Path.GetFullPath(value) + : Path.GetFullPath(Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, value)); + } + + private static string GetLeafName(string value) + { + var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute) ? absolute.AbsolutePath : value; + path = Uri.UnescapeDataString(path ?? string.Empty).Replace('\\', '/').TrimEnd('/'); + var separator = path.LastIndexOf('/'); + return separator < 0 ? path : path.Substring(separator + 1); + } + + private static string EnsureAspx(string value) + { + return value.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase) ? value : value + ".aspx"; + } + + private static string MakeSafeDirectoryName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string((value ?? "enterprise-wiki") + .Select(character => invalid.Contains(character) ? '-' : character) + .ToArray()); + } + } +} From f26d3127ebdb5745cc809cecb88d3e45d6034974 Mon Sep 17 00:00:00 2001 From: TaoziZ03 <106975749+TaoziZ03@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:11:54 +0800 Subject: [PATCH 2/3] refactor: add staged Enterprise Wiki migration cmdlets --- CHANGELOG.md | 2 +- documentation/Copy-PnPEnterpriseWiki.md | 174 ---------- .../Export-PnPEnterpriseWikiPackage.md | 204 ++++++++++++ documentation/Get-PnPEnterpriseWiki.md | 296 ------------------ ...mport-PnPEnterpriseWikiMigrationPackage.md | 221 +++++++++++++ .../New-PnPEnterpriseWikiMigrationPlan.md | 221 +++++++++++++ .../Model/EnterpriseWikiExportResult.cs | 36 +++ .../EnterpriseWikiMigrationPlanResult.cs | 54 ++++ ...Wiki.cs => ExportEnterpriseWikiPackage.cs} | 73 +---- ...> ImportEnterpriseWikiMigrationPackage.cs} | 16 +- .../Pages/NewEnterpriseWikiMigrationPlan.cs | 128 ++++++++ 11 files changed, 887 insertions(+), 538 deletions(-) delete mode 100644 documentation/Copy-PnPEnterpriseWiki.md create mode 100644 documentation/Export-PnPEnterpriseWikiPackage.md delete mode 100644 documentation/Get-PnPEnterpriseWiki.md create mode 100644 documentation/Import-PnPEnterpriseWikiMigrationPackage.md create mode 100644 documentation/New-PnPEnterpriseWikiMigrationPlan.md create mode 100644 src/Commands/Model/EnterpriseWikiExportResult.cs create mode 100644 src/Commands/Model/EnterpriseWikiMigrationPlanResult.cs rename src/Commands/Pages/{GetEnterpriseWiki.cs => ExportEnterpriseWikiPackage.cs} (54%) rename src/Commands/Pages/{CopyEnterpriseWiki.cs => ImportEnterpriseWikiMigrationPackage.cs} (80%) create mode 100644 src/Commands/Pages/NewEnterpriseWikiMigrationPlan.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3c40da1..46480e480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added -- Added `Get-PnPEnterpriseWiki` and `Copy-PnPEnterpriseWiki` to capture an Enterprise Wiki into a sealed, digest-approved migration package and create it with fresh target preflight/readback. +- Added `Export-PnPEnterpriseWikiPackage`, `New-PnPEnterpriseWikiMigrationPlan`, and `Import-PnPEnterpriseWikiMigrationPackage` to preserve a complete source field snapshot, create a digest-approved target plan and field-by-field report, and import only recognized fields with fresh target and lifecycle verification. ### Changed diff --git a/documentation/Copy-PnPEnterpriseWiki.md b/documentation/Copy-PnPEnterpriseWiki.md deleted file mode 100644 index c4971d5f8..000000000 --- a/documentation/Copy-PnPEnterpriseWiki.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -Module Name: PnP.PowerShell -title: Copy-PnPEnterpriseWiki -schema: 2.0.0 -applicable: SharePoint Online -external help file: PnP.PowerShell.dll-Help.xml -online version: https://pnp.github.io/powershell/cmdlets/Copy-PnPEnterpriseWiki.html ---- - -# Copy-PnPEnterpriseWiki - -## SYNOPSIS - -**Required Permissions** - -* SharePoint: Sites.FullControl.All (application) or AllSites.FullControl (delegated) - -Creates an Enterprise Wiki page from an approved sealed migration package. - -## SYNTAX - -### Approved - -```powershell -Copy-PnPEnterpriseWiki [-PackagePath] -ApprovedPlanDigest ` - [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] -``` - -### AutoApprove - -```powershell -Copy-PnPEnterpriseWiki [-PackagePath] -AutoApprove ` - [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] -``` - -## DESCRIPTION - -Validates the snapshot and plan SHA-256 digests, performs a fresh target preflight, and creates the target page using only the approved package. It does not reread or replan from the source. - -The copy is create-only: an existing target page or planned dependency path blocks execution. Captured SharePoint resources are materialized, source web and tenant references are rewritten to the approved target, selected publishing metadata is applied, and shared Web Parts are imported at their captured zone positions. The page is published unless the package was captured with `Get-PnPEnterpriseWiki -Draft`. - -After writing, the command creates a new target context and independently reads back the file identity, Enterprise Wiki content type, version, page content hash, and Web Part count. SharePoint may normalize `PublishingPageContent` storage bytes; browser DOM and screenshot acceptance remain a separate required fidelity gate. - -## EXAMPLES - -### EXAMPLE 1 - -```powershell -$package = Get-Content .\enterprise-wiki\architecture\enterprise-wiki-package.json -Raw | ConvertFrom-Json - -Copy-PnPEnterpriseWiki ` - -PackagePath .\enterprise-wiki\architecture ` - -ApprovedPlanDigest $package.planDigest ` - -Connection $target -``` - -Copies the page only when the supplied digest exactly matches the sealed migration plan. - -### EXAMPLE 2 - -```powershell -Copy-PnPEnterpriseWiki ` - -PackagePath .\enterprise-wiki\architecture ` - -AutoApprove ` - -WhatIf ` - -Connection $target -``` - -Shows the create operation that would be performed. `-AutoApprove` is explicit and uses the digest embedded in the validated package. - -### EXAMPLE 3 - -```powershell -Copy-PnPEnterpriseWiki ` - -PackagePath .\enterprise-wiki\architecture ` - -ApprovedPlanDigest $approvedDigest ` - -ReceiptPath .\evidence\architecture-copy.json ` - -Connection $target -``` - -Creates the page and writes the fresh-readback receipt to the requested local path. - -## PARAMETERS - -### -ApprovedPlanDigest - -SHA-256 digest reviewed and approved from the package's `planDigest` property. - -```yaml -Type: String -Parameter Sets: Approved -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -AutoApprove - -Explicitly approves the plan digest embedded in a valid package. Omit this switch when approval is performed out of band and supply `-ApprovedPlanDigest` instead. - -```yaml -Type: SwitchParameter -Parameter Sets: AutoApprove -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Connection - -Connection to the exact target web recorded in the approved plan. - -```yaml -Type: PnPConnection -Parameter Sets: (All) -Required: False -Position: Named -Default value: Current connection -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Force - -Overwrites an existing local receipt file. It never permits overwriting a target SharePoint page or dependency. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -PackagePath - -Path to `enterprise-wiki-package.json` or its containing directory. - -```yaml -Type: String -Parameter Sets: (All) -Aliases: Path -Required: True -Position: 0 -Default value: None -Accept pipeline input: True (ByValue) -Accept wildcard characters: False -``` - -### -ReceiptPath - -Local receipt file or directory. Defaults to `enterprise-wiki-copy-receipt.json` beside the package. - -```yaml -Type: String -Parameter Sets: (All) -Required: False -Position: Named -Default value: Package directory -Accept pipeline input: False -Accept wildcard characters: False -``` - -## RELATED LINKS - -[Get-PnPEnterpriseWiki](Get-PnPEnterpriseWiki.md) - -[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Export-PnPEnterpriseWikiPackage.md b/documentation/Export-PnPEnterpriseWikiPackage.md new file mode 100644 index 000000000..114fe9d99 --- /dev/null +++ b/documentation/Export-PnPEnterpriseWikiPackage.md @@ -0,0 +1,204 @@ +--- +Module Name: PnP.PowerShell +title: Export-PnPEnterpriseWikiPackage +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Export-PnPEnterpriseWikiPackage.html +--- + +# Export-PnPEnterpriseWikiPackage + +## SYNOPSIS + +**Required Permissions** + +* SharePoint: Sites.Read.All (application) or AllSites.Read (delegated) + +Exports a source-only Enterprise Wiki snapshot that can be planned later without reconnecting to the source. + +## SYNTAX + +### Identity + +```powershell +Export-PnPEnterpriseWikiPackage [-Identity] -OutputPath [-NoWebParts] + [-MaximumDependencyBytes ] [-Force] [-Connection ] +``` + +### All + +```powershell +Export-PnPEnterpriseWikiPackage -All -OutputPath [-NoWebParts] + [-MaximumDependencyBytes ] [-Force] [-Connection ] +``` + +## DESCRIPTION + +Captures an Enterprise Wiki page without requiring or inspecting a target connection. The resulting `enterprise-wiki-export.json` contains a digest-sealed source snapshot. + +The snapshot enumerates every field definition in the source Pages library. Each field receives an entry even when SharePoint did not return a value or the current importer does not understand its runtime type. Entries retain identity, title, type, full schema XML, flags, capture status, structured known values, and best-effort raw type/text/JSON evidence. A later planner can therefore recover newly supported fields without reading the source again. + +Publishing HTML, shared Web Parts, dependencies, security, lifecycle evidence, and a before/after source stability fence are also captured. The returned `EnterpriseWikiExportResult.ExportPath` binds directly to `New-PnPEnterpriseWikiMigrationPlan`. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$source = Connect-PnPOnline https://contoso.sharepoint.com/sites/legacy -Interactive -ReturnConnection +Export-PnPEnterpriseWikiPackage -Identity Pages/R11.aspx -OutputPath .\R11 -Connection $source +``` + +Writes `.\R11\enterprise-wiki-export.json`. No target connection is needed. + +### EXAMPLE 2 + +```powershell +Export-PnPEnterpriseWikiPackage -All -OutputPath .\wiki-export -Connection $source +``` + +Exports every Enterprise Wiki Page into a separate numbered directory. + +### EXAMPLE 3 + +```powershell +$export = Export-PnPEnterpriseWikiPackage Pages/R11.aspx -OutputPath .\R11 -Connection $source +$export.Snapshot.Fields | Select-Object InternalName, TypeAsString, CaptureStatus, HasValue, Kind, RawType +``` + +Reviews the complete source field inventory. + +## PARAMETERS + +### -All + +Exports all pages whose content type derives from Enterprise Wiki Page. Project Page is excluded. + +```yaml +Type: SwitchParameter +Parameter Sets: All +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Optional connection to the source web. Retrieve it with `Connect-PnPOnline -ReturnConnection` or `Get-PnPConnection`. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrites an existing local export file. It does not change migration eligibility or overwrite SharePoint content. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity + +Source page name, relative path, server-relative path, or absolute URL. + +```yaml +Type: String +Parameter Sets: Identity +Aliases: ServerRelativeUrl + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -MaximumDependencyBytes + +Maximum size of each referenced SharePoint resource embedded in the snapshot. The default is 10 MiB. + +```yaml +Type: Int64 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: 10485760 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -NoWebParts + +Skips shared Web Part export. Publishing HTML and all list-item fields are still captured. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -OutputPath + +Local file or directory for the source export. A directory produces `enterprise-wiki-export.json`. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Verbose + +Shows detailed information about the operation. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: vb + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[New-PnPEnterpriseWikiMigrationPlan](New-PnPEnterpriseWikiMigrationPlan.md) diff --git a/documentation/Get-PnPEnterpriseWiki.md b/documentation/Get-PnPEnterpriseWiki.md deleted file mode 100644 index b64ea8839..000000000 --- a/documentation/Get-PnPEnterpriseWiki.md +++ /dev/null @@ -1,296 +0,0 @@ ---- -Module Name: PnP.PowerShell -title: Get-PnPEnterpriseWiki -schema: 2.0.0 -applicable: SharePoint Online -external help file: PnP.PowerShell.dll-Help.xml -online version: https://pnp.github.io/powershell/cmdlets/Get-PnPEnterpriseWiki.html ---- - -# Get-PnPEnterpriseWiki - -## SYNOPSIS - -**Required Permissions** - -* SharePoint: Sites.Read.All (application) or AllSites.Read (delegated) - -Captures an Enterprise Wiki page into a sealed, approval-ready migration package. - -## SYNTAX - -### Identity - -```powershell -Get-PnPEnterpriseWiki [-Identity] -TargetConnection -OutputPath ` - [-TargetPageName ] [-Draft] [-NoWebParts] [-AllowUniquePermissions] ` - [-AllowManagedMetadataSubstitution] [-BlockExternalResources] [-MaximumDependencyBytes ] ` - [-Force] [-Connection ] -``` - -### All - -```powershell -Get-PnPEnterpriseWiki -All -TargetConnection -OutputPath ` - [-TargetPagePrefix ] [-Draft] [-NoWebParts] [-AllowUniquePermissions] ` - [-AllowManagedMetadataSubstitution] [-BlockExternalResources] [-MaximumDependencyBytes ] ` - [-Force] [-Connection ] -``` - -## DESCRIPTION - -Captures the source page, analyzes its Enterprise Wiki ingredients, probes the target publishing environment, and writes a deterministic package containing a sealed source snapshot and migration plan. The command does not write to SharePoint. - -The source must have an Enterprise Wiki Page content type. Project Page content types are deliberately excluded. The default exact profile requires the stock `EnterpriseWiki.aspx` layout, inherited page permissions, and no unresolved managed metadata mapping. Shared Web Parts and authored resource dependencies are captured when possible. Source-list-bound Web Parts, source `ErrorWebPart` instances, and legacy RSS Aggregator Web Parts are sealed as review evidence but block the v1 plan until they have an explicit replacement or target mapping. A source stability fence rejects a page that changes during capture. - -The resulting `planDigest` must be explicitly supplied to `Copy-PnPEnterpriseWiki`, unless that command is invoked with `-AutoApprove`. A package with blockers is still written for review but cannot be copied. - -## EXAMPLES - -### EXAMPLE 1 - -```powershell -$source = Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/source -Interactive -ReturnConnection -$target = Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/communication -Interactive -ReturnConnection - -$package = Get-PnPEnterpriseWiki ` - -Identity "/sites/source/Pages/Architecture.aspx" ` - -TargetConnection $target ` - -TargetPageName "Architecture-copy.aspx" ` - -OutputPath ".\enterprise-wiki\architecture" ` - -Connection $source -``` - -Captures one Enterprise Wiki page, performs target preflight, and writes `enterprise-wiki-package.json` plus a Markdown review report. - -### EXAMPLE 2 - -```powershell -Get-PnPEnterpriseWiki ` - -All ` - -TargetConnection $target ` - -TargetPagePrefix "migration-2026" ` - -OutputPath ".\enterprise-wiki\batch" ` - -Connection $source -``` - -Captures every Enterprise Wiki page in the current web. Each page receives its own package directory and create-only target page name. - -### EXAMPLE 3 - -```powershell -Get-PnPEnterpriseWiki ` - -Identity "Pages/Legacy.aspx" ` - -TargetConnection $target ` - -OutputPath ".\enterprise-wiki\legacy" ` - -AllowManagedMetadataSubstitution ` - -AllowUniquePermissions ` - -Connection $source -``` - -Captures a page while recording managed metadata and unique permissions as reviewed substitutions instead of blockers. These values are evidence-only in the v1 profile and are not applied by the copy command. - -## PARAMETERS - -### -All - -Captures all Enterprise Wiki pages in the current web's publishing Pages library. - -```yaml -Type: SwitchParameter -Parameter Sets: All -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -AllowManagedMetadataSubstitution - -Allows non-empty managed metadata to be recorded as an explicit substitution instead of blocking the plan. The v1 copy profile does not apply those values without a reviewed term mapping. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -AllowUniquePermissions - -Allows a source page with unique role assignments to produce an executable plan. Security is still captured as evidence; the v1 copy profile does not reproduce the unique assignments. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -BlockExternalResources - -Treats externally hosted renderable resources as blockers instead of preserving their URLs. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Connection - -Optional connection used to read the source web. Retrieve it with `Connect-PnPOnline -ReturnConnection` or `Get-PnPConnection`. - -```yaml -Type: PnPConnection -Parameter Sets: (All) -Required: False -Position: Named -Default value: Current connection -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Draft - -Plans the target page as a draft instead of publishing it after copy. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Force - -Overwrites an existing local package and report. It never permits overwriting a target SharePoint page or dependency. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Identity - -Source page name, web-relative path, server-relative path, or absolute URL. - -```yaml -Type: String -Parameter Sets: Identity -Aliases: ServerRelativeUrl -Required: True -Position: 0 -Default value: None -Accept pipeline input: True (ByValue) -Accept wildcard characters: False -``` - -### -MaximumDependencyBytes - -Maximum size in bytes of each authored SharePoint file dependency captured into the sealed package. - -```yaml -Type: Int64 -Parameter Sets: (All) -Required: False -Position: Named -Default value: 10485760 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -NoWebParts - -Skips shared Web Part export. Use only for an explicitly reviewed profile; the package cannot claim Web Part fidelity for skipped parts. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -OutputPath - -Local package file or directory. With `-All`, this is the parent directory for one package directory per page. - -```yaml -Type: String -Parameter Sets: (All) -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -TargetConnection - -Connection used only for read-only target preflight. Capture requires a target so the sealed plan records the actual Pages library, Enterprise Wiki content type, stock layout, and create-only collision state. - -```yaml -Type: PnPConnection -Parameter Sets: (All) -Required: True -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -TargetPageName - -Target file name for a single page. Defaults to the source file name. - -```yaml -Type: String -Parameter Sets: Identity -Required: False -Position: Named -Default value: Source file name -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -TargetPagePrefix - -Prefix used to generate create-only target file names when capturing with `-All`. - -```yaml -Type: String -Parameter Sets: All -Required: False -Position: Named -Default value: pnp-ewiki -Accept pipeline input: False -Accept wildcard characters: False -``` - -## RELATED LINKS - -[Copy-PnPEnterpriseWiki](Copy-PnPEnterpriseWiki.md) - -[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/documentation/Import-PnPEnterpriseWikiMigrationPackage.md b/documentation/Import-PnPEnterpriseWikiMigrationPackage.md new file mode 100644 index 000000000..0ce764030 --- /dev/null +++ b/documentation/Import-PnPEnterpriseWikiMigrationPackage.md @@ -0,0 +1,221 @@ +--- +Module Name: PnP.PowerShell +title: Import-PnPEnterpriseWikiMigrationPackage +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Import-PnPEnterpriseWikiMigrationPackage.html +--- + +# Import-PnPEnterpriseWikiMigrationPackage + +## SYNOPSIS + +**Required Permissions** + +* SharePoint: Sites.FullControl.All (application) or AllSites.FullControl (delegated) + +Imports an Enterprise Wiki page from an explicitly approved migration package. + +## SYNTAX + +### Approved + +```powershell +Import-PnPEnterpriseWikiMigrationPackage [-PackagePath] -ApprovedPlanDigest + [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] +``` + +### AutoApprove + +```powershell +Import-PnPEnterpriseWikiMigrationPackage [-PackagePath] -AutoApprove + [-ReceiptPath ] [-Force] [-Connection ] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +Validates both digests, verifies the target connection, repeats target preflight, and executes only the sealed actions. It does not reconnect to the source or silently reinterpret fields. + +Only field actions marked `Apply` are written. The receipt lists every field action, whether it was attempted, whether it succeeded, and its message. A page planned as `Published` is published only when planned fields succeed. All other source lifecycle states are checked in as `Draft`. Import is create-only; `-Force` only controls local receipt overwrite. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$plan = New-PnPEnterpriseWikiMigrationPlan .\R11\enterprise-wiki-export.json -Connection $target +Import-PnPEnterpriseWikiMigrationPackage $plan.PackagePath -ApprovedPlanDigest $plan.PlanDigest -Connection $target +``` + +Imports exactly the plan whose digest was reviewed. + +### EXAMPLE 2 + +```powershell +Export-PnPEnterpriseWikiPackage Pages/R11.aspx -OutputPath .\R11 -Connection $source | + New-PnPEnterpriseWikiMigrationPlan -Connection $target | + Import-PnPEnterpriseWikiMigrationPackage -AutoApprove -Connection $target +``` + +Runs the three-stage pipeline. `-AutoApprove` skips separate human digest entry, but not validation. + +### EXAMPLE 3 + +```powershell +$receipt = Import-PnPEnterpriseWikiMigrationPackage .\R11\enterprise-wiki-package.json -ApprovedPlanDigest $digest -Connection $target +$receipt.FieldResults | Format-Table InternalName, PlannedDisposition, Attempted, Succeeded, Message +$receipt | Select-Object ExpectedLifecycle, ActualFileLevel, LifecycleMatched +``` + +Reviews field execution and fresh lifecycle readback. + +## PARAMETERS + +### -ApprovedPlanDigest + +Exact SHA-256 plan digest that was reviewed. + +```yaml +Type: String +Parameter Sets: Approved +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AutoApprove + +Uses the digest contained in the package. It does not bypass digest validation or blockers. + +```yaml +Type: SwitchParameter +Parameter Sets: AutoApprove +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm + +Prompts before importing. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Optional connection to the exact target web in the plan. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Force + +Overwrites an existing local receipt. It does not overwrite target content or bypass blockers. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PackagePath + +Path to `enterprise-wiki-package.json` or its directory. Accepts a plan result through `PackagePath`. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: Path + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -ReceiptPath + +Local file or directory for `enterprise-wiki-import-receipt.json`. The default is next to the package. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Verbose + +Shows detailed information about the operation. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: vb + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows what would be imported without writing to SharePoint. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[New-PnPEnterpriseWikiMigrationPlan](New-PnPEnterpriseWikiMigrationPlan.md) diff --git a/documentation/New-PnPEnterpriseWikiMigrationPlan.md b/documentation/New-PnPEnterpriseWikiMigrationPlan.md new file mode 100644 index 000000000..7348abad4 --- /dev/null +++ b/documentation/New-PnPEnterpriseWikiMigrationPlan.md @@ -0,0 +1,221 @@ +--- +Module Name: PnP.PowerShell +title: New-PnPEnterpriseWikiMigrationPlan +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/New-PnPEnterpriseWikiMigrationPlan.html +--- + +# New-PnPEnterpriseWikiMigrationPlan + +## SYNOPSIS + +**Required Permissions** + +* SharePoint: Sites.Read.All (application) or AllSites.Read (delegated) + +Creates a target-specific, digest-sealed Enterprise Wiki migration plan and complete review report. + +## SYNTAX + +```powershell +New-PnPEnterpriseWikiMigrationPlan [-ExportPath] [-OutputPath ] + [-TargetPageName ] [-AllowUniquePermissions] [-AllowManagedMetadataSubstitution] + [-BlockExternalResources] [-Force] [-Connection ] +``` + +## DESCRIPTION + +Reads a source-only export, probes the target web represented by `-Connection`, and writes `enterprise-wiki-package.json` plus `enterprise-wiki-report.md`. It does not modify SharePoint. + +Every source field remains in the snapshot. Only recognized, writable, type-compatible fields with supported values receive `Apply`. Unrecognized fields receive `EvidenceOnly`; user, lookup, and taxonomy values receive `RequiresMapping`; every other skip has an explicit disposition and reason. + +Lifecycle has no publish Boolean. Source `Level = Published` maps to `Published` when checkout and moderation evidence do not conflict. Every other or contradictory state maps conservatively to `Draft`. + +The report covers all envelope, source, policy, fence, lifecycle, content, field, Web Part, dependency, security, target-probe, replacement, assertion, blocker, and warning data. Large payloads are shown by length, SHA-256, and preview while the full value remains in JSON. + +Real captured examples: R11 had version `1.1`, checkout `Online`, level `Draft`, and moderation status `3`, so its new target lifecycle is `Draft`. E05 had checkout `None`, level `Published`, and moderation status `0`, so its target lifecycle is `Published`. An unknown `OOCLReference` value remains in the snapshot with `EvidenceOnly` and can be recovered by a future mapper. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$target = Connect-PnPOnline https://contoso.sharepoint.com/sites/new -Interactive -ReturnConnection +New-PnPEnterpriseWikiMigrationPlan .\R11\enterprise-wiki-export.json -TargetPageName R11.aspx -Connection $target +``` + +Creates the package and report next to the export. + +### EXAMPLE 2 + +```powershell +Export-PnPEnterpriseWikiPackage Pages/R11.aspx -OutputPath .\R11 -Connection $source | + New-PnPEnterpriseWikiMigrationPlan -TargetPageName R11.aspx -Connection $target +``` + +Uses pipeline binding through `EnterpriseWikiExportResult.ExportPath`. + +### EXAMPLE 3 + +```powershell +$plan = New-PnPEnterpriseWikiMigrationPlan .\R11\enterprise-wiki-export.json -Connection $target +$plan.Plan.FieldActions | Group-Object Disposition | Select-Object Name, Count +$plan.ReportPath +$plan.PlanDigest +``` + +Reviews field decisions, the report path, and the approval digest. + +## PARAMETERS + +### -AllowManagedMetadataSubstitution + +Allows planning to continue without a reviewed taxonomy mapping. Those values remain `RequiresMapping` and are not written. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -AllowUniquePermissions + +Allows planning to continue for a source page with unique permissions. Permissions remain evidence-only. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -BlockExternalResources + +Makes external renderable resources blockers instead of preserving their original external URLs. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Optional connection to the target web. Retrieve it with `Connect-PnPOnline -ReturnConnection` or `Get-PnPConnection`. + +```yaml +Type: PnPConnection +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ExportPath + +Path to `enterprise-wiki-export.json` or its directory. Accepts an export result through `ExportPath`. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: Path + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -Force + +Overwrites existing local package and report files. It does not bypass blockers or overwrite target content. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -OutputPath + +Local file or directory for the migration package. By default, files are written next to the export. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetPageName + +Target page filename, server-relative path, or absolute URL. The source filename is used by default. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Verbose + +Shows detailed information about the operation. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: vb + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +## RELATED LINKS + +[Export-PnPEnterpriseWikiPackage](Export-PnPEnterpriseWikiPackage.md) + +[Import-PnPEnterpriseWikiMigrationPackage](Import-PnPEnterpriseWikiMigrationPackage.md) diff --git a/src/Commands/Model/EnterpriseWikiExportResult.cs b/src/Commands/Model/EnterpriseWikiExportResult.cs new file mode 100644 index 000000000..f695c5cad --- /dev/null +++ b/src/Commands/Model/EnterpriseWikiExportResult.cs @@ -0,0 +1,36 @@ +using PnP.Framework.EnterpriseWiki; +using System; + +namespace PnP.PowerShell.Commands.Model +{ + /// + /// Describes a sealed source-only Enterprise Wiki export and its local file. + /// + public sealed class EnterpriseWikiExportResult + { + public EnterpriseWikiExportResult(EnterpriseWikiExportPackage export, string exportPath) + { + Export = export ?? throw new ArgumentNullException(nameof(export)); + ExportPath = string.IsNullOrWhiteSpace(exportPath) + ? throw new ArgumentException("An export path is required.", nameof(exportPath)) + : exportPath; + } + + public EnterpriseWikiExportPackage Export { get; } + + public string ExportPath { get; } + + public string SchemaVersion => Export.SchemaVersion; + + public DateTimeOffset ExportedAtUtc => Export.ExportedAtUtc; + + public string SnapshotDigest => Export.SnapshotDigest; + + public EnterpriseWikiSnapshot Snapshot => Export.Snapshot; + + public override string ToString() + { + return ExportPath; + } + } +} diff --git a/src/Commands/Model/EnterpriseWikiMigrationPlanResult.cs b/src/Commands/Model/EnterpriseWikiMigrationPlanResult.cs new file mode 100644 index 000000000..4d7230bc8 --- /dev/null +++ b/src/Commands/Model/EnterpriseWikiMigrationPlanResult.cs @@ -0,0 +1,54 @@ +using PnP.Framework.EnterpriseWiki; +using System; + +namespace PnP.PowerShell.Commands.Model +{ + /// + /// Describes a sealed Enterprise Wiki migration plan and its review artifacts. + /// + public sealed class EnterpriseWikiMigrationPlanResult + { + public EnterpriseWikiMigrationPlanResult( + EnterpriseWikiMigrationPackage package, + string packagePath, + string reportPath) + { + Package = package ?? throw new ArgumentNullException(nameof(package)); + PackagePath = string.IsNullOrWhiteSpace(packagePath) + ? throw new ArgumentException("A package path is required.", nameof(packagePath)) + : packagePath; + ReportPath = string.IsNullOrWhiteSpace(reportPath) + ? throw new ArgumentException("A report path is required.", nameof(reportPath)) + : reportPath; + } + + public EnterpriseWikiMigrationPackage Package { get; } + + public string PackagePath { get; } + + public string ReportPath { get; } + + public string SchemaVersion => Package.SchemaVersion; + + public DateTimeOffset PlannedAtUtc => Package.PlannedAtUtc; + + public EnterpriseWikiPackageState State => Package.State; + + public bool IsExecutable => Package.Plan?.IsExecutable == true; + + public EnterpriseWikiSnapshot Snapshot => Package.Snapshot; + + public EnterpriseWikiMigrationPlan Plan => Package.Plan; + + public string SnapshotDigest => Package.SnapshotDigest; + + public string PlanDigest => Package.PlanDigest; + + public EnterpriseWikiCustomerReport Report => Package.Report; + + public override string ToString() + { + return PackagePath; + } + } +} diff --git a/src/Commands/Pages/GetEnterpriseWiki.cs b/src/Commands/Pages/ExportEnterpriseWikiPackage.cs similarity index 54% rename from src/Commands/Pages/GetEnterpriseWiki.cs rename to src/Commands/Pages/ExportEnterpriseWikiPackage.cs index 9cadd4ad2..0cbcd2407 100644 --- a/src/Commands/Pages/GetEnterpriseWiki.cs +++ b/src/Commands/Pages/ExportEnterpriseWikiPackage.cs @@ -1,8 +1,6 @@ -using Microsoft.SharePoint.Client; using PnP.Framework.EnterpriseWiki; -using PnP.Framework.Utilities; using PnP.PowerShell.Commands.Attributes; -using PnP.PowerShell.Commands.Base; +using PnP.PowerShell.Commands.Model; using System; using System.Collections.Generic; using System.IO; @@ -11,11 +9,11 @@ namespace PnP.PowerShell.Commands.Pages { - [Cmdlet(VerbsCommon.Get, "PnPEnterpriseWiki", DefaultParameterSetName = ParameterSetIdentity)] - [OutputType(typeof(EnterpriseWikiMigrationPackage))] + [Cmdlet(VerbsData.Export, "PnPEnterpriseWikiPackage", DefaultParameterSetName = ParameterSetIdentity)] + [OutputType(typeof(EnterpriseWikiExportResult))] [RequiredApiApplicationPermissions("sharepoint/Sites.Read.All")] [RequiredApiDelegatedPermissions("sharepoint/AllSites.Read")] - public class GetEnterpriseWiki : PnPWebCmdlet + public class ExportEnterpriseWikiPackage : PnPWebCmdlet { private const string ParameterSetIdentity = "Identity"; private const string ParameterSetAll = "All"; @@ -28,35 +26,13 @@ public class GetEnterpriseWiki : PnPWebCmdlet [Parameter(Mandatory = true, ParameterSetName = ParameterSetAll)] public SwitchParameter All { get; set; } - [Parameter(Mandatory = true)] - [ValidateNotNull] - public PnPConnection TargetConnection { get; set; } - [Parameter(Mandatory = true)] [ValidateNotNullOrEmpty] public string OutputPath { get; set; } - [Parameter(Mandatory = false, ParameterSetName = ParameterSetIdentity)] - public string TargetPageName { get; set; } - - [Parameter(Mandatory = false, ParameterSetName = ParameterSetAll)] - public string TargetPagePrefix { get; set; } = "pnp-ewiki"; - - [Parameter(Mandatory = false)] - public SwitchParameter Draft { get; set; } - [Parameter(Mandatory = false)] public SwitchParameter NoWebParts { get; set; } - [Parameter(Mandatory = false)] - public SwitchParameter AllowUniquePermissions { get; set; } - - [Parameter(Mandatory = false)] - public SwitchParameter AllowManagedMetadataSubstitution { get; set; } - - [Parameter(Mandatory = false)] - public SwitchParameter BlockExternalResources { get; set; } - [Parameter(Mandatory = false)] [ValidateRange(1, long.MaxValue)] public long MaximumDependencyBytes { get; set; } = 10 * 1024 * 1024; @@ -67,14 +43,8 @@ public class GetEnterpriseWiki : PnPWebCmdlet protected override void ExecuteCmdlet() { var service = new EnterpriseWikiMigrationService(); - var sourceContext = Connection.Context; - var targetContext = TargetConnection.Context; - var targetPages = targetContext.Web.GetPagesLibrary(); - targetContext.Load(targetPages.RootFolder, folder => folder.ServerRelativeUrl); - targetContext.ExecuteQueryRetry(); - IReadOnlyList sourcePages = ParameterSetName == ParameterSetAll - ? service.Discover(sourceContext) + ? service.Discover(Connection.Context) : new[] { Identity }; if (sourcePages.Count == 0) { @@ -86,48 +56,38 @@ protected override void ExecuteCmdlet() for (var index = 0; index < sourcePages.Count; index++) { var sourcePage = sourcePages[index]; - var targetName = ParameterSetName == ParameterSetAll - ? $"{TargetPagePrefix}-{index + 1:D3}-{GetLeafName(sourcePage)}" - : string.IsNullOrWhiteSpace(TargetPageName) ? GetLeafName(sourcePage) : TargetPageName; - targetName = EnsureAspx(targetName.ReplaceInvalidUrlChars("-")); - var targetPagePath = $"{targetPages.RootFolder.ServerRelativeUrl.TrimEnd('/')}/{targetName}"; var itemOutputPath = ParameterSetName == ParameterSetAll - ? Path.Combine(outputRoot, $"{index + 1:D3}-{MakeSafeDirectoryName(Path.GetFileNameWithoutExtension(targetName))}") + ? Path.Combine(outputRoot, $"{index + 1:D3}-{MakeSafeDirectoryName(Path.GetFileNameWithoutExtension(GetLeafName(sourcePage)))}") : outputRoot; WriteProgress(new ProgressRecord( 181, - "Capture Enterprise Wiki migration package", + "Export Enterprise Wiki source snapshot", $"{index + 1}/{sourcePages.Count}: {sourcePage}") { PercentComplete = (index * 100) / sourcePages.Count }); - var package = service.Capture(sourceContext, targetContext, new EnterpriseWikiCaptureOptions + var export = service.Export(Connection.Context, new EnterpriseWikiExportOptions { SourcePageServerRelativeUrl = sourcePage, - TargetPageServerRelativeUrl = targetPagePath, IncludeWebParts = !NoWebParts, - Publish = !Draft, - RequireInheritedPermissions = !AllowUniquePermissions, - BlockOnManagedMetadata = !AllowManagedMetadataSubstitution, - AllowExternalResourceReferences = !BlockExternalResources, MaximumDependencyBytes = MaximumDependencyBytes }); - var packagePath = EnterpriseWikiPackageSerializer.Save(itemOutputPath, package, Force); - WriteVerbose($"Enterprise Wiki package written to '{packagePath}'. Plan digest: {package.PlanDigest}"); - foreach (var warning in package.Plan.Warnings) + var exportPath = EnterpriseWikiPackageSerializer.SaveExport(itemOutputPath, export, Force); + WriteVerbose($"Enterprise Wiki export written to '{exportPath}'. Snapshot digest: {export.SnapshotDigest}"); + foreach (var warning in export.Snapshot.Warnings) { WriteWarning(warning); } - foreach (var blocker in package.Plan.Blockers) + foreach (var blocker in export.Snapshot.Blockers) { WriteWarning($"BLOCKER: {blocker}"); } - WriteObject(package); + WriteObject(new EnterpriseWikiExportResult(export, exportPath)); } - WriteProgress(new ProgressRecord(181, "Capture Enterprise Wiki migration package", "Completed") + WriteProgress(new ProgressRecord(181, "Export Enterprise Wiki source snapshot", "Completed") { RecordType = ProgressRecordType.Completed }); @@ -148,11 +108,6 @@ private static string GetLeafName(string value) return separator < 0 ? path : path.Substring(separator + 1); } - private static string EnsureAspx(string value) - { - return value.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase) ? value : value + ".aspx"; - } - private static string MakeSafeDirectoryName(string value) { var invalid = Path.GetInvalidFileNameChars(); diff --git a/src/Commands/Pages/CopyEnterpriseWiki.cs b/src/Commands/Pages/ImportEnterpriseWikiMigrationPackage.cs similarity index 80% rename from src/Commands/Pages/CopyEnterpriseWiki.cs rename to src/Commands/Pages/ImportEnterpriseWikiMigrationPackage.cs index 70d50e503..7c3b2bf5b 100644 --- a/src/Commands/Pages/CopyEnterpriseWiki.cs +++ b/src/Commands/Pages/ImportEnterpriseWikiMigrationPackage.cs @@ -6,16 +6,16 @@ namespace PnP.PowerShell.Commands.Pages { - [Cmdlet(VerbsCommon.Copy, "PnPEnterpriseWiki", DefaultParameterSetName = ParameterSetApproved, SupportsShouldProcess = true)] - [OutputType(typeof(EnterpriseWikiCopyReceipt))] + [Cmdlet(VerbsData.Import, "PnPEnterpriseWikiMigrationPackage", DefaultParameterSetName = ParameterSetApproved, SupportsShouldProcess = true)] + [OutputType(typeof(EnterpriseWikiImportReceipt))] [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] - public class CopyEnterpriseWiki : PnPWebCmdlet + public class ImportEnterpriseWikiMigrationPackage : PnPWebCmdlet { private const string ParameterSetApproved = "Approved"; private const string ParameterSetAutoApprove = "AutoApprove"; - [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)] + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] [Alias("Path")] [ValidateNotNullOrEmpty] public string PackagePath { get; set; } @@ -36,24 +36,24 @@ public class CopyEnterpriseWiki : PnPWebCmdlet protected override void ExecuteCmdlet() { var resolvedPackagePath = ResolveLocalPath(PackagePath); - var package = EnterpriseWikiPackageSerializer.Load(resolvedPackagePath); + var package = EnterpriseWikiPackageSerializer.LoadMigration(resolvedPackagePath); var approvedDigest = ParameterSetName == ParameterSetAutoApprove ? package.PlanDigest : ApprovedPlanDigest; if (!ShouldProcess( package.Plan.TargetPageServerRelativeUrl, - $"Create Enterprise Wiki page from approved plan {approvedDigest}")) + $"Import Enterprise Wiki page from approved plan {approvedDigest}")) { return; } var service = new EnterpriseWikiMigrationService(); - var receipt = service.Copy(Connection.Context, package, approvedDigest); + var receipt = service.Import(Connection.Context, package, approvedDigest); var receiptPath = string.IsNullOrWhiteSpace(ReceiptPath) ? Path.GetDirectoryName(ResolvePackageFile(resolvedPackagePath)) : ResolveLocalPath(ReceiptPath); var savedReceiptPath = EnterpriseWikiPackageSerializer.SaveReceipt(receiptPath, receipt, Force); - WriteVerbose($"Enterprise Wiki copy receipt written to '{savedReceiptPath}'."); + WriteVerbose($"Enterprise Wiki import receipt written to '{savedReceiptPath}'."); foreach (var warning in receipt.Warnings) { WriteWarning(warning); diff --git a/src/Commands/Pages/NewEnterpriseWikiMigrationPlan.cs b/src/Commands/Pages/NewEnterpriseWikiMigrationPlan.cs new file mode 100644 index 000000000..3f3d05b93 --- /dev/null +++ b/src/Commands/Pages/NewEnterpriseWikiMigrationPlan.cs @@ -0,0 +1,128 @@ +using Microsoft.SharePoint.Client; +using PnP.Framework.EnterpriseWiki; +using PnP.Framework.Utilities; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Model; +using System; +using System.IO; +using System.Management.Automation; + +namespace PnP.PowerShell.Commands.Pages +{ + [Cmdlet(VerbsCommon.New, "PnPEnterpriseWikiMigrationPlan")] + [OutputType(typeof(EnterpriseWikiMigrationPlanResult))] + [RequiredApiApplicationPermissions("sharepoint/Sites.Read.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.Read")] + public class NewEnterpriseWikiMigrationPlan : PnPWebCmdlet + { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Path")] + [ValidateNotNullOrEmpty] + public string ExportPath { get; set; } + + [Parameter(Mandatory = false)] + public string OutputPath { get; set; } + + [Parameter(Mandatory = false)] + public string TargetPageName { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowUniquePermissions { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter AllowManagedMetadataSubstitution { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter BlockExternalResources { get; set; } + + [Parameter(Mandatory = false)] + public SwitchParameter Force { get; set; } + + protected override void ExecuteCmdlet() + { + var resolvedExportPath = ResolveLocalPath(ExportPath); + var export = EnterpriseWikiPackageSerializer.LoadExport(resolvedExportPath); + Connection.Context.Load(Connection.Context.Web, web => web.ServerRelativeUrl); + Connection.Context.ExecuteQueryRetry(); + var targetPages = Connection.Context.Web.GetPagesLibrary(); + var targetPagesRoot = $"{Connection.Context.Web.ServerRelativeUrl.TrimEnd('/')}/Pages"; + if (targetPages != null) + { + Connection.Context.Load(targetPages.RootFolder, folder => folder.ServerRelativeUrl); + Connection.Context.ExecuteQueryRetry(); + targetPagesRoot = targetPages.RootFolder.ServerRelativeUrl; + } + + var targetPagePath = ResolveTargetPagePath( + targetPagesRoot, + string.IsNullOrWhiteSpace(TargetPageName) + ? GetLeafName(export.Snapshot.Source.PageServerRelativeUrl) + : TargetPageName); + + var service = new EnterpriseWikiMigrationService(); + var package = service.Plan(Connection.Context, export, new EnterpriseWikiPlanningOptions + { + TargetPageServerRelativeUrl = targetPagePath, + RequireInheritedPermissions = !AllowUniquePermissions, + BlockOnManagedMetadata = !AllowManagedMetadataSubstitution, + AllowExternalResourceReferences = !BlockExternalResources, + CreateOnly = true + }); + var packageOutput = string.IsNullOrWhiteSpace(OutputPath) + ? Path.GetDirectoryName(ResolveExportFile(resolvedExportPath)) + : ResolveLocalPath(OutputPath); + var packagePath = EnterpriseWikiPackageSerializer.SaveMigration(packageOutput, package, Force); + var reportPath = Path.Combine( + Path.GetDirectoryName(packagePath) ?? string.Empty, + EnterpriseWikiPackageSerializer.DefaultReportFileName); + WriteVerbose($"Enterprise Wiki migration plan written to '{packagePath}'. Plan digest: {package.PlanDigest}"); + foreach (var warning in package.Plan.Warnings) + { + WriteWarning(warning); + } + foreach (var blocker in package.Plan.Blockers) + { + WriteWarning($"BLOCKER: {blocker}"); + } + WriteObject(new EnterpriseWikiMigrationPlanResult(package, packagePath, reportPath)); + } + + private string ResolveLocalPath(string value) + { + return Path.IsPathRooted(value) + ? Path.GetFullPath(value) + : Path.GetFullPath(Path.Combine(SessionState.Path.CurrentFileSystemLocation.Path, value)); + } + + private static string ResolveTargetPagePath(string pagesRoot, string value) + { + var candidate = value.Trim(); + if (Uri.TryCreate(candidate, UriKind.Absolute, out _) || candidate.StartsWith("/", StringComparison.Ordinal)) + { + return candidate; + } + + var fileName = candidate.ReplaceInvalidUrlChars("-"); + if (!fileName.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase)) + { + fileName += ".aspx"; + } + return $"{pagesRoot.TrimEnd('/')}/{fileName}"; + } + + private static string ResolveExportFile(string value) + { + return Directory.Exists(value) || string.IsNullOrEmpty(Path.GetExtension(value)) + ? Path.Combine(value, EnterpriseWikiPackageSerializer.DefaultExportFileName) + : value; + } + + private static string GetLeafName(string value) + { + var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute) ? absolute.AbsolutePath : value; + path = Uri.UnescapeDataString(path ?? string.Empty).Replace('\\', '/').TrimEnd('/'); + var separator = path.LastIndexOf('/'); + return separator < 0 ? path : path.Substring(separator + 1); + } + } +} From cb2c40a760d3d5b6bccc98f7bce9fdb1921ec3a8 Mon Sep 17 00:00:00 2001 From: TaoziZ03 <106975749+TaoziZ03@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:00 +0800 Subject: [PATCH 3/3] docs: link Enterprise Wiki changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46480e480..89a8529e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Added -- Added `Export-PnPEnterpriseWikiPackage`, `New-PnPEnterpriseWikiMigrationPlan`, and `Import-PnPEnterpriseWikiMigrationPackage` to preserve a complete source field snapshot, create a digest-approved target plan and field-by-field report, and import only recognized fields with fresh target and lifecycle verification. +- Added `Export-PnPEnterpriseWikiPackage`, `New-PnPEnterpriseWikiMigrationPlan`, and `Import-PnPEnterpriseWikiMigrationPackage` to preserve a complete source field snapshot, create a digest-approved target plan and field-by-field report, and import only recognized fields with fresh target and lifecycle verification. [#5457](https://github.com/pnp/powershell/pull/5457) ### Changed