From 1c78a0bc5eb523cfe0495fa51ac7143c8ad85076 Mon Sep 17 00:00:00 2001 From: titao Date: Mon, 27 Jul 2026 16:31:18 +0800 Subject: [PATCH 01/11] Start PnP-first page transformation guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- docs/toc.yml | 59 ++-- .../assess-sharepoint-modernization.md | 1 + .../assessment-tool-classic-pages-report.md | 59 +--- ...essment-tool-classic-pages-requirements.md | 1 + .../assessment-tool-classic-pages-run.md | 1 + .../assessment-tool-classic-pages.md | 3 +- .../assessment-tool-publishing-coverage.md | 22 +- ...ize-userinterface-site-pages-powershell.md | 334 +++++++++++------- ...ize-userinterface-site-pages-validation.md | 93 +++++ .../modernize-userinterface-site-pages.md | 119 +++++-- .../classic-page-transformation-workflow.md | 5 + 11 files changed, 429 insertions(+), 268 deletions(-) create mode 100644 docs/transform/modernize-userinterface-site-pages-validation.md create mode 100644 includes/snippets/modernization/classic-page-transformation-workflow.md diff --git a/docs/toc.yml b/docs/toc.yml index 44ab5da5f6..43990d5c57 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1858,6 +1858,36 @@ items: - name: Transform with PnP PowerShell href: transform/modernize-userinterface-site-pages-powershell.md + - name: Validate transformed pages + href: transform/modernize-userinterface-site-pages-validation.md + - name: Reference + items: + - name: In-place versus cross-site transformation + href: transform/modernize-userinterface-site-pages-approach.md + - name: Security requirements + href: transform/modernize-userinterface-site-pages-security.md + - name: Configuration options + href: transform/modernize-userinterface-site-pages-configuration.md + - name: Classic Web Part mappings + href: transform/modernize-userinterface-site-pages-webparts.md + - name: Page transformation model + href: transform/modernize-userinterface-site-pages-model.md + - name: Publishing page transformation model + href: transform/modernize-userinterface-site-pages-model-publishing.md + - name: Functions and selectors + href: transform/modernize-userinterface-site-pages-api.md + - name: User mapping + href: transform/modernize-userinterface-site-pages-usermapping.md + - name: URL mapping + href: transform/modernize-userinterface-site-pages-urlmapping.md + - name: Term mapping + href: transform/modernize-userinterface-site-pages-termmapping.md + - name: Layout transformation + href: transform/modernize-userinterface-site-pages-layout.md + - name: .NET integration + href: transform/modernize-userinterface-site-pages-dotnet.md + - name: Videos + href: transform/modernize-userinterface-site-pages-videos.md - name: Modernizing your classic SharePoint sites items: - name: Overview @@ -1877,35 +1907,6 @@ href: transform/modernize-userinterface-lists-and-libraries-optout.md - name: Transform classic pages to modern pages href: transform/modernize-userinterface-site-pages.md - items: - - name: Page transformation via PowerShell - href: transform/modernize-userinterface-site-pages-powershell.md - - name: Page transformation via .Net - href: transform/modernize-userinterface-site-pages-dotnet.md - - name: Page transformation videos - href: transform/modernize-userinterface-site-pages-videos.md - - name: Page transformation configuration options - href: transform/modernize-userinterface-site-pages-configuration.md - - name: Classic web part to modern mapping - href: transform/modernize-userinterface-site-pages-webparts.md - - name: In-place versus cross-site page transformation - href: transform/modernize-userinterface-site-pages-approach.md - - name: Page transformation security - href: transform/modernize-userinterface-site-pages-security.md - - name: Page transformation model - href: transform/modernize-userinterface-site-pages-model.md - - name: Publishing Page transformation model - href: transform/modernize-userinterface-site-pages-model-publishing.md - - name: Page transformation Functions and Selectors - href: transform/modernize-userinterface-site-pages-api.md - - name: User mapping - href: transform/modernize-userinterface-site-pages-usermapping.md - - name: URL mapping - href: transform/modernize-userinterface-site-pages-urlmapping.md - - name: Term mapping - href: transform/modernize-userinterface-site-pages-termmapping.md - - name: Page layout mapping - href: transform/modernize-userinterface-site-pages-layout.md - name: SharePoint Modernization scanner items: - name: Overview diff --git a/docs/transform/assess-sharepoint-modernization.md b/docs/transform/assess-sharepoint-modernization.md index 5da2dd7e34..8ca4c7373f 100644 --- a/docs/transform/assess-sharepoint-modernization.md +++ b/docs/transform/assess-sharepoint-modernization.md @@ -36,6 +36,7 @@ Mapping readiness is an input to planning, not a guarantee that the transformed 1. [Interpret and prioritize the page results](assessment-tool-classic-pages-report.md). 1. [Review publishing-page readiness](assessment-tool-publishing-coverage.md), when applicable. 1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). +1. [Validate the transformed pages](modernize-userinterface-site-pages-validation.md). ## Reference diff --git a/docs/transform/assessment-tool-classic-pages-report.md b/docs/transform/assessment-tool-classic-pages-report.md index 1cb876af88..bfb3292644 100644 --- a/docs/transform/assessment-tool-classic-pages-report.md +++ b/docs/transform/assessment-tool-classic-pages-report.md @@ -81,7 +81,7 @@ Use `classicpagewebparts.csv` for the per-page decision: Use `classicwebpartunique.csv` to identify web part types that affect many pages. `InMappingFile` is weaker than `IsMappable`: it only means that the type has an entry in the mapping file. -The current Assessment implementation deliberately treats the community mappings for `ScriptEditorWebPart` and `SimpleFormWebPart` as unavailable. It also doesn't support the legacy Scanner's on-disk mapping-file override. +The current Assessment implementation deliberately treats the community mappings for `ScriptEditorWebPart` and `SimpleFormWebPart` as unavailable. It also doesn't support an on-disk mapping-file override. ## Use rollups @@ -98,7 +98,7 @@ Rollups are useful for sequencing, but return to the page and web part CSV files `classicpublishingsitesummaries.csv` provides a site-collection-level publishing summary. Detailed legacy web-level publishing configuration isn't included. -See [Understand publishing portal coverage](assessment-tool-publishing-coverage.md) before replacing an established Publishing Scanner workflow. +See [Understand publishing portal coverage](assessment-tool-publishing-coverage.md) before planning publishing page transformation. ## Move from assessment to transformation @@ -123,56 +123,9 @@ Turn the report into a transformation backlog: The Assessment app is read-only. Use a separate PnP PowerShell connection with permission to create or update pages in the source or target web. -### Transform one selected Wiki or Web Part page - -This example selects one assessed page and transforms it in place. Filter on the exact `PageUrl` that you approved for the migration wave. - -```powershell -$row = Import-Csv .\classicpages.csv | - Where-Object PageUrl -eq '/sites/source/SitePages/ApprovedPage.aspx' | - Select-Object -First 1 - -if ($row.PageType -notin @('WikiPage', 'WebPartPage')) { - throw "This example only handles WikiPage and WebPartPage rows." -} - -$sourceWebUrl = if ($row.WebUrl -eq '/') { - $row.SiteUrl -} -else { - "$($row.SiteUrl.TrimEnd('/'))$($row.WebUrl)" -} - -$source = Connect-PnPOnline ` - -Url $sourceWebUrl ` - -Interactive ` - -ClientId ` - -ReturnConnection - -$libraryPath = $row.ListUrl.TrimEnd('/') -if (-not $row.PageUrl.StartsWith("$libraryPath/", [StringComparison]::OrdinalIgnoreCase)) { - throw "PageUrl isn't under ListUrl." -} - -$pageRelativeToLibrary = $row.PageUrl.Substring($libraryPath.Length).TrimStart('/') -$pageName = [IO.Path]::GetFileName($pageRelativeToLibrary) -$folder = [IO.Path]::GetDirectoryName($pageRelativeToLibrary) -replace '\\', '/' -$libraryName = [Uri]::UnescapeDataString([IO.Path]::GetFileName($libraryPath)) - -$parameters = @{ - Identity = $pageName - Connection = $source -} - -if ($libraryName -ne 'SitePages') { - $parameters.Library = $libraryName -} -if (-not [string]::IsNullOrWhiteSpace($folder)) { - $parameters.Folder = $folder -} - -ConvertTo-PnPPage @parameters -``` +### Continue with the transformation task + +Use the field mapping to approve the exact Assessment row and record the expected content. Then follow [Transform selected classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) for the current application, permission, source-identity, draft-output, logging, and validation safeguards. Route other page types deliberately: @@ -180,7 +133,7 @@ Route other page types deliberately: - `BlogPage`: use `-BlogPage`, `PageName` as the blog-title identity, and a target web. Blog rows don't receive the detailed mapping-readiness enrichment described for Wiki, Web Part, and Publishing pages. - `ASPXPage` and `DelveBlogPage`: exclude them from this automated transformation queue; the page assessment doesn't provide an equivalent readiness path for them. -Start with [Transform classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). Use the [page transformation model](modernize-userinterface-site-pages-model.md) for custom Web Part mappings and the [publishing model](modernize-userinterface-site-pages-model-publishing.md) for publishing page layouts. +Start with [Transform selected classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md), and then [validate each transformed page](modernize-userinterface-site-pages-validation.md). Use the [page transformation model](modernize-userinterface-site-pages-model.md) for custom Web Part mappings and the [publishing model](modernize-userinterface-site-pages-model-publishing.md) for publishing page layouts. ## Power BI and CSV diff --git a/docs/transform/assessment-tool-classic-pages-requirements.md b/docs/transform/assessment-tool-classic-pages-requirements.md index 06e33b4879..f5e42ad92e 100644 --- a/docs/transform/assessment-tool-classic-pages-requirements.md +++ b/docs/transform/assessment-tool-classic-pages-requirements.md @@ -101,6 +101,7 @@ Assessment output can contain tenant and site URLs, application identifiers, use 1. [Interpret and prioritize the classic page results](assessment-tool-classic-pages-report.md). 1. [Review publishing-page readiness](assessment-tool-publishing-coverage.md), when applicable. 1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). +1. [Validate the transformed pages](modernize-userinterface-site-pages-validation.md). ## Reference diff --git a/docs/transform/assessment-tool-classic-pages-run.md b/docs/transform/assessment-tool-classic-pages-run.md index c59b874314..57219ffe4d 100644 --- a/docs/transform/assessment-tool-classic-pages-run.md +++ b/docs/transform/assessment-tool-classic-pages-run.md @@ -139,6 +139,7 @@ Use `--delimiter Semicolon` when a semicolon-delimited export is required. Use ` 1. [Interpret and prioritize the classic page results](assessment-tool-classic-pages-report.md). 1. [Review publishing-page readiness](assessment-tool-publishing-coverage.md), when applicable. 1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). +1. [Validate the transformed pages](modernize-userinterface-site-pages-validation.md). ## Reference diff --git a/docs/transform/assessment-tool-classic-pages.md b/docs/transform/assessment-tool-classic-pages.md index fbfa78bc3a..f720390640 100644 --- a/docs/transform/assessment-tool-classic-pages.md +++ b/docs/transform/assessment-tool-classic-pages.md @@ -25,6 +25,7 @@ In the current implementation, Delve Blog pages appear in `classicpages.csv` but 1. [Interpret and prioritize the results](assessment-tool-classic-pages-report.md). 1. [Review publishing-page readiness](assessment-tool-publishing-coverage.md), when applicable. 1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). +1. [Validate the transformed pages](modernize-userinterface-site-pages-validation.md). ### Reference @@ -91,7 +92,7 @@ Use the assessment output to select pages and identify Web Parts that need remed 1. Remove, replace, or map blocking Web Parts. 1. Start with a small representative wave. 1. [Transform the selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). -1. Validate the generated modern pages before expanding the wave. +1. [Validate the generated modern pages](modernize-userinterface-site-pages-validation.md) before expanding the wave. For the complete transformation model and advanced mapping options, see [Transform classic pages to modern pages](modernize-userinterface-site-pages.md). diff --git a/docs/transform/assessment-tool-publishing-coverage.md b/docs/transform/assessment-tool-publishing-coverage.md index 279cdd2334..c0b113be6e 100644 --- a/docs/transform/assessment-tool-publishing-coverage.md +++ b/docs/transform/assessment-tool-publishing-coverage.md @@ -58,31 +58,19 @@ The current page assessment doesn't provide these publishing transformation inpu - Global, security-group, and SharePoint-group audience values. - Master-page, alternate-CSS, navigation, approval, versioning, scheduling, variation, audience, and ownership configuration. -These areas are outside the Page Assessment scope. The lifecycle of legacy Scanner reports is handled separately from this guidance. +These areas are outside the Page Assessment scope. ## Continue to publishing-page transformation 1. Group publishing pages by `Layout`. 1. Review `MappingPercentage` and `UnmappedWebParts`. 1. Identify custom layouts that need a page-layout mapping. -1. Validate representative pages before processing a complete portal. +1. [Transform representative publishing pages](modernize-userinterface-site-pages-powershell.md#route-other-page-types). +1. [Validate representative pages](modernize-userinterface-site-pages-validation.md) before processing a complete portal. -For an out-of-the-box publishing layout, use the built-in layout mapping where possible: +For an out-of-the-box publishing layout, use the built-in layout mapping where possible. For a custom publishing layout, generate and review a mapping before transformation. -```powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/source -Interactive -ClientId - -ConvertTo-PnPPage ` - -PublishingPage ` - -Identity Article.aspx ` - -TargetWebUrl https://contoso.sharepoint.com/sites/target -``` - -For a custom publishing layout, generate and review a mapping before transformation: - -```powershell -Export-PnPPageMapping -CustomPageLayoutMapping -Folder C:\temp -``` +Continue with [Transform selected classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md#route-other-page-types) and the [Publishing Page transformation model](modernize-userinterface-site-pages-model-publishing.md). ## Reference diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index a6b99f5e87..4bc1d9834b 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -1,179 +1,239 @@ --- -title: Transform classic pages to modern pages using PowerShell -description: Explains how to transform classic wiki and web part pages into modern pages using the SharePoint PowerShell -ms.date: 02/09/2023 +title: Transform selected classic pages with PnP PowerShell +description: Prepare a minimally privileged PnP PowerShell connection and transform approved classic pages with source-preserving defaults. +ms.date: 07/27/2026 ms.localizationpriority: high ms.service: sharepoint --- -# Transforming to modern site pages using PowerShell - -> [!IMPORTANT] -> SharePoint PnP Modernization is part of the [PnP Framework](https://github.com/pnp/pnpframework) and is continuously evolving, checkout [the release notes](https://github.com/pnp/pnpframework/blob/dev/src/lib/CHANGELOG.md) to stay up to date on the latest changes. If you encounter problems please file an issue in the [PnP Framework GitHub issue list](https://github.com/pnp/pnpframework/issues). - -The page transformation engine can also be used from PowerShell. This allows it to be integrated in a site modernization script that besides page transformation also does other things like installing solution, connecting the site to a Microsoft 365 group and applying tenant branding. A good example of an all-up modernization script can be found [in the Microsoft 365 group connect article](modernize-connect-to-office365-group.md). - -> [!NOTE] -> Below script shows how to transform pages. It requires [PnP PowerShell](https://aka.ms/pnp/powershell) version 1.3.* (February 2021) or higher. There are additional sample scripts (e.g. for transforming publishing page, for transforming from on-premises SharePoint) available in our [GitHub scripts location](https://github.com/SharePoint/sp-dev-modernization/tree/dev/Scripts/PageTransformation). - -[!code-powershell[transformpages](../../sp-dev-modernization/Scripts/PageTransformation/Convert-WikiAndWebPartPages.ps1 "Transform pages to modern pages using PowerShell")] - -## Options for the ConvertTo-PnPPage cmdlet - -The **ConvertTo-PnPPage** cmdlet is the key cmdlet to modernize a given page. Below table lists the command line parameters that you can use to control the page transformation via this cmdlet. - -Parameter | Default | Supported For | Description -----------|---------|---------------|------------ -Identity (`*`) | | All page types | The page name (e.g. pageA.aspx) for wiki, web part and publishing pages or the blog title for classic blog pages. In case of classic blog pages the first blog page where the title starts with the provided `Identity` will be used or alternatively you can also specify the id (integer value) of the page. -Library | | Wiki/webpart pages | The library holding the page. Use this `-Library` parameter when your wiki or web part page lives outside of the default SitePages library. -Folder | | Wiki/webpart/publishing pages | When the page you want to transform lives in a folder then you can specify that folder (e.g. `-Folder "Folder1/SubFolder"`). -WebPartMappingFile | | All page types | Page transformation is driven by a mapping file. The cmdlet has a default mapping file embedded, but you can also specify your custom web part mapping file (`webpartmapping.xml`) to fit your page transformation needs (e.g. transforming to 3rd party custom web parts). You do this by specifying the path to the file via the `-WebPartMappingFile` parameter. -Overwrite | $false | All page types | When you add `-Overwrite` then the page transformation framework will overwrite the target page if needed. By default the new page name has a prefix of Migrated_, which then implies that if Migrated_YourPage.aspx already exists (typically from a previous page transformation effort) it will be overwritten. -ReplaceHomePageWithDefault | $false | Wiki/webpart pages | The default behavior is to transform your site's home page to a modern page like any other regular page. If you use `-ReplaceHomeWithDefault` then a site's home page will be transformed to a 'default' out-of-the-box modern home page, so the one you would get with a newly created modern team site. -TakeSourcePageName | $false | Wiki/webpart pages | The default behavior is to give the created modern page a name that starts with the prefix Migrated_ and let the original page keep it's existing name. When `-TakeSourcePageName` is specified the newly created page gets the name of the original page and the original page is renamed with a prefix Previous_. Set this option if you're sure you want to move forward with the modern page as it will ensure that all links pointing the original page now result in the new modern page being loaded. -ClearCache | $false | All page types | To optimize performance certain data (e.g. list of available modern web parts, calculated list of fields to copy metadata for) is cached after the first execution. This cache will stay valid during the complete PowerShell session unless you use the `-ClearCache` switch. Restarting your PowerShell session also clears the cache. -SkipItemLevelPermissionCopyToClientSidePage | $false | All page types | By default item level permissions are copied over to the modern page, use the `-SkipItemLevelPermissionCopyToClientSidePage` to prevent this. -CopyPageMetadata | $false | Wiki/webpart/blog pages | The default behavior is to not copy page metadata (so additional columns added to the site pages library). When `-CopyPageMetadata` is specified the values of the custom metadata fields of the page to transform are copied to the newly created page. As of the October 2019 release page metadata copy also works in cross site transformations. -TargetWebUrl (`**`) | | Cross site transformation | If you want to create the transformed modern pages in another site collection then specify the URL to that other site collection. Consult the [web part transformation list](modernize-userinterface-site-pages-webparts.md) article to understand which web parts are transformed in a cross site collection transformation. -TargetConnection (`**`) | | Cross site transformation | Allows for a more flexible definition of the target via a connection object. This allows for example to perform cross tenant transformation of transformation from on-premises to online. -UseCommunityScriptEditor | $false | All page types | Use `-UseCommunityScriptEditor` if you've installed the community script editor and want to use it during transformation. Consult the [web part transformation list](modernize-userinterface-site-pages-webparts.md) article to learn more. -SummaryLinksToHtml | $false | All page types | Use `-SummaryLinksToHtml` if you prefer to transform the SummaryLinks web part to HTML hosted in the text web part instead of the default transformation using the QuickLinks web part. Consult the [web part transformation list](modernize-userinterface-site-pages-webparts.md) article to learn more. -LogType | None | All page types | Use `-LogType` to enabled logging: `File` will log to disk, `SharePoint` will create a log page in the SharePoint SitePages library, `Console` will output data to the console. -LogFolder | | All page types | If `LogType` is set to `File` then you can use `-LogFolder` to specify the folder where the log will be created. -LogVerbose | $false | All page types | Use `-LogVerbose` to generate a verbose log. -LogSkipFlush | $false | All page types | By default each cmdlet call generates a unique log file, use the `-LogSkipFlush` parameter to accumulate log entries. Note that you'll have to end with a call without LogSkipFlush to persist the assembled log file entries. -DontPublish | $false | All page types | Use the `-DontPublish` option to not publish the created modern page. -KeepPageCreationModificationInformation | $false | All page types | Use `-KeepPageCreationModificationInformation` parameter if you want to take over the Author/Editor/Created/Modified page properties. This option only works for when the source page is in the same SPO tenant as the target destination of the modern page. -PostAsNews | $false | All page types | Use the `-PostAsNews` parameter if you want to post the created modern page as news on the site. This also implies that the page will be published, even if you've configured to skip publishing. -SetAuthorInPageHeader | $false | Wiki/webpart/blog pages | Use the `-SetAuthorInPageHeader` parameter if you want to populate the author in the header of the created page. The author will be set the (user mapped) source page author. -DisablePageComments | $false | All page types | Use `-DisablePageComments` if you want to disable the commenting option on the created page -PublishingPage | $false | Publishing pages | Set the `-PublishingPage` parameter if you're transforming a publishing page. For wiki,web part and classic blog pages this parameter must be omitted or set to false. -PageLayoutMapping | | Publishing pages |Via `-PageLayoutMapping` you can specify the path the [page layout mapping file](modernize-userinterface-site-pages-model-publishing.md) that you'll use for your publishing page transformations when the publishing page is using a non out of the box page layout -TargetPageName | | Wiki/webpart/blog pages | Use the `-TargetPageName` parameter to override the default name for the modern page. This is for example needed to prevent overwriting the existing home.aspx page if you a cross site transformation of a classic team site home page to a modern communication site. -PublishingTargetPageName | | Publishing pages | Use the `-PublishingTargetPageName` parameter to override the name for the modern page -TargetPageFolder | | All page types | Use the `-TargetPageFolder` parameter to specify a target folder for the modern page. Note that if a folder was created automatically (e.g. because you were transforming from an extra wiki page library) then the folder specified by this parameter will be combined with the auto-generated folder (unless you specify `-TargetPageFolderOverridesDefaultFolder`). You can specify a folder like this: `MyFolder` or `MyFolder/SubFolder` when you want to create a nested folder structure. Specifying `` as value allows you to target the root of the target sitepages library -TargetPageFolderOverridesDefaultFolder | $false | All page types | Using the `-TargetPageFolderOverridesDefaultFolder` parameter you can force page transformation to use the folder specified via `-TargetPageFolder`, regardless whether there was an automatically created folder -UrlMappingFile | | Cross site transformation | File with custom URL mapping definitions allow you to do more than just the default URL mapping. See the [URL mapping](modernize-userinterface-site-pages-urlmapping.md) article to learn more. -SkipUrlRewriting | $false | Cross site transformation | During publishing page transformation URL's are rewritten to be valid in the target site collection, but using the `-SkipUrlRewriting` you can disable the URL rewriting. See the [URL mapping](modernize-userinterface-site-pages-urlmapping.md) article to learn more. -SkipDefaultUrlRewriting | | Cross site transformation | When you use a custom URL mapping and you want to disable the default URL rewrite logic then set the `-SkipDefaultUrlRewriting` parameter. -AddTableListImageAsImageWebPart | $true | All page types | Images living inside a table/list are also created as separate image web parts underneath that table/list. Use the `-AddTableListImageAsImageWebPart` parameter to stop the creation of these separate image web parts. -BlogPage | $false | Blog pages | Set the `-BlogPage` parameter if you're transforming a classic blog page. For wiki, web part and publishing pages this parameter must be omitted or set to false. -UserMappingFile | | All page types | File with user mapping information. See the [User mapping](modernize-userinterface-site-pages-usermapping.md) article to learn more. -LDAPConnectionString | | All page types | LDAP connection string to query active directory. See the [User mapping](modernize-userinterface-site-pages-usermapping.md) article to learn more. -SkipUserMapping | $false | All page types | Skips the user mapping. For SPO transformations user mapping is off unless you specify a mapping file, for on-premises SharePoint user mapping is always on unless you set this flag. See the [User mapping](modernize-userinterface-site-pages-usermapping.md) article to learn more. -TermMappingFile | | Cross site transformation | File with custom term mapping definitions allow you to do more than just the default term mapping. See the [Term mapping](modernize-userinterface-site-pages-termmapping.md) article to learn more. -SkipTermStoreMapping | $false | Cross site transformation | During page transformation terms are mapped to be valid in the target site collection, but using the `-SkipTermStoreMapping` parameter you can disable the term mapping. See the [Term mapping](modernize-userinterface-site-pages-termmapping.md) article to learn more. - -(`*`) Mandatory command line parameter / (`**`) Mandatory when the `-PublishingPage` or `-BlogPage` parameter was set (either `-TargetWebUrl` or `-TargetConnection`) - -## FAQ - -### How do I transform publishing pages - -Above shown sample shows in-place page transformation, for transforming publishing pages you need a slightly different syntax. Below sample shows how to modernize the mypage.aspx page and create a modern version of it in a communication site. During this transformation the page transformation will either use the built-in page layout mapping if the page is using an out of the box page layout or it will generate a page layout mapping on the fly for custom page layouts: - -```Powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/portaltomodernize -Interactive - -ConvertTo-PnPPage -PublishingPage -Identity mypage.aspx -Overwrite -TargetWebUrl https://contoso.sharepoint.com/sites/moderncommunicationsite -``` +# Transform selected classic pages with PnP PowerShell -If you're using custom page layouts it's highly recommended to tweak the used page layout mapping file before using it. To do so follow these steps: +The Microsoft 365 Assessment tool inventories classic pages but doesn't modify them. Use the Assessment output to approve a page wave, and use PnP PowerShell `ConvertTo-PnPPage` to create the modern pages. -#### Generate a custom page layout mapping file +[!INCLUDE [pnp-powershell](../../includes/snippets/open-source/pnp-powershell.md)] -Use PnP PowerShell to analyze your existing page layouts and generate a mapping file: +## Page transformation workflow -```Powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/portaltomodernize -Interactive +[!INCLUDE [classic-page-transformation-workflow](../../includes/snippets/modernization/classic-page-transformation-workflow.md)] -Export-PnPPageMapping -CustomPageLayoutMapping -Folder c:\temp -``` +## Before you begin -[!INCLUDE [pnp-powershell](../../includes/snippets/open-source/pnp-powershell.md)] +Complete these prerequisites: -If you want to generate the mapping files for OOB page layouts then specify the `AnalyzeOOBPageLayouts` switch. +1. Run and interpret the [Classic pages assessment](assessment-tool-classic-pages.md). +1. Select a representative wave from successful scan coverage. +1. Install PowerShell 7.4.0 or later and the current stable PnP PowerShell release. +1. Register a tenant-owned Microsoft Entra application for PnP PowerShell. +1. Confirm that the signed-in user can edit pages in the source and target webs. -#### Tune the generated mapping file +Since September 9, 2024, interactive PnP PowerShell authentication requires your own application registration and client ID. -Open the [created mapping file](modernize-userinterface-site-pages-model-publishing.md) and review each mapping: +The account that registers the application must be allowed to create app registrations. Tenant consent policy determines whether an administrator must grant consent. -- Set the row and column values correctly for web parts, web part zones and fixed web parts so that content shows up on the right spot on the modern page. You can have as many rows as you want, each row will be a section on the modern page. Column values are restricted to 1, 2 or 3 which translates to the possible column options in a modern page -- Define the fields that need to be transformed as metadata -- Review the generated mapping of fields to web parts -- Review the generated mapping of fields to header +### Permissions -#### Use the custom mapping file +Use a separate application from the read-only Assessment application. -Using the cleaned custom mapping file is simple via the `PageLayoutMapping` parameter: +| Transformation requirement | Delegated SharePoint scope | +| --- | --- | +| Read the source and create, save, and publish the modern page | `AllSites.Manage` | +| Also copy item-level unique permissions | `AllSites.FullControl` | -```Powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/portaltomodernize -Interactive +The signed-in user's site permissions also apply. The application's delegated scope doesn't grant the user access to a site that they couldn't otherwise access. -ConvertTo-PnPPage -PublishingPage -Identity mypage.aspx -Overwrite -TargetWebUrl https://contoso.sharepoint.com/sites/moderncommunicationsite -PageLayoutMapping c:\temp\mypagelayouts.xml -``` +With `AllSites.Manage`, add `-SkipItemLevelPermissionCopyToClientSidePage` so the generated page inherits permissions from its library. Use `AllSites.FullControl` only when the page's unique permissions must be retained. + +See [Migrate classic pages with the least permission possible](https://pnp.github.io/pnpassessment/classic/migrate-minimal-permissions.html). -#### Sample scripts for transforming (on-premises) publishing pages to modern pages in SharePoint Online +## Install or update PnP PowerShell -Check out the scripts in https://github.com/SharePoint/sp-dev-modernization/tree/dev/Scripts/PageTransformation to get started. +PnP PowerShell requires PowerShell 7.4.0 or later. + +```powershell +$PSVersionTable.PSVersion +Install-Module PnP.PowerShell -Scope CurrentUser +``` -### Read publishing page in on-premises SharePoint and create the modern page in SharePoint Online +If PnP PowerShell is already installed, run `Update-Module PnP.PowerShell` from PowerShell 7.4.0 or later. -When you want to bring over your classic on-premises publishing portals you could first move the complete portal from on-premises to a classic portal in SharePoint Online and then do the modernization work. However, often it's easier to directly read the classic publishing page from your SharePoint on-premises portal and create the modern version in SharePoint Online. To do this you need to use PnP PowerShell for SharePoint Online to connect to your on-premises portal like shown in below script: +See [Install PnP PowerShell](https://pnp.github.io/powershell/articles/installation.html). -```Powershell -# Setup connection the target site - must be SPO and must be a modern site -$target = Connect-PnPOnline https://contoso.sharepoint.com/sites/moderncommunicationsite -ReturnConnection +## Register the transformation application -# Connect to your on-premises portal -$source = Connect-PnPOnline https://portal2013.pnp.com/sites/classicportal -TransformationOnPrem -CurrentCredentials -ReturnConnection +The following command creates a public-client application for interactive sign-in: -# Convert a classic page living in the on-premises portal to a modern page in SharePoint Online. Dependent images and videos are copied as well from on-premises to online during this process. -ConvertTo-PnPPage -Identity "page1.aspx" -PublishingPage -TargetConnection $target -LogVerbose -LogType File -LogFolder c:\temp -Connection $source +```powershell +Register-PnPEntraIDAppForInteractiveLogin ` + -ApplicationName "Classic Page Transformation" ` + -Tenant ".onmicrosoft.com" ` + -SharePointDelegatePermissions AllSites.Manage ` + -SignInAudience AzureADMyOrg ``` -> [!NOTE] -> - This feature supports SharePoint 2013, 2016 and 2019 as a source environment. Target environment is always SharePoint Online. Transformation from SharePoint 2010 is possible, but then this requires [the legacy PnP PowerShell version](https://www.powershellgallery.com/packages/SharePointPnPPowerShellOnline/3.29.2101.0) -> - The machine running the PowerShell script needs to be able to connect to both the on-premises SharePoint server as the SharePoint Online environment -> - This approach can also be used for page transformation across tenants (whenever that would make sense) +Copy the returned application ID. Depending on tenant consent policy, an administrator might need to grant consent before the first connection. + +For manual registration and other authentication methods, see [Register an Entra ID application for PnP PowerShell](https://pnp.github.io/powershell/articles/registerapplication.html). + +For GCC High, DoD, or Microsoft 365 operated by 21Vianet, specify the matching `-AzureEnvironment` value when registering the application and connecting. See the [Register-PnPEntraIDAppForInteractiveLogin](https://pnp.github.io/powershell/cmdlets/Register-PnPEntraIDAppForInteractiveLogin.html) and [Connect-PnPOnline](https://pnp.github.io/powershell/cmdlets/Connect-PnPOnline.html) cmdlet references. + +## Map an Assessment row to PnP PowerShell + +| Assessment field | PnP PowerShell use | +| --- | --- | +| `SiteUrl` + `WebUrl` | Source URL for `Connect-PnPOnline`. | +| `ListUrl` + `PageUrl` | Source library, optional folder, and page file name. | +| `PageName` | Blog title identity. | +| `PageType` | Wiki/Web Part, Publishing, or Blog route. | +| `Layout` | Publishing page-layout decision. | + +Before transforming a first-wave page, require: + +- Successful site and web coverage. +- `PageType` equal to `WikiPage` or `WebPartPage`. +- No unresolved unmapped Web Parts. +- `WebPartCount` greater than 0. Handle zero-part pages outside this automated example after manual review. +- A source page that inherits permissions from its library. +- A recorded content and layout baseline for validation. + +## Understand the source-preserving defaults + +> [!CAUTION] +> `-TakeSourcePageName` renames the classic source page, and `-Overwrite` replaces an existing target page. Don't use either option until the generated pages are approved and a rollback plan exists. + +| Option | First-wave guidance | +| --- | --- | +| Default naming | Keep the source name and create `Migrated_.aspx`. | +| `-TakeSourcePageName` | Don't use initially. It renames the classic source with a `Previous_` prefix. | +| `-Overwrite` | Don't use initially. Review an existing target instead of replacing it automatically. | +| `-ReplaceHomePageWithDefault` | Don't use in a representative wave. | +| `-DontPublish` | Use for the first wave so the generated page remains a draft during validation. | +| `-SkipItemLevelPermissionCopyToClientSidePage` | Use with the `AllSites.Manage` application unless unique permissions must be copied. | + +## Transform one selected Wiki or Web Part page + +Filter on the exact `PageUrl` approved for the migration wave: + +```powershell +$row = Import-Csv .\classicpages.csv | + Where-Object PageUrl -eq '/sites/source/SitePages/ApprovedPage.aspx' | + Select-Object -First 1 + +if (-not $row) { + throw "The approved page wasn't found in classicpages.csv." +} +if ($row.PageType -notin @('WikiPage', 'WebPartPage')) { + throw "This example only handles WikiPage and WebPartPage rows." +} +if ($row.WebPartCount -eq '0') { + throw "Inspect a zero-part page manually before adding it to a transformation wave." +} +if ($row.MappingPercentage -ne '100' -or + -not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts)) { + throw "Resolve unmapped Web Parts before using this first-wave example." +} + +$sourceWebUrl = if ($row.WebUrl -eq '/') { + $row.SiteUrl +} +else { + "$($row.SiteUrl.TrimEnd('/'))$($row.WebUrl)" +} + +$source = Connect-PnPOnline ` + -Url $sourceWebUrl ` + -Interactive ` + -ClientId "" ` + -ReturnConnection + +$libraryPath = $row.ListUrl.TrimEnd('/') +if (-not $row.PageUrl.StartsWith("$libraryPath/", [StringComparison]::OrdinalIgnoreCase)) { + throw "PageUrl isn't under ListUrl." +} + +$libraryName = [Uri]::UnescapeDataString([IO.Path]::GetFileName($libraryPath)) +$sourceItem = Get-PnPFile ` + -Url $row.PageUrl ` + -AsListItem ` + -ThrowExceptionIfFileNotFound ` + -Connection $source + +$hasUniquePermissions = Get-PnPProperty ` + -ClientObject $sourceItem ` + -Property HasUniqueRoleAssignments ` + -Connection $source + +if ($hasUniquePermissions) { + throw "This first-wave example handles only pages that inherit library permissions." +} + +$logFolder = Join-Path (Get-Location) 'page-transformation-logs' +New-Item -ItemType Directory -Path $logFolder -Force | Out-Null + +$parameters = @{ + Identity = $sourceItem.Id + Connection = $source + SkipItemLevelPermissionCopyToClientSidePage = $true + DontPublish = $true + LogType = 'File' + LogFolder = $logFolder + LogVerbose = $true +} + +if ($libraryName -ne 'SitePages') { + $parameters.Library = $libraryName +} + +ConvertTo-PnPPage @parameters +``` -### Use the logging features +This command binds the exact approved file by list item ID, preserves the source page, doesn't overwrite an existing migrated page, and leaves the generated page as a draft. -```Powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/portaltomodernize -Interactive +This automated example handles pages stored in a library. Treat a Web Part page stored at the web root as an advanced case and use `-Folder ""` only after validating its exact source identity. -# Convert a series of pages, logs are not yet written -ConvertTo-PnPPage -PublishingPage -Identity mypage.aspx -Overwrite -TargetWebUrl https://contoso.sharepoint.com/sites/moderncommunicationsite -LogSkipFlush -LogType SharePoint -LogVerbose -ConvertTo-PnPPage -PublishingPage -Identity mypage.aspx -Overwrite -TargetWebUrl https://contoso.sharepoint.com/sites/moderncommunicationsite -LogSkipFlush -LogType SharePoint -LogVerbose +## Route other page types -# persist the log data from all previous page transformations to the defined log -Save-PnPPageConversionLog -``` +- `PublishingPage`: connect to a target modern web, add `-PublishingPage`, and review the [publishing page-layout model](modernize-userinterface-site-pages-model-publishing.md). +- `BlogPage`: use `PageName` as the `-Identity`, add `-BlogPage`, and provide a target modern web. +- `ASPXPage` and `DelveBlogPage`: keep them outside this Assessment-driven workflow. +- SharePoint Server source: use `Connect-PnPOnline -TransformationOnPrem` for the source and a separate SharePoint Online target connection. Treat this as an advanced cross-site scenario. -### Transform a page that lives at the root of the site (so outside of a library) +Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-approach.md) before providing `-TargetWebUrl` or `-TargetConnection`. -Some older sites might have web part pages living outside of a library. If you want to modernize these you need to indicate that the page lives in the root of the site via `-Folder ""` as shown below: +## Transform a representative wave -```Powershell -Connect-PnPOnline -Url https://contoso.sharepoint.com/sites/sitetomodernize -Interactive +Process one approved manifest row at a time until the page selection and validation process is proven. Record: -ConvertTo-PnPPage -Identity pageinroot.aspx -Overwrite -Folder "" -``` +- Assessment ID and source page identity. +- Page type, layout, Web Part count, mapping percentage, and unmapped types. +- Expected visible content. +- Target page URL. +- Transformation status and log path. +- Validation owner, result, and notes. -### Modern site pages don't work on the site I want to transform pages in +Stop the wave on an exception, blank target, missing content, or unexpected source-page change. -By default the modern site page capability is enabled on most sites but maybe it was turned off afterwards. If that's the case the [SharePoint Modernization scanner](https://aka.ms/sppnp-modernizationscanner) will tell you which sites have turned off the modern page feature. To remediate this use below sample PnP PowerShell script: +## Options requiring explicit review -```PowerShell -Connect-PnPOnline -Url "" -Interactive +Use the generated [ConvertTo-PnPPage cmdlet reference](https://pnp.github.io/powershell/cmdlets/ConvertTo-PnPPage.html) for the complete parameter contract. -# Enable modern page feature -Enable-PnPFeature -Identity "B6917CB1-93A0-4B97-A84D-7CF49975D4EC" -Scope Web -``` +Review these options only when the scenario requires them: + +- `-WebPartMappingFile` for custom Web Part mappings. +- `-PageLayoutMapping` for custom publishing layouts. +- `-TargetWebUrl` or `-TargetConnection` for cross-site transformation. +- `-CopyPageMetadata` and `-KeepPageCreationModificationInformation`. +- `-UrlMappingFile`, `-UserMappingFile`, and `-TermMappingFile`. +- `-Overwrite` and `-TakeSourcePageName` after approval and rollback planning. + +## Next steps + +1. [Validate each transformed page](modernize-userinterface-site-pages-validation.md). +1. Expand the wave only after every representative page passes validation. -## See also +## Reference -- [Modernize your classic SharePoint sites](modernize-classic-sites.md) -- [Understanding and configuring the page transformation model](modernize-userinterface-site-pages-model.md) -- [Classic and modern web part experiences](https://support.office.com/article/classic-and-modern-web-part-experiences-3fdae6c3-8fc1-49ab-8708-8c104b882e64) -- [Migrate classic pages to modern with the least permission possible](https://pnp.github.io/pnpassessment/classic/migrate-minimal-permissions.html) +- [ConvertTo-PnPPage cmdlet reference](https://pnp.github.io/powershell/cmdlets/ConvertTo-PnPPage.html) +- [Security requirements](modernize-userinterface-site-pages-security.md) +- [In-place versus cross-site transformation](modernize-userinterface-site-pages-approach.md) +- [Page transformation model](modernize-userinterface-site-pages-model.md) +- [Publishing page transformation model](modernize-userinterface-site-pages-model-publishing.md) diff --git a/docs/transform/modernize-userinterface-site-pages-validation.md b/docs/transform/modernize-userinterface-site-pages-validation.md new file mode 100644 index 0000000000..6990ab7a37 --- /dev/null +++ b/docs/transform/modernize-userinterface-site-pages-validation.md @@ -0,0 +1,93 @@ +--- +title: Validate transformed classic pages +description: Validate transformed page content, layout, links, metadata, permissions, and publication state before expanding a migration wave. +ms.date: 07/27/2026 +ms.localizationpriority: high +ms.service: sharepoint +--- + +# Validate transformed classic pages + +Validate every page in a representative transformation wave before processing more pages. A successful command or an HTTP 200 response doesn't prove that the generated page preserved the expected content. + +## Capture the expected result + +Before transformation, record: + +- Source and expected target URLs. +- Page type and layout. +- Visible text and headings. +- Web Parts and their important configuration. +- Links, images, files, lists, and embedded content. +- Metadata, permissions, publication state, and whether the page is a home page. + +Retain the Assessment row and `classicpagewebparts.csv` rows with this baseline. + +For the delegated `AllSites.Manage` first-wave path, select pages that inherit permissions from their library and keep generated pages as drafts. If unique source permissions must be preserved, use the reviewed `AllSites.FullControl` path instead of skipping permission copy. + +## Review the transformation result + +For each page: + +1. Review the PnP transformation log for errors, skipped Web Parts, fallback mappings, and permission-copy messages. +1. Confirm that the classic source page still exists and retains its expected name. +1. Confirm that the expected modern page exists and opens. +1. Compare visible text, Web Parts, sections, columns, and ordering. +1. Test links, images, documents, list views, and other dependencies. +1. Compare required metadata, author and modified values, permissions, comments, and publication state. +1. Record the result and evidence before processing the next page. + +## Detect incomplete output + +Treat the page as failed when: + +- The target page is blank or contains only a title. +- Expected text or a required Web Part is missing. +- A Web Part is present but points to the wrong list, file, site, user, or term. +- The layout or page header prevents the page from meeting its business purpose. +- Required metadata or permissions weren't retained. +- The transformation log contains an unresolved error. + +Manually inspect pages where Assessment reported `WebPartCount=0`, even when `MappingPercentage=100`. Also validate pages reported as fully mapped because runtime data or mapping functions can still cause a transformation failure. + +## Record the wave result + +Use one record per attempted page: + +| Field | Purpose | +| --- | --- | +| `AssessmentId` | Links the page to the source evidence. | +| `SourcePageUrl`, `TargetPageUrl` | Identifies both pages. | +| `PageType`, `Layout` | Groups comparable results. | +| `MappingPercentage`, `UnmappedWebParts` | Preserves the planning signal. | +| `TransformationStatus` | Created, failed, or skipped. | +| `LogPath` | Points to the PnP transformation log. | +| `ValidationStatus` | Passed, failed, or needs remediation. | +| `ValidationNotes` | Records missing content, mapping issues, and follow-up work. | +| `ValidatedBy`, `ValidatedAt` | Records approval ownership. | + +## Decide whether to expand + +Expand the wave only when: + +- Every representative source page remains available. +- Every approved target page passes content and behavior validation. +- Draft targets are published only after approval. +- Common blockers have a documented remediation or mapping. +- Failed or partial pages are excluded from the next wave. +- The next wave has an owner, rollback plan, and retained logs. + +If a page fails, stop processing comparable pages. Review the log and source Web Parts, update the mapping or candidate rule when appropriate, and retry a single representative page before resuming. + +After a draft page is approved, publish it through the page editing experience or with the current [Set-PnPPage](https://pnp.github.io/powershell/cmdlets/Set-PnPPage.html) `-Publish` option. + +## Next steps + +1. Return failed pages to the remediation backlog. +1. Add passed page patterns to the next approved wave. + +## Reference + +- [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) +- [Classic pages assessment CSV reference](assessment-tool-classic-pages-csv.md) +- [Classic Web Part mappings](modernize-userinterface-site-pages-webparts.md) diff --git a/docs/transform/modernize-userinterface-site-pages.md b/docs/transform/modernize-userinterface-site-pages.md index de360190a5..84517a3759 100644 --- a/docs/transform/modernize-userinterface-site-pages.md +++ b/docs/transform/modernize-userinterface-site-pages.md @@ -1,46 +1,103 @@ --- -title: Transform classic pages to modern pages -description: Explains how to transform classic wiki and web part pages into modern pages -ms.date: 08/13/2024 +title: Transform classic SharePoint pages to modern pages +description: Select assessed classic pages, transform a representative wave with PnP PowerShell, validate the results, and expand safely. +ms.date: 07/27/2026 ms.localizationpriority: high ms.service: sharepoint --- -# Transform classic pages to modern pages +# Transform classic SharePoint pages to modern pages -Classic SharePoint sites typically have classic pages being wiki pages, web part pages, blog pages or publishing pages and these pages cannot present themselves using a modern user interface. A classic site however can host modern pages which enable great end user experiences. You can find inspiration via the [SharePoint Look Book](https://adoption.microsoft.com/sharepoint-look-book/) as it allows you to discover the modern experiences you can build with SharePoint in Office 365. +This primary workflow transforms classic pages in SharePoint Online. Use the Microsoft 365 Assessment tool to identify pages and their Web Parts, and then use PnP PowerShell to transform approved pages and validate each result before processing a larger wave. -SharePoint PnP Modernization is part of the [PnP Framework](https://github.com/pnp/pnpframework) library ([source code](https://github.com/pnp/pnpframework/tree/dev/src/lib/PnP.Framework/Modernization)) and does bring page transformation capabilities which will be explained in the upcoming chapters. +This workflow uses PnP PowerShell as the primary execution path. The page transformation engine is part of the open-source [PnP Framework](https://github.com/pnp/pnpframework) and doesn't have a Microsoft support SLA. -> [!IMPORTANT] -> - Page transformation today works for wiki pages, web part pages, blog pages and publishing pages. -> - SharePoint PnP Modernization is part of the [PnP Framework](https://github.com/pnp/pnpframework) and is continuously evolving, checkout [the release notes](https://github.com/pnp/pnpframework/blob/dev/src/lib/CHANGELOG.md) to stay up to date on the latest changes. If you encounter problems please file an issue in the [PnP Framework GitHub issue list](https://github.com/pnp/pnpframework/issues). -> - Modernization tooling and all other PnP components are open-source tools backed by an active community providing support for them. There is no SLA for open-source tool support from official Microsoft support channels. +## Page transformation workflow -Page transformation can be used in one or more of the below models: +[!INCLUDE [classic-page-transformation-workflow](../../includes/snippets/modernization/classic-page-transformation-workflow.md)] -Model | Technique | Audience | Publishing Page support | Comments -------|---------- |----------|-------------------------|--------- -I want to transform pages for my users | [PowerShell](modernize-userinterface-site-pages-powershell.md) | DevOps | Yes | Typically used when you want to modernize pages for multiple site collections. Using [PowerShell](modernize-userinterface-site-pages-powershell.md) is the **easiest and recommended** approach. -I want to have full control on the page transformation process | [.Net](modernize-userinterface-site-pages-dotnet.md) | DevOps | Yes | Typically used when you integrate page transformation into existing services or tools. The core of page transformation is the .Net based SharePoint PnP Modernization framework. Using .Net you can fully tailor the transformation process, this approach is recommended whenever you want full control on the transformation process or when you want to integrate page transformation into another solution/product. +## 1. Select page candidates -Page transformation supports a wide range of SharePoint versions and page types as source, **all of these result in modern pages in SharePoint Online** as a result. Below table shows the possible page types versus pages sources that can be transformed to modern pages using the latest page transformation release. +Start with a completed [Classic pages assessment](assessment-tool-classic-pages.md). Confirm scan coverage before selecting pages. -Page Type | SharePoint 2010 | SharePoint 2013 | SharePoint 2016 | SharePoint 2019 | SharePoint Online -----------|-----------------|-----------------|-----------------|-----------------|------------------ -Wiki page | x | x | x | x | x -Webpart page | x | x | x | x | x -Blog page | x | x | x | x | x -Publishing page | x | x | x | x | x +For the first wave: -> [!Note] -> To transform SharePoint 2010 pages you need to use our legacy versions: [SharePointPnPPowerShellOnline](https://www.powershellgallery.com/packages/SharePointPnPPowerShellOnline/3.29.2101.0) / [PnP Modernization Framework](https://www.nuget.org/packages/SharePointPnPModernizationOnline/). +- Select `WikiPage` or `WebPartPage` rows from successful site and web scans. +- Prefer pages with no unmapped Web Parts and a high mapping percentage. +- Review `classicpagewebparts.csv` for the exact Web Part types and mappings. +- Manually inspect any page with `WebPartCount=0`. A mapping percentage of 100 for a zero-part page only means that Assessment extracted no Web Parts. +- Avoid a home page until the transformation and validation process is proven. +- Record visible text, Web Parts, links, images, and layout that must be present after transformation. -## See also +`MappingPercentage=100` is a planning signal. It doesn't guarantee that the transformation will run successfully or preserve the expected content. -- [Modernize your classic SharePoint sites](modernize-classic-sites.md) -- [Using SharePoint Page Transformation from PowerShell](modernize-userinterface-site-pages-powershell.md) -- [Using SharePoint Page Transformation from .Net](modernize-userinterface-site-pages-dotnet.md) -- [Understanding and configuring the page transformation model](modernize-userinterface-site-pages-model.md) -- [SharePoint Look Book](https://adoption.microsoft.com/sharepoint-look-book/) -- [Classic and modern web part experiences](https://support.office.com/article/classic-and-modern-web-part-experiences-3fdae6c3-8fc1-49ab-8708-8c104b882e64) +Route page types deliberately: + +| Assessment page type | Transformation route | +| --- | --- | +| `WikiPage`, `WebPartPage` | Transform in place for the first wave. Use a target web only when the modern page must be created in another site. | +| `PublishingPage` | Use a target modern web and review the page-layout mapping. | +| `BlogPage` | Use the blog title as the identity and provide a target modern web. | +| `ASPXPage`, `DelveBlogPage` | Keep outside this Assessment-driven workflow because the page assessment doesn't provide equivalent transformation-readiness analysis. | + +For field-to-command mapping and a selected-page example, see [Interpret the classic pages assessment report](assessment-tool-classic-pages-report.md#move-from-assessment-to-transformation). + +## 2. Resolve blockers and choose the target + +Group candidate pages by page type, layout, and unmapped Web Part combination. Resolve common blockers before transforming a wave. + +- For Wiki and Web Part pages, in-place transformation is the preferred starting point because dependencies remain in the source site. +- Publishing and Blog pages require a target modern web. +- Custom publishing layouts require a reviewed page-layout mapping. Without one, the engine generates a default mapping and places the content in a default section. +- Cross-site transformations require additional validation for links, files, lists, users, and taxonomy. + +See [Choose in-place or cross-site transformation](modernize-userinterface-site-pages-approach.md) and [Review classic Web Part mappings](modernize-userinterface-site-pages-webparts.md). + +## 3. Prepare PnP PowerShell + +PnP PowerShell requires a tenant-owned Microsoft Entra application. Use a separate transformation application from the read-only Assessment application. + +For the primary interactive flow, use delegated SharePoint `AllSites.Manage`, together with the signed-in user's existing site permissions. Copying item-level unique permissions requires `AllSites.FullControl`; otherwise, explicitly skip that copy. + +Follow [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) to register the application, connect, and run the first transformation. + +## 4. Transform a representative wave + +Transform a small wave that represents the page types, layouts, and Web Part combinations that you intend to process. + +For the first wave: + +- Let the generated page use the default `Migrated_` prefix. +- Don't use `-TakeSourcePageName`, `-Overwrite`, or home-page replacement options. +- Enable file logging and retain the log with the wave manifest. +- Stop on the first unexpected failure or content mismatch. + +Only enable source-renaming or overwrite behavior after the generated pages have been approved and a rollback plan exists. + +## 5. Validate and expand + +Opening without an error isn't sufficient validation. A generated page can exist and still be blank or incomplete. + +Validate the source and generated page, transformation log, content, Web Parts, layout, links, images, metadata, permissions, and publication state. Expand the wave only when every selected page meets the acceptance criteria. + +See [Validate transformed classic pages](modernize-userinterface-site-pages-validation.md). + +## Next steps + +1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). +1. [Validate the transformed pages](modernize-userinterface-site-pages-validation.md). + +## Advanced reference + +- [Choose in-place or cross-site transformation](modernize-userinterface-site-pages-approach.md) +- [Security requirements](modernize-userinterface-site-pages-security.md) +- [Classic Web Part mappings](modernize-userinterface-site-pages-webparts.md) +- [Page transformation configuration options](modernize-userinterface-site-pages-configuration.md) +- [Page transformation model](modernize-userinterface-site-pages-model.md) +- [Publishing page transformation model](modernize-userinterface-site-pages-model-publishing.md) +- [URL mapping](modernize-userinterface-site-pages-urlmapping.md) +- [User mapping](modernize-userinterface-site-pages-usermapping.md) +- [Term mapping](modernize-userinterface-site-pages-termmapping.md) +- [Layout transformation](modernize-userinterface-site-pages-layout.md) +- [.NET integration](modernize-userinterface-site-pages-dotnet.md) +- [Transform pages from SharePoint Server](modernize-userinterface-site-pages-powershell.md#route-other-page-types) diff --git a/includes/snippets/modernization/classic-page-transformation-workflow.md b/includes/snippets/modernization/classic-page-transformation-workflow.md new file mode 100644 index 0000000000..806f1bbcdc --- /dev/null +++ b/includes/snippets/modernization/classic-page-transformation-workflow.md @@ -0,0 +1,5 @@ +1. Select a representative page wave from completed Assessment coverage. +1. Resolve blocking Web Parts and choose an in-place or cross-site target. +1. Prepare a tenant-owned PnP PowerShell application and the required permissions. +1. Transform the representative wave with source-preserving defaults and logging. +1. Validate every generated page before expanding the wave. From a6d51c512bc16bc155ee87b15a850a5e9c0f1925 Mon Sep 17 00:00:00 2001 From: titao Date: Mon, 27 Jul 2026 18:06:54 +0800 Subject: [PATCH 02/11] Document representative page selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- ...ize-userinterface-site-pages-powershell.md | 83 +++++++++++++++++++ .../modernize-userinterface-site-pages.md | 27 ++++++ 2 files changed, 110 insertions(+) diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index 4bc1d9834b..dc7f850050 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -95,6 +95,89 @@ Before transforming a first-wave page, require: - A source page that inherits permissions from its library. - A recorded content and layout baseline for validation. +## Build representative page groups + +The following script creates a candidate inventory. It doesn't transform pages. + +It groups eligible Wiki and Web Part pages by page type, layout, and ordered Web Part signature. The signature includes Web Part type, mapping result, hidden state, and closed state. + +```powershell +$pages = Import-Csv .\classicpages.csv +$webParts = Import-Csv .\classicpagewebparts.csv +$partsByPage = @{} + +function Get-PageKey { + param($Row) + + '{0}|{1}|{2}|{3}' -f $Row.ScanId, $Row.SiteUrl, $Row.WebUrl, $Row.PageUrl +} + +foreach ($part in $webParts) { + $key = Get-PageKey $part + if (-not $partsByPage.ContainsKey($key)) { + $partsByPage[$key] = [Collections.Generic.List[object]]::new() + } + + $partsByPage[$key].Add($part) +} + +$candidates = foreach ($page in $pages) { + $fileName = [IO.Path]::GetFileName($page.PageUrl) + + if ($page.PageType -notin @('WikiPage', 'WebPartPage') -or + $page.HomePage -eq 'True' -or + $page.WebPartCount -eq '0' -or + $page.MappingPercentage -ne '100' -or + -not [string]::IsNullOrWhiteSpace($page.UnmappedWebParts) -or + $fileName.StartsWith('Migrated_', [StringComparison]::OrdinalIgnoreCase) -or + $fileName.StartsWith('Previous_', [StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $key = Get-PageKey $page + if (-not $partsByPage.ContainsKey($key)) { + throw "Web Part rows are missing for $($page.PageUrl)." + } + + $signature = ( + $partsByPage[$key] | + Sort-Object { [int]$_.WebPartIndex } | + ForEach-Object { + '{0}|Mappable={1}|Hidden={2}|Closed={3}' -f + $_.WebPartTypeShort, $_.IsMappable, $_.Hidden, $_.IsClosed + } + ) -join ';' + + [pscustomobject]@{ + PatternKey = '{0}|{1}|{2}' -f $page.PageType, $page.Layout, $signature + ScanId = $page.ScanId + SiteUrl = $page.SiteUrl + WebUrl = $page.WebUrl + PageUrl = $page.PageUrl + PageType = $page.PageType + Layout = $page.Layout + WebPartCount = [int]$page.WebPartCount + ModifiedAt = $page.ModifiedAt + WebPartSignature = $signature + } +} + +$groups = $candidates | Group-Object PatternKey -AsHashTable -AsString + +$candidateInventory = foreach ($candidate in $candidates) { + $candidate | Select-Object *, + @{ Name = 'PatternPageCount'; Expression = { $groups[$candidate.PatternKey].Count } } +} + +$candidateInventory | + Sort-Object PatternKey, PageUrl | + Export-Csv .\representative-page-groups.csv -NoTypeInformation +``` + +Review `representative-page-groups.csv` and select at least one page from every `PatternKey` that the migration wave will contain. Select additional pages when Web Part properties, linked content, or business behavior differ materially within a pattern. + +Keep zero-part pages, home pages, publishing pages, and pages with unresolved mappings in separate review queues. + ## Understand the source-preserving defaults > [!CAUTION] diff --git a/docs/transform/modernize-userinterface-site-pages.md b/docs/transform/modernize-userinterface-site-pages.md index 84517a3759..9ee28f3c19 100644 --- a/docs/transform/modernize-userinterface-site-pages.md +++ b/docs/transform/modernize-userinterface-site-pages.md @@ -40,6 +40,33 @@ Route page types deliberately: | `BlogPage` | Use the blog title as the identity and provide a target modern web. | | `ASPXPage`, `DelveBlogPage` | Keep outside this Assessment-driven workflow because the page assessment doesn't provide equivalent transformation-readiness analysis. | +### Build a representative wave + +Treat pages as the same transformation pattern only when these dimensions match: + +| Dimension | Why it defines a separate pattern | +| --- | --- | +| `PageType` | Selects the Wiki/Web Part, Publishing, or Blog transformation path. | +| `Layout` | Controls the generated sections and columns. | +| Ordered Web Part signature | Pages with different Web Part types, order, hidden state, or closed state can transform differently. | +| Mapping result | Unmapped or differently mapped Web Parts require separate remediation and validation. | +| Target approach | In-place and cross-site transformations have different dependency and URL behavior. | +| Special handling | Home pages, custom publishing layouts, and zero-part pages require separate review. | + +Use `classicpagewebparts.csv` to build the ordered Web Part signature for each page. Don't group only by `MappingPercentage` or `UnmappedWebParts`; fully mapped pages can still contain different Web Part types and runtime behavior. + +For the first wave: + +1. Exclude failed coverage, unsupported page types, home pages, zero-part pages, and unresolved mappings. +1. Group the remaining pages by page type, layout, and ordered Web Part signature. +1. Select at least one page from each pattern that you plan to migrate. +1. Add another representative when pages in the same pattern use materially different Web Part properties or content dependencies. +1. Prefer a page with a clear content baseline and a business owner who can validate the result. + +Use activity and modification dates to prioritize which pattern to process first, not as a replacement for pattern coverage. + +See [Build representative page groups with PowerShell](modernize-userinterface-site-pages-powershell.md#build-representative-page-groups). + For field-to-command mapping and a selected-page example, see [Interpret the classic pages assessment report](assessment-tool-classic-pages-report.md#move-from-assessment-to-transformation). ## 2. Resolve blockers and choose the target From 05a77451a9c2681d8a6ecab188c73cb800388475 Mon Sep 17 00:00:00 2001 From: titao Date: Mon, 27 Jul 2026 20:05:03 +0800 Subject: [PATCH 03/11] Document Assessment-driven page wave scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- ...ize-userinterface-site-pages-powershell.md | 159 +++++++++--------- ...ize-userinterface-site-pages-validation.md | 13 +- .../modernize-userinterface-site-pages.md | 4 + 3 files changed, 95 insertions(+), 81 deletions(-) diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index dc7f850050..a5676c186c 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -126,6 +126,7 @@ $candidates = foreach ($page in $pages) { if ($page.PageType -notin @('WikiPage', 'WebPartPage') -or $page.HomePage -eq 'True' -or + -not $page.ListUrl.EndsWith('/SitePages', [StringComparison]::OrdinalIgnoreCase) -or $page.WebPartCount -eq '0' -or $page.MappingPercentage -ne '100' -or -not [string]::IsNullOrWhiteSpace($page.UnmappedWebParts) -or @@ -155,10 +156,19 @@ $candidates = foreach ($page in $pages) { WebUrl = $page.WebUrl PageUrl = $page.PageUrl PageType = $page.PageType + ListUrl = $page.ListUrl + ListId = $page.ListId Layout = $page.Layout + HomePage = $page.HomePage WebPartCount = [int]$page.WebPartCount + MappingPercentage = $page.MappingPercentage + UnmappedWebParts = $page.UnmappedWebParts ModifiedAt = $page.ModifiedAt WebPartSignature = $signature + IncludePattern = 'True' + Selected = 'False' + ExpectedVisibleContent = '' + ValidationOwner = '' } } @@ -176,7 +186,10 @@ $candidateInventory | Review `representative-page-groups.csv` and select at least one page from every `PatternKey` that the migration wave will contain. Select additional pages when Web Part properties, linked content, or business behavior differ materially within a pattern. +Set `IncludePattern=False` for patterns outside the planned migration. For each selected page, set `Selected=True` and fill `ExpectedVisibleContent` and `ValidationOwner`. + Keep zero-part pages, home pages, publishing pages, and pages with unresolved mappings in separate review queues. +Pages outside the default `SitePages` library also remain on the separately reviewed single-page path. ## Understand the source-preserving defaults @@ -194,106 +207,92 @@ Keep zero-part pages, home pages, publishing pages, and pages with unresolved ma ## Transform one selected Wiki or Web Part page -Filter on the exact `PageUrl` approved for the migration wave: +Use the representative-page script with one row marked `Selected=True`. This keeps the one-page test on the same validation, authentication, and result contract as a larger wave. -```powershell -$row = Import-Csv .\classicpages.csv | - Where-Object PageUrl -eq '/sites/source/SitePages/ApprovedPage.aspx' | - Select-Object -First 1 +The batch scripts support Wiki and Web Part pages in the default `SitePages` library. They exclude home pages, zero-part pages, pages with unique permissions, and pages modified after Assessment. Handle those pages through a separately reviewed path. -if (-not $row) { - throw "The approved page wasn't found in classicpages.csv." -} -if ($row.PageType -notin @('WikiPage', 'WebPartPage')) { - throw "This example only handles WikiPage and WebPartPage rows." -} -if ($row.WebPartCount -eq '0') { - throw "Inspect a zero-part page manually before adding it to a transformation wave." -} -if ($row.MappingPercentage -ne '100' -or - -not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts)) { - throw "Resolve unmapped Web Parts before using this first-wave example." -} +## Route other page types -$sourceWebUrl = if ($row.WebUrl -eq '/') { - $row.SiteUrl -} -else { - "$($row.SiteUrl.TrimEnd('/'))$($row.WebUrl)" -} +- `PublishingPage`: connect to a target modern web, add `-PublishingPage`, and review the [publishing page-layout model](modernize-userinterface-site-pages-model-publishing.md). +- `BlogPage`: use `PageName` as the `-Identity`, add `-BlogPage`, and provide a target modern web. +- `ASPXPage` and `DelveBlogPage`: keep them outside this Assessment-driven workflow. +- SharePoint Server source: use `Connect-PnPOnline -TransformationOnPrem` for the source and a separate SharePoint Online target connection. Treat this as an advanced cross-site scenario. -$source = Connect-PnPOnline ` - -Url $sourceWebUrl ` - -Interactive ` - -ClientId "" ` - -ReturnConnection +Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-approach.md) before providing `-TargetWebUrl` or `-TargetConnection`. -$libraryPath = $row.ListUrl.TrimEnd('/') -if (-not $row.PageUrl.StartsWith("$libraryPath/", [StringComparison]::OrdinalIgnoreCase)) { - throw "PageUrl isn't under ListUrl." -} +## Transform all representative pages -$libraryName = [Uri]::UnescapeDataString([IO.Path]::GetFileName($libraryPath)) -$sourceItem = Get-PnPFile ` - -Url $row.PageUrl ` - -AsListItem ` - -ThrowExceptionIfFileNotFound ` - -Connection $source +Download the [Assessment page wave scripts](https://github.com/pnp/modernization/tree/dev/Scripts/PageTransformation/AssessmentPageWaves). -$hasUniquePermissions = Get-PnPProperty ` - -ClientObject $sourceItem ` - -Property HasUniqueRoleAssignments ` - -Connection $source +In `representative-page-groups.csv`: -if ($hasUniquePermissions) { - throw "This first-wave example handles only pages that inherit library permissions." -} +1. Set `IncludePattern=True` for each transformation pattern in the planned migration. +1. Set `Selected=True` on at least one page in every included pattern. +1. Fill `ExpectedVisibleContent` and `ValidationOwner` for every selected page. -$logFolder = Join-Path (Get-Location) 'page-transformation-logs' -New-Item -ItemType Directory -Path $logFolder -Force | Out-Null - -$parameters = @{ - Identity = $sourceItem.Id - Connection = $source - SkipItemLevelPermissionCopyToClientSidePage = $true - DontPublish = $true - LogType = 'File' - LogFolder = $logFolder - LogVerbose = $true -} +Preview the complete wave without authenticating or writing SharePoint pages: -if ($libraryName -ne 'SitePages') { - $parameters.Library = $libraryName -} +```powershell +.\Convert-RepresentativePages.ps1 ` + -ManifestPath .\representative-page-groups.csv ` + -ClientId "" ` + -AuthenticationMode Interactive ` + -WhatIf ` + -Force +``` + +Run interactively with High-impact confirmation: -ConvertTo-PnPPage @parameters +```powershell +.\Convert-RepresentativePages.ps1 ` + -ManifestPath .\representative-page-groups.csv ` + -ClientId "" ` + -AuthenticationMode Interactive ` + -Confirm ``` -This command binds the exact approved file by list item ID, preserves the source page, doesn't overwrite an existing migrated page, and leaves the generated page as a draft. +For unattended execution, use a certificate-based application and disable confirmation explicitly: -This automated example handles pages stored in a library. Treat a Web Part page stored at the web root as an advanced case and use `-Folder ""` only after validating its exact source identity. +```powershell +.\Convert-RepresentativePages.ps1 ` + -ManifestPath .\representative-page-groups.csv ` + -ClientId "" ` + -AuthenticationMode CertificateThumbprint ` + -Tenant ".onmicrosoft.com" ` + -Thumbprint "" ` + -Confirm:$false +``` -## Route other page types +The script writes each result immediately to `representative-page-results.csv`. Generated pages remain drafts, and each created row starts with `ValidationStatus=Pending`. -- `PublishingPage`: connect to a target modern web, add `-PublishingPage`, and review the [publishing page-layout model](modernize-userinterface-site-pages-model-publishing.md). -- `BlogPage`: use `PageName` as the `-Identity`, add `-BlogPage`, and provide a target modern web. -- `ASPXPage` and `DelveBlogPage`: keep them outside this Assessment-driven workflow. -- SharePoint Server source: use `Connect-PnPOnline -TransformationOnPrem` for the source and a separate SharePoint Online target connection. Treat this as an advanced cross-site scenario. +Follow [Validate transformed classic pages](modernize-userinterface-site-pages-validation.md), and set `ValidationStatus=Passed` only after the page meets every acceptance criterion. -Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-approach.md) before providing `-TargetWebUrl` or `-TargetConnection`. +## Expand to all user-specified pages + +Create `approved-pages.csv` by copying the additional rows that the user approved from `representative-page-groups.csv`. Retain the Assessment fields, `PatternKey`, `ExpectedVisibleContent`, and `ValidationOwner`. + +Run the expansion script: + +```powershell +.\Convert-SelectedPages.ps1 ` + -PagesPath .\approved-pages.csv ` + -RepresentativeManifestPath .\representative-page-groups.csv ` + -RepresentativeResultsPath .\representative-page-results.csv ` + -ClientId "" ` + -AuthenticationMode Interactive ` + -Confirm +``` -## Transform a representative wave +The script refuses to expand when: -Process one approved manifest row at a time until the page selection and validation process is proven. Record: +- The representative manifest and results don't match. +- Any representative page isn't `Created` and `Passed`. +- The PnP PowerShell version or Web Part mapping differs from the representative run. +- An approved page belongs to a pattern without a passed representative. -- Assessment ID and source page identity. -- Page type, layout, Web Part count, mapping percentage, and unmapped types. -- Expected visible content. -- Target page URL. -- Transformation status and log path. -- Validation owner, result, and notes. +The expanded wave also creates draft pages and writes `selected-page-results.csv`. Validate those pages before publishing them. -Stop the wave on an exception, blank target, missing content, or unexpected source-page change. +For app-only setup and certificate authentication, see [PnP PowerShell authentication](https://pnp.github.io/powershell/articles/authentication.html) and [determine required permissions](https://pnp.github.io/powershell/articles/determinepermissions.html). Run `-WhatIf` before unattended execution. ## Options requiring explicit review @@ -311,7 +310,7 @@ Review these options only when the scenario requires them: ## Next steps 1. [Validate each transformed page](modernize-userinterface-site-pages-validation.md). -1. Expand the wave only after every representative page passes validation. +1. Run `Convert-SelectedPages.ps1` only after every representative page passes validation. ## Reference diff --git a/docs/transform/modernize-userinterface-site-pages-validation.md b/docs/transform/modernize-userinterface-site-pages-validation.md index 6990ab7a37..b312bd2131 100644 --- a/docs/transform/modernize-userinterface-site-pages-validation.md +++ b/docs/transform/modernize-userinterface-site-pages-validation.md @@ -81,10 +81,21 @@ If a page fails, stop processing comparable pages. Review the log and source Web After a draft page is approved, publish it through the page editing experience or with the current [Set-PnPPage](https://pnp.github.io/powershell/cmdlets/Set-PnPPage.html) `-Publish` option. +## Expand the wave + +After validating every representative page: + +1. Set each representative result to `ValidationStatus=Passed`. +1. Create `approved-pages.csv` from the additional grouped rows that the user approved. +1. Run `Convert-SelectedPages.ps1` with the original representative manifest and validated results. +1. Validate every generated page in the expanded wave before publishing it. + +The script blocks expansion if a representative result is missing, stale, not passed, or based on a different PnP PowerShell or Web Part mapping profile. + ## Next steps 1. Return failed pages to the remediation backlog. -1. Add passed page patterns to the next approved wave. +1. Add passed page patterns to `approved-pages.csv` and run the expansion script. ## Reference diff --git a/docs/transform/modernize-userinterface-site-pages.md b/docs/transform/modernize-userinterface-site-pages.md index 9ee28f3c19..e80d47f070 100644 --- a/docs/transform/modernize-userinterface-site-pages.md +++ b/docs/transform/modernize-userinterface-site-pages.md @@ -101,6 +101,8 @@ For the first wave: Only enable source-renaming or overwrite behavior after the generated pages have been approved and a rollback plan exists. +Use `Convert-RepresentativePages.ps1` to process every approved representative page through the same safety and result contract. + ## 5. Validate and expand Opening without an error isn't sufficient validation. A generated page can exist and still be blank or incomplete. @@ -109,6 +111,8 @@ Validate the source and generated page, transformation log, content, Web Parts, See [Validate transformed classic pages](modernize-userinterface-site-pages-validation.md). +After every representative result is marked `Passed`, use `Convert-SelectedPages.ps1` to process all additional user-approved rows. The script rejects pages whose transformation pattern doesn't have a passed representative. + ## Next steps 1. [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md). From ccb85b1eaeea17c265bcea0bc5288eaa64e10e0a Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 09:11:08 +0800 Subject: [PATCH 04/11] Link draft page wave scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- docs/transform/modernize-userinterface-site-pages-powershell.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index a5676c186c..57a9f3f752 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -222,7 +222,7 @@ Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-app ## Transform all representative pages -Download the [Assessment page wave scripts](https://github.com/pnp/modernization/tree/dev/Scripts/PageTransformation/AssessmentPageWaves). +Download the [Assessment page wave scripts](https://github.com/TaoziZ03/modernization/tree/titao/assessment-page-wave-scripts/Scripts/PageTransformation/AssessmentPageWaves). In `representative-page-groups.csv`: From b2f20b34fd0716707abfcd42d50505543e2a7329 Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 09:16:34 +0800 Subject: [PATCH 05/11] Embed page wave scripts in Learn Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- docs/toc.yml | 2 + ...ize-userinterface-site-pages-powershell.md | 2 +- ...ize-userinterface-site-pages-validation.md | 1 + ...e-userinterface-site-pages-wave-scripts.md | 38 + .../modernize-userinterface-site-pages.md | 2 +- .../Convert-RepresentativePages.ps1 | 178 +++++ .../Convert-SelectedPages.ps1 | 254 ++++++ .../PageTransformation.Common.ps1 | 721 ++++++++++++++++++ 8 files changed, 1196 insertions(+), 2 deletions(-) create mode 100644 docs/transform/modernize-userinterface-site-pages-wave-scripts.md create mode 100644 includes/scripts/page-transformation/Convert-RepresentativePages.ps1 create mode 100644 includes/scripts/page-transformation/Convert-SelectedPages.ps1 create mode 100644 includes/scripts/page-transformation/PageTransformation.Common.ps1 diff --git a/docs/toc.yml b/docs/toc.yml index 43990d5c57..f3bd52b51e 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1858,6 +1858,8 @@ items: - name: Transform with PnP PowerShell href: transform/modernize-userinterface-site-pages-powershell.md + - name: Run page wave scripts + href: transform/modernize-userinterface-site-pages-wave-scripts.md - name: Validate transformed pages href: transform/modernize-userinterface-site-pages-validation.md - name: Reference diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index 57a9f3f752..3872162cf9 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -222,7 +222,7 @@ Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-app ## Transform all representative pages -Download the [Assessment page wave scripts](https://github.com/TaoziZ03/modernization/tree/titao/assessment-page-wave-scripts/Scripts/PageTransformation/AssessmentPageWaves). +Save the three embedded files from [Run Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) in the same folder. In `representative-page-groups.csv`: diff --git a/docs/transform/modernize-userinterface-site-pages-validation.md b/docs/transform/modernize-userinterface-site-pages-validation.md index b312bd2131..177941e66d 100644 --- a/docs/transform/modernize-userinterface-site-pages-validation.md +++ b/docs/transform/modernize-userinterface-site-pages-validation.md @@ -100,5 +100,6 @@ The script blocks expansion if a representative result is missing, stale, not pa ## Reference - [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) +- [Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) - [Classic pages assessment CSV reference](assessment-tool-classic-pages-csv.md) - [Classic Web Part mappings](modernize-userinterface-site-pages-webparts.md) diff --git a/docs/transform/modernize-userinterface-site-pages-wave-scripts.md b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md new file mode 100644 index 0000000000..5267f0aa36 --- /dev/null +++ b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md @@ -0,0 +1,38 @@ +--- +title: Run Assessment page wave scripts +description: Save and run the embedded PnP PowerShell scripts for representative and user-approved classic page transformation waves. +ms.date: 07/28/2026 +ms.localizationpriority: high +ms.service: sharepoint +--- + +# Run Assessment page wave scripts + +Use these scripts with the representative-page manifest created from the Classic Pages Assessment output. Save all three files in the same folder. + +The scripts support Wiki and Web Part pages in the default `SitePages` library. They create draft modern pages and exclude home pages, zero-part pages, modified sources, and pages with unique permissions. + +## Script files + +| File | Purpose | +| --- | --- | +| `PageTransformation.Common.ps1` | Shared authentication, source validation, transformation, and result persistence. | +| `Convert-RepresentativePages.ps1` | Transforms all selected representative pages and writes validation-pending results. | +| `Convert-SelectedPages.ps1` | Transforms additional user-approved pages only after every included pattern has a passed representative. | + +## PageTransformation.Common.ps1 + +[!code-powershell[page-transformation-common](../../includes/scripts/page-transformation/PageTransformation.Common.ps1 "Shared page wave functions")] + +## Convert-RepresentativePages.ps1 + +[!code-powershell[convert-representative-pages](../../includes/scripts/page-transformation/Convert-RepresentativePages.ps1 "Transform all selected representative pages")] + +## Convert-SelectedPages.ps1 + +[!code-powershell[convert-selected-pages](../../includes/scripts/page-transformation/Convert-SelectedPages.ps1 "Transform all additional user-approved pages")] + +## Next steps + +1. [Build representative page groups and run the scripts](modernize-userinterface-site-pages-powershell.md#build-representative-page-groups). +1. [Validate every transformed page](modernize-userinterface-site-pages-validation.md). diff --git a/docs/transform/modernize-userinterface-site-pages.md b/docs/transform/modernize-userinterface-site-pages.md index e80d47f070..f11a0b6902 100644 --- a/docs/transform/modernize-userinterface-site-pages.md +++ b/docs/transform/modernize-userinterface-site-pages.md @@ -101,7 +101,7 @@ For the first wave: Only enable source-renaming or overwrite behavior after the generated pages have been approved and a rollback plan exists. -Use `Convert-RepresentativePages.ps1` to process every approved representative page through the same safety and result contract. +Use the embedded [Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) to process every approved representative page through the same safety and result contract. ## 5. Validate and expand diff --git a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 new file mode 100644 index 0000000000..2631855ca9 --- /dev/null +++ b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 @@ -0,0 +1,178 @@ +<# +.SYNOPSIS +Transforms all approved representative Wiki and Web Part pages from an Assessment manifest. + +.DESCRIPTION +Requires one or more rows marked Selected=true for every IncludePattern=true PatternKey. +Generated pages remain drafts and source pages aren't renamed or overwritten. + +.EXAMPLE +.\Convert-RepresentativePages.ps1 ` + -ManifestPath .\representative-page-groups.csv ` + -ClientId "" ` + -AuthenticationMode Interactive ` + -Confirm + +.EXAMPLE +.\Convert-RepresentativePages.ps1 ` + -ManifestPath .\representative-page-groups.csv ` + -ClientId "" ` + -AuthenticationMode CertificateThumbprint ` + -Tenant "contoso.onmicrosoft.com" ` + -Thumbprint "" ` + -Confirm:$false +#> + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory = $true)] + [string]$ManifestPath, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $false)] + [ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')] + [string]$AuthenticationMode = 'Interactive', + + [Parameter(Mandatory = $false)] + [string]$Tenant, + + [Parameter(Mandatory = $false)] + [string]$Thumbprint, + + [Parameter(Mandatory = $false)] + [string]$CertificatePath, + + [Parameter(Mandatory = $false)] + [securestring]$CertificatePassword, + + [Parameter(Mandatory = $false)] + [string]$AzureEnvironment = 'Production', + + [Parameter(Mandatory = $false)] + [string]$ResultPath, + + [Parameter(Mandatory = $false)] + [string]$LogFolder, + + [Parameter(Mandatory = $false)] + [string]$WebPartMappingFile, + + [Parameter(Mandatory = $false)] + [switch]$AllowModifiedPages, + + [Parameter(Mandatory = $false)] + [switch]$Force +) + +Set-StrictMode -Version Latest +. "$PSScriptRoot\PageTransformation.Common.ps1" + +if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + throw "Manifest not found: $ManifestPath" +} + +$ManifestPath = (Resolve-Path -LiteralPath $ManifestPath).Path +$manifest = @(Import-Csv -LiteralPath $ManifestPath) +if ($manifest.Count -eq 0) { + throw "The representative manifest is empty." +} + +foreach ($column in @('PatternKey', 'IncludePattern', 'Selected', 'ExpectedVisibleContent', 'ValidationOwner')) { + if ($manifest[0].PSObject.Properties.Name -notcontains $column) { + throw "The representative manifest requires a $column column." + } +} + +$includedRows = @( + $manifest | + Where-Object { + ConvertTo-PageWaveBoolean ` + -Value $_.IncludePattern ` + -Name "IncludePattern for $($_.PageUrl)" + } +) +$selectedRows = @( + $includedRows | + Where-Object { + ConvertTo-PageWaveBoolean ` + -Value $_.Selected ` + -Name "Selected for $($_.PageUrl)" + } +) + +if ($includedRows.Count -eq 0) { + throw "No patterns are marked IncludePattern=true." +} +if ($selectedRows.Count -eq 0) { + throw "No pages are marked Selected=true." +} + +$includedPatterns = @($includedRows.PatternKey | Sort-Object -Unique) +$selectedPatterns = @($selectedRows.PatternKey | Sort-Object -Unique) +$missingPatterns = @($includedPatterns | Where-Object { $_ -notin $selectedPatterns }) +if ($missingPatterns.Count -gt 0) { + throw "Select at least one representative page for each included pattern: $($missingPatterns -join ', ')" +} + +foreach ($row in $selectedRows) { + if ([string]::IsNullOrWhiteSpace($row.PatternKey)) { + throw "Every selected representative page needs PatternKey: $($row.PageUrl)" + } + if ([string]::IsNullOrWhiteSpace($row.ExpectedVisibleContent) -or + [string]::IsNullOrWhiteSpace($row.ValidationOwner)) { + throw "Every selected representative page needs ExpectedVisibleContent and ValidationOwner: $($row.PageUrl)" + } +} + +$manifestFolder = Split-Path -Parent $ManifestPath +if ([string]::IsNullOrWhiteSpace($ResultPath)) { + $resultName = if ($WhatIfPreference) { + 'representative-page-preview.csv' + } + else { + 'representative-page-results.csv' + } + $ResultPath = Join-Path $manifestFolder $resultName +} +if ([string]::IsNullOrWhiteSpace($LogFolder)) { + $LogFolder = Join-Path $manifestFolder 'representative-page-logs' +} + +$entryCmdlet = $PSCmdlet +$shouldProcess = { + param($target, $action) + $entryCmdlet.ShouldProcess($target, $action) +}.GetNewClosure() +Test-PageWaveAuthentication ` + -AuthenticationMode $AuthenticationMode ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath +Test-AssessmentPageWaveRows -Rows $selectedRows +$transformationProfile = Get-PageWaveTransformationProfile -WebPartMappingFile $WebPartMappingFile +$resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force +$ResultPath = $resultWriter.Path + +$results = Invoke-AssessmentPageWave ` + -Rows $selectedRows ` + -ClientId $ClientId ` + -AuthenticationMode $AuthenticationMode ` + -ShouldProcessCallback $shouldProcess ` + -ResultWriter $resultWriter.Write ` + -TransformationProfile $transformationProfile ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath ` + -CertificatePassword $CertificatePassword ` + -AzureEnvironment $AzureEnvironment ` + -LogFolder $LogFolder ` + -AllowModifiedPages:$AllowModifiedPages + +$failed = @($results | Where-Object TransformationStatus -eq 'Failed') +if ($failed.Count -gt 0) { + throw "$($failed.Count) representative page transformations failed. Review $ResultPath." +} + +Write-Host "Representative page results: $ResultPath" -ForegroundColor Green diff --git a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 new file mode 100644 index 0000000000..4e9cb25c77 --- /dev/null +++ b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 @@ -0,0 +1,254 @@ +<# +.SYNOPSIS +Transforms all user-selected Wiki and Web Part pages after representative validation passes. + +.DESCRIPTION +Refuses to expand the wave unless every representative result has TransformationStatus=Created +and ValidationStatus=Passed. Generated pages remain drafts. + +.EXAMPLE +.\Convert-SelectedPages.ps1 ` + -PagesPath .\approved-pages.csv ` + -RepresentativeManifestPath .\representative-page-groups.csv ` + -RepresentativeResultsPath .\representative-page-results.csv ` + -ClientId "" ` + -AuthenticationMode Interactive ` + -Confirm + +.EXAMPLE +.\Convert-SelectedPages.ps1 ` + -PagesPath .\approved-pages.csv ` + -RepresentativeManifestPath .\representative-page-groups.csv ` + -RepresentativeResultsPath .\representative-page-results.csv ` + -ClientId "" ` + -AuthenticationMode CertificateFile ` + -Tenant "contoso.onmicrosoft.com" ` + -CertificatePath .\page-transformation.pfx ` + -CertificatePassword (Read-Host "Certificate password" -AsSecureString) ` + -Confirm:$false +#> + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory = $true)] + [string]$PagesPath, + + [Parameter(Mandatory = $true)] + [string]$RepresentativeResultsPath, + + [Parameter(Mandatory = $true)] + [string]$RepresentativeManifestPath, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $false)] + [ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')] + [string]$AuthenticationMode = 'Interactive', + + [Parameter(Mandatory = $false)] + [string]$Tenant, + + [Parameter(Mandatory = $false)] + [string]$Thumbprint, + + [Parameter(Mandatory = $false)] + [string]$CertificatePath, + + [Parameter(Mandatory = $false)] + [securestring]$CertificatePassword, + + [Parameter(Mandatory = $false)] + [string]$AzureEnvironment = 'Production', + + [Parameter(Mandatory = $false)] + [string]$ResultPath, + + [Parameter(Mandatory = $false)] + [string]$LogFolder, + + [Parameter(Mandatory = $false)] + [string]$WebPartMappingFile, + + [Parameter(Mandatory = $false)] + [switch]$AllowModifiedPages, + + [Parameter(Mandatory = $false)] + [switch]$Force +) + +Set-StrictMode -Version Latest +. "$PSScriptRoot\PageTransformation.Common.ps1" + +foreach ($path in @($PagesPath, $RepresentativeResultsPath, $RepresentativeManifestPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Input file not found: $path" + } +} + +$PagesPath = (Resolve-Path -LiteralPath $PagesPath).Path +$RepresentativeResultsPath = (Resolve-Path -LiteralPath $RepresentativeResultsPath).Path +$RepresentativeManifestPath = (Resolve-Path -LiteralPath $RepresentativeManifestPath).Path +$representativeManifest = @(Import-Csv -LiteralPath $RepresentativeManifestPath) +$representativeResults = @(Import-Csv -LiteralPath $RepresentativeResultsPath) +if ($representativeManifest.Count -eq 0 -or $representativeResults.Count -eq 0) { + throw "The representative manifest and results files must both contain rows." +} + +$includedRepresentativeRows = @( + $representativeManifest | + Where-Object { + ConvertTo-PageWaveBoolean -Value $_.IncludePattern -Name "IncludePattern for $($_.PageUrl)" + } +) +$representativeRows = @( + $includedRepresentativeRows | + Where-Object { + ConvertTo-PageWaveBoolean -Value $_.Selected -Name "Selected for $($_.PageUrl)" + } +) +if ($representativeRows.Count -eq 0) { + throw "The representative manifest has no included selected rows." +} + +$transformationProfile = Get-PageWaveTransformationProfile -WebPartMappingFile $WebPartMappingFile +$includedPatterns = @($includedRepresentativeRows.PatternKey | Sort-Object -Unique) +$selectedPatterns = @($representativeRows.PatternKey | Sort-Object -Unique) +$patternsWithoutRepresentative = @( + $includedPatterns | + Where-Object { $_ -notin $selectedPatterns } +) +if ($patternsWithoutRepresentative.Count -gt 0) { + throw "Every included pattern needs a selected representative: $($patternsWithoutRepresentative -join ', ')" +} + +$representativeRowsByKey = @{} +foreach ($row in $representativeRows) { + $key = Get-PageWaveKey -Row $row + if ($representativeRowsByKey.ContainsKey($key)) { + throw "Duplicate representative manifest row: $($row.PageUrl)" + } + $representativeRowsByKey[$key] = $row +} + +$representativeResultsByKey = @{} +foreach ($result in $representativeResults) { + $key = Get-PageWaveKey -Row $result + if ($representativeResultsByKey.ContainsKey($key)) { + throw "Duplicate representative result row: $($result.PageUrl)" + } + $representativeResultsByKey[$key] = $result +} + +if ($representativeRowsByKey.Count -ne $representativeResultsByKey.Count) { + throw "Representative manifest and result row counts don't match." +} + +$passedPatterns = @{} +foreach ($key in $representativeRowsByKey.Keys) { + if (-not $representativeResultsByKey.ContainsKey($key)) { + throw "Representative result is missing for $($representativeRowsByKey[$key].PageUrl)." + } + + $manifestRow = $representativeRowsByKey[$key] + $resultRow = $representativeResultsByKey[$key] + if ($resultRow.ManifestRowHash -ne (Get-PageWaveManifestHash -Row $manifestRow)) { + throw "Representative result doesn't match the current manifest row: $($manifestRow.PageUrl)" + } + if ($resultRow.TransformationProfileHash -ne $transformationProfile.Hash) { + throw "Representative results used a different PnP version or Web Part mapping: $($manifestRow.PageUrl)" + } + if ($resultRow.TransformationStatus -ne 'Created' -or + $resultRow.ValidationStatus -ne 'Passed') { + throw "Every representative page must be Created and validated as Passed before expanding the wave: $($manifestRow.PageUrl)" + } + + $passedPatterns[$manifestRow.PatternKey] = $true +} + +$pages = @(Import-Csv -LiteralPath $PagesPath) +if ($pages.Count -eq 0) { + throw "The approved pages file is empty." +} +foreach ($column in @('ExpectedVisibleContent', 'ValidationOwner')) { + if ($pages[0].PSObject.Properties.Name -notcontains $column) { + throw "The approved pages file requires a $column column." + } +} +foreach ($row in $pages) { + if ([string]::IsNullOrWhiteSpace($row.ExpectedVisibleContent) -or + [string]::IsNullOrWhiteSpace($row.ValidationOwner)) { + throw "Every approved page needs ExpectedVisibleContent and ValidationOwner: $($row.PageUrl)" + } + if ([string]::IsNullOrWhiteSpace($row.PatternKey) -or + -not $passedPatterns.ContainsKey($row.PatternKey)) { + throw "Page pattern doesn't have a passed representative: $($row.PageUrl)" + } +} + +$representativeKeys = @{} +foreach ($row in $representativeResults) { + $representativeKeys["$($row.ScanId)|$($row.SiteUrl)|$($row.WebUrl)|$($row.PageUrl)"] = $true +} + +$pagesToTransform = @( + $pages | + Where-Object { + $key = "$($_.ScanId)|$($_.SiteUrl)|$($_.WebUrl)|$($_.PageUrl)" + -not $representativeKeys.ContainsKey($key) + } +) + +if ($pagesToTransform.Count -eq 0) { + throw "No additional pages remain after excluding validated representative pages." +} + +$pagesFolder = Split-Path -Parent $PagesPath +if ([string]::IsNullOrWhiteSpace($ResultPath)) { + $resultName = if ($WhatIfPreference) { + 'selected-page-preview.csv' + } + else { + 'selected-page-results.csv' + } + $ResultPath = Join-Path $pagesFolder $resultName +} +if ([string]::IsNullOrWhiteSpace($LogFolder)) { + $LogFolder = Join-Path $pagesFolder 'selected-page-logs' +} + +$entryCmdlet = $PSCmdlet +$shouldProcess = { + param($target, $action) + $entryCmdlet.ShouldProcess($target, $action) +}.GetNewClosure() +Test-PageWaveAuthentication ` + -AuthenticationMode $AuthenticationMode ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath +Test-AssessmentPageWaveRows -Rows $pagesToTransform +$resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force +$ResultPath = $resultWriter.Path + +$results = Invoke-AssessmentPageWave ` + -Rows $pagesToTransform ` + -ClientId $ClientId ` + -AuthenticationMode $AuthenticationMode ` + -ShouldProcessCallback $shouldProcess ` + -ResultWriter $resultWriter.Write ` + -TransformationProfile $transformationProfile ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath ` + -CertificatePassword $CertificatePassword ` + -AzureEnvironment $AzureEnvironment ` + -LogFolder $LogFolder ` + -AllowModifiedPages:$AllowModifiedPages + +$failed = @($results | Where-Object TransformationStatus -eq 'Failed') +if ($failed.Count -gt 0) { + throw "$($failed.Count) selected page transformations failed. Review $ResultPath." +} + +Write-Host "Selected page results: $ResultPath" -ForegroundColor Green diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 new file mode 100644 index 0000000000..dc96852f65 --- /dev/null +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -0,0 +1,721 @@ +Set-StrictMode -Version Latest + +function ConvertTo-PageWaveBoolean { + param( + [Parameter(Mandatory = $true)] + [object]$Value, + + [Parameter(Mandatory = $false)] + [string]$Name = 'Boolean value' + ) + + if ($Value -is [bool]) { + return $Value + } + + $text = [string]$Value + if ([string]::IsNullOrWhiteSpace($text)) { + throw "$Name can't be empty." + } + + switch ($text.Trim().ToLowerInvariant()) { + { $_ -in @('true', 'yes', '1') } { return $true } + { $_ -in @('false', 'no', '0') } { return $false } + default { throw "$Name must be True or False. Received '$text'." } + } +} + +function ConvertTo-PageWaveInteger { + param( + [Parameter(Mandatory = $true)] + [object]$Value, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + [int]$Minimum = 0, + + [Parameter(Mandatory = $false)] + [int]$Maximum = [int]::MaxValue + ) + + $parsed = 0 + if (-not [int]::TryParse([string]$Value, [ref]$parsed) -or + $parsed -lt $Minimum -or + $parsed -gt $Maximum) { + throw "$Name must be an integer from $Minimum through $Maximum. Received '$Value'." + } + + return $parsed +} + +function Get-PageWaveSourceUrl { + param( + [Parameter(Mandatory = $true)] + [object]$Row + ) + + if ([string]::IsNullOrWhiteSpace($Row.SiteUrl) -or + [string]::IsNullOrWhiteSpace($Row.WebUrl)) { + throw "SiteUrl and WebUrl are required for $($Row.PageUrl)." + } + + if ($Row.WebUrl -eq '/') { + return $Row.SiteUrl.TrimEnd('/') + } + + return "$($Row.SiteUrl.TrimEnd('/'))$($Row.WebUrl)" +} + +function Get-PageWaveValue { + param( + [Parameter(Mandatory = $true)] + [object]$Row, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Row.PSObject.Properties.Name -contains $Name) { + return $Row.$Name + } + + return '' +} + +function Get-PageWaveKey { + param( + [Parameter(Mandatory = $true)] + [object]$Row + ) + + return ( + '{0}|{1}|{2}|{3}' -f + (Get-PageWaveValue -Row $Row -Name 'ScanId'), + (Get-PageWaveValue -Row $Row -Name 'SiteUrl'), + (Get-PageWaveValue -Row $Row -Name 'WebUrl'), + (Get-PageWaveValue -Row $Row -Name 'PageUrl') + ).ToLowerInvariant() +} + +function Get-PageWaveManifestHash { + param( + [Parameter(Mandatory = $true)] + [object]$Row + ) + + $fields = @( + 'ScanId', + 'SiteUrl', + 'WebUrl', + 'PageUrl', + 'PageType', + 'ListUrl', + 'ListTitle', + 'ListId', + 'ModifiedAt', + 'Layout', + 'HomePage', + 'WebPartCount', + 'MappingPercentage', + 'UnmappedWebParts', + 'WebPartSignature', + 'PatternKey', + 'IncludePattern', + 'Selected', + 'ExpectedVisibleContent', + 'ValidationOwner' + ) + $payload = ($fields | ForEach-Object { [string](Get-PageWaveValue -Row $Row -Name $_) }) -join [char]0x1f + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [Convert]::ToHexString( + $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload)) + ).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +function Get-PageWaveTransformationProfile { + param( + [Parameter(Mandatory = $false)] + [string]$WebPartMappingFile + ) + + Import-Module PnP.PowerShell -ErrorAction Stop + $module = Get-Module PnP.PowerShell | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $module) { + throw "PnP.PowerShell isn't loaded." + } + + $mappingHash = 'embedded-default' + if (-not [string]::IsNullOrWhiteSpace($WebPartMappingFile)) { + if (-not (Test-Path -LiteralPath $WebPartMappingFile -PathType Leaf)) { + throw "Web Part mapping file not found: $WebPartMappingFile" + } + $WebPartMappingFile = (Resolve-Path -LiteralPath $WebPartMappingFile).Path + $mappingHash = (Get-FileHash -LiteralPath $WebPartMappingFile -Algorithm SHA256).Hash.ToLowerInvariant() + } + + $scriptVersion = '1.0.0' + $payload = "Script=$scriptVersion|PnP=$($module.Version)|Mapping=$mappingHash|Draft=True|UniquePermissions=Excluded" + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $profileHash = [Convert]::ToHexString( + $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload)) + ).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } + + return [pscustomobject]@{ + Hash = $profileHash + ScriptVersion = $scriptVersion + PnPPowerShellVersion = $module.Version.ToString() + WebPartMappingHash = $mappingHash + WebPartMappingFile = $WebPartMappingFile + } +} + +function Test-PageWaveAuthentication { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')] + [string]$AuthenticationMode, + + [Parameter(Mandatory = $false)] + [string]$Tenant, + + [Parameter(Mandatory = $false)] + [string]$Thumbprint, + + [Parameter(Mandatory = $false)] + [string]$CertificatePath + ) + + switch ($AuthenticationMode) { + 'DeviceLogin' { + if ([string]::IsNullOrWhiteSpace($Tenant)) { + throw "Tenant is required for DeviceLogin authentication." + } + } + 'CertificateThumbprint' { + if ([string]::IsNullOrWhiteSpace($Tenant) -or + [string]::IsNullOrWhiteSpace($Thumbprint)) { + throw "Tenant and Thumbprint are required for CertificateThumbprint authentication." + } + } + 'CertificateFile' { + if ([string]::IsNullOrWhiteSpace($Tenant) -or + [string]::IsNullOrWhiteSpace($CertificatePath)) { + throw "Tenant and CertificatePath are required for CertificateFile authentication." + } + if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) { + throw "Certificate file not found: $CertificatePath" + } + } + } +} + +function New-PageWaveResultWriter { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $fullPath = if ([IO.Path]::IsPathRooted($Path)) { + [IO.Path]::GetFullPath($Path) + } + else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path)) + } + $folder = Split-Path -Parent $fullPath + if ([string]::IsNullOrWhiteSpace($folder)) { + $folder = (Get-Location).Path + } + New-Item -ItemType Directory -Path $folder -Force -WhatIf:$false | Out-Null + + if ((Test-Path -LiteralPath $fullPath) -and -not $Force) { + throw "Result file already exists. Use -Force to replace it: $fullPath" + } + + $fileMode = if ($Force) { [IO.FileMode]::Create } else { [IO.FileMode]::CreateNew } + $stream = [IO.File]::Open($fullPath, $fileMode, [IO.FileAccess]::Write, [IO.FileShare]::None) + $stream.Dispose() + + $state = [pscustomobject]@{ First = $true } + $writer = { + param($Row) + + if ($state.First) { + $Row | Export-Csv -LiteralPath $fullPath -NoTypeInformation -Force -ErrorAction Stop -WhatIf:$false + $state.First = $false + } + else { + $Row | Export-Csv -LiteralPath $fullPath -NoTypeInformation -Append -ErrorAction Stop -WhatIf:$false + } + }.GetNewClosure() + + return [pscustomobject]@{ + Path = $fullPath + Write = $writer + } +} + +function New-PageWaveConnection { + param( + [Parameter(Mandatory = $true)] + [string]$Url, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')] + [string]$AuthenticationMode, + + [Parameter(Mandatory = $false)] + [string]$Tenant, + + [Parameter(Mandatory = $false)] + [string]$Thumbprint, + + [Parameter(Mandatory = $false)] + [string]$CertificatePath, + + [Parameter(Mandatory = $false)] + [securestring]$CertificatePassword, + + [Parameter(Mandatory = $false)] + [string]$AzureEnvironment = 'Production' + ) + + $parameters = @{ + Url = $Url + ClientId = $ClientId + ReturnConnection = $true + AzureEnvironment = $AzureEnvironment + } + + switch ($AuthenticationMode) { + 'Interactive' { + $parameters.Interactive = $true + } + 'DeviceLogin' { + if ([string]::IsNullOrWhiteSpace($Tenant)) { + throw "Tenant is required for DeviceLogin authentication." + } + + $parameters.DeviceLogin = $true + $parameters.Tenant = $Tenant + } + 'CertificateThumbprint' { + if ([string]::IsNullOrWhiteSpace($Tenant) -or + [string]::IsNullOrWhiteSpace($Thumbprint)) { + throw "Tenant and Thumbprint are required for CertificateThumbprint authentication." + } + + $parameters.Tenant = $Tenant + $parameters.Thumbprint = $Thumbprint + } + 'CertificateFile' { + if ([string]::IsNullOrWhiteSpace($Tenant) -or + [string]::IsNullOrWhiteSpace($CertificatePath)) { + throw "Tenant and CertificatePath are required for CertificateFile authentication." + } + if (-not (Test-Path -LiteralPath $CertificatePath -PathType Leaf)) { + throw "Certificate file not found: $CertificatePath" + } + + $parameters.Tenant = $Tenant + $parameters.CertificatePath = (Resolve-Path -LiteralPath $CertificatePath).Path + if ($CertificatePassword) { + $parameters.CertificatePassword = $CertificatePassword + } + } + } + + Connect-PnPOnline @parameters +} + +function Test-AssessmentPageWaveRows { + param( + [Parameter(Mandatory = $true)] + [object[]]$Rows + ) + + $requiredProperties = @( + 'ScanId', + 'SiteUrl', + 'WebUrl', + 'PageUrl', + 'PageType', + 'ListUrl', + 'ListId', + 'ModifiedAt', + 'HomePage', + 'WebPartCount', + 'MappingPercentage', + 'UnmappedWebParts' + ) + $requiredValues = @( + 'ScanId', + 'SiteUrl', + 'WebUrl', + 'PageUrl', + 'PageType', + 'ListUrl', + 'ListId', + 'ModifiedAt' + ) + $keys = @{} + + foreach ($row in $Rows) { + foreach ($requiredProperty in $requiredProperties) { + if ($row.PSObject.Properties.Name -notcontains $requiredProperty) { + throw "$requiredProperty is required for every manifest row." + } + } + foreach ($requiredValue in $requiredValues) { + if ([string]::IsNullOrWhiteSpace($row.$requiredValue)) { + throw "$requiredValue can't be empty." + } + } + + $listId = [guid]::Empty + if (-not [guid]::TryParse([string]$row.ListId, [ref]$listId)) { + throw "ListId must be a GUID: $($row.ListId)" + } + + $modifiedAt = [datetime]::MinValue + if (-not [datetime]::TryParseExact( + [string]$row.ModifiedAt, + 'MM/dd/yyyy HH:mm:ss', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None, + [ref]$modifiedAt + )) { + throw "ModifiedAt must use MM/dd/yyyy HH:mm:ss: $($row.ModifiedAt)" + } + + ConvertTo-PageWaveBoolean -Value $row.HomePage -Name "HomePage for $($row.PageUrl)" | Out-Null + ConvertTo-PageWaveInteger -Value $row.WebPartCount -Name "WebPartCount for $($row.PageUrl)" | Out-Null + ConvertTo-PageWaveInteger ` + -Value $row.MappingPercentage ` + -Name "MappingPercentage for $($row.PageUrl)" ` + -Maximum 100 | Out-Null + + $key = Get-PageWaveKey -Row $row + if ($keys.ContainsKey($key)) { + throw "Duplicate manifest row: $($row.PageUrl)" + } + $keys[$key] = $true + } +} + +function Invoke-AssessmentPageWave { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object[]]$Rows, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [ValidateSet('Interactive', 'DeviceLogin', 'CertificateThumbprint', 'CertificateFile')] + [string]$AuthenticationMode, + + [Parameter(Mandatory = $true)] + [scriptblock]$ShouldProcessCallback, + + [Parameter(Mandatory = $true)] + [scriptblock]$ResultWriter, + + [Parameter(Mandatory = $true)] + [object]$TransformationProfile, + + [Parameter(Mandatory = $false)] + [string]$Tenant, + + [Parameter(Mandatory = $false)] + [string]$Thumbprint, + + [Parameter(Mandatory = $false)] + [string]$CertificatePath, + + [Parameter(Mandatory = $false)] + [securestring]$CertificatePassword, + + [Parameter(Mandatory = $false)] + [string]$AzureEnvironment = 'Production', + + [Parameter(Mandatory = $false)] + [string]$LogFolder = (Join-Path (Get-Location) 'page-transformation-logs'), + + [Parameter(Mandatory = $false)] + [switch]$AllowModifiedPages + ) + + if ($PSVersionTable.PSVersion -lt [version]'7.4.0') { + throw "PnP PowerShell requires PowerShell 7.4.0 or later." + } + + Test-PageWaveAuthentication ` + -AuthenticationMode $AuthenticationMode ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath + Test-AssessmentPageWaveRows -Rows $Rows + + Import-Module PnP.PowerShell -ErrorAction Stop + $WebPartMappingFile = $TransformationProfile.WebPartMappingFile + + if (Test-Path -LiteralPath $LogFolder -PathType Container) { + $LogFolder = (Resolve-Path -LiteralPath $LogFolder).Path + } + else { + $LogFolder = if ([IO.Path]::IsPathRooted($LogFolder)) { + [IO.Path]::GetFullPath($LogFolder) + } + else { + [IO.Path]::GetFullPath((Join-Path (Get-Location).Path $LogFolder)) + } + if (-not $WhatIfPreference) { + New-Item -ItemType Directory -Path $LogFolder -Force | Out-Null + } + } + + $connections = @{} + $connectionErrors = @{} + $webs = @{} + $results = [Collections.Generic.List[object]]::new() + + foreach ($row in $Rows) { + $startedAt = Get-Date + $sourceWebUrl = $null + $targetPageUrl = $null + $status = 'Failed' + $errorMessage = $null + $sourceItemId = $null + $sourceUniqueId = $null + $observedListId = $null + $observedModifiedAt = $null + + try { + if ($row.PageType -notin @('WikiPage', 'WebPartPage')) { + throw "Page type '$($row.PageType)' isn't supported by these Wiki/Web Part wave scripts." + } + if (ConvertTo-PageWaveBoolean -Value $row.HomePage -Name "HomePage for $($row.PageUrl)") { + throw "Home pages require a separate approved migration path: $($row.PageUrl)" + } + $webPartCount = ConvertTo-PageWaveInteger ` + -Value $row.WebPartCount ` + -Name "WebPartCount for $($row.PageUrl)" + $mappingPercentage = ConvertTo-PageWaveInteger ` + -Value $row.MappingPercentage ` + -Name "MappingPercentage for $($row.PageUrl)" ` + -Maximum 100 + + if ($webPartCount -eq 0) { + throw "Zero-part pages are excluded from these batch scripts: $($row.PageUrl)" + } + if ([string]::IsNullOrWhiteSpace($WebPartMappingFile) -and + ($mappingPercentage -ne 100 -or + -not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts))) { + throw "Resolve unmapped Web Parts or provide WebPartMappingFile: $($row.PageUrl)" + } + + $sourceWebUrl = Get-PageWaveSourceUrl -Row $row + $action = "Create a draft modern page from $($row.PageUrl)" + if (-not (& $ShouldProcessCallback $sourceWebUrl $action)) { + $status = 'Skipped' + $errorMessage = "The operation wasn't approved or was run with -WhatIf." + continue + } + + if ($connectionErrors.ContainsKey($sourceWebUrl)) { + throw $connectionErrors[$sourceWebUrl] + } + if (-not $connections.ContainsKey($sourceWebUrl)) { + try { + $connections[$sourceWebUrl] = New-PageWaveConnection ` + -Url $sourceWebUrl ` + -ClientId $ClientId ` + -AuthenticationMode $AuthenticationMode ` + -Tenant $Tenant ` + -Thumbprint $Thumbprint ` + -CertificatePath $CertificatePath ` + -CertificatePassword $CertificatePassword ` + -AzureEnvironment $AzureEnvironment + } + catch { + $connectionErrors[$sourceWebUrl] = $_.Exception.Message + throw + } + } + $connection = $connections[$sourceWebUrl] + + if (-not $webs.ContainsKey($sourceWebUrl)) { + $webs[$sourceWebUrl] = Get-PnPWeb ` + -Includes WelcomePage, ServerRelativeUrl ` + -Connection $connection + } + $web = $webs[$sourceWebUrl] + if (-not [string]::IsNullOrWhiteSpace($web.WelcomePage)) { + $currentHomePageUrl = ( + '{0}/{1}' -f + $web.ServerRelativeUrl.TrimEnd('/'), + $web.WelcomePage.TrimStart('/') + ).Replace('//', '/') + if ($currentHomePageUrl.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) { + throw "The page is currently the web home page and is excluded: $($row.PageUrl)" + } + } + + $libraryPath = $row.ListUrl.TrimEnd('/') + if (-not $row.PageUrl.StartsWith("$libraryPath/", [StringComparison]::OrdinalIgnoreCase)) { + throw "PageUrl isn't under ListUrl: $($row.PageUrl)" + } + + $sourceList = Get-PnPList ` + -Identity ([guid]$row.ListId) ` + -ThrowExceptionIfListNotFound ` + -Connection $connection + $observedListId = $sourceList.Id.ToString() + if ($sourceList.Id -ne [guid]$row.ListId) { + throw "ListId changed for $($row.PageUrl). Rerun Assessment before transforming this page." + } + + Get-PnPProperty -ClientObject $sourceList -Property RootFolder -Connection $connection | Out-Null + Get-PnPProperty ` + -ClientObject $sourceList.RootFolder ` + -Property ServerRelativeUrl ` + -Connection $connection | Out-Null + if (-not $sourceList.RootFolder.ServerRelativeUrl.Equals( + $libraryPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "ListUrl changed for $($row.PageUrl). Rerun Assessment before transforming this page." + } + + $sourceItem = Get-PnPFile ` + -Url $row.PageUrl ` + -AsListItem ` + -ThrowExceptionIfFileNotFound ` + -Connection $connection + $sourceItemId = $sourceItem.Id + + Get-PnPProperty -ClientObject $sourceItem -Property ParentList -Connection $connection | Out-Null + Get-PnPProperty -ClientObject $sourceItem.ParentList -Property Id -Connection $connection | Out-Null + if ($sourceItem.ParentList.Id -ne $sourceList.Id) { + throw "The approved file no longer belongs to the assessed list: $($row.PageUrl)" + } + + $observedFileRef = [string]$sourceItem.FieldValues['FileRef'] + if (-not $observedFileRef.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) { + throw "The current file URL doesn't match the approved PageUrl: $($row.PageUrl)" + } + + $sourceUniqueId = [string]$sourceItem.FieldValues['UniqueId'] + $observedModified = [datetime]$sourceItem.FieldValues['Modified'] + $observedModifiedAt = $observedModified.ToString( + 'MM/dd/yyyy HH:mm:ss', + [Globalization.CultureInfo]::InvariantCulture + ) + if (-not $AllowModifiedPages -and $observedModifiedAt -ne $row.ModifiedAt) { + throw "The source was modified after Assessment. Rerun Assessment or use -AllowModifiedPages after reapproval: $($row.PageUrl)" + } + + $hasUniquePermissions = Get-PnPProperty ` + -ClientObject $sourceItem ` + -Property HasUniqueRoleAssignments ` + -Connection $connection + + if ($hasUniquePermissions) { + throw "The source has unique permissions and is excluded from these batch scripts: $($row.PageUrl)" + } + + $webServerRelativeUrl = $web.ServerRelativeUrl.TrimEnd('/') + $libraryRelativeUrl = if ([string]::IsNullOrWhiteSpace($webServerRelativeUrl)) { + $sourceList.RootFolder.ServerRelativeUrl.TrimStart('/') + } + else { + $sourceList.RootFolder.ServerRelativeUrl.Substring($webServerRelativeUrl.Length).TrimStart('/') + } + if (-not $libraryRelativeUrl.Equals('SitePages', [StringComparison]::OrdinalIgnoreCase)) { + throw "These batch scripts only support pages in the default SitePages library: $($row.PageUrl)" + } + + $parameters = @{ + Identity = $sourceItem.Id + Connection = $connection + DontPublish = $true + LogType = 'File' + LogFolder = $LogFolder + LogVerbose = $true + SkipItemLevelPermissionCopyToClientSidePage = $true + } + + if (-not [string]::IsNullOrWhiteSpace($WebPartMappingFile)) { + $parameters.WebPartMappingFile = $WebPartMappingFile + } + + $conversionOutput = @(ConvertTo-PnPPage @parameters) + $targetPageUrl = $conversionOutput | + Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Last 1 + + if ([string]::IsNullOrWhiteSpace($targetPageUrl)) { + throw "ConvertTo-PnPPage didn't return a target page URL." + } + + $status = 'Created' + } + catch { + $errorMessage = $_.Exception.Message + } + finally { + $resultRow = [pscustomobject][ordered]@{ + ManifestRowHash = Get-PageWaveManifestHash -Row $row + TransformationProfileHash = $TransformationProfile.Hash + ScriptVersion = $TransformationProfile.ScriptVersion + PnPPowerShellVersion = $TransformationProfile.PnPPowerShellVersion + WebPartMappingHash = $TransformationProfile.WebPartMappingHash + ScanId = Get-PageWaveValue -Row $row -Name 'ScanId' + SiteUrl = Get-PageWaveValue -Row $row -Name 'SiteUrl' + WebUrl = Get-PageWaveValue -Row $row -Name 'WebUrl' + PageUrl = Get-PageWaveValue -Row $row -Name 'PageUrl' + PageType = Get-PageWaveValue -Row $row -Name 'PageType' + Layout = Get-PageWaveValue -Row $row -Name 'Layout' + PatternKey = Get-PageWaveValue -Row $row -Name 'PatternKey' + ExpectedVisibleContent = Get-PageWaveValue -Row $row -Name 'ExpectedVisibleContent' + ValidationOwner = Get-PageWaveValue -Row $row -Name 'ValidationOwner' + SourceWebUrl = $sourceWebUrl + SourceItemId = $sourceItemId + SourceUniqueId = $sourceUniqueId + ObservedListId = $observedListId + ApprovedModifiedAt = Get-PageWaveValue -Row $row -Name 'ModifiedAt' + ObservedModifiedAt = $observedModifiedAt + TargetPageUrl = $targetPageUrl + TransformationStatus = $status + ValidationStatus = if ($status -eq 'Created') { 'Pending' } else { '' } + LogFolder = $LogFolder + StartedAt = $startedAt.ToString('o') + FinishedAt = (Get-Date).ToString('o') + Error = $errorMessage + } + $results.Add($resultRow) + & $ResultWriter $resultRow + } + } + + return $results +} From 111b7c95453926b315cf044bc2eec724b70ee8c6 Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 11:21:55 +0800 Subject: [PATCH 06/11] Add inline guidance to page wave scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- .../Convert-RepresentativePages.ps1 | 17 ++++++++ .../Convert-SelectedPages.ps1 | 18 ++++++++ .../PageTransformation.Common.ps1 | 43 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 index 2631855ca9..7c825f657d 100644 --- a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 +++ b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 @@ -67,8 +67,11 @@ param( ) Set-StrictMode -Version Latest +# Keep shared authentication, manifest validation, and conversion behavior identical +# between the representative and expanded-wave entry points. . "$PSScriptRoot\PageTransformation.Common.ps1" +# Resolve the manifest once so result and log defaults stay next to the reviewed input. if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw "Manifest not found: $ManifestPath" } @@ -85,6 +88,8 @@ foreach ($column in @('PatternKey', 'IncludePattern', 'Selected', 'ExpectedVisib } } +# IncludePattern defines the patterns in the planned migration. Selected identifies +# the concrete pages that must prove those patterns before the wave can expand. $includedRows = @( $manifest | Where-Object { @@ -109,6 +114,7 @@ if ($selectedRows.Count -eq 0) { throw "No pages are marked Selected=true." } +# A representative run is incomplete when any included pattern lacks a selected page. $includedPatterns = @($includedRows.PatternKey | Sort-Object -Unique) $selectedPatterns = @($selectedRows.PatternKey | Sort-Object -Unique) $missingPatterns = @($includedPatterns | Where-Object { $_ -notin $selectedPatterns }) @@ -126,6 +132,8 @@ foreach ($row in $selectedRows) { } } +# Preview runs use a separate result name. Existing evidence is never replaced unless +# the caller explicitly supplies -Force. $manifestFolder = Split-Path -Parent $ManifestPath if ([string]::IsNullOrWhiteSpace($ResultPath)) { $resultName = if ($WhatIfPreference) { @@ -140,6 +148,8 @@ if ([string]::IsNullOrWhiteSpace($LogFolder)) { $LogFolder = Join-Path $manifestFolder 'representative-page-logs' } +# Capture the entry script's High-impact ShouldProcess context. The shared engine calls +# this closure before it authenticates or reads SharePoint. $entryCmdlet = $PSCmdlet $shouldProcess = { param($target, $action) @@ -151,7 +161,13 @@ Test-PageWaveAuthentication ` -Thumbprint $Thumbprint ` -CertificatePath $CertificatePath Test-AssessmentPageWaveRows -Rows $selectedRows + +# The profile binds validation to the exact script version, PnP.PowerShell version, +# and Web Part mapping used during this representative run. $transformationProfile = Get-PageWaveTransformationProfile -WebPartMappingFile $WebPartMappingFile + +# Reserve the result file before the first SharePoint write, then append every result +# immediately so an interrupted wave retains completed evidence. $resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force $ResultPath = $resultWriter.Path @@ -170,6 +186,7 @@ $results = Invoke-AssessmentPageWave ` -LogFolder $LogFolder ` -AllowModifiedPages:$AllowModifiedPages +# The detailed per-page failures are already durable in the result CSV. $failed = @($results | Where-Object TransformationStatus -eq 'Failed') if ($failed.Count -gt 0) { throw "$($failed.Count) representative page transformations failed. Review $ResultPath." diff --git a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 index 4e9cb25c77..a673781383 100644 --- a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 +++ b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 @@ -78,8 +78,11 @@ param( ) Set-StrictMode -Version Latest +# Use the same source validation and conversion engine as the representative run. . "$PSScriptRoot\PageTransformation.Common.ps1" +# Expansion always requires all three artifacts: the pages to add, the original +# representative manifest, and the manually validated representative results. foreach ($path in @($PagesPath, $RepresentativeResultsPath, $RepresentativeManifestPath)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Input file not found: $path" @@ -95,6 +98,8 @@ if ($representativeManifest.Count -eq 0 -or $representativeResults.Count -eq 0) throw "The representative manifest and results files must both contain rows." } +# Rebuild the complete representative scope from the current manifest. A newly included +# pattern cannot be ignored simply because it has no result yet. $includedRepresentativeRows = @( $representativeManifest | Where-Object { @@ -122,6 +127,8 @@ if ($patternsWithoutRepresentative.Count -gt 0) { throw "Every included pattern needs a selected representative: $($patternsWithoutRepresentative -join ', ')" } +# Normalize manifest and result rows by Assessment identity, rejecting duplicates before +# any target page can be created. $representativeRowsByKey = @{} foreach ($row in $representativeRows) { $key = Get-PageWaveKey -Row $row @@ -144,6 +151,9 @@ if ($representativeRowsByKey.Count -ne $representativeResultsByKey.Count) { throw "Representative manifest and result row counts don't match." } +# Bind each approval to both the complete manifest row and the transformation profile. +# This blocks stale results and prevents a different PnP or mapping configuration from +# inheriting an earlier validation decision. $passedPatterns = @{} foreach ($key in $representativeRowsByKey.Keys) { if (-not $representativeResultsByKey.ContainsKey($key)) { @@ -186,6 +196,8 @@ foreach ($row in $pages) { } } +# Representative pages are already transformed and validated. Exclude them from the +# expanded wave even if the user accidentally leaves them in approved-pages.csv. $representativeKeys = @{} foreach ($row in $representativeResults) { $representativeKeys["$($row.ScanId)|$($row.SiteUrl)|$($row.WebUrl)|$($row.PageUrl)"] = $true @@ -203,6 +215,7 @@ if ($pagesToTransform.Count -eq 0) { throw "No additional pages remain after excluding validated representative pages." } +# Keep preview and execution evidence separate and refuse implicit result replacement. $pagesFolder = Split-Path -Parent $PagesPath if ([string]::IsNullOrWhiteSpace($ResultPath)) { $resultName = if ($WhatIfPreference) { @@ -217,6 +230,8 @@ if ([string]::IsNullOrWhiteSpace($LogFolder)) { $LogFolder = Join-Path $pagesFolder 'selected-page-logs' } +# Preserve the High-impact confirmation behavior of this entry script when the shared +# engine asks permission for each page. $entryCmdlet = $PSCmdlet $shouldProcess = { param($target, $action) @@ -228,6 +243,8 @@ Test-PageWaveAuthentication ` -Thumbprint $Thumbprint ` -CertificatePath $CertificatePath Test-AssessmentPageWaveRows -Rows $pagesToTransform + +# Persist each page result at the moment it finishes, rather than after the full wave. $resultWriter = New-PageWaveResultWriter -Path $ResultPath -Force:$Force $ResultPath = $resultWriter.Path @@ -246,6 +263,7 @@ $results = Invoke-AssessmentPageWave ` -LogFolder $LogFolder ` -AllowModifiedPages:$AllowModifiedPages +# Surface a failing process exit after every individual error has been written. $failed = @($results | Where-Object TransformationStatus -eq 'Failed') if ($failed.Count -gt 0) { throw "$($failed.Count) selected page transformations failed. Review $ResultPath." diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index dc96852f65..9cbf6bec0d 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -1,5 +1,7 @@ Set-StrictMode -Version Latest +# CSV values arrive as strings. Parse safety-critical values strictly instead of +# treating blanks or misspellings as False. function ConvertTo-PageWaveBoolean { param( [Parameter(Mandatory = $true)] @@ -25,6 +27,7 @@ function ConvertTo-PageWaveBoolean { } } +# Use bounded integer parsing for Assessment counts and readiness percentages. function ConvertTo-PageWaveInteger { param( [Parameter(Mandatory = $true)] @@ -50,6 +53,7 @@ function ConvertTo-PageWaveInteger { return $parsed } +# Reconstruct the exact web URL from the Assessment SiteUrl + WebUrl contract. function Get-PageWaveSourceUrl { param( [Parameter(Mandatory = $true)] @@ -68,6 +72,7 @@ function Get-PageWaveSourceUrl { return "$($Row.SiteUrl.TrimEnd('/'))$($Row.WebUrl)" } +# Read optional manifest/result properties without breaking StrictMode error handling. function Get-PageWaveValue { param( [Parameter(Mandatory = $true)] @@ -84,6 +89,7 @@ function Get-PageWaveValue { return '' } +# This key uniquely identifies one assessed page across scans, sites, and webs. function Get-PageWaveKey { param( [Parameter(Mandatory = $true)] @@ -99,6 +105,8 @@ function Get-PageWaveKey { ).ToLowerInvariant() } +# Hash every transformation-relevant manifest value. The expansion script uses this +# hash to prove that the validated representative row hasn't changed. function Get-PageWaveManifestHash { param( [Parameter(Mandatory = $true)] @@ -139,6 +147,8 @@ function Get-PageWaveManifestHash { } } +# Validation is meaningful only when the same scripts, PnP.PowerShell version, and Web +# Part mapping are used for both the representative and expanded waves. function Get-PageWaveTransformationProfile { param( [Parameter(Mandatory = $false)] @@ -181,6 +191,7 @@ function Get-PageWaveTransformationProfile { } } +# Validate unattended inputs before result files are reserved or page processing begins. function Test-PageWaveAuthentication { param( [Parameter(Mandatory = $true)] @@ -221,6 +232,8 @@ function Test-PageWaveAuthentication { } } +# Reserve the output path before the first SharePoint write. The returned callback +# appends one durable result row after every page attempt. function New-PageWaveResultWriter { param( [Parameter(Mandatory = $true)] @@ -269,6 +282,8 @@ function New-PageWaveResultWriter { } } +# Build a connection without logging secrets. Connections are cached by web by the +# calling engine. function New-PageWaveConnection { param( [Parameter(Mandatory = $true)] @@ -345,6 +360,8 @@ function New-PageWaveConnection { Connect-PnPOnline @parameters } +# Validate the whole manifest before any page is transformed. This prevents a malformed +# later row from stopping a wave after earlier pages have already been created. function Test-AssessmentPageWaveRows { param( [Parameter(Mandatory = $true)] @@ -420,6 +437,7 @@ function Test-AssessmentPageWaveRows { } } +# Shared execution engine used by both public entry scripts. function Invoke-AssessmentPageWave { [CmdletBinding()] param( @@ -468,6 +486,7 @@ function Invoke-AssessmentPageWave { throw "PnP PowerShell requires PowerShell 7.4.0 or later." } + # Repeat the entry-point validation because this function can also be invoked directly. Test-PageWaveAuthentication ` -AuthenticationMode $AuthenticationMode ` -Tenant $Tenant ` @@ -478,6 +497,8 @@ function Invoke-AssessmentPageWave { Import-Module PnP.PowerShell -ErrorAction Stop $WebPartMappingFile = $TransformationProfile.WebPartMappingFile + # WhatIf doesn't create a SharePoint connection or log folder, but still writes a + # preview result CSV through the supplied ResultWriter. if (Test-Path -LiteralPath $LogFolder -PathType Container) { $LogFolder = (Resolve-Path -LiteralPath $LogFolder).Path } @@ -510,6 +531,7 @@ function Invoke-AssessmentPageWave { $observedModifiedAt = $null try { + # Reject page categories that require a separately reviewed migration path. if ($row.PageType -notin @('WikiPage', 'WebPartPage')) { throw "Page type '$($row.PageType)' isn't supported by these Wiki/Web Part wave scripts." } @@ -535,12 +557,16 @@ function Invoke-AssessmentPageWave { $sourceWebUrl = Get-PageWaveSourceUrl -Row $row $action = "Create a draft modern page from $($row.PageUrl)" + + # Ask permission before authentication or any tenant read. -WhatIf exits here. if (-not (& $ShouldProcessCallback $sourceWebUrl $action)) { $status = 'Skipped' $errorMessage = "The operation wasn't approved or was run with -WhatIf." continue } + # Reuse successful connections and avoid repeating a known authentication + # failure for every remaining page in the same web. if ($connectionErrors.ContainsKey($sourceWebUrl)) { throw $connectionErrors[$sourceWebUrl] } @@ -563,6 +589,8 @@ function Invoke-AssessmentPageWave { } $connection = $connections[$sourceWebUrl] + # Assessment HomePage can become stale without changing the page itself. + # Re-read the current welcome page immediately before conversion. if (-not $webs.ContainsKey($sourceWebUrl)) { $webs[$sourceWebUrl] = Get-PnPWeb ` -Includes WelcomePage, ServerRelativeUrl ` @@ -585,6 +613,8 @@ function Invoke-AssessmentPageWave { throw "PageUrl isn't under ListUrl: $($row.PageUrl)" } + # Resolve the assessed list by GUID, then verify its URL. This prevents a + # reused item ID in another library from being transformed. $sourceList = Get-PnPList ` -Identity ([guid]$row.ListId) ` -ThrowExceptionIfListNotFound ` @@ -606,6 +636,8 @@ function Invoke-AssessmentPageWave { throw "ListUrl changed for $($row.PageUrl). Rerun Assessment before transforming this page." } + # Resolve the exact approved file and prove that it still belongs to the + # assessed list. $sourceItem = Get-PnPFile ` -Url $row.PageUrl ` -AsListItem ` @@ -619,6 +651,8 @@ function Invoke-AssessmentPageWave { throw "The approved file no longer belongs to the assessed list: $($row.PageUrl)" } + # Compare the live file identity and modification timestamp with Assessment. + # -AllowModifiedPages is an explicit reapproval escape hatch. $observedFileRef = [string]$sourceItem.FieldValues['FileRef'] if (-not $observedFileRef.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) { throw "The current file URL doesn't match the approved PageUrl: $($row.PageUrl)" @@ -634,6 +668,8 @@ function Invoke-AssessmentPageWave { throw "The source was modified after Assessment. Rerun Assessment or use -AllowModifiedPages after reapproval: $($row.PageUrl)" } + # Batch scripts deliberately exclude unique ACLs. Silent permission-copy + # failures could otherwise expose the draft through broader inheritance. $hasUniquePermissions = Get-PnPProperty ` -ClientObject $sourceItem ` -Property HasUniqueRoleAssignments ` @@ -643,6 +679,7 @@ function Invoke-AssessmentPageWave { throw "The source has unique permissions and is excluded from these batch scripts: $($row.PageUrl)" } + # Keep source and target in the same default SitePages permission scope. $webServerRelativeUrl = $web.ServerRelativeUrl.TrimEnd('/') $libraryRelativeUrl = if ([string]::IsNullOrWhiteSpace($webServerRelativeUrl)) { $sourceList.RootFolder.ServerRelativeUrl.TrimStart('/') @@ -654,6 +691,8 @@ function Invoke-AssessmentPageWave { throw "These batch scripts only support pages in the default SitePages library: $($row.PageUrl)" } + # Always preserve the source, create a draft, skip ACL copying, and log. + # Overwrite and TakeSourcePageName are intentionally unavailable here. $parameters = @{ Identity = $sourceItem.Id Connection = $connection @@ -668,6 +707,8 @@ function Invoke-AssessmentPageWave { $parameters.WebPartMappingFile = $WebPartMappingFile } + # ConvertTo-PnPPage returns the created server-relative URL. A missing URL is + # treated as a failure even if the cmdlet didn't throw. $conversionOutput = @(ConvertTo-PnPPage @parameters) $targetPageUrl = $conversionOutput | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | @@ -683,6 +724,8 @@ function Invoke-AssessmentPageWave { $errorMessage = $_.Exception.Message } finally { + # Persist success, failure, skip, observed source identity, and validation + # profile before moving to the next page. $resultRow = [pscustomobject][ordered]@{ ManifestRowHash = Get-PageWaveManifestHash -Row $row TransformationProfileHash = $TransformationProfile.Hash From be29be87992c3e6ce8c3b4fdbb72f169d07f5aa1 Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 15:00:59 +0800 Subject: [PATCH 07/11] Normalize embedded script timestamps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- ...ize-userinterface-site-pages-powershell.md | 3 + .../PageTransformation.Common.ps1 | 60 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index 3872162cf9..fa41e269e5 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -158,6 +158,7 @@ $candidates = foreach ($page in $pages) { PageType = $page.PageType ListUrl = $page.ListUrl ListId = $page.ListId + AssessmentTimeZoneId = [TimeZoneInfo]::Local.Id Layout = $page.Layout HomePage = $page.HomePage WebPartCount = [int]$page.WebPartCount @@ -188,6 +189,8 @@ Review `representative-page-groups.csv` and select at least one page from every Set `IncludePattern=False` for patterns outside the planned migration. For each selected page, set `Selected=True` and fill `ExpectedVisibleContent` and `ValidationOwner`. +Run the grouping step on the same machine that generated the Assessment CSVs. `AssessmentTimeZoneId` records the timezone used to interpret the offset-free `ModifiedAt` value. + Keep zero-part pages, home pages, publishing pages, and pages with unresolved mappings in separate review queues. Pages outside the default `SitePages` library also remain on the separately reviewed single-page path. diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index 9cbf6bec0d..a705987324 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -123,6 +123,7 @@ function Get-PageWaveManifestHash { 'ListTitle', 'ListId', 'ModifiedAt', + 'AssessmentTimeZoneId', 'Layout', 'HomePage', 'WebPartCount', @@ -377,6 +378,7 @@ function Test-AssessmentPageWaveRows { 'ListUrl', 'ListId', 'ModifiedAt', + 'AssessmentTimeZoneId', 'HomePage', 'WebPartCount', 'MappingPercentage', @@ -390,7 +392,8 @@ function Test-AssessmentPageWaveRows { 'PageType', 'ListUrl', 'ListId', - 'ModifiedAt' + 'ModifiedAt', + 'AssessmentTimeZoneId' ) $keys = @{} @@ -421,6 +424,12 @@ function Test-AssessmentPageWaveRows { )) { throw "ModifiedAt must use MM/dd/yyyy HH:mm:ss: $($row.ModifiedAt)" } + try { + [TimeZoneInfo]::FindSystemTimeZoneById([string]$row.AssessmentTimeZoneId) | Out-Null + } + catch { + throw "AssessmentTimeZoneId isn't available on this machine: $($row.AssessmentTimeZoneId)" + } ConvertTo-PageWaveBoolean -Value $row.HomePage -Name "HomePage for $($row.PageUrl)" | Out-Null ConvertTo-PageWaveInteger -Value $row.WebPartCount -Name "WebPartCount for $($row.PageUrl)" | Out-Null @@ -517,6 +526,7 @@ function Invoke-AssessmentPageWave { $connections = @{} $connectionErrors = @{} $webs = @{} + $siteTimeZones = @{} $results = [Collections.Generic.List[object]]::new() foreach ($row in $Rows) { @@ -529,6 +539,10 @@ function Invoke-AssessmentPageWave { $sourceUniqueId = $null $observedListId = $null $observedModifiedAt = $null + $approvedModifiedUtc = $null + $observedModifiedUtc = $null + $siteTimeZoneId = $null + $siteTimeZoneDescription = $null try { # Reject page categories that require a separately reviewed migration path. @@ -593,10 +607,21 @@ function Invoke-AssessmentPageWave { # Re-read the current welcome page immediately before conversion. if (-not $webs.ContainsKey($sourceWebUrl)) { $webs[$sourceWebUrl] = Get-PnPWeb ` - -Includes WelcomePage, ServerRelativeUrl ` + -Includes WelcomePage, ServerRelativeUrl, RegionalSettings ` + -Connection $connection + $siteTimeZones[$sourceWebUrl] = Get-PnPProperty ` + -ClientObject $webs[$sourceWebUrl].RegionalSettings ` + -Property TimeZone ` -Connection $connection + Get-PnPProperty ` + -ClientObject $siteTimeZones[$sourceWebUrl] ` + -Property Id, Description ` + -Connection $connection | Out-Null } $web = $webs[$sourceWebUrl] + $siteTimeZone = $siteTimeZones[$sourceWebUrl] + $siteTimeZoneId = $siteTimeZone.Id + $siteTimeZoneDescription = $siteTimeZone.Description if (-not [string]::IsNullOrWhiteSpace($web.WelcomePage)) { $currentHomePageUrl = ( '{0}/{1}' -f @@ -651,8 +676,9 @@ function Invoke-AssessmentPageWave { throw "The approved file no longer belongs to the assessed list: $($row.PageUrl)" } - # Compare the live file identity and modification timestamp with Assessment. - # -AllowModifiedPages is an explicit reapproval escape hatch. + # Assessment exports runner-local time without an offset, while PnP returns + # site-local time. Normalize both through their recorded time zones before + # deciding whether the source changed. $observedFileRef = [string]$sourceItem.FieldValues['FileRef'] if (-not $observedFileRef.Equals($row.PageUrl, [StringComparison]::OrdinalIgnoreCase)) { throw "The current file URL doesn't match the approved PageUrl: $($row.PageUrl)" @@ -664,7 +690,26 @@ function Invoke-AssessmentPageWave { 'MM/dd/yyyy HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture ) - if (-not $AllowModifiedPages -and $observedModifiedAt -ne $row.ModifiedAt) { + $assessmentLocal = [datetime]::ParseExact( + [string]$row.ModifiedAt, + 'MM/dd/yyyy HH:mm:ss', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::None + ) + $assessmentTimeZone = [TimeZoneInfo]::FindSystemTimeZoneById( + [string]$row.AssessmentTimeZoneId + ) + $approvedModifiedUtc = [TimeZoneInfo]::ConvertTimeToUtc( + [datetime]::SpecifyKind($assessmentLocal, [DateTimeKind]::Unspecified), + $assessmentTimeZone + ) + + $siteUtcResult = $siteTimeZone.LocalTimeToUTC($observedModified) + Invoke-PnPQuery -Connection $connection + $observedModifiedUtc = [datetime]$siteUtcResult.Value + + if (-not $AllowModifiedPages -and + [Math]::Abs(($approvedModifiedUtc - $observedModifiedUtc).TotalSeconds) -ge 1) { throw "The source was modified after Assessment. Rerun Assessment or use -AllowModifiedPages after reapproval: $($row.PageUrl)" } @@ -747,6 +792,11 @@ function Invoke-AssessmentPageWave { ObservedListId = $observedListId ApprovedModifiedAt = Get-PageWaveValue -Row $row -Name 'ModifiedAt' ObservedModifiedAt = $observedModifiedAt + AssessmentTimeZoneId = Get-PageWaveValue -Row $row -Name 'AssessmentTimeZoneId' + ApprovedModifiedUtc = if ($approvedModifiedUtc) { $approvedModifiedUtc.ToString('o') } else { '' } + ObservedModifiedUtc = if ($observedModifiedUtc) { $observedModifiedUtc.ToString('o') } else { '' } + SiteTimeZoneId = $siteTimeZoneId + SiteTimeZoneDescription = $siteTimeZoneDescription TargetPageUrl = $targetPageUrl TransformationStatus = $status ValidationStatus = if ($status -eq 'Created') { 'Pending' } else { '' } From e5b2518c5848a6f29119a1ef93b9e7a872ae15c1 Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 18:14:33 +0800 Subject: [PATCH 08/11] Address Stage 2 documentation review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- docs/toc.yml | 8 +- .../assessment-tool-publishing-coverage.md | 10 +- ...ize-userinterface-site-pages-powershell.md | 89 ++++++---- ...ize-userinterface-site-pages-validation.md | 21 ++- ...e-userinterface-site-pages-wave-scripts.md | 62 ++++++- .../modernize-userinterface-site-pages.md | 29 ++-- .../Convert-RepresentativePages.ps1 | 14 +- .../Convert-SelectedPages.ps1 | 73 ++++++--- .../PageTransformation.Common.ps1 | 155 ++++++++++++++---- 9 files changed, 328 insertions(+), 133 deletions(-) diff --git a/docs/toc.yml b/docs/toc.yml index f3bd52b51e..77a5d00922 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1858,12 +1858,12 @@ items: - name: Transform with PnP PowerShell href: transform/modernize-userinterface-site-pages-powershell.md - - name: Run page wave scripts - href: transform/modernize-userinterface-site-pages-wave-scripts.md - name: Validate transformed pages href: transform/modernize-userinterface-site-pages-validation.md - name: Reference items: + - name: Page wave script reference + href: transform/modernize-userinterface-site-pages-wave-scripts.md - name: In-place versus cross-site transformation href: transform/modernize-userinterface-site-pages-approach.md - name: Security requirements @@ -1907,8 +1907,8 @@ href: transform/modernize-userinterface-lists-and-libraries-rollout.md - name: Opting out from modern href: transform/modernize-userinterface-lists-and-libraries-optout.md - - name: Transform classic pages to modern pages - href: transform/modernize-userinterface-site-pages.md + - name: Assess and transform classic pages + href: transform/assess-sharepoint-modernization.md - name: SharePoint Modernization scanner items: - name: Overview diff --git a/docs/transform/assessment-tool-publishing-coverage.md b/docs/transform/assessment-tool-publishing-coverage.md index c0b113be6e..fcc270c173 100644 --- a/docs/transform/assessment-tool-publishing-coverage.md +++ b/docs/transform/assessment-tool-publishing-coverage.md @@ -1,7 +1,7 @@ --- title: Assess publishing pages for transformation description: Use Microsoft 365 Assessment tool publishing-page output to plan page transformation and page-layout mapping. -ms.date: 07/27/2026 +ms.date: 07/28/2026 ms.localizationpriority: high ms.service: sharepoint --- @@ -60,17 +60,17 @@ The current page assessment doesn't provide these publishing transformation inpu These areas are outside the Page Assessment scope. -## Continue to publishing-page transformation +## Prepare a publishing-page backlog 1. Group publishing pages by `Layout`. 1. Review `MappingPercentage` and `UnmappedWebParts`. 1. Identify custom layouts that need a page-layout mapping. -1. [Transform representative publishing pages](modernize-userinterface-site-pages-powershell.md#route-other-page-types). -1. [Validate representative pages](modernize-userinterface-site-pages-validation.md) before processing a complete portal. +1. Define the target modern web, page-layout mapping, metadata, and validation owner for each layout group. +1. Validate a separately reviewed publishing procedure before processing a complete portal. For an out-of-the-box publishing layout, use the built-in layout mapping where possible. For a custom publishing layout, generate and review a mapping before transformation. -Continue with [Transform selected classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md#route-other-page-types) and the [Publishing Page transformation model](modernize-userinterface-site-pages-model-publishing.md). +The Assessment-driven page wave scripts don't transform publishing pages. Use the [Publishing Page transformation model](modernize-userinterface-site-pages-model-publishing.md) as advanced reference while defining and validating the separate procedure. ## Reference diff --git a/docs/transform/modernize-userinterface-site-pages-powershell.md b/docs/transform/modernize-userinterface-site-pages-powershell.md index fa41e269e5..3898b29553 100644 --- a/docs/transform/modernize-userinterface-site-pages-powershell.md +++ b/docs/transform/modernize-userinterface-site-pages-powershell.md @@ -1,7 +1,7 @@ --- title: Transform selected classic pages with PnP PowerShell description: Prepare a minimally privileged PnP PowerShell connection and transform approved classic pages with source-preserving defaults. -ms.date: 07/27/2026 +ms.date: 07/28/2026 ms.localizationpriority: high ms.service: sharepoint --- @@ -23,7 +23,7 @@ Complete these prerequisites: 1. Run and interpret the [Classic pages assessment](assessment-tool-classic-pages.md). 1. Select a representative wave from successful scan coverage. 1. Install PowerShell 7.4.0 or later and the current stable PnP PowerShell release. -1. Register a tenant-owned Microsoft Entra application for PnP PowerShell. +1. Register a tenant-owned Microsoft Entra application for interactive PnP PowerShell. 1. Confirm that the signed-in user can edit pages in the source and target webs. Since September 9, 2024, interactive PnP PowerShell authentication requires your own application registration and client ID. @@ -32,7 +32,7 @@ The account that registers the application must be allowed to create app registr ### Permissions -Use a separate application from the read-only Assessment application. +Use a separate delegated application from the read-only Assessment application. | Transformation requirement | Delegated SharePoint scope | | --- | --- | @@ -58,7 +58,7 @@ If PnP PowerShell is already installed, run `Update-Module PnP.PowerShell` from See [Install PnP PowerShell](https://pnp.github.io/powershell/articles/installation.html). -## Register the transformation application +## Register the interactive transformation application The following command creates a public-client application for interactive sign-in: @@ -72,6 +72,8 @@ Register-PnPEntraIDAppForInteractiveLogin ` Copy the returned application ID. Depending on tenant consent policy, an administrator might need to grant consent before the first connection. +This public-client registration is for delegated interactive or device login. It doesn't provide the application permissions or certificate required for unattended authentication. + For manual registration and other authentication methods, see [Register an Entra ID application for PnP PowerShell](https://pnp.github.io/powershell/articles/registerapplication.html). For GCC High, DoD, or Microsoft 365 operated by 21Vianet, specify the matching `-AzureEnvironment` value when registering the application and connecting. See the [Register-PnPEntraIDAppForInteractiveLogin](https://pnp.github.io/powershell/cmdlets/Register-PnPEntraIDAppForInteractiveLogin.html) and [Connect-PnPOnline](https://pnp.github.io/powershell/cmdlets/Connect-PnPOnline.html) cmdlet references. @@ -81,10 +83,9 @@ For GCC High, DoD, or Microsoft 365 operated by 21Vianet, specify the matching ` | Assessment field | PnP PowerShell use | | --- | --- | | `SiteUrl` + `WebUrl` | Source URL for `Connect-PnPOnline`. | -| `ListUrl` + `PageUrl` | Source library, optional folder, and page file name. | -| `PageName` | Blog title identity. | -| `PageType` | Wiki/Web Part, Publishing, or Blog route. | -| `Layout` | Publishing page-layout decision. | +| `ListUrl`, `ListId`, `PageUrl` | Exact `SitePages` library and source file identity. | +| `PageType` | Must be `WikiPage` or `WebPartPage` for this workflow. | +| `Layout` | Representative-pattern grouping and validation. | Before transforming a first-wave page, require: @@ -214,18 +215,9 @@ Use the representative-page script with one row marked `Selected=True`. This kee The batch scripts support Wiki and Web Part pages in the default `SitePages` library. They exclude home pages, zero-part pages, pages with unique permissions, and pages modified after Assessment. Handle those pages through a separately reviewed path. -## Route other page types - -- `PublishingPage`: connect to a target modern web, add `-PublishingPage`, and review the [publishing page-layout model](modernize-userinterface-site-pages-model-publishing.md). -- `BlogPage`: use `PageName` as the `-Identity`, add `-BlogPage`, and provide a target modern web. -- `ASPXPage` and `DelveBlogPage`: keep them outside this Assessment-driven workflow. -- SharePoint Server source: use `Connect-PnPOnline -TransformationOnPrem` for the source and a separate SharePoint Online target connection. Treat this as an advanced cross-site scenario. - -Use [in-place versus cross-site guidance](modernize-userinterface-site-pages-approach.md) before providing `-TargetWebUrl` or `-TargetConnection`. - ## Transform all representative pages -Save the three embedded files from [Run Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) in the same folder. +Save the three embedded files from [Page wave script reference](modernize-userinterface-site-pages-wave-scripts.md) in the same folder. In `representative-page-groups.csv`: @@ -244,35 +236,41 @@ Preview the complete wave without authenticating or writing SharePoint pages: -Force ``` -Run interactively with High-impact confirmation: +The preview CSV records `PlannedAction` and `PlannedTargetPageUrl`. Because `-WhatIf` doesn't authenticate, `TargetExists` is `NotChecked`. + +Run an authenticated read-only preflight to verify the current source, home page, permissions, timestamps, and target absence: ```powershell .\Convert-RepresentativePages.ps1 ` -ManifestPath .\representative-page-groups.csv ` -ClientId "" ` -AuthenticationMode Interactive ` - -Confirm + -PreflightOnly ` + -Confirm:$false ` + -Force ``` -For unattended execution, use a certificate-based application and disable confirmation explicitly: +Every row must report `TransformationStatus=PreflightPassed` before live conversion. + +Run interactively with High-impact confirmation: ```powershell .\Convert-RepresentativePages.ps1 ` -ManifestPath .\representative-page-groups.csv ` -ClientId "" ` - -AuthenticationMode CertificateThumbprint ` - -Tenant ".onmicrosoft.com" ` - -Thumbprint "" ` - -Confirm:$false + -AuthenticationMode Interactive ` + -Confirm ``` The script writes each result immediately to `representative-page-results.csv`. Generated pages remain drafts, and each created row starts with `ValidationStatus=Pending`. -Follow [Validate transformed classic pages](modernize-userinterface-site-pages-validation.md), and set `ValidationStatus=Passed` only after the page meets every acceptance criterion. +Follow [Validate transformed classic pages](modernize-userinterface-site-pages-validation.md). Set `ValidationStatus=Passed`, `ValidationNotes`, `ValidatedBy`, and `ValidatedAt` only after the page meets every acceptance criterion. ## Expand to all user-specified pages -Create `approved-pages.csv` by copying the additional rows that the user approved from `representative-page-groups.csv`. Retain the Assessment fields, `PatternKey`, `ExpectedVisibleContent`, and `ValidationOwner`. +Create `approved-pages.csv` by copying the additional included rows that the user approved from the original `representative-page-groups.csv`. Retain every generated field, and fill `ExpectedVisibleContent` and `ValidationOwner`. + +Run the selected-page script with `-PreflightOnly -Confirm:$false -Force` first. Every row must report `PreflightPassed`. Run the expansion script: @@ -290,26 +288,49 @@ The script refuses to expand when: - The representative manifest and results don't match. - Any representative page isn't `Created` and `Passed`. -- The PnP PowerShell version or Web Part mapping differs from the representative run. +- Validation notes, validator, or validation timestamp are missing. +- The PnP PowerShell version or any of the three script files differs from the representative run. +- An approved page isn't an unchanged included row from the original manifest. - An approved page belongs to a pattern without a passed representative. The expanded wave also creates draft pages and writes `selected-page-results.csv`. Validate those pages before publishing them. -For app-only setup and certificate authentication, see [PnP PowerShell authentication](https://pnp.github.io/powershell/articles/authentication.html) and [determine required permissions](https://pnp.github.io/powershell/articles/determinepermissions.html). Run `-WhatIf` before unattended execution. - ## Options requiring explicit review Use the generated [ConvertTo-PnPPage cmdlet reference](https://pnp.github.io/powershell/cmdlets/ConvertTo-PnPPage.html) for the complete parameter contract. -Review these options only when the scenario requires them: +The batch scripts don't support custom Web Part mappings, publishing pages, or cross-site targets. Handle those scenarios through a separately reviewed single-page or advanced procedure. + +Review these options only outside the batch workflow: -- `-WebPartMappingFile` for custom Web Part mappings. -- `-PageLayoutMapping` for custom publishing layouts. -- `-TargetWebUrl` or `-TargetConnection` for cross-site transformation. - `-CopyPageMetadata` and `-KeepPageCreationModificationInformation`. - `-UrlMappingFile`, `-UserMappingFile`, and `-TermMappingFile`. - `-Overwrite` and `-TakeSourcePageName` after approval and rollback planning. +## Unattended authentication and other page types + +The delegated public-client application created earlier can't be reused for certificate app-only authentication unless it is separately configured for app-only access. + +Before unattended execution: + +1. Create a separate app-only registration and certificate by following [Register an Entra ID application for app-only access](https://pnp.github.io/powershell/articles/registerapplication.html#setting-up-access-to-your-own-entra-id-app-for-app-only-access). +1. Configure SharePoint **application** permissions or site assignments for every source site by following [Determine required PnP PowerShell permissions](https://pnp.github.io/powershell/articles/determinepermissions.html#when-using-an-app-only-context). +1. Grant the required administrator consent. +1. Run the batch script with `-PreflightOnly -Confirm:$false` and the certificate authentication mode. +1. Remove `-PreflightOnly` only after the authenticated preflight passes in a test tenant. + +> [!IMPORTANT] +> The certificate parameter paths are Pester-tested, but the Stage 2 live tenant run validated delegated interactive authentication. Validate the chosen app-only permission profile in your tenant before unattended writes. + +Publishing pages, Blog pages, pages outside `SitePages`, home pages, custom Web Part mappings, and SharePoint Server sources aren't supported by the batch scripts. Use these advanced references: + +- [In-place versus cross-site transformation](modernize-userinterface-site-pages-approach.md) +- [Publishing page readiness](assessment-tool-publishing-coverage.md) +- [Publishing page transformation model](modernize-userinterface-site-pages-model-publishing.md) +- [Page transformation model and custom Web Part mappings](modernize-userinterface-site-pages-model.md) +- [Modernize classic Blog pages](modernize-blogs.md) +- [Transform pages from SharePoint Server](modernize-userinterface-site-pages-approach.md#cross-site-transformation) + ## Next steps 1. [Validate each transformed page](modernize-userinterface-site-pages-validation.md). diff --git a/docs/transform/modernize-userinterface-site-pages-validation.md b/docs/transform/modernize-userinterface-site-pages-validation.md index 177941e66d..34f751558a 100644 --- a/docs/transform/modernize-userinterface-site-pages-validation.md +++ b/docs/transform/modernize-userinterface-site-pages-validation.md @@ -1,7 +1,7 @@ --- title: Validate transformed classic pages description: Validate transformed page content, layout, links, metadata, permissions, and publication state before expanding a migration wave. -ms.date: 07/27/2026 +ms.date: 07/28/2026 ms.localizationpriority: high ms.service: sharepoint --- @@ -56,13 +56,14 @@ Use one record per attempted page: | Field | Purpose | | --- | --- | -| `AssessmentId` | Links the page to the source evidence. | -| `SourcePageUrl`, `TargetPageUrl` | Identifies both pages. | +| `ScanId` | Links the page to the Assessment evidence. | +| `SiteUrl`, `WebUrl`, `PageUrl` | Identifies the source page. | +| `PlannedTargetPageUrl`, `TargetPageUrl` | Identifies the reviewed plan and created target. | | `PageType`, `Layout` | Groups comparable results. | | `MappingPercentage`, `UnmappedWebParts` | Preserves the planning signal. | -| `TransformationStatus` | Created, failed, or skipped. | -| `LogPath` | Points to the PnP transformation log. | -| `ValidationStatus` | Passed, failed, or needs remediation. | +| `TransformationStatus` | `Skipped`, `PreflightPassed`, `Created`, or `Failed`. | +| `LogPath` | Points to the exact PnP transformation log for the page. | +| `ValidationStatus` | `Pending`, `Passed`, or `Failed`. | | `ValidationNotes` | Records missing content, mapping issues, and follow-up work. | | `ValidatedBy`, `ValidatedAt` | Records approval ownership. | @@ -86,11 +87,13 @@ After a draft page is approved, publish it through the page editing experience o After validating every representative page: 1. Set each representative result to `ValidationStatus=Passed`. +1. Fill `ValidationNotes`, `ValidatedBy`, and the ISO-8601 `ValidatedAt` timestamp. 1. Create `approved-pages.csv` from the additional grouped rows that the user approved. -1. Run `Convert-SelectedPages.ps1` with the original representative manifest and validated results. +1. Run `Convert-SelectedPages.ps1 -PreflightOnly` with the original representative manifest and validated results. +1. Run the live expansion only after every preflight row passes. 1. Validate every generated page in the expanded wave before publishing it. -The script blocks expansion if a representative result is missing, stale, not passed, or based on a different PnP PowerShell or Web Part mapping profile. +The script blocks expansion if a representative result is missing, stale, not passed, or based on a different PnP PowerShell or script profile. ## Next steps @@ -100,6 +103,6 @@ The script blocks expansion if a representative result is missing, stale, not pa ## Reference - [Transform selected pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) -- [Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) +- [Page wave script reference](modernize-userinterface-site-pages-wave-scripts.md) - [Classic pages assessment CSV reference](assessment-tool-classic-pages-csv.md) - [Classic Web Part mappings](modernize-userinterface-site-pages-webparts.md) diff --git a/docs/transform/modernize-userinterface-site-pages-wave-scripts.md b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md index 5267f0aa36..e3b9ce7b1c 100644 --- a/docs/transform/modernize-userinterface-site-pages-wave-scripts.md +++ b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md @@ -1,24 +1,70 @@ --- -title: Run Assessment page wave scripts -description: Save and run the embedded PnP PowerShell scripts for representative and user-approved classic page transformation waves. +title: Page wave script reference +description: Reference the embedded PnP PowerShell scripts, parameters, outputs, and safety boundaries for Assessment-driven page waves. ms.date: 07/28/2026 ms.localizationpriority: high ms.service: sharepoint --- -# Run Assessment page wave scripts +# Page wave script reference -Use these scripts with the representative-page manifest created from the Classic Pages Assessment output. Save all three files in the same folder. +This article contains the source for the Assessment-driven Wiki and Web Part page wave scripts. Follow [Transform selected classic pages with PnP PowerShell](modernize-userinterface-site-pages-powershell.md) for the ordered procedure. -The scripts support Wiki and Web Part pages in the default `SitePages` library. They create draft modern pages and exclude home pages, zero-part pages, modified sources, and pages with unique permissions. +## Prerequisites + +- PowerShell 7.4.0 or later. +- Current PnP PowerShell. +- A reviewed `representative-page-groups.csv` generated by the primary procedure. +- A tenant-owned interactive application, or a separately configured app-only certificate application. + +Save all three embedded files in the same folder with the exact file names shown. ## Script files | File | Purpose | | --- | --- | -| `PageTransformation.Common.ps1` | Shared authentication, source validation, transformation, and result persistence. | -| `Convert-RepresentativePages.ps1` | Transforms all selected representative pages and writes validation-pending results. | -| `Convert-SelectedPages.ps1` | Transforms additional user-approved pages only after every included pattern has a passed representative. | +| `PageTransformation.Common.ps1` | Shared authentication, source validation, preflight, transformation, and result persistence. | +| `Convert-RepresentativePages.ps1` | Preflights or transforms every selected representative page. | +| `Convert-SelectedPages.ps1` | Preflights or transforms additional approved pages after representative validation passes. | + +## Common parameters + +| Parameter | Purpose | +| --- | --- | +| `-ClientId` | Tenant-owned PnP PowerShell application ID. | +| `-AuthenticationMode` | `Interactive`, `DeviceLogin`, `CertificateThumbprint`, or `CertificateFile`. | +| `-Tenant` | Required for device or certificate authentication. | +| `-AzureEnvironment` | Selects the Microsoft 365 cloud environment. | +| `-WhatIf` | Writes a plan without authenticating; target existence remains `NotChecked`. | +| `-PreflightOnly` | Authenticates and validates the source and target plan without converting. | +| `-Confirm` | Requests High-impact confirmation for each page. Use `-Confirm:$false` only for an approved unattended run. | +| `-Force` | Replaces an existing local preview, preflight, or result CSV. It never enables SharePoint target overwrite. | +| `-ResultPath`, `-LogFolder` | Select local evidence locations. | + +`Convert-RepresentativePages.ps1` requires `-ManifestPath`. `Convert-SelectedPages.ps1` requires `-PagesPath`, `-RepresentativeManifestPath`, and `-RepresentativeResultsPath`. + +## Result contract + +Each attempt is written immediately. Important fields include: + +| Field | Meaning | +| --- | --- | +| `ManifestRowHash`, `CandidateRowHash` | Bind the result and approved page to reviewed manifest content. | +| `TransformationProfileHash` and script hashes | Bind representative validation to the executed scripts and PnP PowerShell version. | +| `PlannedAction`, `PlannedTargetPageUrl`, `TargetExists` | Retain the preview or authenticated preflight plan. | +| `TransformationStatus` | `Skipped`, `PreflightPassed`, `Created`, or `Failed`. | +| `TargetPageUrl`, `LogPath` | Created draft and exact PnP log when available. | +| `ValidationStatus`, `ValidationNotes`, `ValidatedBy`, `ValidatedAt` | Manual validation evidence required before expansion. | + +## Supported and excluded pages + +| Page condition | Batch-script route | +| --- | --- | +| Wiki or Web Part page in the default `SitePages` library | Supported after all manifest and preflight checks pass. | +| Home page, zero-part page, modified source, unique permissions, or existing target | Excluded; return to separate review. | +| Publishing or Blog page | Use a separately validated advanced procedure. | +| Custom Web Part mapping or cross-site target | Use a separately reviewed single-page or advanced procedure. | +| SharePoint Server source or web-root page | Use advanced cross-site guidance. | ## PageTransformation.Common.ps1 diff --git a/docs/transform/modernize-userinterface-site-pages.md b/docs/transform/modernize-userinterface-site-pages.md index f11a0b6902..bd32535509 100644 --- a/docs/transform/modernize-userinterface-site-pages.md +++ b/docs/transform/modernize-userinterface-site-pages.md @@ -1,14 +1,16 @@ --- title: Transform classic SharePoint pages to modern pages description: Select assessed classic pages, transform a representative wave with PnP PowerShell, validate the results, and expand safely. -ms.date: 07/27/2026 +ms.date: 07/28/2026 ms.localizationpriority: high ms.service: sharepoint --- # Transform classic SharePoint pages to modern pages -This primary workflow transforms classic pages in SharePoint Online. Use the Microsoft 365 Assessment tool to identify pages and their Web Parts, and then use PnP PowerShell to transform approved pages and validate each result before processing a larger wave. +This primary workflow transforms classic Wiki and Web Part pages in the default `SitePages` library in SharePoint Online. Use the Microsoft 365 Assessment tool to identify pages and their Web Parts, and then use PnP PowerShell to transform approved pages and validate each result before processing a larger wave. + +Publishing pages, Blog pages, pages outside `SitePages`, home pages, and SharePoint Server sources require separate advanced procedures and aren't accepted by the page wave scripts. This workflow uses PnP PowerShell as the primary execution path. The page transformation engine is part of the open-source [PnP Framework](https://github.com/pnp/pnpframework) and doesn't have a Microsoft support SLA. @@ -31,22 +33,13 @@ For the first wave: `MappingPercentage=100` is a planning signal. It doesn't guarantee that the transformation will run successfully or preserve the expected content. -Route page types deliberately: - -| Assessment page type | Transformation route | -| --- | --- | -| `WikiPage`, `WebPartPage` | Transform in place for the first wave. Use a target web only when the modern page must be created in another site. | -| `PublishingPage` | Use a target modern web and review the page-layout mapping. | -| `BlogPage` | Use the blog title as the identity and provide a target modern web. | -| `ASPXPage`, `DelveBlogPage` | Keep outside this Assessment-driven workflow because the page assessment doesn't provide equivalent transformation-readiness analysis. | - ### Build a representative wave Treat pages as the same transformation pattern only when these dimensions match: | Dimension | Why it defines a separate pattern | | --- | --- | -| `PageType` | Selects the Wiki/Web Part, Publishing, or Blog transformation path. | +| `PageType` | Separates Wiki and Web Part transformation behavior. | | `Layout` | Controls the generated sections and columns. | | Ordered Web Part signature | Pages with different Web Part types, order, hidden state, or closed state can transform differently. | | Mapping result | Unmapped or differently mapped Web Parts require separate remediation and validation. | @@ -74,9 +67,7 @@ For field-to-command mapping and a selected-page example, see [Interpret the cla Group candidate pages by page type, layout, and unmapped Web Part combination. Resolve common blockers before transforming a wave. - For Wiki and Web Part pages, in-place transformation is the preferred starting point because dependencies remain in the source site. -- Publishing and Blog pages require a target modern web. -- Custom publishing layouts require a reviewed page-layout mapping. Without one, the engine generates a default mapping and places the content in a default section. -- Cross-site transformations require additional validation for links, files, lists, users, and taxonomy. +- Cross-site Wiki and Web Part transformations require additional validation for links, files, lists, users, and taxonomy. See [Choose in-place or cross-site transformation](modernize-userinterface-site-pages-approach.md) and [Review classic Web Part mappings](modernize-userinterface-site-pages-webparts.md). @@ -101,7 +92,7 @@ For the first wave: Only enable source-renaming or overwrite behavior after the generated pages have been approved and a rollback plan exists. -Use the embedded [Assessment page wave scripts](modernize-userinterface-site-pages-wave-scripts.md) to process every approved representative page through the same safety and result contract. +Use the embedded scripts from [Page wave script reference](modernize-userinterface-site-pages-wave-scripts.md) to process every approved representative page through the same safety and result contract. ## 5. Validate and expand @@ -125,10 +116,12 @@ After every representative result is marked `Passed`, use `Convert-SelectedPages - [Classic Web Part mappings](modernize-userinterface-site-pages-webparts.md) - [Page transformation configuration options](modernize-userinterface-site-pages-configuration.md) - [Page transformation model](modernize-userinterface-site-pages-model.md) -- [Publishing page transformation model](modernize-userinterface-site-pages-model-publishing.md) - [URL mapping](modernize-userinterface-site-pages-urlmapping.md) - [User mapping](modernize-userinterface-site-pages-usermapping.md) - [Term mapping](modernize-userinterface-site-pages-termmapping.md) - [Layout transformation](modernize-userinterface-site-pages-layout.md) - [.NET integration](modernize-userinterface-site-pages-dotnet.md) -- [Transform pages from SharePoint Server](modernize-userinterface-site-pages-powershell.md#route-other-page-types) +- [Transform pages from SharePoint Server](modernize-userinterface-site-pages-approach.md#cross-site-transformation) +- [Publishing page readiness](assessment-tool-publishing-coverage.md) +- [Publishing page transformation model](modernize-userinterface-site-pages-model-publishing.md) +- [Modernize classic Blog pages](modernize-blogs.md) diff --git a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 index 7c825f657d..8bb24c517c 100644 --- a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 +++ b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 @@ -57,10 +57,7 @@ param( [string]$LogFolder, [Parameter(Mandatory = $false)] - [string]$WebPartMappingFile, - - [Parameter(Mandatory = $false)] - [switch]$AllowModifiedPages, + [switch]$PreflightOnly, [Parameter(Mandatory = $false)] [switch]$Force @@ -139,6 +136,9 @@ if ([string]::IsNullOrWhiteSpace($ResultPath)) { $resultName = if ($WhatIfPreference) { 'representative-page-preview.csv' } + elseif ($PreflightOnly) { + 'representative-page-preflight.csv' + } else { 'representative-page-results.csv' } @@ -163,8 +163,8 @@ Test-PageWaveAuthentication ` Test-AssessmentPageWaveRows -Rows $selectedRows # The profile binds validation to the exact script version, PnP.PowerShell version, -# and Web Part mapping used during this representative run. -$transformationProfile = Get-PageWaveTransformationProfile -WebPartMappingFile $WebPartMappingFile +# and exact page wave scripts used during this representative run. +$transformationProfile = Get-PageWaveTransformationProfile # Reserve the result file before the first SharePoint write, then append every result # immediately so an interrupted wave retains completed evidence. @@ -184,7 +184,7 @@ $results = Invoke-AssessmentPageWave ` -CertificatePassword $CertificatePassword ` -AzureEnvironment $AzureEnvironment ` -LogFolder $LogFolder ` - -AllowModifiedPages:$AllowModifiedPages + -PreflightOnly:$PreflightOnly # The detailed per-page failures are already durable in the result CSV. $failed = @($results | Where-Object TransformationStatus -eq 'Failed') diff --git a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 index a673781383..a41a48d5d1 100644 --- a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 +++ b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 @@ -68,10 +68,7 @@ param( [string]$LogFolder, [Parameter(Mandatory = $false)] - [string]$WebPartMappingFile, - - [Parameter(Mandatory = $false)] - [switch]$AllowModifiedPages, + [switch]$PreflightOnly, [Parameter(Mandatory = $false)] [switch]$Force @@ -116,7 +113,7 @@ if ($representativeRows.Count -eq 0) { throw "The representative manifest has no included selected rows." } -$transformationProfile = Get-PageWaveTransformationProfile -WebPartMappingFile $WebPartMappingFile +$transformationProfile = Get-PageWaveTransformationProfile $includedPatterns = @($includedRepresentativeRows.PatternKey | Sort-Object -Unique) $selectedPatterns = @($representativeRows.PatternKey | Sort-Object -Unique) $patternsWithoutRepresentative = @( @@ -172,6 +169,15 @@ foreach ($key in $representativeRowsByKey.Keys) { $resultRow.ValidationStatus -ne 'Passed') { throw "Every representative page must be Created and validated as Passed before expanding the wave: $($manifestRow.PageUrl)" } + foreach ($validationField in @('ValidationNotes', 'ValidatedBy', 'ValidatedAt')) { + if ([string]::IsNullOrWhiteSpace($resultRow.$validationField)) { + throw "Passed representative result requires $validationField`: $($manifestRow.PageUrl)" + } + } + $validatedAt = [datetimeoffset]::MinValue + if (-not [datetimeoffset]::TryParse([string]$resultRow.ValidatedAt, [ref]$validatedAt)) { + throw "ValidatedAt must be an ISO-8601 timestamp: $($manifestRow.PageUrl)" + } $passedPatterns[$manifestRow.PatternKey] = $true } @@ -190,26 +196,52 @@ foreach ($row in $pages) { [string]::IsNullOrWhiteSpace($row.ValidationOwner)) { throw "Every approved page needs ExpectedVisibleContent and ValidationOwner: $($row.PageUrl)" } - if ([string]::IsNullOrWhiteSpace($row.PatternKey) -or - -not $passedPatterns.ContainsKey($row.PatternKey)) { - throw "Page pattern doesn't have a passed representative: $($row.PageUrl)" + if ($row.PSObject.Properties.Name -notcontains 'PatternKey' -or + [string]::IsNullOrWhiteSpace($row.PatternKey)) { + throw "Every approved page requires PatternKey: $($row.PageUrl)" } } -# Representative pages are already transformed and validated. Exclude them from the -# expanded wave even if the user accidentally leaves them in approved-pages.csv. +# Bind every approved page to the original included manifest. The caller cannot assign +# a passed PatternKey to an unknown or altered page. +$includedRowsByKey = @{} +foreach ($row in $includedRepresentativeRows) { + $key = Get-PageWaveKey -Row $row + if ($includedRowsByKey.ContainsKey($key)) { + throw "Duplicate included manifest row: $($row.PageUrl)" + } + $includedRowsByKey[$key] = $row +} + +# Representative pages are already transformed and validated. $representativeKeys = @{} foreach ($row in $representativeResults) { - $representativeKeys["$($row.ScanId)|$($row.SiteUrl)|$($row.WebUrl)|$($row.PageUrl)"] = $true + $representativeKeys[(Get-PageWaveKey -Row $row)] = $true } -$pagesToTransform = @( - $pages | - Where-Object { - $key = "$($_.ScanId)|$($_.SiteUrl)|$($_.WebUrl)|$($_.PageUrl)" - -not $representativeKeys.ContainsKey($key) - } -) +$pagesToTransform = [Collections.Generic.List[object]]::new() +foreach ($approvedRow in $pages) { + $key = Get-PageWaveKey -Row $approvedRow + if ($representativeKeys.ContainsKey($key)) { + continue + } + if (-not $includedRowsByKey.ContainsKey($key)) { + throw "Approved page isn't present in the included representative manifest: $($approvedRow.PageUrl)" + } + + $manifestRow = $includedRowsByKey[$key] + if ((Get-PageWaveCandidateHash -Row $approvedRow) -ne + (Get-PageWaveCandidateHash -Row $manifestRow)) { + throw "Approved page doesn't match the reviewed manifest row: $($approvedRow.PageUrl)" + } + if (-not $passedPatterns.ContainsKey($manifestRow.PatternKey)) { + throw "Page pattern doesn't have a passed representative: $($approvedRow.PageUrl)" + } + + # PatternKey is authoritative from the reviewed manifest. + $approvedRow.PatternKey = $manifestRow.PatternKey + $pagesToTransform.Add($approvedRow) +} if ($pagesToTransform.Count -eq 0) { throw "No additional pages remain after excluding validated representative pages." @@ -221,6 +253,9 @@ if ([string]::IsNullOrWhiteSpace($ResultPath)) { $resultName = if ($WhatIfPreference) { 'selected-page-preview.csv' } + elseif ($PreflightOnly) { + 'selected-page-preflight.csv' + } else { 'selected-page-results.csv' } @@ -261,7 +296,7 @@ $results = Invoke-AssessmentPageWave ` -CertificatePassword $CertificatePassword ` -AzureEnvironment $AzureEnvironment ` -LogFolder $LogFolder ` - -AllowModifiedPages:$AllowModifiedPages + -PreflightOnly:$PreflightOnly # Surface a failing process exit after every individual error has been written. $failed = @($results | Where-Object TransformationStatus -eq 'Failed') diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index a705987324..e378f4cadf 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -148,31 +148,81 @@ function Get-PageWaveManifestHash { } } -# Validation is meaningful only when the same scripts, PnP.PowerShell version, and Web -# Part mapping are used for both the representative and expanded waves. -function Get-PageWaveTransformationProfile { +function Get-PageWaveCandidateHash { param( - [Parameter(Mandatory = $false)] - [string]$WebPartMappingFile + [Parameter(Mandatory = $true)] + [object]$Row ) + $fields = @( + 'ScanId', + 'SiteUrl', + 'WebUrl', + 'PageUrl', + 'PageType', + 'ListUrl', + 'ListTitle', + 'ListId', + 'ModifiedAt', + 'AssessmentTimeZoneId', + 'Layout', + 'HomePage', + 'WebPartCount', + 'MappingPercentage', + 'UnmappedWebParts', + 'WebPartSignature', + 'PatternKey', + 'IncludePattern' + ) + $payload = ($fields | ForEach-Object { [string](Get-PageWaveValue -Row $Row -Name $_) }) -join [char]0x1f + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [Convert]::ToHexString( + $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload)) + ).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + +# Validation is meaningful only when the same scripts, PnP.PowerShell version, and Web +# Part transformation implementation are used for both waves. +function Get-PageWaveTransformationProfile { Import-Module PnP.PowerShell -ErrorAction Stop $module = Get-Module PnP.PowerShell | Sort-Object Version -Descending | Select-Object -First 1 if (-not $module) { throw "PnP.PowerShell isn't loaded." } - $mappingHash = 'embedded-default' - if (-not [string]::IsNullOrWhiteSpace($WebPartMappingFile)) { - if (-not (Test-Path -LiteralPath $WebPartMappingFile -PathType Leaf)) { - throw "Web Part mapping file not found: $WebPartMappingFile" + $scriptNames = @( + 'PageTransformation.Common.ps1', + 'Convert-RepresentativePages.ps1', + 'Convert-SelectedPages.ps1' + ) + $scriptHashes = @{} + foreach ($scriptName in $scriptNames) { + $scriptPath = Join-Path $PSScriptRoot $scriptName + if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) { + throw "Required page wave script not found: $scriptPath" } - $WebPartMappingFile = (Resolve-Path -LiteralPath $WebPartMappingFile).Path - $mappingHash = (Get-FileHash -LiteralPath $WebPartMappingFile -Algorithm SHA256).Hash.ToLowerInvariant() + $scriptHashes[$scriptName] = ( + Get-FileHash -LiteralPath $scriptPath -Algorithm SHA256 + ).Hash.ToLowerInvariant() } $scriptVersion = '1.0.0' - $payload = "Script=$scriptVersion|PnP=$($module.Version)|Mapping=$mappingHash|Draft=True|UniquePermissions=Excluded" + $payload = ( + "Script=$scriptVersion", + "PnP=$($module.Version)", + "Common=$($scriptHashes['PageTransformation.Common.ps1'])", + "Representative=$($scriptHashes['Convert-RepresentativePages.ps1'])", + "Selected=$($scriptHashes['Convert-SelectedPages.ps1'])", + 'Mapping=embedded-default', + 'Draft=True', + 'UniquePermissions=Excluded', + 'ModifiedSources=Excluded' + ) -join '|' $sha256 = [Security.Cryptography.SHA256]::Create() try { $profileHash = [Convert]::ToHexString( @@ -187,8 +237,10 @@ function Get-PageWaveTransformationProfile { Hash = $profileHash ScriptVersion = $scriptVersion PnPPowerShellVersion = $module.Version.ToString() - WebPartMappingHash = $mappingHash - WebPartMappingFile = $WebPartMappingFile + CommonScriptHash = $scriptHashes['PageTransformation.Common.ps1'] + RepresentativeScriptHash = $scriptHashes['Convert-RepresentativePages.ps1'] + SelectedScriptHash = $scriptHashes['Convert-SelectedPages.ps1'] + WebPartMappingHash = 'embedded-default' } } @@ -488,7 +540,7 @@ function Invoke-AssessmentPageWave { [string]$LogFolder = (Join-Path (Get-Location) 'page-transformation-logs'), [Parameter(Mandatory = $false)] - [switch]$AllowModifiedPages + [switch]$PreflightOnly ) if ($PSVersionTable.PSVersion -lt [version]'7.4.0') { @@ -504,7 +556,6 @@ function Invoke-AssessmentPageWave { Test-AssessmentPageWaveRows -Rows $Rows Import-Module PnP.PowerShell -ErrorAction Stop - $WebPartMappingFile = $TransformationProfile.WebPartMappingFile # WhatIf doesn't create a SharePoint connection or log folder, but still writes a # preview result CSV through the supplied ResultWriter. @@ -532,6 +583,9 @@ function Invoke-AssessmentPageWave { foreach ($row in $Rows) { $startedAt = Get-Date $sourceWebUrl = $null + $plannedAction = $null + $plannedTargetPageUrl = $null + $targetExists = 'NotChecked' $targetPageUrl = $null $status = 'Failed' $errorMessage = $null @@ -543,6 +597,9 @@ function Invoke-AssessmentPageWave { $observedModifiedUtc = $null $siteTimeZoneId = $null $siteTimeZoneDescription = $null + $logPath = $null + $logFilesBefore = @() + $conversionAttempted = $false try { # Reject page categories that require a separately reviewed migration path. @@ -563,17 +620,24 @@ function Invoke-AssessmentPageWave { if ($webPartCount -eq 0) { throw "Zero-part pages are excluded from these batch scripts: $($row.PageUrl)" } - if ([string]::IsNullOrWhiteSpace($WebPartMappingFile) -and - ($mappingPercentage -ne 100 -or - -not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts))) { - throw "Resolve unmapped Web Parts or provide WebPartMappingFile: $($row.PageUrl)" + if ($mappingPercentage -ne 100 -or + -not [string]::IsNullOrWhiteSpace($row.UnmappedWebParts)) { + throw "These batch scripts require Assessment readiness of 100 with no unmapped Web Parts: $($row.PageUrl)" } $sourceWebUrl = Get-PageWaveSourceUrl -Row $row - $action = "Create a draft modern page from $($row.PageUrl)" + $sourceDirectory = ([IO.Path]::GetDirectoryName($row.PageUrl) -replace '\\', '/').TrimEnd('/') + $sourceFileName = [IO.Path]::GetFileName($row.PageUrl) + $plannedTargetPageUrl = "$sourceDirectory/Migrated_$sourceFileName" + $plannedAction = if ($PreflightOnly) { + "Validate the source and target plan for $($row.PageUrl)" + } + else { + "Create a draft modern page from $($row.PageUrl)" + } # Ask permission before authentication or any tenant read. -WhatIf exits here. - if (-not (& $ShouldProcessCallback $sourceWebUrl $action)) { + if (-not (& $ShouldProcessCallback $sourceWebUrl $plannedAction)) { $status = 'Skipped' $errorMessage = "The operation wasn't approved or was run with -WhatIf." continue @@ -708,9 +772,8 @@ function Invoke-AssessmentPageWave { Invoke-PnPQuery -Connection $connection $observedModifiedUtc = [datetime]$siteUtcResult.Value - if (-not $AllowModifiedPages -and - [Math]::Abs(($approvedModifiedUtc - $observedModifiedUtc).TotalSeconds) -ge 1) { - throw "The source was modified after Assessment. Rerun Assessment or use -AllowModifiedPages after reapproval: $($row.PageUrl)" + if ([Math]::Abs(($approvedModifiedUtc - $observedModifiedUtc).TotalSeconds) -ge 1) { + throw "The source was modified after Assessment. Rerun Assessment before transforming this page: $($row.PageUrl)" } # Batch scripts deliberately exclude unique ACLs. Silent permission-copy @@ -736,6 +799,19 @@ function Invoke-AssessmentPageWave { throw "These batch scripts only support pages in the default SitePages library: $($row.PageUrl)" } + $existingTarget = Get-PnPFile ` + -Url $plannedTargetPageUrl ` + -AsListItem ` + -Connection $connection + $targetExists = $null -ne $existingTarget + if ($targetExists) { + throw "The planned target already exists and must be reviewed instead of overwritten: $plannedTargetPageUrl" + } + if ($PreflightOnly) { + $status = 'PreflightPassed' + continue + } + # Always preserve the source, create a draft, skip ACL copying, and log. # Overwrite and TakeSourcePageName are intentionally unavailable here. $parameters = @{ @@ -748,12 +824,13 @@ function Invoke-AssessmentPageWave { SkipItemLevelPermissionCopyToClientSidePage = $true } - if (-not [string]::IsNullOrWhiteSpace($WebPartMappingFile)) { - $parameters.WebPartMappingFile = $WebPartMappingFile - } - # ConvertTo-PnPPage returns the created server-relative URL. A missing URL is # treated as a failure even if the cmdlet didn't throw. + $logFilesBefore = @( + Get-ChildItem -LiteralPath $LogFolder -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + $conversionAttempted = $true $conversionOutput = @(ConvertTo-PnPPage @parameters) $targetPageUrl = $conversionOutput | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | @@ -769,13 +846,24 @@ function Invoke-AssessmentPageWave { $errorMessage = $_.Exception.Message } finally { + if ($conversionAttempted) { + $logPath = Get-ChildItem -LiteralPath $LogFolder -File -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -notin $logFilesBefore } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 -ExpandProperty FullName + } + # Persist success, failure, skip, observed source identity, and validation # profile before moving to the next page. $resultRow = [pscustomobject][ordered]@{ ManifestRowHash = Get-PageWaveManifestHash -Row $row + CandidateRowHash = Get-PageWaveCandidateHash -Row $row TransformationProfileHash = $TransformationProfile.Hash ScriptVersion = $TransformationProfile.ScriptVersion PnPPowerShellVersion = $TransformationProfile.PnPPowerShellVersion + CommonScriptHash = $TransformationProfile.CommonScriptHash + RepresentativeScriptHash = $TransformationProfile.RepresentativeScriptHash + SelectedScriptHash = $TransformationProfile.SelectedScriptHash WebPartMappingHash = $TransformationProfile.WebPartMappingHash ScanId = Get-PageWaveValue -Row $row -Name 'ScanId' SiteUrl = Get-PageWaveValue -Row $row -Name 'SiteUrl' @@ -797,10 +885,19 @@ function Invoke-AssessmentPageWave { ObservedModifiedUtc = if ($observedModifiedUtc) { $observedModifiedUtc.ToString('o') } else { '' } SiteTimeZoneId = $siteTimeZoneId SiteTimeZoneDescription = $siteTimeZoneDescription + MappingPercentage = Get-PageWaveValue -Row $row -Name 'MappingPercentage' + UnmappedWebParts = Get-PageWaveValue -Row $row -Name 'UnmappedWebParts' + PlannedAction = $plannedAction + PlannedTargetPageUrl = $plannedTargetPageUrl + TargetExists = $targetExists TargetPageUrl = $targetPageUrl TransformationStatus = $status ValidationStatus = if ($status -eq 'Created') { 'Pending' } else { '' } LogFolder = $LogFolder + LogPath = $logPath + ValidationNotes = '' + ValidatedBy = '' + ValidatedAt = '' StartedAt = $startedAt.ToString('o') FinishedAt = (Get-Date).ToString('o') Error = $errorMessage From ab60d385110b37b204e3a99c0236727d887eb0ac Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 18:39:49 +0800 Subject: [PATCH 09/11] Embed sealed page wave validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- ...e-userinterface-site-pages-wave-scripts.md | 2 +- .../Convert-RepresentativePages.ps1 | 2 ++ .../Convert-SelectedPages.ps1 | 11 ++++++- .../PageTransformation.Common.ps1 | 31 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/transform/modernize-userinterface-site-pages-wave-scripts.md b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md index e3b9ce7b1c..e93929a822 100644 --- a/docs/transform/modernize-userinterface-site-pages-wave-scripts.md +++ b/docs/transform/modernize-userinterface-site-pages-wave-scripts.md @@ -49,7 +49,7 @@ Each attempt is written immediately. Important fields include: | Field | Meaning | | --- | --- | -| `ManifestRowHash`, `CandidateRowHash` | Bind the result and approved page to reviewed manifest content. | +| `ManifestRowHash`, `CandidateRowHash`, `IncludedManifestHash` | Bind each result, approved page, and the complete included scope to reviewed manifest content. | | `TransformationProfileHash` and script hashes | Bind representative validation to the executed scripts and PnP PowerShell version. | | `PlannedAction`, `PlannedTargetPageUrl`, `TargetExists` | Retain the preview or authenticated preflight plan. | | `TransformationStatus` | `Skipped`, `PreflightPassed`, `Created`, or `Failed`. | diff --git a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 index 8bb24c517c..8659d8b656 100644 --- a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 +++ b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 @@ -165,6 +165,7 @@ Test-AssessmentPageWaveRows -Rows $selectedRows # The profile binds validation to the exact script version, PnP.PowerShell version, # and exact page wave scripts used during this representative run. $transformationProfile = Get-PageWaveTransformationProfile +$includedManifestHash = Get-PageWaveIncludedManifestHash -Rows $includedRows # Reserve the result file before the first SharePoint write, then append every result # immediately so an interrupted wave retains completed evidence. @@ -178,6 +179,7 @@ $results = Invoke-AssessmentPageWave ` -ShouldProcessCallback $shouldProcess ` -ResultWriter $resultWriter.Write ` -TransformationProfile $transformationProfile ` + -IncludedManifestHash $includedManifestHash ` -Tenant $Tenant ` -Thumbprint $Thumbprint ` -CertificatePath $CertificatePath ` diff --git a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 index a41a48d5d1..2d43ef98f3 100644 --- a/includes/scripts/page-transformation/Convert-SelectedPages.ps1 +++ b/includes/scripts/page-transformation/Convert-SelectedPages.ps1 @@ -114,6 +114,7 @@ if ($representativeRows.Count -eq 0) { } $transformationProfile = Get-PageWaveTransformationProfile +$includedManifestHash = Get-PageWaveIncludedManifestHash -Rows $includedRepresentativeRows $includedPatterns = @($includedRepresentativeRows.PatternKey | Sort-Object -Unique) $selectedPatterns = @($representativeRows.PatternKey | Sort-Object -Unique) $patternsWithoutRepresentative = @( @@ -163,7 +164,10 @@ foreach ($key in $representativeRowsByKey.Keys) { throw "Representative result doesn't match the current manifest row: $($manifestRow.PageUrl)" } if ($resultRow.TransformationProfileHash -ne $transformationProfile.Hash) { - throw "Representative results used a different PnP version or Web Part mapping: $($manifestRow.PageUrl)" + throw "Representative results used a different PnP version or script profile: $($manifestRow.PageUrl)" + } + if ($resultRow.IncludedManifestHash -ne $includedManifestHash) { + throw "Representative results don't match the complete included manifest." } if ($resultRow.TransformationStatus -ne 'Created' -or $resultRow.ValidationStatus -ne 'Passed') { @@ -178,6 +182,10 @@ foreach ($key in $representativeRowsByKey.Keys) { if (-not [datetimeoffset]::TryParse([string]$resultRow.ValidatedAt, [ref]$validatedAt)) { throw "ValidatedAt must be an ISO-8601 timestamp: $($manifestRow.PageUrl)" } + if ([string]::IsNullOrWhiteSpace($resultRow.LogPath) -or + -not (Test-Path -LiteralPath $resultRow.LogPath -PathType Leaf)) { + throw "Passed representative result requires a retained LogPath: $($manifestRow.PageUrl)" + } $passedPatterns[$manifestRow.PatternKey] = $true } @@ -290,6 +298,7 @@ $results = Invoke-AssessmentPageWave ` -ShouldProcessCallback $shouldProcess ` -ResultWriter $resultWriter.Write ` -TransformationProfile $transformationProfile ` + -IncludedManifestHash $includedManifestHash ` -Tenant $Tenant ` -Thumbprint $Thumbprint ` -CertificatePath $CertificatePath ` diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index e378f4cadf..b05a7ca6d2 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -186,6 +186,33 @@ function Get-PageWaveCandidateHash { } } +function Get-PageWaveIncludedManifestHash { + param( + [Parameter(Mandatory = $true)] + [object[]]$Rows + ) + + $entries = @( + $Rows | + Sort-Object { Get-PageWaveKey -Row $_ } | + ForEach-Object { + '{0}|{1}' -f + (Get-PageWaveKey -Row $_), + (Get-PageWaveCandidateHash -Row $_) + } + ) + $payload = $entries -join [char]0x1e + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [Convert]::ToHexString( + $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($payload)) + ).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } +} + # Validation is meaningful only when the same scripts, PnP.PowerShell version, and Web # Part transformation implementation are used for both waves. function Get-PageWaveTransformationProfile { @@ -521,6 +548,9 @@ function Invoke-AssessmentPageWave { [Parameter(Mandatory = $true)] [object]$TransformationProfile, + [Parameter(Mandatory = $true)] + [string]$IncludedManifestHash, + [Parameter(Mandatory = $false)] [string]$Tenant, @@ -858,6 +888,7 @@ function Invoke-AssessmentPageWave { $resultRow = [pscustomobject][ordered]@{ ManifestRowHash = Get-PageWaveManifestHash -Row $row CandidateRowHash = Get-PageWaveCandidateHash -Row $row + IncludedManifestHash = $IncludedManifestHash TransformationProfileHash = $TransformationProfile.Hash ScriptVersion = $TransformationProfile.ScriptVersion PnPPowerShellVersion = $TransformationProfile.PnPPowerShellVersion From 0fa9dfbd1d746de8a03d185920c092c3be15c84a Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 18:57:55 +0800 Subject: [PATCH 10/11] Embed deterministic manifest sealing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- .../Convert-RepresentativePages.ps1 | 3 ++- .../PageTransformation.Common.ps1 | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 index 8659d8b656..38629bde4c 100644 --- a/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 +++ b/includes/scripts/page-transformation/Convert-RepresentativePages.ps1 @@ -160,7 +160,8 @@ Test-PageWaveAuthentication ` -Tenant $Tenant ` -Thumbprint $Thumbprint ` -CertificatePath $CertificatePath -Test-AssessmentPageWaveRows -Rows $selectedRows +# Validate every included row before sealing the complete migration scope. +Test-AssessmentPageWaveRows -Rows $includedRows # The profile binds validation to the exact script version, PnP.PowerShell version, # and exact page wave scripts used during this representative run. diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index b05a7ca6d2..66765e358e 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -194,12 +194,14 @@ function Get-PageWaveIncludedManifestHash { $entries = @( $Rows | - Sort-Object { Get-PageWaveKey -Row $_ } | ForEach-Object { - '{0}|{1}' -f - (Get-PageWaveKey -Row $_), - (Get-PageWaveCandidateHash -Row $_) - } + [pscustomobject]@{ + PageKey = Get-PageWaveKey -Row $_ + CandidateHash = Get-PageWaveCandidateHash -Row $_ + } + } | + Sort-Object PageKey, CandidateHash | + ForEach-Object { '{0}|{1}' -f $_.PageKey, $_.CandidateHash } ) $payload = $entries -join [char]0x1e $sha256 = [Security.Cryptography.SHA256]::Create() From 9eb1e27a175ee86cc014c568d38fdd6ea0b2123c Mon Sep 17 00:00:00 2001 From: titao Date: Tue, 28 Jul 2026 19:09:39 +0800 Subject: [PATCH 11/11] Embed ordinal manifest hashing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7db0d98-be53-48c2-9488-9a226f457144 --- .../PageTransformation.Common.ps1 | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/includes/scripts/page-transformation/PageTransformation.Common.ps1 b/includes/scripts/page-transformation/PageTransformation.Common.ps1 index 66765e358e..527922d29d 100644 --- a/includes/scripts/page-transformation/PageTransformation.Common.ps1 +++ b/includes/scripts/page-transformation/PageTransformation.Common.ps1 @@ -192,18 +192,17 @@ function Get-PageWaveIncludedManifestHash { [object[]]$Rows ) - $entries = @( - $Rows | - ForEach-Object { - [pscustomobject]@{ - PageKey = Get-PageWaveKey -Row $_ - CandidateHash = Get-PageWaveCandidateHash -Row $_ - } - } | - Sort-Object PageKey, CandidateHash | - ForEach-Object { '{0}|{1}' -f $_.PageKey, $_.CandidateHash } - ) - $payload = $entries -join [char]0x1e + $entries = [Collections.Generic.List[string]]::new() + foreach ($row in $Rows) { + $entries.Add( + (Get-PageWaveKey -Row $row) + + '|' + + (Get-PageWaveCandidateHash -Row $row) + ) + } + $sortedEntries = $entries.ToArray() + [Array]::Sort($sortedEntries, [StringComparer]::Ordinal) + $payload = $sortedEntries -join [char]0x1e $sha256 = [Security.Cryptography.SHA256]::Create() try { return [Convert]::ToHexString(