diff --git a/FieldMapping.sample.csv b/FieldMapping.sample.csv new file mode 100644 index 0000000..7adabb7 --- /dev/null +++ b/FieldMapping.sample.csv @@ -0,0 +1,4 @@ +SourceInternalName;TargetInternalName +DHS2016Persdat;Persdat +DHS2016Dienstgrad;Dienstgrad +DHS2016SecurityClearance;SecurityClearance diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4272e7 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Start-SPMigration + +PowerShell-Skript fuer die Migration von SharePoint On-Premise Inhalten ueber das serverseitige Objektmodell. + +Das Skript kann: + +- Dokumentbibliotheken exportieren +- Dateien inklusive Metadaten exportieren +- normale SharePoint-Listen und deren Eintraege exportieren +- exportierte Inhalte wieder in ein Ziel-Web importieren +- Feldwerte ueber eine Mapping-CSV von Quell- auf Ziel-InternalNames abbilden + +## Voraussetzungen + +- SharePoint On-Premise Server +- Ausfuehrung auf einem Server mit installiertem SharePoint PowerShell-Modul +- Berechtigungen auf Quell- und Ziel-Web +- PowerShell mit Zugriff auf `SharePointServer` + +Das Skript laedt intern: + +```powershell +Import-Module SharePointServer +``` + +## Dateien im Projekt + +- `Start-SPMigration.ps1`: Hauptskript fuer Export und optionalen Import +- `FieldMapping.sample.csv`: Beispiel fuer das Feldmapping + +## Exportierte Struktur + +Beim Export entsteht unter `-OutputPath` folgende Struktur: + +```text +OutputPath +|-- Files +| |-- +| | |-- Unterordner +| | | |-- Dokument.pdf +| | | |-- Dokument.pdf.properties.json +|-- Lists +| |-- ListeA.json +|-- manifest.csv +``` + +### Dateien + +- Die Originaldatei wird in `Files\\...` gespeichert. +- Die Metadaten liegen direkt daneben als Sidecar-Datei: + `Dateiname.ext.properties.json` + +### Listen + +- Pro Liste wird genau eine JSON-Datei unter `Lists\` erzeugt. +- Die Eintraege enthalten insbesondere: + - `ContentTypeId` + - `FieldValues` + - `FieldTextValues` + - `Fields` + +## Wichtige Metadaten + +Fuer den Import sind vor allem diese Informationen relevant: + +- `FieldValues` +- `FieldTextValues` +- `ContentTypeId` +- interne Feldnamen (`InternalName`) + +Der Name des Inhaltstyps wird bewusst nicht als fuehrende Importinformation verwendet, da er sich im Zielsystem unterscheiden kann. + +## Mapping-CSV + +Format: + +```csv +SourceInternalName;TargetInternalName +DHS2016Persdat;Persdat +DHS2016Dienstgrad;Dienstgrad +DHS2016SecurityClearance;SecurityClearance +``` + +Bedeutung: + +- `SourceInternalName`: interner Feldname aus dem Export +- `TargetInternalName`: interner Feldname im Zielsystem + +## Parameter + +### Pflichtparameter + +- `WebUrl`: Quell-Web fuer den Export +- `OutputPath`: Ausgabeordner fuer den Export + +### Exportparameter + +- `IncludeHiddenLibraries`: exportiert auch versteckte Bibliotheken +- `IncludeHiddenLists`: exportiert auch versteckte Listen +- `ExportOnly`: fuehrt nur den Export aus + +### Importparameter + +- `TargetWebUrl`: Ziel-Web fuer den Import +- `MappingCsvPath`: CSV fuer Feldmapping +- `ImportFiles`: importiert nur Dateien/Bibliotheken +- `ImportLists`: importiert nur Listen +- `OverwriteFiles`: ueberschreibt vorhandene Dateien beim Import + +Hinweis: + +- Wenn weder `ImportFiles` noch `ImportLists` gesetzt sind, werden beim Import beide Bereiche verarbeitet. +- Wenn `ExportOnly` gesetzt ist, wird kein Import gestartet. + +## Beispiele + +### Nur Export + +```powershell +.\Start-SPMigration.ps1 ` + -WebUrl "http://sharepoint/sites/Quelle" ` + -OutputPath "C:\Temp\SP-Export" ` + -ExportOnly +``` + +### Export und danach Import + +```powershell +.\Start-SPMigration.ps1 ` + -WebUrl "http://sharepoint/sites/Quelle" ` + -OutputPath "C:\Temp\SP-Export" ` + -TargetWebUrl "http://sharepoint/sites/Ziel" ` + -MappingCsvPath ".\FieldMapping.sample.csv" +``` + +### Nur Dateien importrelevant ausfuehren + +```powershell +.\Start-SPMigration.ps1 ` + -WebUrl "http://sharepoint/sites/Quelle" ` + -OutputPath "C:\Temp\SP-Export" ` + -TargetWebUrl "http://sharepoint/sites/Ziel" ` + -MappingCsvPath ".\FieldMapping.sample.csv" ` + -ImportFiles ` + -OverwriteFiles +``` + +### Nur Listen importrelevant ausfuehren + +```powershell +.\Start-SPMigration.ps1 ` + -WebUrl "http://sharepoint/sites/Quelle" ` + -OutputPath "C:\Temp\SP-Export" ` + -TargetWebUrl "http://sharepoint/sites/Ziel" ` + -MappingCsvPath ".\FieldMapping.sample.csv" ` + -ImportLists +``` + +## Aktuelles Verhalten beim Import + +### Dokumentbibliotheken + +- Dateien werden in die gleichnamige Zielbibliothek importiert. +- Unterordner werden bei Bedarf angelegt. +- Metadaten werden anschliessend ueber das CSV-Mapping gesetzt. + +### Listen + +- Listeneintraege werden neu angelegt. +- Feldwerte werden ueber `FieldValues` und `FieldTextValues` gemappt. + +## Bekannte Einschraenkungen + +- Zielbibliotheken und Ziellisten werden aktuell ueber den Titel aufgeloest. +- Listenanhaenge werden derzeit noch nicht physisch mit importiert. +- Ordner in normalen Listen werden beim Import derzeit uebersprungen. +- Sehr spezielle Feldtypen koennen je nach Farm-Konfiguration zusaetzliche Anpassungen benoetigen. +- Das Skript ist fuer SharePoint On-Premise mit serverseitigem Objektmodell gedacht, nicht fuer SharePoint Online. + +## Empfehlung + +Zuerst immer mit `-ExportOnly` testen und die exportierten `*.properties.json` sowie die Listen-JSONs pruefen. Danach das Feldmapping vervollstaendigen und erst dann den kombinierten Export/Import gegen das Zielsystem laufen lassen. diff --git a/Start-SPMigration.ps1 b/Start-SPMigration.ps1 index dce9baa..182ac2e 100644 --- a/Start-SPMigration.ps1 +++ b/Start-SPMigration.ps1 @@ -6,16 +6,28 @@ param( [Parameter(Mandatory = $true)] [string]$OutputPath, - [switch]$IncludeHiddenLibraries + [switch]$IncludeHiddenLibraries, + + [switch]$IncludeHiddenLists, + + [string]$TargetWebUrl, + + [string]$MappingCsvPath, + + [switch]$ImportFiles, + + [switch]$ImportLists, + + [switch]$OverwriteFiles, + + [switch]$ExportOnly ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Initialize-SharePointPowerShell { - if (-not (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } + Import-Module SharePointServer } function Ensure-Directory { @@ -29,6 +41,63 @@ function Ensure-Directory { } } +function Invoke-MigrationImport { + param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + + [Parameter(Mandatory = $true)] + [string]$TargetWebUrl, + + [Parameter(Mandatory = $true)] + [string]$MappingCsvPath, + + [switch]$ImportFiles, + + [switch]$ImportLists, + + [switch]$OverwriteFiles + ) + + $resolvedInputPath = [System.IO.Path]::GetFullPath($InputPath) + $filesRootPath = [System.IO.Path]::Combine($resolvedInputPath, "Files") + $listsRootPath = [System.IO.Path]::Combine($resolvedInputPath, "Lists") + $fieldMapping = Get-FieldMapping -Path $MappingCsvPath + + if ($fieldMapping.Count -eq 0) { + throw "Die Mapping-CSV enthaelt keine gueltigen Zuordnungen." + } + + $shouldImportFiles = $ImportFiles.IsPresent + $shouldImportLists = $ImportLists.IsPresent + + if (-not $shouldImportFiles -and -not $shouldImportLists) { + $shouldImportFiles = $true + $shouldImportLists = $true + } + + $targetWeb = $null + + try { + $targetWeb = Get-SPWeb -Identity $TargetWebUrl -ErrorAction Stop + + if ($shouldImportFiles) { + Import-SPDocumentLibraries -Web $targetWeb -FilesRootPath $filesRootPath -FieldMapping $fieldMapping -OverwriteFiles:$OverwriteFiles + } + + if ($shouldImportLists) { + Import-SPLists -Web $targetWeb -ListsRootPath $listsRootPath -FieldMapping $fieldMapping + } + + Write-Host ("Import abgeschlossen. Quelle: {0}" -f $resolvedInputPath) + } + finally { + if ($null -ne $targetWeb) { + $targetWeb.Dispose() + } + } +} + function Get-SafePathSegment { param( [Parameter(Mandatory = $true)] @@ -51,6 +120,44 @@ function Get-SafePathSegment { return $safeName } +function Test-IsCatalogList { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List + ) + + try { + return [bool]$List.IsCatalog + } + catch { + return $false + } +} + +function Get-SafeCollectionCount { + param( + $Value + ) + + if ($null -eq $Value) { + return 0 + } + + try { + return [int]$Value.Count + } + catch { + } + + try { + return [int]$Value.Length + } + catch { + } + + return @($Value).Length +} + function Combine-PathSegments { param( [Parameter(Mandatory = $true)] @@ -170,11 +277,166 @@ function Get-ItemFieldTextValue { [string]$InternalName ) - if (-not $Item.Fields.ContainsField($InternalName)) { + $field = $null + + try { + $field = $Item.Fields.GetFieldByInternalName($InternalName) + } + catch { + $field = $null + } + + if ($null -eq $field) { + try { + $field = $Item.Fields.GetFieldByStaticName($InternalName) + } + catch { + $field = $null + } + } + + if ($null -eq $field) { return $null } - return Get-FieldTextValue -Field $Item.Fields[$InternalName] -RawValue $Item[$InternalName] + $rawValue = $null + + try { + $rawValue = $Item[$field.InternalName] + } + catch { + try { + $rawValue = $Item[$field.Id] + } + catch { + $rawValue = $null + } + } + + return Get-FieldTextValue -Field $field -RawValue $rawValue +} + +function Get-ListItemFieldMetadata { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item + ) + + $fieldMetadata = @() + + foreach ($field in $Item.Fields) { + $rawValue = $null + + try { + $rawValue = $Item[$field.InternalName] + } + catch { + try { + $rawValue = $Item[$field.Id] + } + catch { + $rawValue = $null + } + } + + $fieldMetadata += [PSCustomObject]@{ + InternalName = $field.InternalName + TypeAsString = $field.TypeAsString + TextValue = Get-FieldTextValue -Field $field -RawValue $rawValue + Value = Convert-SPFieldValue $rawValue + } + } + + return $fieldMetadata +} + +function Get-ListItemFieldValueMap { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [switch]$AsText + ) + + $fieldValues = [ordered]@{} + + foreach ($field in $Item.Fields) { + $rawValue = $null + + try { + $rawValue = $Item[$field.InternalName] + } + catch { + try { + $rawValue = $Item[$field.Id] + } + catch { + $rawValue = $null + } + } + + if ($AsText) { + $fieldValues[$field.InternalName] = Get-FieldTextValue -Field $field -RawValue $rawValue + } + else { + $fieldValues[$field.InternalName] = Convert-SPFieldValue $rawValue + } + } + + return [PSCustomObject]$fieldValues +} + +function Get-ListItemAttachmentMetadata { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item + ) + + $attachments = @() + $attachmentCollection = $Item.Attachments + + if ((Get-SafeCollectionCount $attachmentCollection) -eq 0) { + return $attachments + } + + foreach ($attachmentName in @($attachmentCollection)) { + $attachments += [PSCustomObject]@{ + FileName = $attachmentName + Url = $attachmentCollection.UrlPrefix + $attachmentName + } + } + + return $attachments +} + +function Get-ListItemMetadataObject { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item + ) + + $attachmentCollection = $Item.Attachments + + return [PSCustomObject]@{ + Id = $Item.ID + UniqueId = $Item.UniqueId.ToString() + Title = if ($Item.Fields.ContainsField("Title")) { $Item["Title"] } else { $null } + ContentTypeId = $Item.ContentTypeId.ToString() + FileSystemObjectType = $Item.FileSystemObjectType.ToString() + DisplayName = $Item.DisplayName + Name = if ($Item.Name) { $Item.Name } else { $null } + Url = if ($Item.Url) { $Item.Url } else { $null } + ServerRelativeUrl = if ($Item.Folder) { $Item.Folder.ServerRelativeUrl } else { $null } + Created = if ($null -ne $Item["Created"]) { ([datetime]$Item["Created"]).ToString("o") } else { $null } + Modified = if ($null -ne $Item["Modified"]) { ([datetime]$Item["Modified"]).ToString("o") } else { $null } + CreatedBy = Get-ItemFieldTextValue -Item $Item -InternalName "Author" + ModifiedBy = Get-ItemFieldTextValue -Item $Item -InternalName "Editor" + HasAttachments = (Get-SafeCollectionCount $attachmentCollection) -gt 0 + Attachments = Get-ListItemAttachmentMetadata -Item $Item + FieldValues = Get-ListItemFieldValueMap -Item $Item + FieldTextValues = Get-ListItemFieldValueMap -Item $Item -AsText + Fields = Get-ListItemFieldMetadata -Item $Item + } } function Get-FileMetadataObject { @@ -190,21 +452,6 @@ function Get-FileMetadataObject { ) $item = $File.Item - $fieldMetadata = @() - - if ($null -ne $item) { - foreach ($field in $item.Fields) { - $rawValue = $item[$field.InternalName] - - $fieldMetadata += [PSCustomObject]@{ - InternalName = $field.InternalName - Title = $field.Title - TypeAsString = $field.TypeAsString - TextValue = Get-FieldTextValue -Field $field -RawValue $rawValue - Value = Convert-SPFieldValue $rawValue - } - } - } return [PSCustomObject]@{ ExportedAtUtc = [System.DateTime]::UtcNow.ToString("o") @@ -234,17 +481,7 @@ function Get-FileMetadataObject { MinorVersion = $File.MinorVersion } Item = if ($null -ne $item) { - [PSCustomObject]@{ - Id = $item.ID - UniqueId = $item.UniqueId.ToString() - ContentTypeId = $item.ContentTypeId.ToString() - ContentTypeName = $item.ContentType.Name - Created = if ($null -ne $item["Created"]) { ([datetime]$item["Created"]).ToString("o") } else { $null } - Modified = if ($null -ne $item["Modified"]) { ([datetime]$item["Modified"]).ToString("o") } else { $null } - CreatedBy = Get-ItemFieldTextValue -Item $item -InternalName "Author" - ModifiedBy = Get-ItemFieldTextValue -Item $item -InternalName "Editor" - Fields = $fieldMetadata - } + Get-ListItemMetadataObject -Item $item } else { $null @@ -294,6 +531,22 @@ function Get-RelativeFilePathSegments { return @($relativeUrl.Split("/") | ForEach-Object { Get-SafePathSegment $_ }) } +function Get-SPListItemsRecursive { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List + ) + + $query = New-Object Microsoft.SharePoint.SPQuery + $query.Query = "" + $query.Folder = $List.RootFolder + $query.ViewAttributes = "Scope='RecursiveAll'" + $query.RowLimit = 0 + $query.ViewFieldsOnly = $false + + return @($List.GetItems($query)) +} + function Export-SPFile { param( [Parameter(Mandatory = $true)] @@ -308,30 +561,29 @@ function Export-SPFile { [Parameter(Mandatory = $true)] [string]$FilesRootPath, - [Parameter(Mandatory = $true)] - [string]$MetadataRootPath, - [Parameter(Mandatory = $true)] [string]$ManifestPath ) - $relativeSegments = Get-RelativeFilePathSegments -List $List -File $File + $relativeSegments = @(Get-RelativeFilePathSegments -List $List -File $File) $relativeDirectorySegments = @() - if ($relativeSegments.Count -gt 1) { - $relativeDirectorySegments = $relativeSegments[0..($relativeSegments.Count - 2)] + if ($relativeSegments.Length -gt 1) { + $relativeDirectorySegments = $relativeSegments[0..($relativeSegments.Length - 2)] } $safeLibraryName = Get-SafePathSegment $List.Title $fileDirectory = Combine-PathSegments -BasePath ([System.IO.Path]::Combine($FilesRootPath, $safeLibraryName)) -Segments $relativeDirectorySegments - $metadataDirectory = Combine-PathSegments -BasePath ([System.IO.Path]::Combine($MetadataRootPath, $safeLibraryName)) -Segments $relativeDirectorySegments Ensure-Directory -Path $fileDirectory - Ensure-Directory -Path $metadataDirectory + + if ($relativeSegments.Length -eq 0) { + throw ("Konnte keinen relativen Dateipfad fuer '{0}' ermitteln." -f $File.ServerRelativeUrl) + } $fileName = $relativeSegments[-1] $localFilePath = [System.IO.Path]::Combine($fileDirectory, $fileName) - $metadataPath = [System.IO.Path]::Combine($metadataDirectory, "$fileName.metadata.json") + $metadataPath = [System.IO.Path]::Combine($fileDirectory, "$fileName.properties.json") [System.IO.File]::WriteAllBytes($localFilePath, $File.OpenBinary()) @@ -367,9 +619,6 @@ function Export-SPFolder { [Parameter(Mandatory = $true)] [string]$FilesRootPath, - [Parameter(Mandatory = $true)] - [string]$MetadataRootPath, - [Parameter(Mandatory = $true)] [string]$ManifestPath, @@ -377,7 +626,7 @@ function Export-SPFolder { ) foreach ($file in $Folder.Files) { - Export-SPFile -Web $Web -List $List -File $file -FilesRootPath $FilesRootPath -MetadataRootPath $MetadataRootPath -ManifestPath $ManifestPath + Export-SPFile -Web $Web -List $List -File $file -FilesRootPath $FilesRootPath -ManifestPath $ManifestPath $FileCount.Value++ } @@ -386,7 +635,7 @@ function Export-SPFolder { continue } - Export-SPFolder -Web $Web -List $List -Folder $subFolder -FilesRootPath $FilesRootPath -MetadataRootPath $MetadataRootPath -ManifestPath $ManifestPath -FileCount $FileCount + Export-SPFolder -Web $Web -List $List -Folder $subFolder -FilesRootPath $FilesRootPath -ManifestPath $ManifestPath -FileCount $FileCount } } @@ -401,9 +650,6 @@ function Export-SPDocumentLibrary { [Parameter(Mandatory = $true)] [string]$FilesRootPath, - [Parameter(Mandatory = $true)] - [string]$MetadataRootPath, - [Parameter(Mandatory = $true)] [string]$ManifestPath ) @@ -411,21 +657,698 @@ function Export-SPDocumentLibrary { Write-Host ("Exportiere Bibliothek: {0}" -f $List.Title) $fileCount = 0 - Export-SPFolder -Web $Web -List $List -Folder $List.RootFolder -FilesRootPath $FilesRootPath -MetadataRootPath $MetadataRootPath -ManifestPath $ManifestPath -FileCount ([ref]$fileCount) + Export-SPFolder -Web $Web -List $List -Folder $List.RootFolder -FilesRootPath $FilesRootPath -ManifestPath $ManifestPath -FileCount ([ref]$fileCount) Write-Host (" Dateien exportiert: {0}" -f $fileCount) } +function Export-SPList { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPWeb]$Web, + + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List, + + [Parameter(Mandatory = $true)] + [string]$ListsRootPath + ) + + Write-Host ("Exportiere Liste: {0}" -f $List.Title) + + $safeListName = Get-SafePathSegment $List.Title + $listPath = [System.IO.Path]::Combine($ListsRootPath, "$safeListName.json") + $items = @() + + foreach ($item in @(Get-SPListItemsRecursive -List $List)) { + $items += Get-ListItemMetadataObject -Item $item + } + + $listExport = [PSCustomObject]@{ + ExportedAtUtc = [System.DateTime]::UtcNow.ToString("o") + Web = [PSCustomObject]@{ + Title = $Web.Title + Url = $Web.Url + ServerRelativeUrl = $Web.ServerRelativeUrl + Id = $Web.ID.ToString() + } + List = [PSCustomObject]@{ + Title = $List.Title + Id = $List.ID.ToString() + BaseType = $List.BaseType.ToString() + BaseTemplate = [int]$List.BaseTemplate + DefaultViewUrl = $List.DefaultViewUrl + RootFolderUrl = $List.RootFolder.ServerRelativeUrl + ItemCount = $List.ItemCount + Hidden = $List.Hidden + } + Items = $items + } + + Write-MetadataJson -MetadataObject $listExport -Path $listPath + + Write-Host (" Listeneintraege exportiert: {0}" -f $items.Length) +} + +function Get-ObjectPropertyValue { + param( + $Object, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + $DefaultValue = $null + ) + + if ($null -eq $Object) { + return $DefaultValue + } + + $property = $Object.PSObject.Properties[$PropertyName] + if ($null -eq $property) { + return $DefaultValue + } + + return $property.Value +} + +function Test-ObjectHasProperty { + param( + $Object, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($null -eq $Object) { + return $false + } + + return $null -ne $Object.PSObject.Properties[$PropertyName] +} + +function Read-JsonFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + return Get-Content -Path $Path -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Resolve-SPField { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPFieldCollection]$Fields, + + [Parameter(Mandatory = $true)] + [string]$InternalName + ) + + try { + return $Fields.GetFieldByInternalName($InternalName) + } + catch { + } + + try { + return $Fields.GetFieldByStaticName($InternalName) + } + catch { + } + + foreach ($field in $Fields) { + if ($field.InternalName -eq $InternalName -or $field.StaticName -eq $InternalName) { + return $field + } + } + + return $null +} + +function Get-FieldMapping { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $rows = Import-Csv -Path $Path -Delimiter ";" + $mapping = [ordered]@{} + + foreach ($row in $rows) { + $sourceInternalName = [string](Get-ObjectPropertyValue -Object $row -PropertyName "SourceInternalName") + $targetInternalName = [string](Get-ObjectPropertyValue -Object $row -PropertyName "TargetInternalName") + + if ([string]::IsNullOrWhiteSpace($sourceInternalName) -or [string]::IsNullOrWhiteSpace($targetInternalName)) { + continue + } + + $mapping[$sourceInternalName.Trim()] = $targetInternalName.Trim() + } + + return $mapping +} + +function Convert-ToBoolean { + param( + $RawValue, + $TextValue + ) + + if ($RawValue -is [bool]) { + return $RawValue + } + + $candidate = $RawValue + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + $candidate = $TextValue + } + + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + return $null + } + + return [System.Convert]::ToBoolean($candidate) +} + +function Convert-ToInt32 { + param( + $RawValue, + $TextValue + ) + + $candidate = $RawValue + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + $candidate = $TextValue + } + + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + return $null + } + + return [int]$candidate +} + +function Convert-ToDouble { + param( + $RawValue, + $TextValue + ) + + $candidate = $RawValue + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + $candidate = $TextValue + } + + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + return $null + } + + return [double]::Parse( + [string]$candidate, + [System.Globalization.NumberStyles]::Any, + [System.Globalization.CultureInfo]::InvariantCulture + ) +} + +function Convert-ToDateTimeValue { + param( + $RawValue, + $TextValue + ) + + $candidate = $RawValue + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + $candidate = $TextValue + } + + if ($null -eq $candidate -or [string]::IsNullOrWhiteSpace([string]$candidate)) { + return $null + } + + return [datetime]::Parse( + [string]$candidate, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind + ) +} + +function Resolve-SPUser { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPWeb]$Web, + + $RawUserValue, + + [string]$TextValue + ) + + $candidates = @() + + if ($null -ne $RawUserValue) { + $loginName = Get-ObjectPropertyValue -Object $RawUserValue -PropertyName "LoginName" + $email = Get-ObjectPropertyValue -Object $RawUserValue -PropertyName "Email" + $lookupValue = Get-ObjectPropertyValue -Object $RawUserValue -PropertyName "LookupValue" + + if (-not [string]::IsNullOrWhiteSpace([string]$loginName)) { + $candidates += [string]$loginName + } + + if (-not [string]::IsNullOrWhiteSpace([string]$email)) { + $candidates += [string]$email + } + + if (-not [string]::IsNullOrWhiteSpace([string]$lookupValue)) { + $candidates += [string]$lookupValue + } + } + + if (-not [string]::IsNullOrWhiteSpace([string]$TextValue)) { + $candidates += [string]$TextValue + } + + foreach ($candidate in $candidates | Select-Object -Unique) { + try { + return $Web.EnsureUser($candidate) + } + catch { + } + } + + return $null +} + +function Convert-ToUserFieldValue { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + $RawValue, + + [string]$TextValue + ) + + $user = Resolve-SPUser -Web $Item.Web -RawUserValue $RawValue -TextValue $TextValue + if ($null -eq $user) { + return $null + } + + return New-Object Microsoft.SharePoint.SPFieldUserValue($Item.Web, $user.ID, $user.LoginName) +} + +function Convert-ToUserMultiFieldValue { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + $RawValue, + + [string]$TextValue + ) + + $collection = New-Object Microsoft.SharePoint.SPFieldUserValueCollection + $rawUsers = @($RawValue) + + if (($rawUsers.Length -eq 1) -and $null -eq $rawUsers[0] -and -not [string]::IsNullOrWhiteSpace($TextValue)) { + $rawUsers = @($TextValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) + } + + foreach ($rawUser in $rawUsers) { + $userValue = Convert-ToUserFieldValue -Item $Item -RawValue $rawUser -TextValue ([string]$rawUser) + if ($null -ne $userValue) { + [void]$collection.Add($userValue) + } + } + + return $collection +} + +function Convert-ToUrlFieldValue { + param( + $RawValue, + + [string]$TextValue + ) + + $url = Get-ObjectPropertyValue -Object $RawValue -PropertyName "Url" + $description = Get-ObjectPropertyValue -Object $RawValue -PropertyName "Description" + + if (-not [string]::IsNullOrWhiteSpace([string]$url)) { + $fieldValue = New-Object Microsoft.SharePoint.SPFieldUrlValue + $fieldValue.Url = [string]$url + $fieldValue.Description = [string]$description + return $fieldValue + } + + if (-not [string]::IsNullOrWhiteSpace([string]$TextValue)) { + $fieldValue = New-Object Microsoft.SharePoint.SPFieldUrlValue + try { + $fieldValue.FromString([string]$TextValue) + return $fieldValue + } + catch { + } + } + + return $null +} + +function Set-SPItemFieldValue { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [Parameter(Mandatory = $true)] + [string]$TargetInternalName, + + $RawValue, + + [string]$TextValue + ) + + $field = Resolve-SPField -Fields $Item.Fields -InternalName $TargetInternalName + if ($null -eq $field) { + Write-Warning ("Zielfeld nicht gefunden: {0}" -f $TargetInternalName) + return + } + + if ($field.ReadOnlyField -or $field.Sealed) { + Write-Warning ("Zielfeld ist schreibgeschuetzt und wird uebersprungen: {0}" -f $TargetInternalName) + return + } + + try { + switch ($field.TypeAsString) { + "Boolean" { + $Item[$field.InternalName] = Convert-ToBoolean -RawValue $RawValue -TextValue $TextValue + return + } + "Integer" { + $Item[$field.InternalName] = Convert-ToInt32 -RawValue $RawValue -TextValue $TextValue + return + } + "Counter" { + return + } + "Number" { + $Item[$field.InternalName] = Convert-ToDouble -RawValue $RawValue -TextValue $TextValue + return + } + "Currency" { + $Item[$field.InternalName] = Convert-ToDouble -RawValue $RawValue -TextValue $TextValue + return + } + "DateTime" { + $Item[$field.InternalName] = Convert-ToDateTimeValue -RawValue $RawValue -TextValue $TextValue + return + } + "URL" { + $Item[$field.InternalName] = Convert-ToUrlFieldValue -RawValue $RawValue -TextValue $TextValue + return + } + "User" { + $Item[$field.InternalName] = Convert-ToUserFieldValue -Item $Item -RawValue $RawValue -TextValue $TextValue + return + } + "UserMulti" { + $Item[$field.InternalName] = Convert-ToUserMultiFieldValue -Item $Item -RawValue $RawValue -TextValue $TextValue + return + } + default { + if ($null -eq $RawValue) { + $Item[$field.InternalName] = $TextValue + } + else { + $Item[$field.InternalName] = $RawValue + } + + return + } + } + } + catch { + if (-not [string]::IsNullOrWhiteSpace($TextValue)) { + try { + $field.ParseAndSetValue($Item, $TextValue) + return + } + catch { + } + } + + throw ("Konnte Feld '{0}' nicht setzen. {1}" -f $TargetInternalName, $_.Exception.Message) + } +} + +function Apply-FieldMappingToItem { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$FieldMapping, + + $SourceFieldValues, + + $SourceFieldTextValues + ) + + foreach ($sourceInternalName in $FieldMapping.Keys) { + $targetInternalName = [string]$FieldMapping[$sourceInternalName] + + $hasRawValue = Test-ObjectHasProperty -Object $SourceFieldValues -PropertyName $sourceInternalName + $hasTextValue = Test-ObjectHasProperty -Object $SourceFieldTextValues -PropertyName $sourceInternalName + + if (-not $hasRawValue -and -not $hasTextValue) { + continue + } + + $rawValue = if ($hasRawValue) { + Get-ObjectPropertyValue -Object $SourceFieldValues -PropertyName $sourceInternalName + } + else { + $null + } + + $textValue = if ($hasTextValue) { + [string](Get-ObjectPropertyValue -Object $SourceFieldTextValues -PropertyName $sourceInternalName) + } + else { + $null + } + + Set-SPItemFieldValue -Item $Item -TargetInternalName $targetInternalName -RawValue $rawValue -TextValue $textValue + } +} + +function Save-SPListItem { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPListItem]$Item + ) + + try { + $Item.UpdateOverwriteVersion() + return + } + catch { + } + + $Item.Update() +} + +function Get-RelativeSegmentsFromMetadataPath { + param( + [Parameter(Mandatory = $true)] + [string]$MetadataFilePath, + + [Parameter(Mandatory = $true)] + [string]$FilesRootPath + ) + + $relativePath = $MetadataFilePath.Substring($FilesRootPath.Length).TrimStart("\") + return @($relativePath.Split("\") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} + +function Ensure-SPFolderHierarchy { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List, + + [string[]]$FolderSegments = @() + ) + + $currentFolder = $List.RootFolder + + foreach ($folderSegment in $FolderSegments) { + $nextFolder = $null + + foreach ($existingFolder in $currentFolder.SubFolders) { + if ($existingFolder.Name -eq $folderSegment) { + $nextFolder = $existingFolder + break + } + } + + if ($null -eq $nextFolder) { + $nextFolder = $currentFolder.SubFolders.Add($folderSegment) + } + + $currentFolder = $nextFolder + } + + return $currentFolder +} + +function Import-SPDocumentLibraries { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPWeb]$Web, + + [Parameter(Mandatory = $true)] + [string]$FilesRootPath, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$FieldMapping, + + [switch]$OverwriteFiles + ) + + if (-not [System.IO.Directory]::Exists($FilesRootPath)) { + Write-Warning ("Kein Files-Ordner gefunden: {0}" -f $FilesRootPath) + return + } + + $metadataFiles = @(Get-ChildItem -Path $FilesRootPath -Recurse -File -Filter *.properties.json) + Write-Host ("Gefundene Datei-Metadaten: {0}" -f $metadataFiles.Length) + + foreach ($metadataFile in $metadataFiles) { + $sourceFilePath = $metadataFile.FullName.Substring(0, $metadataFile.FullName.Length - ".properties.json".Length) + if (-not [System.IO.File]::Exists($sourceFilePath)) { + Write-Warning ("Quelldatei zur Metadatei nicht gefunden: {0}" -f $metadataFile.FullName) + continue + } + + $metadata = Read-JsonFile -Path $metadataFile.FullName + $libraryMetadata = Get-ObjectPropertyValue -Object $metadata -PropertyName "Library" + $itemMetadata = Get-ObjectPropertyValue -Object $metadata -PropertyName "Item" + $libraryTitle = [string](Get-ObjectPropertyValue -Object $libraryMetadata -PropertyName "Title") + + if ([string]::IsNullOrWhiteSpace($libraryTitle)) { + Write-Warning ("Bibliothekstitel fehlt in: {0}" -f $metadataFile.FullName) + continue + } + + $targetLibrary = $Web.Lists.TryGetList($libraryTitle) + if ($null -eq $targetLibrary) { + Write-Warning ("Zielbibliothek nicht gefunden: {0}" -f $libraryTitle) + continue + } + + if ($targetLibrary.BaseType -ne [Microsoft.SharePoint.SPBaseType]::DocumentLibrary) { + Write-Warning ("Zielliste ist keine Dokumentbibliothek: {0}" -f $libraryTitle) + continue + } + + $relativeSegments = @(Get-RelativeSegmentsFromMetadataPath -MetadataFilePath $metadataFile.FullName -FilesRootPath $FilesRootPath) + if ($relativeSegments.Length -lt 2) { + Write-Warning ("Dateipfad konnte nicht relativ zur Bibliothek ermittelt werden: {0}" -f $metadataFile.FullName) + continue + } + + $folderSegments = @() + if ($relativeSegments.Length -gt 2) { + $folderSegments = $relativeSegments[1..($relativeSegments.Length - 2)] + } + + $targetFolder = Ensure-SPFolderHierarchy -List $targetLibrary -FolderSegments $folderSegments + $fileName = [System.IO.Path]::GetFileName($sourceFilePath) + $fileBytes = [System.IO.File]::ReadAllBytes($sourceFilePath) + + Write-Host ("Importiere Datei: {0} -> {1}" -f $sourceFilePath, $targetLibrary.Title) + + $spFile = $targetFolder.Files.Add($fileName, $fileBytes, $OverwriteFiles.IsPresent) + $spItem = $spFile.Item + + if ($null -ne $itemMetadata) { + Apply-FieldMappingToItem -Item $spItem -FieldMapping $FieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $itemMetadata -PropertyName "FieldTextValues") + Save-SPListItem -Item $spItem + } + } +} + +function Import-SPLists { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPWeb]$Web, + + [Parameter(Mandatory = $true)] + [string]$ListsRootPath, + + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$FieldMapping + ) + + if (-not [System.IO.Directory]::Exists($ListsRootPath)) { + Write-Warning ("Kein Lists-Ordner gefunden: {0}" -f $ListsRootPath) + return + } + + $listFiles = @(Get-ChildItem -Path $ListsRootPath -File -Filter *.json) + Write-Host ("Gefundene Listen-JSONs: {0}" -f $listFiles.Length) + + foreach ($listFile in $listFiles) { + $listExport = Read-JsonFile -Path $listFile.FullName + $listMetadata = Get-ObjectPropertyValue -Object $listExport -PropertyName "List" + $items = @(Get-ObjectPropertyValue -Object $listExport -PropertyName "Items" -DefaultValue @()) + $listTitle = [string](Get-ObjectPropertyValue -Object $listMetadata -PropertyName "Title") + + if ([string]::IsNullOrWhiteSpace($listTitle)) { + Write-Warning ("Listentitel fehlt in: {0}" -f $listFile.FullName) + continue + } + + $targetList = $Web.Lists.TryGetList($listTitle) + if ($null -eq $targetList) { + Write-Warning ("Zielliste nicht gefunden: {0}" -f $listTitle) + continue + } + + if ($targetList.BaseType -eq [Microsoft.SharePoint.SPBaseType]::DocumentLibrary) { + Write-Warning ("Liste ist eine Dokumentbibliothek und wird im Listenimport uebersprungen: {0}" -f $listTitle) + continue + } + + Write-Host ("Importiere Liste: {0}" -f $listTitle) + + foreach ($sourceItem in $items) { + $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 $listTitle) + continue + } + + if ((Get-ObjectPropertyValue -Object $sourceItem -PropertyName "HasAttachments" -DefaultValue $false)) { + Write-Warning ("Listenanlagen wurden im Export nicht physisch gesichert und werden uebersprungen. Liste: {0}, ItemId: {1}" -f $listTitle, (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 + } + } +} + Initialize-SharePointPowerShell $resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath) $filesRootPath = [System.IO.Path]::Combine($resolvedOutputPath, "Files") -$metadataRootPath = [System.IO.Path]::Combine($resolvedOutputPath, "Metadata") +$listsRootPath = [System.IO.Path]::Combine($resolvedOutputPath, "Lists") $manifestPath = [System.IO.Path]::Combine($resolvedOutputPath, "manifest.csv") Ensure-Directory -Path $resolvedOutputPath Ensure-Directory -Path $filesRootPath -Ensure-Directory -Path $metadataRootPath +Ensure-Directory -Path $listsRootPath if ([System.IO.File]::Exists($manifestPath)) { [System.IO.File]::Delete($manifestPath) @@ -440,22 +1363,58 @@ try { $web.Lists | Where-Object { $_.BaseType -eq [Microsoft.SharePoint.SPBaseType]::DocumentLibrary -and ($IncludeHiddenLibraries -or -not $_.Hidden) -and - -not $_.IsCatalog + -not (Test-IsCatalogList -List $_) } ) - if ($documentLibraries.Count -eq 0) { - Write-Warning ("Keine Dokumentbibliotheken im Web gefunden: {0}" -f $WebUrl) + $lists = @( + $web.Lists | Where-Object { + $_.BaseType -ne [Microsoft.SharePoint.SPBaseType]::DocumentLibrary -and + ($IncludeHiddenLists -or -not $_.Hidden) -and + -not (Test-IsCatalogList -List $_) + } + ) + + Write-Host ("Gefundene Bibliotheken: {0}" -f $documentLibraries.Length) + Write-Host ("Gefundene Listen: {0}" -f $lists.Length) + + foreach ($library in $documentLibraries) { + Export-SPDocumentLibrary -Web $web -List $library -FilesRootPath $filesRootPath -ManifestPath $manifestPath + } + + foreach ($list in $lists) { + Export-SPList -Web $web -List $list -ListsRootPath $listsRootPath + } + + if ($documentLibraries.Length -eq 0 -and $lists.Length -eq 0) { + Write-Warning ("Keine exportierbaren Bibliotheken oder Listen im Web gefunden: {0}" -f $WebUrl) return } - Write-Host ("Gefundene Bibliotheken: {0}" -f $documentLibraries.Count) + Write-Host ("Export abgeschlossen. Ausgabe: {0}" -f $resolvedOutputPath) - foreach ($library in $documentLibraries) { - Export-SPDocumentLibrary -Web $web -List $library -FilesRootPath $filesRootPath -MetadataRootPath $metadataRootPath -ManifestPath $manifestPath + $importConfigurationProvided = + (-not [string]::IsNullOrWhiteSpace($TargetWebUrl)) -or + (-not [string]::IsNullOrWhiteSpace($MappingCsvPath)) -or + $ImportFiles -or + $ImportLists -or + $OverwriteFiles + + if ($ExportOnly) { + Write-Host "ExportOnly ist gesetzt. Import wird uebersprungen." + return } - Write-Host ("Export abgeschlossen. Ausgabe: {0}" -f $resolvedOutputPath) + if (-not $importConfigurationProvided) { + return + } + + if ([string]::IsNullOrWhiteSpace($TargetWebUrl) -or [string]::IsNullOrWhiteSpace($MappingCsvPath)) { + throw "Fuer den automatischen Import muessen TargetWebUrl und MappingCsvPath angegeben werden." + } + + Write-Host ("Starte Import nach Export. Zielweb: {0}" -f $TargetWebUrl) + Invoke-MigrationImport -InputPath $resolvedOutputPath -TargetWebUrl $TargetWebUrl -MappingCsvPath $MappingCsvPath -ImportFiles:$ImportFiles -ImportLists:$ImportLists -OverwriteFiles:$OverwriteFiles } finally { if ($null -ne $web) {