From 68d7ca6755b132158e929cb860f77cc21d5ae904 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Thu, 16 Apr 2026 13:11:23 +0200 Subject: [PATCH] Refactor metadata column mappings in README and PowerShell script - Updated README to reflect changes in metadata column mappings structure, separating system and custom columns. - Modified PowerShell script to support new metadata column mapping structure. - Introduced functions to handle taxonomy fields and improved error handling during item saving. - Enhanced import functionality to manage existing files and handle required fields more effectively. --- README.md | 67 ++- Start-SPMigration.ps1 | 1011 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 1023 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b91c894..d8f4714 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ Beim Export wird automatisch eine `MappingTable.json` erzeugt. Diese Datei entha - `LibraryMappings`: Mapping von Quellbibliothek auf Zielbibliothek - `ListMappings`: Mapping von Quellliste auf Zielliste -- `MetadataColumnMappings`: Mapping der Metadaten-Spalten je Liste/Bibliothek +- `MetadataColumnMappings.SystemColumns`: erkannte Systemspalten +- `MetadataColumnMappings.CustomColumns`: erkannte eigene Fachspalten Beispiel: @@ -110,19 +111,44 @@ Beispiel: "Hidden": false } ], - "MetadataColumnMappings": [ - { - "ObjectType": "DocumentLibrary", - "ContainerSourceTitle": "Documents", - "SourceInternalName": "DHS2016Persdat", - "TargetInternalName": "Persdat", - "DisplayName": "Personaldaten", - "TypeAsString": "Text", - "Hidden": false, - "ReadOnly": false, - "Sealed": false - } - ] + "MetadataColumnMappings": { + "SystemColumns": [ + { + "ObjectType": "List", + "ContainerSourceTitle": "Kalender", + "SourceInternalName": "EventDate", + "SourceCanonicalInternalName": "EventDate", + "SourceSupportingInternalNames": [], + "TargetInternalName": "EventDate", + "DisplayName": "Beginnt", + "TypeAsString": "DateTime", + "Hidden": false, + "ReadOnly": false, + "Sealed": true, + "IsSystemColumn": true, + "ImportSupported": true + } + ], + "CustomColumns": [ + { + "ObjectType": "DocumentLibrary", + "ContainerSourceTitle": "Documents", + "SourceInternalName": "SecurityClearance", + "SourceCanonicalInternalName": "SecurityClearance", + "SourceSupportingInternalNames": [ + "SecurityClearance_0" + ], + "TargetInternalName": "GMNSecurityClearance", + "DisplayName": "Security Clearance", + "TypeAsString": "TaxonomyFieldType", + "Hidden": false, + "ReadOnly": false, + "Sealed": false, + "IsSystemColumn": false, + "ImportSupported": true + } + ] + } } ``` @@ -130,6 +156,11 @@ Fuer das Mapping ist vor allem relevant: - `TargetTitle`: Zielname von Liste oder Bibliothek - `TargetInternalName`: Zielfeld fuer die exportierte Metadatenspalte +- `SystemColumns`: SharePoint-Standardfelder wie `EventDate`, `EndDate`, `Title` oder `Created` +- `CustomColumns`: eigene Fachspalten, die im Ziel oft umbenannt oder anders aufgebaut sind +- `SourceSupportingInternalNames`: technische Begleitspalten wie `_0` werden nur noch informativ aufgefuehrt und nicht separat gemappt +- `ImportSupported`: steuert, ob eine Spalte beim Import ueberhaupt beruecksichtigt wird +- Taxonomy-Felder werden beim Import ueber den exportierten `TermGuid` gesetzt; dabei wird angenommen, dass derselbe Term im Ziel bereits mit identischer GUID existiert Wenn eine Ziel-Liste oder Ziel-Bibliothek nicht existiert, gibt das Skript eine Warnung aus, dass dieser Container manuell angelegt werden soll. @@ -154,13 +185,17 @@ Wenn eine Ziel-Liste oder Ziel-Bibliothek nicht existiert, gibt das Skript eine - `MappingTable`: JSON-Datei mit Listen-, Bibliotheks- und Spalten-Mappings - `ImportFiles`: importiert nur Dateien/Bibliotheken - `ImportLists`: importiert nur Listen -- `OverwriteFiles`: ueberschreibt vorhandene Dateien beim Import +- `Overwrite`: ueberschreibt vorhandene Dateien beim Import Hinweis: - Wenn weder `ImportFiles` noch `ImportLists` gesetzt sind, werden beim Import beide Bereiche verarbeitet. - Wird `MappingTable` nicht angegeben, verwendet das Skript standardmaessig `OutputPath\MappingTable.json`. - Die alte CSV-Variante wird fuer reines Feldmapping weiterhin als Fallback gelesen. +- Werden keine Schalter gesetzt, leitet das Skript den Modus weiterhin aus `SourceUrl` und `TargetUrl` ab. +- Wenn `Overwrite` nicht gesetzt ist und eine Datei bereits existiert, wird bei aktiver Versionierung eine neue Version geschrieben; ohne Versionierung wird die Datei uebersprungen. +- Nicht importierbare Systemfelder wie `Attachments`, `Created`, `Modified`, `Author` oder `Editor` werden automatisch aus der MappingTable herausgehalten bzw. beim Import uebersprungen. +- Vor dem Speichern prueft das Skript fehlende Pflichtfelder. Wenn ein einzelnes Listen- oder Datei-Metadatenobjekt trotzdem nicht gespeichert werden kann, wird es mit Kontext-Warnung uebersprungen und der Import laeuft weiter. ## Beispiele @@ -212,7 +247,7 @@ Anschliessend: -Import ` -MappingTable "C:\Temp\SP-Export\MappingTable.json" ` -ImportFiles ` - -OverwriteFiles + -Overwrite ``` ### Nur Listen importrelevant ausfuehren diff --git a/Start-SPMigration.ps1 b/Start-SPMigration.ps1 index 2727d64..2a316a5 100644 --- a/Start-SPMigration.ps1 +++ b/Start-SPMigration.ps1 @@ -23,20 +23,31 @@ param( [switch]$ImportLists, - [switch]$OverwriteFiles, - - [Alias("ExportOnly")] - [switch]$ExportOnly, - - [Alias("ImportOnly")] - [switch]$ImportOnly + [Alias("OverwriteFiles")] + [switch]$Overwrite ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +$script:SkippedImportFieldWarnings = @{} function Initialize-SharePointPowerShell { - Import-Module SharePointServer + if($null -eq $(Get-Module SharePointServer)) { + Import-Module SharePointServer + }else{ + Write-Host "SharePoint Server PowerShell-Modul bereits geladen." + } + + try { + [void][System.Reflection.Assembly]::Load("Microsoft.SharePoint.Taxonomy") + } + catch { + try { + Add-Type -AssemblyName "Microsoft.SharePoint.Taxonomy" + } + catch { + } + } } function Ensure-Directory { @@ -65,7 +76,7 @@ function Invoke-MigrationImport { [switch]$ImportLists, - [switch]$OverwriteFiles + [switch]$Overwrite ) $resolvedInputPath = [System.IO.Path]::GetFullPath($InputPath) @@ -88,7 +99,7 @@ function Invoke-MigrationImport { $targetWeb = Get-SPWeb -Identity $TargetWebUrl -ErrorAction Stop if ($shouldImportFiles) { - Import-SPDocumentLibraries -Web $targetWeb -FilesRootPath $filesRootPath -MigrationMappingTable $migrationMappingTable -OverwriteFiles:$OverwriteFiles + Import-SPDocumentLibraries -Web $targetWeb -FilesRootPath $filesRootPath -MigrationMappingTable $migrationMappingTable -Overwrite:$Overwrite } if ($shouldImportLists) { @@ -848,6 +859,250 @@ function New-MigrationContainerMappingEntry { } } +function Test-SPFieldSupportedForImport { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field + ) + + $unsupportedInternalNames = @( + "AccessPolicy", + "AppAuthor", + "AppEditor", + "Attachments", + "BannerUrl", + "BaseName", + "Author", + "ComplianceAssetId", + "ContentType", + "ContentTypeId", + "ContentVersion", + "Created", + "Created_x0020_Date", + "DocIcon", + "Duration", + "Edit", + "Editor", + "EncodedAbsUrl", + "EventCanceled", + "EventType", + "FileDirRef", + "File_x0020_Type", + "FileLeafRef", + "FileRef", + "FolderChildCount", + "FreeBusy", + "FSObjType", + "Geolocation", + "GUID", + "HTML_x0020_File_x0020_Type", + "ID", + "InstanceID", + "ItemChildCount", + "Last_x0020_Modified", + "LinkFilename", + "LinkFilename2", + "LinkFilenameNoMenu", + "LinkTitle", + "LinkTitle2", + "LinkTitleNoMenu", + "MasterSeriesItemID", + "MetaInfo", + "Modified", + "NoExecute", + "Order", + "OriginatorId", + "Overbook", + "owshiddenversion", + "Participants", + "PermMask", + "ProgId", + "RecurrenceData", + "RecurrenceID", + "Restricted", + "ScopeId", + "SelectTitle", + "ServerUrl", + "SMLastModifiedDate", + "SMTotalFileCount", + "SMTotalFileStreamSize", + "SMTotalSize", + "SortBehavior", + "SyncClientId", + "TimeZone", + "UID", + "UniqueId", + "webPartRteVersion", + "WorkflowInstanceID", + "WorkflowVersion", + "Workspace", + "WorkspaceLink", + "XMLTZone", + "_Level", + "_ModerationStatus", + "_UIVersion", + "_UIVersionString" + ) + + $unsupportedFieldTypes = @( + "Attachments", + "Computed" + ) + + if ($Field.ReadOnlyField) { + return $false + } + + if ([string]$Field.InternalName -like "_*") { + return $false + } + + if ($unsupportedFieldTypes -contains [string]$Field.TypeAsString) { + return $false + } + + return -not ($unsupportedInternalNames -contains [string]$Field.InternalName) +} + +function Resolve-SPFieldById { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFieldCollection]$Fields, + + [Parameter(Mandatory = $true)] + [Guid]$FieldId + ) + + try { + return $Fields[$FieldId] + } + catch { + } + + foreach ($candidateField in $Fields) { + if ($candidateField.Id -eq $FieldId) { + return $candidateField + } + } + + return $null +} + +function Get-SPFieldSupportingInternalNames { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field, + + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFieldCollection]$Fields + ) + + $supportingInternalNames = @() + + $typeName = [string]$Field.GetType().FullName + if ($typeName -like "*TaxonomyField*") { + try { + $textFieldId = [Guid]$Field.TextField + if ($textFieldId -ne [Guid]::Empty) { + $supportingField = Resolve-SPFieldById -Fields $Fields -FieldId $textFieldId + if ($null -ne $supportingField -and -not [string]::IsNullOrWhiteSpace([string]$supportingField.InternalName)) { + $supportingInternalNames += [string]$supportingField.InternalName + } + } + } + catch { + } + } + + $internalNamePrefix = "{0}_" -f $Field.InternalName + foreach ($candidateField in $Fields) { + if ($candidateField.Id -eq $Field.Id) { + continue + } + + if (-not [bool]$candidateField.Hidden) { + continue + } + + if ([string]$candidateField.InternalName -like ($internalNamePrefix + "*")) { + $supportingInternalNames += [string]$candidateField.InternalName + } + } + + return @($supportingInternalNames | Sort-Object -Unique) +} + +function Test-SPFieldShownInMappingTable { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field, + + [hashtable]$SupportingFieldLookup = @{} + ) + + $fieldKey = ([string]$Field.InternalName).ToLowerInvariant() + if ($SupportingFieldLookup.ContainsKey($fieldKey)) { + return $false + } + + return $true +} + +function Test-SPFieldIsSystemColumn { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field + ) + + try { + if ([bool]$Field.FromBaseType) { + return $true + } + } + catch { + } + + try { + if (-not [bool]$Field.CanBeDeleted) { + return $true + } + } + catch { + } + + if ([bool]$Field.Hidden) { + return $true + } + + if ([string]$Field.InternalName -like "_*") { + return $true + } + + if (-not (Test-SPFieldSupportedForImport -Field $Field)) { + return $true + } + + return $false +} + +function Write-SkippedImportFieldWarning { + param( + [Parameter(Mandatory = $true)] + [string]$FieldInternalName, + + [Parameter(Mandatory = $true)] + [string]$Message + ) + + $warningKey = $FieldInternalName.ToLowerInvariant() + if ($script:SkippedImportFieldWarnings.ContainsKey($warningKey)) { + return + } + + $script:SkippedImportFieldWarnings[$warningKey] = $true + Write-Warning $Message +} + function New-MigrationFieldMappingEntries { param( [Parameter(Mandatory = $true)] @@ -858,18 +1113,35 @@ function New-MigrationFieldMappingEntries { ) $rows = @() + $supportingFieldLookup = @{} + + foreach ($field in @($List.Fields)) { + foreach ($supportingInternalName in @(Get-SPFieldSupportingInternalNames -Field $field -Fields $List.Fields)) { + if (-not [string]::IsNullOrWhiteSpace([string]$supportingInternalName)) { + $supportingFieldLookup[[string]$supportingInternalName.ToLowerInvariant()] = $true + } + } + } foreach ($field in @($List.Fields | Sort-Object -Property InternalName)) { + if (-not (Test-SPFieldShownInMappingTable -Field $field -SupportingFieldLookup $supportingFieldLookup)) { + continue + } + $rows += [PSCustomObject]@{ ObjectType = $ObjectType ContainerSourceTitle = $List.Title SourceInternalName = $field.InternalName + SourceCanonicalInternalName = $field.InternalName + SourceSupportingInternalNames = @(Get-SPFieldSupportingInternalNames -Field $field -Fields $List.Fields) TargetInternalName = $field.InternalName DisplayName = $field.Title TypeAsString = $field.TypeAsString Hidden = [bool]$field.Hidden ReadOnly = [bool]$field.ReadOnlyField Sealed = [bool]$field.Sealed + IsSystemColumn = (Test-SPFieldIsSystemColumn -Field $field) + ImportSupported = (Test-SPFieldSupportedForImport -Field $field) } } @@ -888,16 +1160,21 @@ function New-MigrationMappingTable { $libraryMappings = @() $listMappings = @() - $metadataColumnMappings = @() + $systemColumnMappings = @() + $customColumnMappings = @() foreach ($library in @($DocumentLibraries)) { $libraryMappings += New-MigrationContainerMappingEntry -ObjectType "DocumentLibrary" -List $library - $metadataColumnMappings += New-MigrationFieldMappingEntries -ObjectType "DocumentLibrary" -List $library + $fieldMappings = @(New-MigrationFieldMappingEntries -ObjectType "DocumentLibrary" -List $library) + $systemColumnMappings += @($fieldMappings | Where-Object { [bool](Get-ObjectPropertyValue -Object $_ -PropertyName "IsSystemColumn" -DefaultValue $false) }) + $customColumnMappings += @($fieldMappings | Where-Object { -not [bool](Get-ObjectPropertyValue -Object $_ -PropertyName "IsSystemColumn" -DefaultValue $false) }) } foreach ($list in @($Lists)) { $listMappings += New-MigrationContainerMappingEntry -ObjectType "List" -List $list - $metadataColumnMappings += New-MigrationFieldMappingEntries -ObjectType "List" -List $list + $fieldMappings = @(New-MigrationFieldMappingEntries -ObjectType "List" -List $list) + $systemColumnMappings += @($fieldMappings | Where-Object { [bool](Get-ObjectPropertyValue -Object $_ -PropertyName "IsSystemColumn" -DefaultValue $false) }) + $customColumnMappings += @($fieldMappings | Where-Object { -not [bool](Get-ObjectPropertyValue -Object $_ -PropertyName "IsSystemColumn" -DefaultValue $false) }) } return [PSCustomObject]@{ @@ -906,7 +1183,10 @@ function New-MigrationMappingTable { SourceWebUrl = $SourceWebUrl LibraryMappings = $libraryMappings ListMappings = $listMappings - MetadataColumnMappings = $metadataColumnMappings + MetadataColumnMappings = [PSCustomObject]@{ + SystemColumns = $systemColumnMappings + CustomColumns = $customColumnMappings + } } } @@ -1056,12 +1336,16 @@ function Get-MigrationMappingTable { ObjectType = "*" ContainerSourceTitle = "*" SourceInternalName = [string]$sourceInternalName + SourceCanonicalInternalName = [string]$sourceInternalName + SourceSupportingInternalNames = @() TargetInternalName = [string]$fieldMapping[$sourceInternalName] DisplayName = "" TypeAsString = "" Hidden = $false ReadOnly = $false Sealed = $false + IsSystemColumn = $false + ImportSupported = $true } } @@ -1071,7 +1355,10 @@ function Get-MigrationMappingTable { SourceWebUrl = "" LibraryMappings = @() ListMappings = @() - MetadataColumnMappings = $metadataColumnMappings + MetadataColumnMappings = [PSCustomObject]@{ + SystemColumns = @() + CustomColumns = $metadataColumnMappings + } } } else { @@ -1097,6 +1384,139 @@ function Get-ContainerMappingTableEntryKey { return ("{0}|{1}" -f $ObjectType.Trim(), $SourceTitle.Trim()).ToLowerInvariant() } +function Get-MetadataColumnMappingRows { + param( + [Parameter(Mandatory = $true)] + $MigrationMappingTable + ) + + $metadataColumnMappings = Get-ObjectPropertyValue -Object $MigrationMappingTable -PropertyName "MetadataColumnMappings" -DefaultValue @() + if ($null -eq $metadataColumnMappings) { + return @() + } + + if ($null -ne $metadataColumnMappings.PSObject.Properties["SystemColumns"] -or $null -ne $metadataColumnMappings.PSObject.Properties["CustomColumns"]) { + return @( + @(Get-ObjectPropertyValue -Object $metadataColumnMappings -PropertyName "SystemColumns" -DefaultValue @()) + + @(Get-ObjectPropertyValue -Object $metadataColumnMappings -PropertyName "CustomColumns" -DefaultValue @()) + ) + } + + return @($metadataColumnMappings) +} + +function Test-MetadataColumnMappingRowImportSupported { + param( + [Parameter(Mandatory = $true)] + $Row + ) + + if (Test-ObjectHasProperty -Object $Row -PropertyName "ImportSupported") { + return [bool](Get-ObjectPropertyValue -Object $Row -PropertyName "ImportSupported" -DefaultValue $false) + } + + $sourceInternalName = [string](Get-ObjectPropertyValue -Object $Row -PropertyName "SourceInternalName") + $typeAsString = [string](Get-ObjectPropertyValue -Object $Row -PropertyName "TypeAsString") + $readOnly = [bool](Get-ObjectPropertyValue -Object $Row -PropertyName "ReadOnly" -DefaultValue $false) + + $unsupportedInternalNames = @( + "AccessPolicy", + "AppAuthor", + "AppEditor", + "Attachments", + "BannerUrl", + "BaseName", + "Author", + "ComplianceAssetId", + "ContentType", + "ContentTypeId", + "ContentVersion", + "Created", + "Created_x0020_Date", + "DocIcon", + "Duration", + "Edit", + "Editor", + "EncodedAbsUrl", + "EventCanceled", + "EventType", + "FileDirRef", + "File_x0020_Type", + "FileLeafRef", + "FileRef", + "FolderChildCount", + "FreeBusy", + "FSObjType", + "Geolocation", + "GUID", + "HTML_x0020_File_x0020_Type", + "ID", + "InstanceID", + "ItemChildCount", + "Last_x0020_Modified", + "LinkFilename", + "LinkFilename2", + "LinkFilenameNoMenu", + "LinkTitle", + "LinkTitle2", + "LinkTitleNoMenu", + "MasterSeriesItemID", + "MetaInfo", + "Modified", + "NoExecute", + "Order", + "OriginatorId", + "Overbook", + "owshiddenversion", + "Participants", + "PermMask", + "ProgId", + "RecurrenceData", + "RecurrenceID", + "Restricted", + "ScopeId", + "SelectTitle", + "ServerUrl", + "SMLastModifiedDate", + "SMTotalFileCount", + "SMTotalFileStreamSize", + "SMTotalSize", + "SortBehavior", + "SyncClientId", + "TimeZone", + "UID", + "UniqueId", + "webPartRteVersion", + "WorkflowInstanceID", + "WorkflowVersion", + "Workspace", + "WorkspaceLink", + "XMLTZone", + "_Level", + "_ModerationStatus", + "_UIVersion", + "_UIVersionString" + ) + + if ($readOnly) { + return $false + } + + if ([string]::IsNullOrWhiteSpace($sourceInternalName)) { + return $false + } + + if ([string]$sourceInternalName -like "_*") { + return $false + } + + if ($typeAsString -in @("Attachments", "Computed")) { + return $false + } + + return -not ($unsupportedInternalNames -contains $sourceInternalName) +} + function Get-ContainerMappingMap { param( [Parameter(Mandatory = $true)] @@ -1186,14 +1606,17 @@ function Get-FieldMappingForContainer { [string]$SourceTitle ) - $rows = @(Get-ObjectPropertyValue -Object $MigrationMappingTable -PropertyName "MetadataColumnMappings" -DefaultValue @()) + $rows = @(Get-MetadataColumnMappingRows -MigrationMappingTable $MigrationMappingTable) $mapping = [ordered]@{} + $mappingPriority = @{} foreach ($row in $rows) { $rowObjectType = [string](Get-ObjectPropertyValue -Object $row -PropertyName "ObjectType") $rowContainerSourceTitle = [string](Get-ObjectPropertyValue -Object $row -PropertyName "ContainerSourceTitle") $sourceInternalName = [string](Get-ObjectPropertyValue -Object $row -PropertyName "SourceInternalName") + $sourceCanonicalInternalName = [string](Get-ObjectPropertyValue -Object $row -PropertyName "SourceCanonicalInternalName") $targetInternalName = [string](Get-ObjectPropertyValue -Object $row -PropertyName "TargetInternalName") + $isHiddenRow = [bool](Get-ObjectPropertyValue -Object $row -PropertyName "Hidden" -DefaultValue $false) if ([string]::IsNullOrWhiteSpace($sourceInternalName) -or [string]::IsNullOrWhiteSpace($targetInternalName)) { continue @@ -1206,7 +1629,32 @@ function Get-FieldMappingForContainer { continue } - $mapping[$sourceInternalName.Trim()] = $targetInternalName.Trim() + if (-not (Test-MetadataColumnMappingRowImportSupported -Row $row)) { + continue + } + + $normalizedSourceInternalName = $sourceCanonicalInternalName + if ([string]::IsNullOrWhiteSpace($normalizedSourceInternalName)) { + $normalizedSourceInternalName = $sourceInternalName + } + + if ($isHiddenRow -and $sourceInternalName -match "^(.*)_\d+$") { + $normalizedSourceInternalName = $matches[1] + } + + if ([string]::IsNullOrWhiteSpace($normalizedSourceInternalName)) { + continue + } + + $priority = if ($isHiddenRow) { 1 } else { 2 } + $normalizedSourceInternalName = $normalizedSourceInternalName.Trim() + + if ($mappingPriority.ContainsKey($normalizedSourceInternalName) -and $mappingPriority[$normalizedSourceInternalName] -gt $priority) { + continue + } + + $mapping[$normalizedSourceInternalName] = $targetInternalName.Trim() + $mappingPriority[$normalizedSourceInternalName] = $priority } return $mapping @@ -1469,6 +1917,250 @@ function Convert-ToUrlFieldValue { return $null } +function Convert-ToTaxonomyTermEntries { + param( + $RawValue, + + [string]$TextValue + ) + + $termEntries = @() + $rawTerms = @($RawValue) + + if (($rawTerms.Length -eq 1) -and $null -eq $rawTerms[0]) { + $rawTerms = @() + } + + foreach ($rawTerm in $rawTerms) { + $termGuid = [string](Get-ObjectPropertyValue -Object $rawTerm -PropertyName "TermGuid") + $label = [string](Get-ObjectPropertyValue -Object $rawTerm -PropertyName "Label") + + if ([string]::IsNullOrWhiteSpace($termGuid) -and ($rawTerm -is [string])) { + $rawTermString = [string]$rawTerm + + if ($rawTermString -match "^(.*)\|([0-9a-fA-F-]{36})$") { + $label = $matches[1] + $termGuid = $matches[2] + } + else { + $parsedGuid = [Guid]::Empty + if ([guid]::TryParse($rawTermString, [ref]$parsedGuid)) { + $termGuid = $rawTermString + } + } + } + + if ([string]::IsNullOrWhiteSpace($label) -and -not [string]::IsNullOrWhiteSpace($TextValue) -and $rawTerms.Length -le 1) { + $label = $TextValue + } + + if ([string]::IsNullOrWhiteSpace($termGuid)) { + continue + } + + if ([string]::IsNullOrWhiteSpace($label)) { + $label = $termGuid + } + + $termEntries += [PSCustomObject]@{ + Label = $label + TermGuid = $termGuid + } + } + + if ($termEntries.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($TextValue)) { + $segments = @($TextValue -split ";#") + + foreach ($segment in $segments) { + if ([string]::IsNullOrWhiteSpace([string]$segment)) { + continue + } + + if ([string]$segment -match "^(.*)\|([0-9a-fA-F-]{36})$") { + $termEntries += [PSCustomObject]@{ + Label = $matches[1] + TermGuid = $matches[2] + } + } + } + } + + return @($termEntries) +} + +function New-TaxonomyFieldValueObject { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field, + + [Parameter(Mandatory = $true)] + $TermEntry + ) + + $label = [string](Get-ObjectPropertyValue -Object $TermEntry -PropertyName "Label") + $termGuid = [string](Get-ObjectPropertyValue -Object $TermEntry -PropertyName "TermGuid") + $labelGuidPair = "{0}|{1}" -f $label, $termGuid + + $taxonomyField = [Microsoft.SharePoint.Taxonomy.TaxonomyField]$Field + $taxonomyValue = $null + + try { + $taxonomyValue = New-Object Microsoft.SharePoint.Taxonomy.TaxonomyFieldValue($taxonomyField) + } + catch { + } + + if ($null -eq $taxonomyValue) { + try { + $taxonomyValue = New-Object Microsoft.SharePoint.Taxonomy.TaxonomyFieldValue + } + catch { + } + } + + if ($null -eq $taxonomyValue) { + throw ("Konnte kein TaxonomyFieldValue fuer Feld '{0}' erstellen." -f $Field.InternalName) + } + + $isPopulated = $false + + try { + $taxonomyValue.PopulateFromLabelGuidPair($labelGuidPair) + $isPopulated = $true + } + catch { + } + + if (-not $isPopulated) { + try { + $taxonomyValue.Label = $label + $taxonomyValue.TermGuid = $termGuid + $taxonomyValue.WssId = -1 + $isPopulated = $true + } + catch { + } + } + + if (-not $isPopulated) { + throw ("Konnte TaxonomyFieldValue fuer Feld '{0}' nicht befuellen." -f $Field.InternalName) + } + + return $taxonomyValue +} + +function Set-SPTaxonomyFieldValue { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field, + + $RawValue, + + [string]$TextValue + ) + + $taxonomyField = [Microsoft.SharePoint.Taxonomy.TaxonomyField]$Field + $termEntries = @(Convert-ToTaxonomyTermEntries -RawValue $RawValue -TextValue $TextValue) + + if ($termEntries.Count -eq 0) { + $Item[$Field.InternalName] = $null + return + } + + if ([string]$Field.TypeAsString -eq "TaxonomyFieldTypeMulti") { + $labelGuidPairs = @() + + foreach ($termEntry in $termEntries) { + $label = [string](Get-ObjectPropertyValue -Object $termEntry -PropertyName "Label") + $termGuid = [string](Get-ObjectPropertyValue -Object $termEntry -PropertyName "TermGuid") + $labelGuidPairs += ("{0}|{1}" -f $label, $termGuid) + } + + $pairString = $labelGuidPairs -join ";#" + $taxonomyCollection = $null + + try { + $taxonomyCollection = New-Object Microsoft.SharePoint.Taxonomy.TaxonomyFieldValueCollection($Item.Web, "", $taxonomyField) + } + catch { + } + + if ($null -eq $taxonomyCollection) { + try { + $taxonomyCollection = New-Object Microsoft.SharePoint.Taxonomy.TaxonomyFieldValueCollection($Item.Web, $pairString, $taxonomyField) + } + catch { + } + } + + if ($null -eq $taxonomyCollection) { + throw ("Konnte keine TaxonomyFieldValueCollection fuer Feld '{0}' erstellen." -f $Field.InternalName) + } + + $collectionIsPopulated = $false + + try { + $taxonomyCollection.PopulateFromLabelGuidPairs($pairString) + $collectionIsPopulated = $true + } + catch { + } + + if (-not $collectionIsPopulated) { + try { + foreach ($termEntry in $termEntries) { + [void]$taxonomyCollection.Add((New-TaxonomyFieldValueObject -Field $Field -TermEntry $termEntry)) + } + $collectionIsPopulated = $true + } + catch { + } + } + + if (-not $collectionIsPopulated) { + throw ("Konnte die TaxonomyFieldValueCollection fuer Feld '{0}' nicht befuellen." -f $Field.InternalName) + } + + try { + $taxonomyField.SetFieldValueByValueCollection($Item, $taxonomyCollection) + return + } + catch { + } + + try { + $taxonomyField.SetFieldValue($Item, $taxonomyCollection) + return + } + catch { + } + + $Item[$Field.InternalName] = $taxonomyCollection + return + } + + $taxonomyValue = New-TaxonomyFieldValueObject -Field $Field -TermEntry $termEntries[0] + + try { + $taxonomyField.SetFieldValueByValue($Item, $taxonomyValue) + return + } + catch { + } + + try { + $taxonomyField.SetFieldValue($Item, $taxonomyValue) + return + } + catch { + } + + $Item[$Field.InternalName] = $taxonomyValue +} + function Set-SPItemFieldValue { param( [Parameter(Mandatory = $true)] @@ -1484,17 +2176,25 @@ function Set-SPItemFieldValue { $field = Resolve-SPField -Fields $Item.Fields -InternalName $TargetInternalName if ($null -eq $field) { - Write-Warning ("Zielfeld nicht gefunden: {0}" -f $TargetInternalName) + Write-SkippedImportFieldWarning -FieldInternalName $TargetInternalName -Message ("Zielfeld nicht gefunden: {0}" -f $TargetInternalName) return } - if ($field.ReadOnlyField -or $field.Sealed) { - Write-Warning ("Zielfeld ist schreibgeschuetzt und wird uebersprungen: {0}" -f $TargetInternalName) + if (-not (Test-SPFieldSupportedForImport -Field $field)) { + Write-SkippedImportFieldWarning -FieldInternalName $TargetInternalName -Message ("Zielfeld wird nicht importiert und wird uebersprungen: {0}" -f $TargetInternalName) return } try { switch ($field.TypeAsString) { + "TaxonomyFieldType" { + Set-SPTaxonomyFieldValue -Item $Item -Field $field -RawValue $RawValue -TextValue $TextValue + return + } + "TaxonomyFieldTypeMulti" { + Set-SPTaxonomyFieldValue -Item $Item -Field $field -RawValue $RawValue -TextValue $TextValue + return + } "Boolean" { $Item[$field.InternalName] = Convert-ToBoolean -RawValue $RawValue -TextValue $TextValue return @@ -1552,7 +2252,8 @@ function Set-SPItemFieldValue { } } - throw ("Konnte Feld '{0}' nicht setzen. {1}" -f $TargetInternalName, $_.Exception.Message) + Write-SkippedImportFieldWarning -FieldInternalName $TargetInternalName -Message ("Konnte Feld '{0}' nicht setzen und ueberspringe es. {1}" -f $TargetInternalName, $_.Exception.Message) + return } } @@ -1575,6 +2276,16 @@ function Apply-FieldMappingToItem { $hasRawValue = Test-ObjectHasProperty -Object $SourceFieldValues -PropertyName $sourceInternalName $hasTextValue = Test-ObjectHasProperty -Object $SourceFieldTextValues -PropertyName $sourceInternalName + if (-not $hasRawValue -and -not $hasTextValue -and $sourceInternalName -match "^(.*)_\d+$") { + $canonicalSourceInternalName = $matches[1] + $hasRawValue = Test-ObjectHasProperty -Object $SourceFieldValues -PropertyName $canonicalSourceInternalName + $hasTextValue = Test-ObjectHasProperty -Object $SourceFieldTextValues -PropertyName $canonicalSourceInternalName + + if ($hasRawValue -or $hasTextValue) { + $sourceInternalName = $canonicalSourceInternalName + } + } + if (-not $hasRawValue -and -not $hasTextValue) { continue } @@ -1597,20 +2308,134 @@ function Apply-FieldMappingToItem { } } -function Save-SPListItem { +function Get-SPItemFieldRawValue { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPField]$Field + ) + + try { + return $Item[$Field.InternalName] + } + catch { + try { + return $Item[$Field.Id] + } + catch { + return $null + } + } +} + +function Test-SPValuePresent { + param( + $Value + ) + + if ($null -eq $Value) { + return $false + } + + if ($Value -is [string]) { + return -not [string]::IsNullOrWhiteSpace($Value) + } + + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { + return @($Value).Count -gt 0 + } + + return $true +} + +function Get-SPItemMissingRequiredFields { param( [Parameter(Mandatory = $true)] [Microsoft.SharePoint.SPListItem]$Item ) + $missingFields = @() + + foreach ($field in $Item.Fields) { + if (-not $field.Required) { + continue + } + + if ($field.TypeAsString -eq "Computed" -or $field.TypeAsString -eq "Attachments") { + continue + } + + $rawValue = Get-SPItemFieldRawValue -Item $Item -Field $field + if (-not (Test-SPValuePresent -Value $rawValue)) { + $missingFields += $field.InternalName + } + } + + return $missingFields +} + +function Save-SPListItem { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [string]$Context = "" + ) + + $missingRequiredFields = @(Get-SPItemMissingRequiredFields -Item $Item) + if ($missingRequiredFields.Count -gt 0) { + $message = "Pflichtfelder ohne Wert: {0}" -f ($missingRequiredFields -join ", ") + if (-not [string]::IsNullOrWhiteSpace($Context)) { + $message = "{0}. {1}" -f $Context, $message + } + + throw $message + } + + $saveErrors = @() + try { $Item.UpdateOverwriteVersion() return } catch { + $saveErrors += ("UpdateOverwriteVersion: {0}" -f $_.Exception.Message) } - $Item.Update() + try { + $Item.SystemUpdate($false) + return + } + catch { + $saveErrors += ("SystemUpdate(false): {0}" -f $_.Exception.Message) + } + + try { + $Item.Update() + return + } + catch { + $saveErrors += ("Update: {0}" -f $_.Exception.Message) + } + + $missingRequiredFields = @(Get-SPItemMissingRequiredFields -Item $Item) + $messageParts = @() + + if (-not [string]::IsNullOrWhiteSpace($Context)) { + $messageParts += $Context + } + + if ($missingRequiredFields.Count -gt 0) { + $messageParts += ("Pflichtfelder ohne Wert: {0}" -f ($missingRequiredFields -join ", ")) + } + + if ($saveErrors.Count -gt 0) { + $messageParts += ("Save-Versuche fehlgeschlagen: {0}" -f ($saveErrors -join " | ")) + } + + throw ("Konnte Listenelement nicht speichern. {0}" -f ($messageParts -join ". ")) } function Get-RelativeSegmentsFromMetadataPath { @@ -1656,6 +2481,101 @@ function Ensure-SPFolderHierarchy { return $currentFolder } +function Get-SPFolderFileByName { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFolder]$Folder, + + [Parameter(Mandatory = $true)] + [string]$FileName + ) + + foreach ($existingFile in $Folder.Files) { + if ($existingFile.Name -eq $FileName) { + return $existingFile + } + } + + return $null +} + +function Update-SPExistingFileAsNewVersion { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFile]$ExistingFile, + + [Parameter(Mandatory = $true)] + [byte[]]$FileBytes + ) + + $requiresCheckout = $false + + try { + $requiresCheckout = [bool]$ExistingFile.ParentFolder.DocumentLibrary.ForceCheckout + } + catch { + $requiresCheckout = $false + } + + if ($requiresCheckout -and $ExistingFile.CheckOutType -eq [Microsoft.SharePoint.SPFile+SPCheckOutType]::None) { + $ExistingFile.CheckOut() + } + + $existingFile.SaveBinary($FileBytes) + + if ($ExistingFile.CheckOutType -ne [Microsoft.SharePoint.SPFile+SPCheckOutType]::None) { + $checkInType = [Microsoft.SharePoint.SPCheckinType]::MajorCheckIn + + try { + if ([bool]$ExistingFile.ParentFolder.DocumentLibrary.EnableMinorVersions) { + $checkInType = [Microsoft.SharePoint.SPCheckinType]::MinorCheckIn + } + } + catch { + } + + $ExistingFile.CheckIn("Imported by Start-SPMigration", $checkInType) + } + + return $ExistingFile +} + +function Import-SPFileToFolder { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFolder]$TargetFolder, + + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$TargetLibrary, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [byte[]]$FileBytes, + + [switch]$Overwrite + ) + + $existingFile = Get-SPFolderFileByName -Folder $TargetFolder -FileName $FileName + + if ($null -eq $existingFile) { + return $TargetFolder.Files.Add($FileName, $FileBytes, $false) + } + + if ($Overwrite.IsPresent) { + return $TargetFolder.Files.Add($FileName, $FileBytes, $true) + } + + if (-not [bool]$TargetLibrary.EnableVersioning) { + Write-Warning ("Datei uebersprungen, da sie bereits existiert und in der Zielbibliothek keine Versionierung aktiv ist: {0}/{1}" -f $TargetLibrary.Title, $FileName) + return $null + } + + Write-Host ("Datei existiert bereits. Erzeuge neue Version: {0}/{1}" -f $TargetLibrary.Title, $FileName) + return Update-SPExistingFileAsNewVersion -ExistingFile $existingFile -FileBytes $FileBytes +} + function Import-SPDocumentLibraries { param( [Parameter(Mandatory = $true)] @@ -1667,7 +2587,7 @@ function Import-SPDocumentLibraries { [Parameter(Mandatory = $true)] $MigrationMappingTable, - [switch]$OverwriteFiles + [switch]$Overwrite ) if (-not [System.IO.Directory]::Exists($FilesRootPath)) { @@ -1730,13 +2650,22 @@ function Import-SPDocumentLibraries { Write-Host ("Importiere Datei: {0} -> {1}" -f $sourceFilePath, $targetLibrary.Title) - $spFile = $targetFolder.Files.Add($fileName, $fileBytes, $OverwriteFiles.IsPresent) + $spFile = Import-SPFileToFolder -TargetFolder $targetFolder -TargetLibrary $targetLibrary -FileName $fileName -FileBytes $fileBytes -Overwrite:$Overwrite + if ($null -eq $spFile) { + continue + } + $spItem = $spFile.Item if ($null -ne $itemMetadata) { - $fieldMapping = Get-FieldMappingForContainer -MigrationMappingTable $MigrationMappingTable -ObjectType "DocumentLibrary" -SourceTitle $sourceLibraryTitle - Apply-FieldMappingToItem -Item $spItem -FieldMapping $FieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldTextValues") - Save-SPListItem -Item $spItem + try { + $fieldMapping = Get-FieldMappingForContainer -MigrationMappingTable $MigrationMappingTable -ObjectType "DocumentLibrary" -SourceTitle $sourceLibraryTitle + Apply-FieldMappingToItem -Item $spItem -FieldMapping $fieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldTextValues") + Save-SPListItem -Item $spItem -Context ("Bibliothek '{0}', Datei '{1}'" -f $targetLibrary.Title, $fileName) + } + catch { + Write-Warning ("Metadaten fuer Datei konnten nicht gespeichert werden: {0}" -f $_.Exception.Message) + } } } } @@ -1794,6 +2723,8 @@ function Import-SPLists { $fieldMapping = Get-FieldMappingForContainer -MigrationMappingTable $MigrationMappingTable -ObjectType "List" -SourceTitle $sourceListTitle foreach ($sourceItem in $items) { + $sourceItemId = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "Id") + $sourceItemTitle = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "Title") $fileSystemObjectType = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FileSystemObjectType") if ($fileSystemObjectType -eq "Folder") { Write-Warning ("Ordner in normalen Listen werden in dieser Version uebersprungen: {0}" -f $targetListTitle) @@ -1804,9 +2735,15 @@ function Import-SPLists { Write-Warning ("Listenanlagen wurden im Export nicht physisch gesichert und werden uebersprungen. Liste: {0}, ItemId: {1}" -f $targetListTitle, (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "Id")) } - $targetItem = $targetList.Items.Add() - Apply-FieldMappingToItem -Item $targetItem -FieldMapping $FieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldTextValues") - Save-SPListItem -Item $targetItem + try { + $targetItem = $targetList.Items.Add() + Apply-FieldMappingToItem -Item $targetItem -FieldMapping $fieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldTextValues") + Save-SPListItem -Item $targetItem -Context ("Liste '{0}', Quell-ItemId '{1}', Titel '{2}'" -f $targetListTitle, $sourceItemId, $sourceItemTitle) + } + catch { + Write-Warning ("Listenelement wurde uebersprungen: {0}" -f $_.Exception.Message) + continue + } } } } @@ -1824,18 +2761,14 @@ else { [System.IO.Path]::GetFullPath($MappingTable) } -$shouldExport = $Export.IsPresent -or $ExportOnly.IsPresent -$shouldImport = $Import.IsPresent -or $ImportOnly.IsPresent +$shouldExport = $Export.IsPresent +$shouldImport = $Import.IsPresent if (-not $shouldExport -and -not $shouldImport) { $shouldExport = -not [string]::IsNullOrWhiteSpace($SourceUrl) $shouldImport = -not [string]::IsNullOrWhiteSpace($TargetUrl) } -if ($ExportOnly.IsPresent -and $ImportOnly.IsPresent) { - throw "ExportOnly und ImportOnly koennen nicht gleichzeitig gesetzt werden." -} - if (-not $shouldExport -and -not $shouldImport) { throw "Bitte mindestens -Export oder -Import angeben." } @@ -1849,7 +2782,7 @@ if ($shouldImport -and -not $shouldExport) { throw ("MappingTable nicht gefunden: {0}" -f $mappingTablePath) } - Invoke-MigrationImport -InputPath $resolvedOutputPath -TargetWebUrl $TargetUrl -MappingTablePath $mappingTablePath -ImportFiles:$ImportFiles -ImportLists:$ImportLists -OverwriteFiles:$OverwriteFiles + Invoke-MigrationImport -InputPath $resolvedOutputPath -TargetWebUrl $TargetUrl -MappingTablePath $mappingTablePath -ImportFiles:$ImportFiles -ImportLists:$ImportLists -Overwrite:$Overwrite return } @@ -1914,7 +2847,7 @@ try { } Write-Host ("Starte Import nach Export. Zielweb: {0}" -f $TargetUrl) - Invoke-MigrationImport -InputPath $resolvedOutputPath -TargetWebUrl $TargetUrl -MappingTablePath $mappingTablePath -ImportFiles:$ImportFiles -ImportLists:$ImportLists -OverwriteFiles:$OverwriteFiles + Invoke-MigrationImport -InputPath $resolvedOutputPath -TargetWebUrl $TargetUrl -MappingTablePath $mappingTablePath -ImportFiles:$ImportFiles -ImportLists:$ImportLists -Overwrite:$Overwrite } finally { if ($null -ne $web) {