From aa3fefd2b8e67004d7e621a95850c29498b53e73 Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:09:29 +0200 Subject: [PATCH 01/10] Add tenant search crawled property support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Add-PnPTenantSearchCrawledProperty.md | 158 +++++++++ .../Add-TenantSearchCrawledProperty.ps1 | 330 ++++++++++++++++++ .../Enums/SearchCrawledPropertySet.cs | 23 ++ .../Search/AddTenantSearchCrawledProperty.cs | 325 +++++++++++++++++ .../AddPnPTenantSearchCrawledPropertyTests.cs | 65 ++++ 5 files changed, 901 insertions(+) create mode 100644 documentation/Add-PnPTenantSearchCrawledProperty.md create mode 100644 samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 create mode 100644 src/Commands/Enums/SearchCrawledPropertySet.cs create mode 100644 src/Commands/Search/AddTenantSearchCrawledProperty.cs create mode 100644 src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs diff --git a/documentation/Add-PnPTenantSearchCrawledProperty.md b/documentation/Add-PnPTenantSearchCrawledProperty.md new file mode 100644 index 0000000000..c8e0759a12 --- /dev/null +++ b/documentation/Add-PnPTenantSearchCrawledProperty.md @@ -0,0 +1,158 @@ +--- +Module Name: PnP.PowerShell +title: Add-PnPTenantSearchCrawledProperty +schema: 2.0.0 +applicable: SharePoint Online +external help file: PnP.PowerShell.dll-Help.xml +online version: https://pnp.github.io/powershell/cmdlets/Add-PnPTenantSearchCrawledProperty.html +--- + +# Add-PnPTenantSearchCrawledProperty + +## SYNOPSIS + +Adds a tenant-level search crawled property. + +## SYNTAX + +### KnownPropertySet + +```powershell +Add-PnPTenantSearchCrawledProperty -Name -PropertySet + [-Force] [-Connection ] +``` + +### PropertySetGuid + +```powershell +Add-PnPTenantSearchCrawledProperty -Name -PropertySetGuid + [-Force] [-Connection ] +``` + +## DESCRIPTION + +Creates a tenant-level crawled property by importing an additive search schema configuration package. This cmdlet must be run against the tenant admin site. + +Most SharePoint crawled properties should use one of these property sets: SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich. Other property sets are supported for advanced scenarios, but the cmdlet will ask for confirmation unless -Force is specified. + +This cmdlet is additive only. SharePoint Online does not expose a supported PnP PowerShell command to delete crawled properties created in error or to move an existing crawled property to a different property set. Verify the crawled property name and property set before running this cmdlet in a production tenant. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +Add-PnPTenantSearchCrawledProperty -Name "ows_ProjectCode" -PropertySet SharePointDefault +``` + +Creates a common SharePoint crawled property. + +### EXAMPLE 2 + +```powershell +Add-PnPTenantSearchCrawledProperty -Name "ows_q_TEXT_ProjectCode" -PropertySet SharePointStructured +``` + +Creates a structured SharePoint crawled property. + +### EXAMPLE 3 + +```powershell +Add-PnPTenantSearchCrawledProperty -Name "ows_taxId_ProjectCategory" -PropertySet SharePointTaxonomy +``` + +Creates a taxonomy crawled property. + +### EXAMPLE 4 + +```powershell +Add-PnPTenantSearchCrawledProperty -Name "ows_r_HTML_Description" -PropertySet SharePointRich +``` + +Creates a crawled property for a rich or complex field. + +### EXAMPLE 5 + +```powershell +Add-PnPTenantSearchCrawledProperty -Name "ows_ProjectCode" -PropertySetGuid "00130329-0000-0130-C000-000000131346" -Force +``` + +Creates a crawled property by specifying the supported property set GUID directly. + +## PARAMETERS + +### -Force + +Suppresses confirmation prompts for less common property sets, direct GUID usage, or crawled property names that do not match the selected property set. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Name + +Name of the crawled property to create. + +```yaml +Type: String +Parameter Sets: (All) + +Required: True +Position: 0 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PropertySet + +Known property set to use for the crawled property. Recommended values are SharePointDefault, SharePointTaxonomy, SharePointStructured, and SharePointRich. + +```yaml +Type: SearchCrawledPropertySet +Parameter Sets: KnownPropertySet +Accepted values: SharePointDefault, SharePointTaxonomy, SharePointStructured, SharePointRich, OfficeSummary, OfficeDocumentSummary, SharePointCrawl, SharePointInternal, Storage, Basic, BasicExtended, BasicContent, SharePointDav, SharePointList, BasicLegacy, PublicStrings, SharePointContent + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PropertySetGuid + +Supported property set GUID to use directly. Prefer -PropertySet for normal usage. + +```yaml +Type: Guid +Parameter Sets: PropertySetGuid + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Connection + +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either specifying -ReturnConnection on Connect-PnPOnline or by executing Get-PnPConnection. + +```yaml +Type: PnPConnection +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` diff --git a/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 b/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 new file mode 100644 index 0000000000..587bed1b42 --- /dev/null +++ b/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 @@ -0,0 +1,330 @@ +<# +.SYNOPSIS +Creates a tenant-level SharePoint Online search crawled property using PnP.PowerShell. + +.DESCRIPTION +This script imports an additive tenant search schema configuration package to create a crawled property. +It is intended as a temporary support script until equivalent functionality is available in PnP.PowerShell. + +Important: +- Run this against the tenant admin site, for example https://contoso-admin.sharepoint.com. +- This script is additive only. There is no supported PnP.PowerShell command to delete crawled properties + created in error or to move an existing crawled property to a different property set. +- Verify the crawled property name and property set before running in a production tenant. + +.EXAMPLE +Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -ClientId "" -Tenant ".onmicrosoft.com" -CertificatePath "" -CertificatePassword (Read-Host -AsSecureString) +.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_ProjectCode" -PropertySet SharePointDefault + +.EXAMPLE +.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_q_TEXT_ProjectCode" -PropertySet SharePointStructured + +.EXAMPLE +.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_taxId_ProjectCategory" -PropertySet SharePointTaxonomy + +.EXAMPLE +.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_ProjectCode" -PropertySetGuid "00130329-0000-0130-C000-000000131346" -Force +#> + +[CmdletBinding(DefaultParameterSetName = "KnownPropertySet", SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter(Mandatory = $true, ParameterSetName = "KnownPropertySet")] + [ValidateSet( + "SharePointDefault", + "SharePointTaxonomy", + "SharePointStructured", + "SharePointRich", + "OfficeSummary", + "OfficeDocumentSummary", + "SharePointCrawl", + "SharePointInternal", + "Storage", + "Basic", + "BasicExtended", + "BasicContent", + "SharePointDav", + "SharePointList", + "BasicLegacy", + "PublicStrings", + "SharePointContent", + IgnoreCase = $true)] + [string] $PropertySet, + + [Parameter(Mandatory = $true, ParameterSetName = "PropertySetGuid")] + [Guid] $PropertySetGuid, + + [Parameter(Mandatory = $false)] + [switch] $Force, + + [Parameter(Mandatory = $false)] + [switch] $PrintConfig +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = "Stop" + +$propertySets = @{ + SharePointDefault = @{ Id = [Guid]"00130329-0000-0130-C000-000000131346"; CategoryName = "SharePoint"; MapToContents = $true; Recommended = $true } + SharePointTaxonomy = @{ Id = [Guid]"158D7563-AEFF-4DBF-BF16-4A1445F0366C"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } + SharePointStructured = @{ Id = [Guid]"ED280121-B677-4E2A-8FBC-0D9E2325B0A2"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } + SharePointRich = @{ Id = [Guid]"FEA84DF6-A0FC-492C-9AA7-D28B8DCB08B3"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } + OfficeSummary = @{ Id = [Guid]"F29F85E0-4FF9-1068-AB91-08002B27B3D9"; CategoryName = "Office"; MapToContents = $false; Recommended = $false } + OfficeDocumentSummary = @{ Id = [Guid]"D5CDD502-2E9C-101B-9397-08002B2CF9AE"; CategoryName = "Office"; MapToContents = $false; Recommended = $false } + SharePointCrawl = @{ Id = [Guid]"D1B5D3F0-C0B3-11CF-9A92-00A0C908DBF1"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } + SharePointInternal = @{ Id = [Guid]"012357BD-1113-171D-1F25-292BB0B0B0B0"; CategoryName = "Internal"; MapToContents = $false; Recommended = $false } + Storage = @{ Id = [Guid]"B725F130-47EF-101A-A5F1-02608C9EEBAC"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } + Basic = @{ Id = [Guid]"49691C90-7E17-101A-A91C-08002B2ECDA9"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } + BasicExtended = @{ Id = [Guid]"C82BF597-B831-11D0-B733-00AA00A1EBD2"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } + BasicContent = @{ Id = [Guid]"70EB7A10-55D9-11CF-B75B-00AA0051FE20"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } + SharePointDav = @{ Id = [Guid]"00140329-0000-0140-C000-000000141446"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } + SharePointList = @{ Id = [Guid]"00110329-0000-0110-C000-000000111146"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } + BasicLegacy = @{ Id = [Guid]"0B63E343-9CCC-11D0-BCDB-00805FCCCE04"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } + PublicStrings = @{ Id = [Guid]"00020329-0000-0000-C000-000000000046"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } + SharePointContent = @{ Id = [Guid]"0C4B2ABA-0518-4EC2-807E-25DD264B660F"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } +} + +function Import-RequiredModule { + param([Parameter(Mandatory = $true)][string] $Name) + + if (-not (Get-Module -ListAvailable -Name $Name)) { + throw "Required PowerShell module '$Name' was not found. Install it with: Install-Module $Name -Scope CurrentUser" + } + + Import-Module $Name -ErrorAction Stop +} + +function Get-ExpectedPropertySet { + param([Parameter(Mandatory = $true)][string] $CrawledPropertyName) + + if ($CrawledPropertyName -match "^ows_taxId") { return "SharePointTaxonomy" } + if ($CrawledPropertyName -match "^ows_q_") { return "SharePointStructured" } + if ($CrawledPropertyName -match "^ows_r_") { return "SharePointRich" } + if ($CrawledPropertyName -match "^ows_") { return "SharePointDefault" } + + return $null +} + +function Confirm-SafetyChecks { + param( + [Parameter(Mandatory = $true)][string] $CrawledPropertyName, + [Parameter(Mandatory = $true)][string] $ResolvedPropertySetName, + [Parameter(Mandatory = $true)][hashtable] $ResolvedPropertySet, + [Parameter(Mandatory = $true)][bool] $UsedGuidParameter + ) + + $warnings = New-Object System.Collections.Generic.List[string] + + if ($UsedGuidParameter) { + $warnings.Add("You are using a property set GUID directly. Prefer -PropertySet unless you are reproducing an existing crawled property pattern.") + } + + if (-not $ResolvedPropertySet.Recommended) { + $warnings.Add("'$ResolvedPropertySetName' is a less common property set. Most SharePoint crawled properties should use SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich.") + } + + $expected = Get-ExpectedPropertySet -CrawledPropertyName $CrawledPropertyName + if ($null -ne $expected -and $ResolvedPropertySetName -ne $expected) { + $warnings.Add("The crawled property name '$CrawledPropertyName' usually belongs in '$expected', but '$ResolvedPropertySetName' was selected.") + } + + if ($warnings.Count -eq 0 -or $Force) { + foreach ($warning in $warnings) { + Write-Warning $warning + } + return + } + + foreach ($warning in $warnings) { + Write-Warning $warning + } + + $caption = "Confirm crawled property creation" + $message = "Crawled properties cannot be deleted or moved to a different property set through supported PnP.PowerShell commands. Continue?" + if (-not $PSCmdlet.ShouldContinue($message, $caption)) { + throw "Crawled property creation cancelled." + } +} + +function Resolve-PropertySet { + if ($PSCmdlet.ParameterSetName -eq "PropertySetGuid") { + foreach ($entry in $propertySets.GetEnumerator()) { + if ($entry.Value.Id -eq $PropertySetGuid) { + return [PSCustomObject]@{ + Name = $entry.Key + Info = $entry.Value + UsedGuidParameter = $true + } + } + } + + throw "Property set '$PropertySetGuid' is not supported by this script." + } + + return [PSCustomObject]@{ + Name = $PropertySet + Info = $propertySets[$PropertySet] + UsedGuidParameter = $false + } +} + +function Get-TenantSchemaId { + $searchConfig = Get-PnPSearchConfiguration -Scope Subscription + $xml = [xml]$searchConfig + $schemaIdNode = $xml.SelectSingleNode("//*[local-name()='SchemaId']") + + if ($null -ne $schemaIdNode -and -not [string]::IsNullOrWhiteSpace($schemaIdNode.InnerText)) { + return [int]$schemaIdNode.InnerText + } + + throw "Could not resolve the tenant search schema ID from Get-PnPSearchConfiguration -Scope Subscription. No changes were made." +} + +function New-EmptyInfoCollectionXml { + param( + [Parameter(Mandatory = $true)][string] $ElementName, + [Parameter(Mandatory = $false)][bool] $IncludeTotalCount = $false + ) + + $xml = @" +<$ElementName xmlns:d3p1="http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"> + + +"@ + + if ($IncludeTotalCount) { + $xml += "`n 0" + } + + $xml += "`n" + return $xml +} + +function New-EmptyDictionaryCollectionXml { + param([Parameter(Mandatory = $true)][string] $ElementName) + + return @" +<$ElementName xmlns:d3p1="http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"> + + +"@ +} + +function New-SearchConfigurationXml { + param( + [Parameter(Mandatory = $true)][string] $CrawledPropertyName, + [Parameter(Mandatory = $true)][Guid] $PropertySetId, + [Parameter(Mandatory = $true)][string] $CategoryName, + [Parameter(Mandatory = $true)][bool] $MapToContents, + [Parameter(Mandatory = $true)][int] $SchemaId + ) + + $escapedName = [System.Security.SecurityElement]::Escape($CrawledPropertyName) + $escapedCategory = [System.Security.SecurityElement]::Escape($CategoryName) + $propertySetText = $PropertySetId.ToString("D") + $mapToContentsText = $MapToContents.ToString().ToLowerInvariant() + + $aliases = New-EmptyInfoCollectionXml -ElementName "Aliases" + $crawledProperties = New-EmptyInfoCollectionXml -ElementName "CrawledProperties" + $managedProperties = New-EmptyInfoCollectionXml -ElementName "ManagedProperties" -IncludeTotalCount $true + $mappings = New-EmptyDictionaryCollectionXml -ElementName "Mappings" + $overrides = New-EmptyDictionaryCollectionXml -ElementName "Overrides" + + return @" + + + + + 00000000-0000-0000-0000-000000000000 + true + false + false + + + + + + + + + + + + $aliases + + + $propertySetText + + $escapedName + + + $escapedName + + $escapedName + $escapedCategory + false + $mapToContentsText + false + + $propertySetText + + $SchemaId + + + + + + + $crawledProperties + $managedProperties + $mappings + $overrides + $SchemaId + + + + +"@ +} + +Import-RequiredModule -Name "PnP.PowerShell" + +$connection = Get-PnPConnection -ErrorAction SilentlyContinue +if ($null -eq $connection) { + throw "No active PnP connection was found. Connect to the tenant admin site first, for example: Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -ClientId -Tenant .onmicrosoft.com -CertificatePath " +} + +if ($connection.Url -notmatch "-admin\.sharepoint\.") { + throw "The active PnP connection is '$($connection.Url)'. Connect to the SharePoint Online tenant admin site before running this script." +} + +$resolved = Resolve-PropertySet +Confirm-SafetyChecks -CrawledPropertyName $Name -ResolvedPropertySetName $resolved.Name -ResolvedPropertySet $resolved.Info -UsedGuidParameter $resolved.UsedGuidParameter + +$schemaId = Get-TenantSchemaId +$configuration = New-SearchConfigurationXml -CrawledPropertyName $Name -PropertySetId $resolved.Info.Id -CategoryName $resolved.Info.CategoryName -MapToContents $resolved.Info.MapToContents -SchemaId $schemaId + +Write-Verbose "Creating tenant crawled property '$Name' using property set '$($resolved.Name)' ($($resolved.Info.Id)), category '$($resolved.Info.CategoryName)', and schema ID '$schemaId'." +Write-Debug $configuration + +if ($PrintConfig) { + $configuration + return +} + +if ($PSCmdlet.ShouldProcess($Name, "Create tenant search crawled property in property set '$($resolved.Name)'")) { + Set-PnPSearchConfiguration -Scope Subscription -Configuration $configuration + + [PSCustomObject]@{ + Name = $Name + PropertySet = $resolved.Name + PropertySetGuid = $resolved.Info.Id + CategoryName = $resolved.Info.CategoryName + MapToContents = $resolved.Info.MapToContents + SchemaId = $schemaId + Imported = $true + } +} diff --git a/src/Commands/Enums/SearchCrawledPropertySet.cs b/src/Commands/Enums/SearchCrawledPropertySet.cs new file mode 100644 index 0000000000..400749dd6c --- /dev/null +++ b/src/Commands/Enums/SearchCrawledPropertySet.cs @@ -0,0 +1,23 @@ +namespace PnP.PowerShell.Commands.Enums +{ + public enum SearchCrawledPropertySet + { + SharePointDefault, + SharePointTaxonomy, + SharePointStructured, + SharePointRich, + OfficeSummary, + OfficeDocumentSummary, + SharePointCrawl, + SharePointInternal, + Storage, + Basic, + BasicExtended, + BasicContent, + SharePointDav, + SharePointList, + BasicLegacy, + PublicStrings, + SharePointContent + } +} diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs new file mode 100644 index 0000000000..4f381b0e3c --- /dev/null +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management.Automation; +using System.Xml.Linq; +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Search.Administration; +using Microsoft.SharePoint.Client.Search.Portability; +using PnP.PowerShell.Commands.Enums; +using Resources = PnP.PowerShell.Commands.Properties.Resources; + +namespace PnP.PowerShell.Commands.Search +{ + [Cmdlet(VerbsCommon.Add, "PnPTenantSearchCrawledProperty", DefaultParameterSetName = ParameterSetKnownPropertySet)] + public class AddTenantSearchCrawledProperty : PnPWebCmdlet + { + private const string ParameterSetKnownPropertySet = "KnownPropertySet"; + private const string ParameterSetPropertySetGuid = "PropertySetGuid"; + + private static readonly IReadOnlyDictionary PropertySets = + new Dictionary + { + { SearchCrawledPropertySet.SharePointDefault, new CrawledPropertySetInfo("00130329-0000-0130-C000-000000131346", "SharePoint", true, true) }, + { SearchCrawledPropertySet.SharePointTaxonomy, new CrawledPropertySetInfo("158D7563-AEFF-4DBF-BF16-4A1445F0366C", "SharePoint", false, true) }, + { SearchCrawledPropertySet.SharePointStructured, new CrawledPropertySetInfo("ED280121-B677-4E2A-8FBC-0D9E2325B0A2", "SharePoint", false, true) }, + { SearchCrawledPropertySet.SharePointRich, new CrawledPropertySetInfo("FEA84DF6-A0FC-492C-9AA7-D28B8DCB08B3", "SharePoint", false, true) }, + { SearchCrawledPropertySet.OfficeSummary, new CrawledPropertySetInfo("F29F85E0-4FF9-1068-AB91-08002B27B3D9", "Office", false, false) }, + { SearchCrawledPropertySet.OfficeDocumentSummary, new CrawledPropertySetInfo("D5CDD502-2E9C-101B-9397-08002B2CF9AE", "Office", false, false) }, + { SearchCrawledPropertySet.SharePointCrawl, new CrawledPropertySetInfo("D1B5D3F0-C0B3-11CF-9A92-00A0C908DBF1", "SharePoint", false, false) }, + { SearchCrawledPropertySet.SharePointInternal, new CrawledPropertySetInfo("012357BD-1113-171D-1F25-292BB0B0B0B0", "Internal", false, false) }, + { SearchCrawledPropertySet.Storage, new CrawledPropertySetInfo("B725F130-47EF-101A-A5F1-02608C9EEBAC", "Basic", false, false) }, + { SearchCrawledPropertySet.Basic, new CrawledPropertySetInfo("49691C90-7E17-101A-A91C-08002B2ECDA9", "Basic", false, false) }, + { SearchCrawledPropertySet.BasicExtended, new CrawledPropertySetInfo("C82BF597-B831-11D0-B733-00AA00A1EBD2", "Basic", false, false) }, + { SearchCrawledPropertySet.BasicContent, new CrawledPropertySetInfo("70EB7A10-55D9-11CF-B75B-00AA0051FE20", "Basic", false, false) }, + { SearchCrawledPropertySet.SharePointDav, new CrawledPropertySetInfo("00140329-0000-0140-C000-000000141446", "SharePoint", false, false) }, + { SearchCrawledPropertySet.SharePointList, new CrawledPropertySetInfo("00110329-0000-0110-C000-000000111146", "SharePoint", false, false) }, + { SearchCrawledPropertySet.BasicLegacy, new CrawledPropertySetInfo("0B63E343-9CCC-11D0-BCDB-00805FCCCE04", "Basic", false, false) }, + { SearchCrawledPropertySet.PublicStrings, new CrawledPropertySetInfo("00020329-0000-0000-C000-000000000046", "SharePoint", false, false) }, + { SearchCrawledPropertySet.SharePointContent, new CrawledPropertySetInfo("0C4B2ABA-0518-4EC2-807E-25DD264B660F", "SharePoint", false, false) } + }; + + private static readonly IReadOnlyDictionary PropertySetIds = + PropertySets.ToDictionary(kvp => kvp.Value.Id, kvp => kvp.Key); + + [Parameter(Mandatory = true, Position = 0)] + [ValidateNotNullOrEmpty] + public string Name; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetKnownPropertySet)] + public SearchCrawledPropertySet PropertySet; + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetPropertySetGuid)] + public Guid PropertySetGuid; + + [Parameter(Mandatory = false)] + public SwitchParameter Force; + + protected override void ExecuteCmdlet() + { + if (!ClientContext.Url.ToLower(CultureInfo.InvariantCulture).Contains("-admin")) + { + throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); + } + + var propertySet = ResolvePropertySet(); + ConfirmIfNeeded(propertySet); + + var schemaId = ResolveTenantSchemaId(); + var configuration = BuildSearchConfigurationXml(Name, propertySet.Info.Id, propertySet.Info.CategoryName, propertySet.Info.MapToContents, schemaId); + LogDebug($"Creating tenant crawled property '{Name}' using property set '{propertySet.KnownPropertySet}' ({propertySet.Info.Id}), category '{propertySet.Info.CategoryName}', and schema ID '{schemaId}'."); + LogDebug(configuration); + + try + { + ClientContext.ImportSearchSettingsConfiguration(configuration, SearchObjectLevel.SPSiteSubscription); + } + catch (ServerException ex) + { + var details = $"Search configuration import failed: {ex.Message}"; + if (!string.IsNullOrEmpty(ex.ServerErrorTypeName)) + { + details += $" ServerErrorTypeName: {ex.ServerErrorTypeName}."; + } + if (!string.IsNullOrEmpty(ex.ServerErrorTraceCorrelationId)) + { + details += $" CorrelationId: {ex.ServerErrorTraceCorrelationId}."; + } + throw new InvalidOperationException(details, ex); + } + + WriteObject(new + { + Name, + PropertySet = propertySet.KnownPropertySet?.ToString(), + PropertySetGuid = propertySet.Info.Id, + propertySet.Info.CategoryName, + propertySet.Info.MapToContents, + SchemaId = schemaId, + Imported = true + }); + } + + internal static string BuildSearchConfigurationXml(string name, Guid propertySetId, string categoryName, bool mapToContents, int schemaId) + { + XNamespace portability = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Portability"; + XNamespace admin = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"; + XNamespace query = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration.Query"; + XNamespace arrays = "http://schemas.microsoft.com/2003/10/Serialization/Arrays"; + XNamespace knownTypes = "http://www.microsoft.com/sharepoint/search/KnownTypes/2008/08"; + XNamespace i = "http://www.w3.org/2001/XMLSchema-instance"; + + return new XDocument( + new XElement(portability + "SearchConfigurationSettings", + new XAttribute(XNamespace.Xmlns + "i", i), + CreateSearchQueryConfigurationSettings(portability, admin, query, knownTypes), + new XElement(portability + "SearchRankingModelConfigurationSettings", + new XElement(portability + "RankingModels", + new XAttribute(XNamespace.Xmlns + "d3p1", arrays))), + new XElement(portability + "SearchSchemaConfigurationSettings", + CreateEmptyInfoCollection(portability + "Aliases", admin, arrays, i), + new XElement(portability + "CategoriesAndCrawledProperties", + new XAttribute(XNamespace.Xmlns + "d3p1", arrays), + new XElement(arrays + "KeyValueOfguidCrawledPropertyInfoCollectionaSYUqUE_P", + new XElement(arrays + "Key", propertySetId.ToString("D", CultureInfo.InvariantCulture)), + new XElement(arrays + "Value", + new XAttribute(XNamespace.Xmlns + "d5p1", admin), + new XElement(admin + "LastItemName", name), + new XElement(admin + "dictionary", + new XElement(arrays + "KeyValueOfstringCrawledPropertyInfoy6h3NzC8", + new XElement(arrays + "Key", name), + new XElement(arrays + "Value", + new XElement(admin + "Name", name), + new XElement(admin + "CategoryName", categoryName), + new XElement(admin + "IsImplicit", false), + new XElement(admin + "IsMappedToContents", mapToContents), + new XElement(admin + "IsNameEnum", false), + new XElement(admin + "MappedManagedProperties"), + new XElement(admin + "Propset", propertySetId.ToString("D", CultureInfo.InvariantCulture)), + new XElement(admin + "Samples"), + new XElement(admin + "SchemaId", schemaId))))))), + CreateEmptyInfoCollection(portability + "CrawledProperties", admin, arrays, i), + CreateEmptyInfoCollection(portability + "ManagedProperties", admin, arrays, i, includeTotalCount: true), + CreateEmptyDictionaryCollection(portability + "Mappings", admin, arrays), + CreateEmptyDictionaryCollection(portability + "Overrides", admin, arrays), + new XElement(portability + "SchemaId", schemaId)), + new XElement(portability + "SearchSubscriptionSettingsConfigurationSettings", new XAttribute(i + "nil", "true")), + new XElement(portability + "SearchTaxonomyConfigurationSettings", new XAttribute(i + "nil", "true")))) + .ToString(SaveOptions.DisableFormatting); + } + + private static XElement CreateSearchQueryConfigurationSettings(XNamespace portability, XNamespace admin, XNamespace query, XNamespace knownTypes) + { + return new XElement(portability + "SearchQueryConfigurationSettings", + new XElement(portability + "SearchQueryConfigurationSettings", + new XElement(portability + "BestBets", new XAttribute(XNamespace.Xmlns + "d4p1", knownTypes)), + new XElement(portability + "DefaultSourceId", Guid.Empty.ToString("D", CultureInfo.InvariantCulture)), + new XElement(portability + "DefaultSourceIdSet", true), + new XElement(portability + "DeployToParent", false), + new XElement(portability + "DisableInheritanceOnImport", false), + new XElement(portability + "QueryRuleGroups", new XAttribute(XNamespace.Xmlns + "d4p1", knownTypes)), + new XElement(portability + "QueryRules", new XAttribute(XNamespace.Xmlns + "d4p1", knownTypes)), + new XElement(portability + "ResultTypes", new XAttribute(XNamespace.Xmlns + "d4p1", admin)), + new XElement(portability + "Sources", new XAttribute(XNamespace.Xmlns + "d4p1", query)), + new XElement(portability + "UserSegments", new XAttribute(XNamespace.Xmlns + "d4p1", knownTypes)))); + } + + private static XElement CreateEmptyInfoCollection(XName name, XNamespace itemNamespace, XNamespace dictionaryNamespace, XNamespace nilNamespace, bool includeTotalCount = false) + { + var element = new XElement(name, + new XAttribute(XNamespace.Xmlns + "d3p1", itemNamespace), + new XElement(itemNamespace + "LastItemName", new XAttribute(nilNamespace + "nil", "true")), + new XElement(itemNamespace + "dictionary", new XAttribute(XNamespace.Xmlns + "d4p1", dictionaryNamespace))); + + if (includeTotalCount) + { + element.Add(new XElement(itemNamespace + "TotalCount", 0)); + } + + return element; + } + + private static XElement CreateEmptyDictionaryCollection(XName name, XNamespace itemNamespace, XNamespace dictionaryNamespace) + { + return new XElement(name, + new XAttribute(XNamespace.Xmlns + "d3p1", itemNamespace), + new XElement(itemNamespace + "dictionary", new XAttribute(XNamespace.Xmlns + "d4p1", dictionaryNamespace))); + } + + private int ResolveTenantSchemaId() + { + SearchObjectOwner owningScope = new SearchObjectOwner(ClientContext, SearchObjectLevel.SPSiteSubscription); + var config = new SearchConfigurationPortability(ClientContext); + ClientResult configuration = config.ExportSearchConfiguration(owningScope); + ClientContext.ExecuteQueryRetry(); + + var schemaIdValue = XDocument.Parse(configuration.Value) + .Descendants() + .FirstOrDefault(element => element.Name.LocalName == "SchemaId")?.Value; + + if (int.TryParse(schemaIdValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var schemaId)) + { + return schemaId; + } + + const int fallbackSchemaId = 143692; + LogWarning($"Could not resolve the tenant search schema ID from the exported configuration. Falling back to {fallbackSchemaId}."); + return fallbackSchemaId; + } + + private ResolvedPropertySet ResolvePropertySet() + { + if (ParameterSetName == ParameterSetPropertySetGuid) + { + if (!PropertySetIds.TryGetValue(PropertySetGuid, out var knownPropertySet)) + { + throw new ArgumentException($"Property set '{PropertySetGuid}' is not supported by this cmdlet."); + } + + return new ResolvedPropertySet(knownPropertySet, PropertySets[knownPropertySet], true); + } + + return new ResolvedPropertySet(PropertySet, PropertySets[PropertySet], false); + } + + private void ConfirmIfNeeded(ResolvedPropertySet propertySet) + { + var warnings = GetWarnings(Name, propertySet).ToList(); + if (warnings.Count == 0) + { + return; + } + + var message = string.Join(Environment.NewLine, warnings) + Environment.NewLine + "Do you want to continue creating the tenant crawled property?"; + if (!Force && !ShouldContinue(message, Properties.Resources.Confirm)) + { + throw new InvalidOperationException("Crawled property creation cancelled."); + } + + foreach (var warning in warnings) + { + LogWarning(warning); + } + } + + private static IEnumerable GetWarnings(string name, ResolvedPropertySet propertySet) + { + if (propertySet.UsedGuidParameter) + { + yield return "You are using a property set GUID directly. Prefer -PropertySet unless you are reproducing an existing crawled property pattern."; + } + + if (!propertySet.Info.IsRecommended) + { + yield return $"'{propertySet.KnownPropertySet}' is a less common property set. Most SharePoint crawled properties should use SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich."; + } + + var expectedPropertySet = GetExpectedPropertySet(name); + if (expectedPropertySet.HasValue && propertySet.KnownPropertySet != expectedPropertySet.Value) + { + yield return $"The crawled property name '{name}' usually belongs in '{expectedPropertySet.Value}', but '{propertySet.KnownPropertySet}' was selected."; + } + } + + private static SearchCrawledPropertySet? GetExpectedPropertySet(string name) + { + if (name.StartsWith("ows_taxId", StringComparison.OrdinalIgnoreCase)) + { + return SearchCrawledPropertySet.SharePointTaxonomy; + } + + if (name.StartsWith("ows_q_", StringComparison.OrdinalIgnoreCase)) + { + return SearchCrawledPropertySet.SharePointStructured; + } + + if (name.StartsWith("ows_r_", StringComparison.OrdinalIgnoreCase)) + { + return SearchCrawledPropertySet.SharePointRich; + } + + if (name.StartsWith("ows_", StringComparison.OrdinalIgnoreCase)) + { + return SearchCrawledPropertySet.SharePointDefault; + } + + return null; + } + + private sealed class CrawledPropertySetInfo + { + internal CrawledPropertySetInfo(string id, string categoryName, bool mapToContents, bool isRecommended) + { + Id = Guid.Parse(id); + CategoryName = categoryName; + MapToContents = mapToContents; + IsRecommended = isRecommended; + } + + internal Guid Id { get; } + + internal string CategoryName { get; } + + internal bool MapToContents { get; } + + internal bool IsRecommended { get; } + } + + private sealed class ResolvedPropertySet + { + internal ResolvedPropertySet(SearchCrawledPropertySet? knownPropertySet, CrawledPropertySetInfo info, bool usedGuidParameter) + { + KnownPropertySet = knownPropertySet; + Info = info; + UsedGuidParameter = usedGuidParameter; + } + + internal SearchCrawledPropertySet? KnownPropertySet { get; } + + internal CrawledPropertySetInfo Info { get; } + + internal bool UsedGuidParameter { get; } + } + } +} diff --git a/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs new file mode 100644 index 0000000000..a4fc5c880e --- /dev/null +++ b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs @@ -0,0 +1,65 @@ +using System; +using System.Reflection; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PnP.PowerShell.Commands.Search; + +namespace PnP.PowerShell.Tests.Search +{ + [TestClass] + public class AddTenantSearchCrawledPropertyTests + { + [TestMethod] + public void BuildSearchConfigurationXmlCreatesTenantCrawledPropertyPayload() + { + var propertySetId = Guid.Parse("00130329-0000-0130-C000-000000131346"); + var xml = BuildSearchConfigurationXml("ows_ProjectCode", propertySetId, "SharePoint", true, 143692); + var document = XDocument.Parse(xml); + + XNamespace portability = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Portability"; + XNamespace admin = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"; + XNamespace arrays = "http://schemas.microsoft.com/2003/10/Serialization/Arrays"; + + Assert.AreEqual(portability + "SearchConfigurationSettings", document.Root.Name); + Assert.AreEqual("ows_ProjectCode", document.Descendants(admin + "Name").SingleValue()); + Assert.AreEqual("SharePoint", document.Descendants(admin + "CategoryName").SingleValue()); + Assert.AreEqual(propertySetId.ToString("D"), document.Descendants(admin + "Propset").SingleValue()); + Assert.AreEqual("true", document.Descendants(admin + "IsMappedToContents").SingleValue()); + Assert.AreEqual("143692", document.Descendants(admin + "SchemaId").SingleValue()); + Assert.AreEqual("ows_ProjectCode", document.Descendants(arrays + "Key").LastValue()); + } + + [TestMethod] + public void BuildSearchConfigurationXmlCreatesStructuredPayloadWithoutMappingToContents() + { + var propertySetId = Guid.Parse("ED280121-B677-4E2A-8FBC-0D9E2325B0A2"); + var xml = BuildSearchConfigurationXml("ows_q_TEXT_ProjectCode", propertySetId, "SharePoint", false, 143692); + var document = XDocument.Parse(xml); + + XNamespace admin = "http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"; + + Assert.AreEqual("ows_q_TEXT_ProjectCode", document.Descendants(admin + "Name").SingleValue()); + Assert.AreEqual(propertySetId.ToString("D"), document.Descendants(admin + "Propset").SingleValue()); + Assert.AreEqual("false", document.Descendants(admin + "IsMappedToContents").SingleValue()); + } + + private static string BuildSearchConfigurationXml(string name, Guid propertySetId, string categoryName, bool mapToContents, int schemaId) + { + var method = typeof(AddTenantSearchCrawledProperty).GetMethod("BuildSearchConfigurationXml", BindingFlags.Static | BindingFlags.NonPublic); + return (string)method.Invoke(null, new object[] { name, propertySetId, categoryName, mapToContents, schemaId }); + } + } + + internal static class SearchCrawledPropertyTestExtensions + { + internal static string SingleValue(this System.Collections.Generic.IEnumerable elements) + { + return System.Linq.Enumerable.Single(elements).Value; + } + + internal static string LastValue(this System.Collections.Generic.IEnumerable elements) + { + return System.Linq.Enumerable.Last(elements).Value; + } + } +} From 7a299b5e74564b1728b4a5ef2c12f1fbfe1519f3 Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:46:49 +0200 Subject: [PATCH 02/10] Add tenant search crawled property support --- .../Add-PnPTenantSearchCrawledProperty.md | 2 ++ .../Search/AddTenantSearchCrawledProperty.cs | 36 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/documentation/Add-PnPTenantSearchCrawledProperty.md b/documentation/Add-PnPTenantSearchCrawledProperty.md index c8e0759a12..27f839e0b7 100644 --- a/documentation/Add-PnPTenantSearchCrawledProperty.md +++ b/documentation/Add-PnPTenantSearchCrawledProperty.md @@ -37,6 +37,8 @@ Most SharePoint crawled properties should use one of these property sets: ShareP This cmdlet is additive only. SharePoint Online does not expose a supported PnP PowerShell command to delete crawled properties created in error or to move an existing crawled property to a different property set. Verify the crawled property name and property set before running this cmdlet in a production tenant. +If this cmdlet is used to make an implicit crawled property explicit, SharePoint Online will stop automatically creating an implicit managed property for that crawled property going forward. + ## EXAMPLES ### EXAMPLE 1 diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 4f381b0e3c..72858f2e62 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -203,9 +203,7 @@ private int ResolveTenantSchemaId() return schemaId; } - const int fallbackSchemaId = 143692; - LogWarning($"Could not resolve the tenant search schema ID from the exported configuration. Falling back to {fallbackSchemaId}."); - return fallbackSchemaId; + throw new InvalidOperationException("Could not resolve the tenant search schema ID from the exported search configuration."); } private ResolvedPropertySet ResolvePropertySet() @@ -287,6 +285,22 @@ private static IEnumerable GetWarnings(string name, ResolvedPropertySet return null; } + private sealed class ResolvedPropertySet + { + internal ResolvedPropertySet(SearchCrawledPropertySet? knownPropertySet, CrawledPropertySetInfo info, bool usedGuidParameter) + { + KnownPropertySet = knownPropertySet; + Info = info; + UsedGuidParameter = usedGuidParameter; + } + + internal SearchCrawledPropertySet? KnownPropertySet { get; } + + internal CrawledPropertySetInfo Info { get; } + + internal bool UsedGuidParameter { get; } + } + private sealed class CrawledPropertySetInfo { internal CrawledPropertySetInfo(string id, string categoryName, bool mapToContents, bool isRecommended) @@ -305,21 +319,5 @@ internal CrawledPropertySetInfo(string id, string categoryName, bool mapToConten internal bool IsRecommended { get; } } - - private sealed class ResolvedPropertySet - { - internal ResolvedPropertySet(SearchCrawledPropertySet? knownPropertySet, CrawledPropertySetInfo info, bool usedGuidParameter) - { - KnownPropertySet = knownPropertySet; - Info = info; - UsedGuidParameter = usedGuidParameter; - } - - internal SearchCrawledPropertySet? KnownPropertySet { get; } - - internal CrawledPropertySetInfo Info { get; } - - internal bool UsedGuidParameter { get; } - } } } From 71e10c9dbaa2422c345f38d62047070806d58304 Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:49:58 +0200 Subject: [PATCH 03/10] Remove temporary crawled property sample script --- .../Add-TenantSearchCrawledProperty.ps1 | 330 ------------------ 1 file changed, 330 deletions(-) delete mode 100644 samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 diff --git a/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 b/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 deleted file mode 100644 index 587bed1b42..0000000000 --- a/samples/Search.CrawledProperties/Add-TenantSearchCrawledProperty.ps1 +++ /dev/null @@ -1,330 +0,0 @@ -<# -.SYNOPSIS -Creates a tenant-level SharePoint Online search crawled property using PnP.PowerShell. - -.DESCRIPTION -This script imports an additive tenant search schema configuration package to create a crawled property. -It is intended as a temporary support script until equivalent functionality is available in PnP.PowerShell. - -Important: -- Run this against the tenant admin site, for example https://contoso-admin.sharepoint.com. -- This script is additive only. There is no supported PnP.PowerShell command to delete crawled properties - created in error or to move an existing crawled property to a different property set. -- Verify the crawled property name and property set before running in a production tenant. - -.EXAMPLE -Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -ClientId "" -Tenant ".onmicrosoft.com" -CertificatePath "" -CertificatePassword (Read-Host -AsSecureString) -.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_ProjectCode" -PropertySet SharePointDefault - -.EXAMPLE -.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_q_TEXT_ProjectCode" -PropertySet SharePointStructured - -.EXAMPLE -.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_taxId_ProjectCategory" -PropertySet SharePointTaxonomy - -.EXAMPLE -.\Add-TenantSearchCrawledProperty.ps1 -Name "ows_ProjectCode" -PropertySetGuid "00130329-0000-0130-C000-000000131346" -Force -#> - -[CmdletBinding(DefaultParameterSetName = "KnownPropertySet", SupportsShouldProcess = $true)] -param( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [string] $Name, - - [Parameter(Mandatory = $true, ParameterSetName = "KnownPropertySet")] - [ValidateSet( - "SharePointDefault", - "SharePointTaxonomy", - "SharePointStructured", - "SharePointRich", - "OfficeSummary", - "OfficeDocumentSummary", - "SharePointCrawl", - "SharePointInternal", - "Storage", - "Basic", - "BasicExtended", - "BasicContent", - "SharePointDav", - "SharePointList", - "BasicLegacy", - "PublicStrings", - "SharePointContent", - IgnoreCase = $true)] - [string] $PropertySet, - - [Parameter(Mandatory = $true, ParameterSetName = "PropertySetGuid")] - [Guid] $PropertySetGuid, - - [Parameter(Mandatory = $false)] - [switch] $Force, - - [Parameter(Mandatory = $false)] - [switch] $PrintConfig -) - -Set-StrictMode -Version 3.0 -$ErrorActionPreference = "Stop" - -$propertySets = @{ - SharePointDefault = @{ Id = [Guid]"00130329-0000-0130-C000-000000131346"; CategoryName = "SharePoint"; MapToContents = $true; Recommended = $true } - SharePointTaxonomy = @{ Id = [Guid]"158D7563-AEFF-4DBF-BF16-4A1445F0366C"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } - SharePointStructured = @{ Id = [Guid]"ED280121-B677-4E2A-8FBC-0D9E2325B0A2"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } - SharePointRich = @{ Id = [Guid]"FEA84DF6-A0FC-492C-9AA7-D28B8DCB08B3"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $true } - OfficeSummary = @{ Id = [Guid]"F29F85E0-4FF9-1068-AB91-08002B27B3D9"; CategoryName = "Office"; MapToContents = $false; Recommended = $false } - OfficeDocumentSummary = @{ Id = [Guid]"D5CDD502-2E9C-101B-9397-08002B2CF9AE"; CategoryName = "Office"; MapToContents = $false; Recommended = $false } - SharePointCrawl = @{ Id = [Guid]"D1B5D3F0-C0B3-11CF-9A92-00A0C908DBF1"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } - SharePointInternal = @{ Id = [Guid]"012357BD-1113-171D-1F25-292BB0B0B0B0"; CategoryName = "Internal"; MapToContents = $false; Recommended = $false } - Storage = @{ Id = [Guid]"B725F130-47EF-101A-A5F1-02608C9EEBAC"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } - Basic = @{ Id = [Guid]"49691C90-7E17-101A-A91C-08002B2ECDA9"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } - BasicExtended = @{ Id = [Guid]"C82BF597-B831-11D0-B733-00AA00A1EBD2"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } - BasicContent = @{ Id = [Guid]"70EB7A10-55D9-11CF-B75B-00AA0051FE20"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } - SharePointDav = @{ Id = [Guid]"00140329-0000-0140-C000-000000141446"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } - SharePointList = @{ Id = [Guid]"00110329-0000-0110-C000-000000111146"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } - BasicLegacy = @{ Id = [Guid]"0B63E343-9CCC-11D0-BCDB-00805FCCCE04"; CategoryName = "Basic"; MapToContents = $false; Recommended = $false } - PublicStrings = @{ Id = [Guid]"00020329-0000-0000-C000-000000000046"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } - SharePointContent = @{ Id = [Guid]"0C4B2ABA-0518-4EC2-807E-25DD264B660F"; CategoryName = "SharePoint"; MapToContents = $false; Recommended = $false } -} - -function Import-RequiredModule { - param([Parameter(Mandatory = $true)][string] $Name) - - if (-not (Get-Module -ListAvailable -Name $Name)) { - throw "Required PowerShell module '$Name' was not found. Install it with: Install-Module $Name -Scope CurrentUser" - } - - Import-Module $Name -ErrorAction Stop -} - -function Get-ExpectedPropertySet { - param([Parameter(Mandatory = $true)][string] $CrawledPropertyName) - - if ($CrawledPropertyName -match "^ows_taxId") { return "SharePointTaxonomy" } - if ($CrawledPropertyName -match "^ows_q_") { return "SharePointStructured" } - if ($CrawledPropertyName -match "^ows_r_") { return "SharePointRich" } - if ($CrawledPropertyName -match "^ows_") { return "SharePointDefault" } - - return $null -} - -function Confirm-SafetyChecks { - param( - [Parameter(Mandatory = $true)][string] $CrawledPropertyName, - [Parameter(Mandatory = $true)][string] $ResolvedPropertySetName, - [Parameter(Mandatory = $true)][hashtable] $ResolvedPropertySet, - [Parameter(Mandatory = $true)][bool] $UsedGuidParameter - ) - - $warnings = New-Object System.Collections.Generic.List[string] - - if ($UsedGuidParameter) { - $warnings.Add("You are using a property set GUID directly. Prefer -PropertySet unless you are reproducing an existing crawled property pattern.") - } - - if (-not $ResolvedPropertySet.Recommended) { - $warnings.Add("'$ResolvedPropertySetName' is a less common property set. Most SharePoint crawled properties should use SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich.") - } - - $expected = Get-ExpectedPropertySet -CrawledPropertyName $CrawledPropertyName - if ($null -ne $expected -and $ResolvedPropertySetName -ne $expected) { - $warnings.Add("The crawled property name '$CrawledPropertyName' usually belongs in '$expected', but '$ResolvedPropertySetName' was selected.") - } - - if ($warnings.Count -eq 0 -or $Force) { - foreach ($warning in $warnings) { - Write-Warning $warning - } - return - } - - foreach ($warning in $warnings) { - Write-Warning $warning - } - - $caption = "Confirm crawled property creation" - $message = "Crawled properties cannot be deleted or moved to a different property set through supported PnP.PowerShell commands. Continue?" - if (-not $PSCmdlet.ShouldContinue($message, $caption)) { - throw "Crawled property creation cancelled." - } -} - -function Resolve-PropertySet { - if ($PSCmdlet.ParameterSetName -eq "PropertySetGuid") { - foreach ($entry in $propertySets.GetEnumerator()) { - if ($entry.Value.Id -eq $PropertySetGuid) { - return [PSCustomObject]@{ - Name = $entry.Key - Info = $entry.Value - UsedGuidParameter = $true - } - } - } - - throw "Property set '$PropertySetGuid' is not supported by this script." - } - - return [PSCustomObject]@{ - Name = $PropertySet - Info = $propertySets[$PropertySet] - UsedGuidParameter = $false - } -} - -function Get-TenantSchemaId { - $searchConfig = Get-PnPSearchConfiguration -Scope Subscription - $xml = [xml]$searchConfig - $schemaIdNode = $xml.SelectSingleNode("//*[local-name()='SchemaId']") - - if ($null -ne $schemaIdNode -and -not [string]::IsNullOrWhiteSpace($schemaIdNode.InnerText)) { - return [int]$schemaIdNode.InnerText - } - - throw "Could not resolve the tenant search schema ID from Get-PnPSearchConfiguration -Scope Subscription. No changes were made." -} - -function New-EmptyInfoCollectionXml { - param( - [Parameter(Mandatory = $true)][string] $ElementName, - [Parameter(Mandatory = $false)][bool] $IncludeTotalCount = $false - ) - - $xml = @" -<$ElementName xmlns:d3p1="http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"> - - -"@ - - if ($IncludeTotalCount) { - $xml += "`n 0" - } - - $xml += "`n" - return $xml -} - -function New-EmptyDictionaryCollectionXml { - param([Parameter(Mandatory = $true)][string] $ElementName) - - return @" -<$ElementName xmlns:d3p1="http://schemas.datacontract.org/2004/07/Microsoft.Office.Server.Search.Administration"> - - -"@ -} - -function New-SearchConfigurationXml { - param( - [Parameter(Mandatory = $true)][string] $CrawledPropertyName, - [Parameter(Mandatory = $true)][Guid] $PropertySetId, - [Parameter(Mandatory = $true)][string] $CategoryName, - [Parameter(Mandatory = $true)][bool] $MapToContents, - [Parameter(Mandatory = $true)][int] $SchemaId - ) - - $escapedName = [System.Security.SecurityElement]::Escape($CrawledPropertyName) - $escapedCategory = [System.Security.SecurityElement]::Escape($CategoryName) - $propertySetText = $PropertySetId.ToString("D") - $mapToContentsText = $MapToContents.ToString().ToLowerInvariant() - - $aliases = New-EmptyInfoCollectionXml -ElementName "Aliases" - $crawledProperties = New-EmptyInfoCollectionXml -ElementName "CrawledProperties" - $managedProperties = New-EmptyInfoCollectionXml -ElementName "ManagedProperties" -IncludeTotalCount $true - $mappings = New-EmptyDictionaryCollectionXml -ElementName "Mappings" - $overrides = New-EmptyDictionaryCollectionXml -ElementName "Overrides" - - return @" - - - - - 00000000-0000-0000-0000-000000000000 - true - false - false - - - - - - - - - - - - $aliases - - - $propertySetText - - $escapedName - - - $escapedName - - $escapedName - $escapedCategory - false - $mapToContentsText - false - - $propertySetText - - $SchemaId - - - - - - - $crawledProperties - $managedProperties - $mappings - $overrides - $SchemaId - - - - -"@ -} - -Import-RequiredModule -Name "PnP.PowerShell" - -$connection = Get-PnPConnection -ErrorAction SilentlyContinue -if ($null -eq $connection) { - throw "No active PnP connection was found. Connect to the tenant admin site first, for example: Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -ClientId -Tenant .onmicrosoft.com -CertificatePath " -} - -if ($connection.Url -notmatch "-admin\.sharepoint\.") { - throw "The active PnP connection is '$($connection.Url)'. Connect to the SharePoint Online tenant admin site before running this script." -} - -$resolved = Resolve-PropertySet -Confirm-SafetyChecks -CrawledPropertyName $Name -ResolvedPropertySetName $resolved.Name -ResolvedPropertySet $resolved.Info -UsedGuidParameter $resolved.UsedGuidParameter - -$schemaId = Get-TenantSchemaId -$configuration = New-SearchConfigurationXml -CrawledPropertyName $Name -PropertySetId $resolved.Info.Id -CategoryName $resolved.Info.CategoryName -MapToContents $resolved.Info.MapToContents -SchemaId $schemaId - -Write-Verbose "Creating tenant crawled property '$Name' using property set '$($resolved.Name)' ($($resolved.Info.Id)), category '$($resolved.Info.CategoryName)', and schema ID '$schemaId'." -Write-Debug $configuration - -if ($PrintConfig) { - $configuration - return -} - -if ($PSCmdlet.ShouldProcess($Name, "Create tenant search crawled property in property set '$($resolved.Name)'")) { - Set-PnPSearchConfiguration -Scope Subscription -Configuration $configuration - - [PSCustomObject]@{ - Name = $Name - PropertySet = $resolved.Name - PropertySetGuid = $resolved.Info.Id - CategoryName = $resolved.Info.CategoryName - MapToContents = $resolved.Info.MapToContents - SchemaId = $schemaId - Imported = $true - } -} From 6e5d4c63c04bb75bcade3e282cfa983a14f4939d Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:41:09 +0200 Subject: [PATCH 04/10] Add tenant search crawled property support --- .../Add-PnPTenantSearchCrawledProperty.md | 4 +- .../Enums/SearchCrawledPropertySet.cs | 15 +--- .../Search/AddTenantSearchCrawledProperty.cs | 72 ++++++++++--------- 3 files changed, 42 insertions(+), 49 deletions(-) diff --git a/documentation/Add-PnPTenantSearchCrawledProperty.md b/documentation/Add-PnPTenantSearchCrawledProperty.md index 27f839e0b7..6e7115f526 100644 --- a/documentation/Add-PnPTenantSearchCrawledProperty.md +++ b/documentation/Add-PnPTenantSearchCrawledProperty.md @@ -33,7 +33,7 @@ Add-PnPTenantSearchCrawledProperty -Name -PropertySetGuid Creates a tenant-level crawled property by importing an additive search schema configuration package. This cmdlet must be run against the tenant admin site. -Most SharePoint crawled properties should use one of these property sets: SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich. Other property sets are supported for advanced scenarios, but the cmdlet will ask for confirmation unless -Force is specified. +Most SharePoint crawled properties should use one of these property sets: SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich. Other supported property sets can be specified by GUID for advanced scenarios, but the cmdlet will ask for confirmation unless -Force is specified. This cmdlet is additive only. SharePoint Online does not expose a supported PnP PowerShell command to delete crawled properties created in error or to move an existing crawled property to a different property set. Verify the crawled property name and property set before running this cmdlet in a production tenant. @@ -120,7 +120,7 @@ Known property set to use for the crawled property. Recommended values are Share ```yaml Type: SearchCrawledPropertySet Parameter Sets: KnownPropertySet -Accepted values: SharePointDefault, SharePointTaxonomy, SharePointStructured, SharePointRich, OfficeSummary, OfficeDocumentSummary, SharePointCrawl, SharePointInternal, Storage, Basic, BasicExtended, BasicContent, SharePointDav, SharePointList, BasicLegacy, PublicStrings, SharePointContent +Accepted values: SharePointDefault, SharePointTaxonomy, SharePointStructured, SharePointRich Required: True Position: Named diff --git a/src/Commands/Enums/SearchCrawledPropertySet.cs b/src/Commands/Enums/SearchCrawledPropertySet.cs index 400749dd6c..341715cd4e 100644 --- a/src/Commands/Enums/SearchCrawledPropertySet.cs +++ b/src/Commands/Enums/SearchCrawledPropertySet.cs @@ -5,19 +5,6 @@ public enum SearchCrawledPropertySet SharePointDefault, SharePointTaxonomy, SharePointStructured, - SharePointRich, - OfficeSummary, - OfficeDocumentSummary, - SharePointCrawl, - SharePointInternal, - Storage, - Basic, - BasicExtended, - BasicContent, - SharePointDav, - SharePointList, - BasicLegacy, - PublicStrings, - SharePointContent + SharePointRich } } diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 72858f2e62..3392be5d56 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -18,30 +18,34 @@ public class AddTenantSearchCrawledProperty : PnPWebCmdlet private const string ParameterSetKnownPropertySet = "KnownPropertySet"; private const string ParameterSetPropertySetGuid = "PropertySetGuid"; - private static readonly IReadOnlyDictionary PropertySets = + private static readonly IReadOnlyDictionary RecommendedPropertySets = new Dictionary { - { SearchCrawledPropertySet.SharePointDefault, new CrawledPropertySetInfo("00130329-0000-0130-C000-000000131346", "SharePoint", true, true) }, - { SearchCrawledPropertySet.SharePointTaxonomy, new CrawledPropertySetInfo("158D7563-AEFF-4DBF-BF16-4A1445F0366C", "SharePoint", false, true) }, - { SearchCrawledPropertySet.SharePointStructured, new CrawledPropertySetInfo("ED280121-B677-4E2A-8FBC-0D9E2325B0A2", "SharePoint", false, true) }, - { SearchCrawledPropertySet.SharePointRich, new CrawledPropertySetInfo("FEA84DF6-A0FC-492C-9AA7-D28B8DCB08B3", "SharePoint", false, true) }, - { SearchCrawledPropertySet.OfficeSummary, new CrawledPropertySetInfo("F29F85E0-4FF9-1068-AB91-08002B27B3D9", "Office", false, false) }, - { SearchCrawledPropertySet.OfficeDocumentSummary, new CrawledPropertySetInfo("D5CDD502-2E9C-101B-9397-08002B2CF9AE", "Office", false, false) }, - { SearchCrawledPropertySet.SharePointCrawl, new CrawledPropertySetInfo("D1B5D3F0-C0B3-11CF-9A92-00A0C908DBF1", "SharePoint", false, false) }, - { SearchCrawledPropertySet.SharePointInternal, new CrawledPropertySetInfo("012357BD-1113-171D-1F25-292BB0B0B0B0", "Internal", false, false) }, - { SearchCrawledPropertySet.Storage, new CrawledPropertySetInfo("B725F130-47EF-101A-A5F1-02608C9EEBAC", "Basic", false, false) }, - { SearchCrawledPropertySet.Basic, new CrawledPropertySetInfo("49691C90-7E17-101A-A91C-08002B2ECDA9", "Basic", false, false) }, - { SearchCrawledPropertySet.BasicExtended, new CrawledPropertySetInfo("C82BF597-B831-11D0-B733-00AA00A1EBD2", "Basic", false, false) }, - { SearchCrawledPropertySet.BasicContent, new CrawledPropertySetInfo("70EB7A10-55D9-11CF-B75B-00AA0051FE20", "Basic", false, false) }, - { SearchCrawledPropertySet.SharePointDav, new CrawledPropertySetInfo("00140329-0000-0140-C000-000000141446", "SharePoint", false, false) }, - { SearchCrawledPropertySet.SharePointList, new CrawledPropertySetInfo("00110329-0000-0110-C000-000000111146", "SharePoint", false, false) }, - { SearchCrawledPropertySet.BasicLegacy, new CrawledPropertySetInfo("0B63E343-9CCC-11D0-BCDB-00805FCCCE04", "Basic", false, false) }, - { SearchCrawledPropertySet.PublicStrings, new CrawledPropertySetInfo("00020329-0000-0000-C000-000000000046", "SharePoint", false, false) }, - { SearchCrawledPropertySet.SharePointContent, new CrawledPropertySetInfo("0C4B2ABA-0518-4EC2-807E-25DD264B660F", "SharePoint", false, false) } + { SearchCrawledPropertySet.SharePointDefault, new CrawledPropertySetInfo("00130329-0000-0130-C000-000000131346", "SharePoint", true, true, SearchCrawledPropertySet.SharePointDefault) }, + { SearchCrawledPropertySet.SharePointTaxonomy, new CrawledPropertySetInfo("158D7563-AEFF-4DBF-BF16-4A1445F0366C", "SharePoint", false, true, SearchCrawledPropertySet.SharePointTaxonomy) }, + { SearchCrawledPropertySet.SharePointStructured, new CrawledPropertySetInfo("ED280121-B677-4E2A-8FBC-0D9E2325B0A2", "SharePoint", false, true, SearchCrawledPropertySet.SharePointStructured) }, + { SearchCrawledPropertySet.SharePointRich, new CrawledPropertySetInfo("FEA84DF6-A0FC-492C-9AA7-D28B8DCB08B3", "SharePoint", false, true, SearchCrawledPropertySet.SharePointRich) } }; - private static readonly IReadOnlyDictionary PropertySetIds = - PropertySets.ToDictionary(kvp => kvp.Value.Id, kvp => kvp.Key); + private static readonly IReadOnlyDictionary AllowedPropertySetIds = + RecommendedPropertySets.Values + .Concat(new[] + { + new CrawledPropertySetInfo("F29F85E0-4FF9-1068-AB91-08002B27B3D9", "Office", false, false), + new CrawledPropertySetInfo("D5CDD502-2E9C-101B-9397-08002B2CF9AE", "Office", false, false), + new CrawledPropertySetInfo("D1B5D3F0-C0B3-11CF-9A92-00A0C908DBF1", "SharePoint", false, false), + new CrawledPropertySetInfo("012357BD-1113-171D-1F25-292BB0B0B0B0", "Internal", false, false), + new CrawledPropertySetInfo("B725F130-47EF-101A-A5F1-02608C9EEBAC", "Basic", false, false), + new CrawledPropertySetInfo("49691C90-7E17-101A-A91C-08002B2ECDA9", "Basic", false, false), + new CrawledPropertySetInfo("C82BF597-B831-11D0-B733-00AA00A1EBD2", "Basic", false, false), + new CrawledPropertySetInfo("70EB7A10-55D9-11CF-B75B-00AA0051FE20", "Basic", false, false), + new CrawledPropertySetInfo("00140329-0000-0140-C000-000000141446", "SharePoint", false, false), + new CrawledPropertySetInfo("00110329-0000-0110-C000-000000111146", "SharePoint", false, false), + new CrawledPropertySetInfo("0B63E343-9CCC-11D0-BCDB-00805FCCCE04", "Basic", false, false), + new CrawledPropertySetInfo("00020329-0000-0000-C000-000000000046", "SharePoint", false, false), + new CrawledPropertySetInfo("0C4B2ABA-0518-4EC2-807E-25DD264B660F", "SharePoint", false, false) + }) + .ToDictionary(propertySet => propertySet.Id); [Parameter(Mandatory = true, Position = 0)] [ValidateNotNullOrEmpty] @@ -68,7 +72,8 @@ protected override void ExecuteCmdlet() var schemaId = ResolveTenantSchemaId(); var configuration = BuildSearchConfigurationXml(Name, propertySet.Info.Id, propertySet.Info.CategoryName, propertySet.Info.MapToContents, schemaId); - LogDebug($"Creating tenant crawled property '{Name}' using property set '{propertySet.KnownPropertySet}' ({propertySet.Info.Id}), category '{propertySet.Info.CategoryName}', and schema ID '{schemaId}'."); + var propertySetDescription = propertySet.Info.PropertySet?.ToString() ?? propertySet.Info.Id.ToString("D", CultureInfo.InvariantCulture); + LogDebug($"Creating tenant crawled property '{Name}' using property set '{propertySetDescription}' ({propertySet.Info.Id}), category '{propertySet.Info.CategoryName}', and schema ID '{schemaId}'."); LogDebug(configuration); try @@ -92,7 +97,7 @@ protected override void ExecuteCmdlet() WriteObject(new { Name, - PropertySet = propertySet.KnownPropertySet?.ToString(), + PropertySet = propertySet.Info.PropertySet?.ToString(), PropertySetGuid = propertySet.Info.Id, propertySet.Info.CategoryName, propertySet.Info.MapToContents, @@ -210,15 +215,15 @@ private ResolvedPropertySet ResolvePropertySet() { if (ParameterSetName == ParameterSetPropertySetGuid) { - if (!PropertySetIds.TryGetValue(PropertySetGuid, out var knownPropertySet)) + if (!AllowedPropertySetIds.TryGetValue(PropertySetGuid, out var propertySetInfo)) { throw new ArgumentException($"Property set '{PropertySetGuid}' is not supported by this cmdlet."); } - return new ResolvedPropertySet(knownPropertySet, PropertySets[knownPropertySet], true); + return new ResolvedPropertySet(propertySetInfo, true); } - return new ResolvedPropertySet(PropertySet, PropertySets[PropertySet], false); + return new ResolvedPropertySet(RecommendedPropertySets[PropertySet], false); } private void ConfirmIfNeeded(ResolvedPropertySet propertySet) @@ -250,13 +255,14 @@ private static IEnumerable GetWarnings(string name, ResolvedPropertySet if (!propertySet.Info.IsRecommended) { - yield return $"'{propertySet.KnownPropertySet}' is a less common property set. Most SharePoint crawled properties should use SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich."; + yield return "The selected property set GUID is for a less common property set. Most SharePoint crawled properties should use SharePointDefault, SharePointTaxonomy, SharePointStructured, or SharePointRich."; } var expectedPropertySet = GetExpectedPropertySet(name); - if (expectedPropertySet.HasValue && propertySet.KnownPropertySet != expectedPropertySet.Value) + if (expectedPropertySet.HasValue && propertySet.Info.PropertySet != expectedPropertySet.Value) { - yield return $"The crawled property name '{name}' usually belongs in '{expectedPropertySet.Value}', but '{propertySet.KnownPropertySet}' was selected."; + var selectedPropertySet = propertySet.Info.PropertySet?.ToString() ?? propertySet.Info.Id.ToString("D", CultureInfo.InvariantCulture); + yield return $"The crawled property name '{name}' usually belongs in '{expectedPropertySet.Value}', but '{selectedPropertySet}' was selected."; } } @@ -287,15 +293,12 @@ private static IEnumerable GetWarnings(string name, ResolvedPropertySet private sealed class ResolvedPropertySet { - internal ResolvedPropertySet(SearchCrawledPropertySet? knownPropertySet, CrawledPropertySetInfo info, bool usedGuidParameter) + internal ResolvedPropertySet(CrawledPropertySetInfo info, bool usedGuidParameter) { - KnownPropertySet = knownPropertySet; Info = info; UsedGuidParameter = usedGuidParameter; } - internal SearchCrawledPropertySet? KnownPropertySet { get; } - internal CrawledPropertySetInfo Info { get; } internal bool UsedGuidParameter { get; } @@ -303,12 +306,13 @@ internal ResolvedPropertySet(SearchCrawledPropertySet? knownPropertySet, Crawled private sealed class CrawledPropertySetInfo { - internal CrawledPropertySetInfo(string id, string categoryName, bool mapToContents, bool isRecommended) + internal CrawledPropertySetInfo(string id, string categoryName, bool mapToContents, bool isRecommended, SearchCrawledPropertySet? propertySet = null) { Id = Guid.Parse(id); CategoryName = categoryName; MapToContents = mapToContents; IsRecommended = isRecommended; + PropertySet = propertySet; } internal Guid Id { get; } @@ -318,6 +322,8 @@ internal CrawledPropertySetInfo(string id, string categoryName, bool mapToConten internal bool MapToContents { get; } internal bool IsRecommended { get; } + + internal SearchCrawledPropertySet? PropertySet { get; } } } } From f9e89f61e5bc9bca21fb7db618efd1981588417f Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:13:26 +0200 Subject: [PATCH 05/10] Address tenant crawled property review feedback --- .../Add-PnPTenantSearchCrawledProperty.md | 30 +++++++++++-------- .../Search/AddTenantSearchCrawledProperty.cs | 8 ++--- .../AddPnPTenantSearchCrawledPropertyTests.cs | 1 + 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/documentation/Add-PnPTenantSearchCrawledProperty.md b/documentation/Add-PnPTenantSearchCrawledProperty.md index 6e7115f526..11d1729be3 100644 --- a/documentation/Add-PnPTenantSearchCrawledProperty.md +++ b/documentation/Add-PnPTenantSearchCrawledProperty.md @@ -83,6 +83,21 @@ Creates a crawled property by specifying the supported property set GUID directl ## PARAMETERS +### -Connection + +Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either specifying -ReturnConnection on Connect-PnPOnline or by executing Get-PnPConnection. + +```yaml +Type: PnPConnection +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Force Suppresses confirmation prompts for less common property sets, direct GUID usage, or crawled property names that do not match the selected property set. @@ -144,17 +159,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Connection - -Optional connection to be used by the cmdlet. Retrieve the value for this parameter by either specifying -ReturnConnection on Connect-PnPOnline or by executing Get-PnPConnection. - -```yaml -Type: PnPConnection -Parameter Sets: (All) +## RELATED LINKS -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` +Microsoft 365 Patterns and Practices https://aka.ms/m365pnp diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 3392be5d56..a78c0122cb 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -97,10 +97,10 @@ protected override void ExecuteCmdlet() WriteObject(new { Name, - PropertySet = propertySet.Info.PropertySet?.ToString(), + PropertySet = propertySet.Info.PropertySet?.ToString(), PropertySetGuid = propertySet.Info.Id, - propertySet.Info.CategoryName, - propertySet.Info.MapToContents, + CategoryName = propertySet.Info.CategoryName, + MapToContents = propertySet.Info.MapToContents, SchemaId = schemaId, Imported = true }); @@ -235,7 +235,7 @@ private void ConfirmIfNeeded(ResolvedPropertySet propertySet) } var message = string.Join(Environment.NewLine, warnings) + Environment.NewLine + "Do you want to continue creating the tenant crawled property?"; - if (!Force && !ShouldContinue(message, Properties.Resources.Confirm)) + if (!Force && !ShouldContinue(message, Resources.Confirm)) { throw new InvalidOperationException("Crawled property creation cancelled."); } diff --git a/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs index a4fc5c880e..9e9d661797 100644 --- a/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs +++ b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs @@ -46,6 +46,7 @@ public void BuildSearchConfigurationXmlCreatesStructuredPayloadWithoutMappingToC private static string BuildSearchConfigurationXml(string name, Guid propertySetId, string categoryName, bool mapToContents, int schemaId) { var method = typeof(AddTenantSearchCrawledProperty).GetMethod("BuildSearchConfigurationXml", BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(method, "Could not find BuildSearchConfigurationXml helper on AddTenantSearchCrawledProperty."); return (string)method.Invoke(null, new object[] { name, propertySetId, categoryName, mapToContents, schemaId }); } } From 9b2b68e1845cb8c8147bfc5c80a90ffbcf0375d4 Mon Sep 17 00:00:00 2001 From: Patrick Schneider <46457644+pschneid@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:43:16 +0200 Subject: [PATCH 06/10] Avoid allocating and culture-lowering the URL when checking for the tenant admin site. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Commands/Search/AddTenantSearchCrawledProperty.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index a78c0122cb..86f5e54408 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -62,10 +62,10 @@ public class AddTenantSearchCrawledProperty : PnPWebCmdlet protected override void ExecuteCmdlet() { - if (!ClientContext.Url.ToLower(CultureInfo.InvariantCulture).Contains("-admin")) - { - throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); - } + if (!ClientContext.Url.Contains("-admin", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); + } var propertySet = ResolvePropertySet(); ConfirmIfNeeded(propertySet); From a36aadbaed79e01e1a78a789937513371b8edc07 Mon Sep 17 00:00:00 2001 From: Mikael Svenson <51104+wobba@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:00:42 +0200 Subject: [PATCH 07/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Commands/Search/AddTenantSearchCrawledProperty.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 86f5e54408..0cb809b98a 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -62,10 +62,10 @@ public class AddTenantSearchCrawledProperty : PnPWebCmdlet protected override void ExecuteCmdlet() { - if (!ClientContext.Url.Contains("-admin", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); - } + if (!ClientContext.Url.Contains("-admin", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); + } var propertySet = ResolvePropertySet(); ConfirmIfNeeded(propertySet); From 9996a29fe81bc7c388c2057b4f8348719c456364 Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:38:43 +0200 Subject: [PATCH 08/10] Add required permissions for crawled property cmdlet --- src/Commands/Search/AddTenantSearchCrawledProperty.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 0cb809b98a..277993da39 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -7,12 +7,15 @@ using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client.Search.Administration; using Microsoft.SharePoint.Client.Search.Portability; +using PnP.PowerShell.Commands.Attributes; using PnP.PowerShell.Commands.Enums; using Resources = PnP.PowerShell.Commands.Properties.Resources; namespace PnP.PowerShell.Commands.Search { [Cmdlet(VerbsCommon.Add, "PnPTenantSearchCrawledProperty", DefaultParameterSetName = ParameterSetKnownPropertySet)] + [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] + [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] public class AddTenantSearchCrawledProperty : PnPWebCmdlet { private const string ParameterSetKnownPropertySet = "KnownPropertySet"; From be72be75406fc15d50a50b95de6f4e4e5cc54d6d Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:37:45 +0200 Subject: [PATCH 09/10] Address crawled property cmdlet review feedback --- documentation/Add-PnPTenantSearchCrawledProperty.md | 2 ++ src/Commands/Search/AddTenantSearchCrawledProperty.cs | 11 ++++++++--- .../Search/AddPnPTenantSearchCrawledPropertyTests.cs | 5 +---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/documentation/Add-PnPTenantSearchCrawledProperty.md b/documentation/Add-PnPTenantSearchCrawledProperty.md index 11d1729be3..8a2e012ff2 100644 --- a/documentation/Add-PnPTenantSearchCrawledProperty.md +++ b/documentation/Add-PnPTenantSearchCrawledProperty.md @@ -39,6 +39,8 @@ This cmdlet is additive only. SharePoint Online does not expose a supported PnP If this cmdlet is used to make an implicit crawled property explicit, SharePoint Online will stop automatically creating an implicit managed property for that crawled property going forward. +This cmdlet supports PowerShell's standard -WhatIf and -Confirm parameters. Because creating a tenant crawled property is difficult to undo, the cmdlet asks for confirmation by default. Use -WhatIf to preview the operation or -Confirm:$false to suppress the standard confirmation prompt. + ## EXAMPLES ### EXAMPLE 1 diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 277993da39..6689e39b22 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -13,7 +13,7 @@ namespace PnP.PowerShell.Commands.Search { - [Cmdlet(VerbsCommon.Add, "PnPTenantSearchCrawledProperty", DefaultParameterSetName = ParameterSetKnownPropertySet)] + [Cmdlet(VerbsCommon.Add, "PnPTenantSearchCrawledProperty", DefaultParameterSetName = ParameterSetKnownPropertySet, SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] public class AddTenantSearchCrawledProperty : PnPWebCmdlet @@ -71,11 +71,16 @@ protected override void ExecuteCmdlet() } var propertySet = ResolvePropertySet(); - ConfirmIfNeeded(propertySet); + var propertySetDescription = propertySet.Info.PropertySet?.ToString() ?? propertySet.Info.Id.ToString("D", CultureInfo.InvariantCulture); + + if (!ShouldProcess(Name, $"Create tenant search crawled property in property set '{propertySetDescription}'")) + { + return; + } + ConfirmIfNeeded(propertySet); var schemaId = ResolveTenantSchemaId(); var configuration = BuildSearchConfigurationXml(Name, propertySet.Info.Id, propertySet.Info.CategoryName, propertySet.Info.MapToContents, schemaId); - var propertySetDescription = propertySet.Info.PropertySet?.ToString() ?? propertySet.Info.Id.ToString("D", CultureInfo.InvariantCulture); LogDebug($"Creating tenant crawled property '{Name}' using property set '{propertySetDescription}' ({propertySet.Info.Id}), category '{propertySet.Info.CategoryName}', and schema ID '{schemaId}'."); LogDebug(configuration); diff --git a/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs index 9e9d661797..459a8cda3b 100644 --- a/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs +++ b/src/Tests/Search/AddPnPTenantSearchCrawledPropertyTests.cs @@ -1,5 +1,4 @@ using System; -using System.Reflection; using System.Xml.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; using PnP.PowerShell.Commands.Search; @@ -45,9 +44,7 @@ public void BuildSearchConfigurationXmlCreatesStructuredPayloadWithoutMappingToC private static string BuildSearchConfigurationXml(string name, Guid propertySetId, string categoryName, bool mapToContents, int schemaId) { - var method = typeof(AddTenantSearchCrawledProperty).GetMethod("BuildSearchConfigurationXml", BindingFlags.Static | BindingFlags.NonPublic); - Assert.IsNotNull(method, "Could not find BuildSearchConfigurationXml helper on AddTenantSearchCrawledProperty."); - return (string)method.Invoke(null, new object[] { name, propertySetId, categoryName, mapToContents, schemaId }); + return AddTenantSearchCrawledProperty.BuildSearchConfigurationXml(name, propertySetId, categoryName, mapToContents, schemaId); } } From 237e70098d62eea84115aa2f6d2b1fec77655139 Mon Sep 17 00:00:00 2001 From: pschneid <46457644+pschneid@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:12:40 +0200 Subject: [PATCH 10/10] Use admin context for tenant crawled property cmdlet --- .../Search/AddTenantSearchCrawledProperty.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Commands/Search/AddTenantSearchCrawledProperty.cs b/src/Commands/Search/AddTenantSearchCrawledProperty.cs index 6689e39b22..636d91cc0c 100644 --- a/src/Commands/Search/AddTenantSearchCrawledProperty.cs +++ b/src/Commands/Search/AddTenantSearchCrawledProperty.cs @@ -8,6 +8,7 @@ using Microsoft.SharePoint.Client.Search.Administration; using Microsoft.SharePoint.Client.Search.Portability; using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Base; using PnP.PowerShell.Commands.Enums; using Resources = PnP.PowerShell.Commands.Properties.Resources; @@ -16,7 +17,7 @@ namespace PnP.PowerShell.Commands.Search [Cmdlet(VerbsCommon.Add, "PnPTenantSearchCrawledProperty", DefaultParameterSetName = ParameterSetKnownPropertySet, SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] [RequiredApiApplicationPermissions("sharepoint/Sites.FullControl.All")] [RequiredApiDelegatedPermissions("sharepoint/AllSites.FullControl")] - public class AddTenantSearchCrawledProperty : PnPWebCmdlet + public class AddTenantSearchCrawledProperty : PnPSharePointOnlineAdminCmdlet { private const string ParameterSetKnownPropertySet = "KnownPropertySet"; private const string ParameterSetPropertySetGuid = "PropertySetGuid"; @@ -65,11 +66,6 @@ public class AddTenantSearchCrawledProperty : PnPWebCmdlet protected override void ExecuteCmdlet() { - if (!ClientContext.Url.Contains("-admin", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException(Resources.CurrentSiteIsNoTenantAdminSite); - } - var propertySet = ResolvePropertySet(); var propertySetDescription = propertySet.Info.PropertySet?.ToString() ?? propertySet.Info.Id.ToString("D", CultureInfo.InvariantCulture); @@ -86,7 +82,7 @@ protected override void ExecuteCmdlet() try { - ClientContext.ImportSearchSettingsConfiguration(configuration, SearchObjectLevel.SPSiteSubscription); + AdminContext.ImportSearchSettingsConfiguration(configuration, SearchObjectLevel.SPSiteSubscription); } catch (ServerException ex) { @@ -202,10 +198,10 @@ private static XElement CreateEmptyDictionaryCollection(XName name, XNamespace i private int ResolveTenantSchemaId() { - SearchObjectOwner owningScope = new SearchObjectOwner(ClientContext, SearchObjectLevel.SPSiteSubscription); - var config = new SearchConfigurationPortability(ClientContext); + SearchObjectOwner owningScope = new SearchObjectOwner(AdminContext, SearchObjectLevel.SPSiteSubscription); + var config = new SearchConfigurationPortability(AdminContext); ClientResult configuration = config.ExportSearchConfiguration(owningScope); - ClientContext.ExecuteQueryRetry(); + AdminContext.ExecuteQueryRetry(); var schemaIdValue = XDocument.Parse(configuration.Value) .Descendants()