From 0702baf7f161950320d363b5d226a8800ee18b2e Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Mon, 29 Jun 2026 13:37:18 +0200 Subject: [PATCH] Add column mapping defaults functionality and example JSON file --- README.md | 40 +- Start-SPMigration.ps1 | 82 +++- Start-SPMigrationGUI.ps1 | 635 ++++++++++++++++++++++++++-- column-mapping-defaults.sample.json | 34 ++ 4 files changed, 762 insertions(+), 29 deletions(-) create mode 100644 column-mapping-defaults.sample.json diff --git a/README.md b/README.md index d8f4714..66995dd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Import-Module SharePointServer - `Start-SPMigration.ps1`: Hauptskript fuer Export und optionalen Import - `FieldMapping.sample.csv`: altes CSV-Beispiel fuer Feldmapping, weiterhin als Fallback lesbar +- `column-mapping-defaults.sample.json`: Beispiel fuer wiederverwendbare Column-Mapping-Defaults ## Exportierte Struktur @@ -164,6 +165,42 @@ Fuer das Mapping ist vor allem relevant: Wenn eine Ziel-Liste oder Ziel-Bibliothek nicht existiert, gibt das Skript eine Warnung aus, dass dieser Container manuell angelegt werden soll. +## Column Mapping Defaults + +Wenn Bibliotheks- und Listen-Mappings je Migration unterschiedlich sind, die Spalten-Mappings aber gleich bleiben, kann die GUI eine separate Column-Defaults-Datei verwenden. + +- Die `MappingTable.json` bleibt migrationsspezifisch und enthaelt weiterhin `LibraryMappings`, `ListMappings` und die konkret angewendeten `MetadataColumnMappings`. +- Die Defaults-Datei enthaelt nur wiederverwendbare Spaltenregeln unter `Rules`. +- In der GUI kann ein Defaults-Pfad im Feld `ColumnDefaults` gepflegt werden. Nach einem Export werden vorhandene Defaults automatisch auf die frisch geladene `MappingTable.json` angewendet und gespeichert. +- Mit `Anwenden` koennen Defaults erneut auf die aktuelle MappingTable gelegt werden. Danach bleiben manuelle Anpassungen pro Migration weiterhin moeglich. +- Mit `Speichern` erzeugt die GUI aus den aktuellen Column-Mappings ein Defaults-Template. Gleichartige Spalten werden dabei auf `ContainerSourceTitle = "*"` generalisiert; widerspruechliche Regeln bleiben container-spezifisch. + +Regeln werden von spezifisch nach allgemein angewendet: + +- `ObjectType + ContainerSourceTitle + SourceInternalName` +- `ObjectType + * + SourceInternalName` +- `* + ContainerSourceTitle + SourceInternalName` +- `* + * + SourceInternalName` + +Beispiel: + +```json +{ + "SchemaVersion": 1, + "TemplateType": "ColumnMappings", + "Rules": [ + { + "ObjectType": "*", + "ContainerSourceTitle": "*", + "SourceInternalName": "SecurityClearance", + "TargetInternalName": "GMNSecurityClearance", + "ImportSupported": true + } + ] +} +``` + + ## Parameter ### Pflichtparameter @@ -196,6 +233,7 @@ Hinweis: - 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. +- Beim Listenimport legt das Skript bei Bedarf ein verstecktes Textfeld `StartSPMigrationSourceUniqueId` in der Zielliste an. Wiederholte Imports aktualisieren damit vorhandene Elemente anhand der exportierten Source-`UniqueId` statt sie erneut anzulegen. ## Beispiele @@ -270,7 +308,7 @@ Anschliessend: ### Listen -- Listeneintraege werden neu angelegt. +- Listeneintraege werden neu angelegt oder bei erneutem Import ueber `StartSPMigrationSourceUniqueId` wiedergefunden und aktualisiert. - Feldwerte werden ueber `FieldValues`, `FieldTextValues` und `MetadataColumnMappings` gemappt. ## Bekannte Einschraenkungen diff --git a/Start-SPMigration.ps1 b/Start-SPMigration.ps1 index 92bd83e..72ac2b5 100644 --- a/Start-SPMigration.ps1 +++ b/Start-SPMigration.ps1 @@ -32,6 +32,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $script:SkippedImportFieldWarnings = @{} +$script:ListImportTrackingFieldInternalName = "StartSPMigrationSourceUniqueId" +$script:ListImportTrackingFieldDisplayName = "Start-SPMigration Source UniqueId" function Initialize-SharePointPowerShell { if($null -eq $(Get-Module SharePointServer)) { @@ -2704,6 +2706,68 @@ function Import-SPDocumentLibraries { } } +function Ensure-SPMigrationListTrackingField { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List + ) + + $field = Resolve-SPField -Fields $List.Fields -InternalName $script:ListImportTrackingFieldInternalName + if ($null -ne $field) { + return $field + } + + [void]$List.Fields.Add($script:ListImportTrackingFieldInternalName, [Microsoft.SharePoint.SPFieldType]::Text, $false) + $List.Update() + + $field = Resolve-SPField -Fields $List.Fields -InternalName $script:ListImportTrackingFieldInternalName + if ($null -eq $field) { + throw ("Konnte Tracking-Feld fuer idempotenten Listenimport nicht anlegen: {0}" -f $List.Title) + } + + $field.Title = $script:ListImportTrackingFieldDisplayName + $field.Hidden = $true + + try { + $field.Indexed = $true + } + catch { + Write-Warning ("Tracking-Feld konnte nicht indiziert werden: {0}. {1}" -f $List.Title, $_.Exception.Message) + } + + $field.Update() + $List.Update() + + return $field +} + +function Get-SPListItemByMigrationSourceUniqueId { + param( + [Parameter(Mandatory = $true)] + [Microsoft.SharePoint.SPList]$List, + + [Parameter(Mandatory = $true)] + [string]$FieldInternalName, + + [string]$SourceUniqueId + ) + + if ([string]::IsNullOrWhiteSpace($SourceUniqueId)) { + return $null + } + + $escapedValue = [System.Security.SecurityElement]::Escape($SourceUniqueId) + $query = New-Object Microsoft.SharePoint.SPQuery + $query.RowLimit = 1 + $query.Query = "$escapedValue" + + $matchingItems = $List.GetItems($query) + if ($matchingItems.Count -eq 0) { + return $null + } + + return $matchingItems[0] +} function Import-SPLists { param( [Parameter(Mandatory = $true)] @@ -2755,10 +2819,12 @@ function Import-SPLists { Write-Host ("Importiere Liste: {0}" -f $targetListTitle) $fieldMapping = Get-FieldMappingForContainer -MigrationMappingTable $MigrationMappingTable -ObjectType "List" -SourceTitle $sourceListTitle + $trackingField = Ensure-SPMigrationListTrackingField -List $targetList foreach ($sourceItem in $items) { $sourceItemId = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "Id") $sourceItemTitle = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "Title") + $sourceItemUniqueId = [string](Get-ObjectPropertyValue -Object $sourceItem -PropertyName "UniqueId") $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) @@ -2770,8 +2836,22 @@ function Import-SPLists { } try { - $targetItem = $targetList.Items.Add() + $targetItem = Get-SPListItemByMigrationSourceUniqueId -List $targetList -FieldInternalName $trackingField.InternalName -SourceUniqueId $sourceItemUniqueId + $isExistingItem = $null -ne $targetItem + + if ($isExistingItem) { + Write-Host ("Aktualisiere vorhandenes Listenelement: Liste '{0}', Quell-ItemId '{1}'" -f $targetListTitle, $sourceItemId) + } + else { + $targetItem = $targetList.Items.Add() + } + Apply-FieldMappingToItem -Item $targetItem -FieldMapping $fieldMapping -SourceFieldValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldValues") -SourceFieldTextValues (Get-ObjectPropertyValue -Object $sourceItem -PropertyName "FieldTextValues") + + if (-not [string]::IsNullOrWhiteSpace($sourceItemUniqueId)) { + $targetItem[$trackingField.InternalName] = $sourceItemUniqueId + } + Save-SPListItem -Item $targetItem -Context ("Liste '{0}', Quell-ItemId '{1}', Titel '{2}'" -f $targetListTitle, $sourceItemId, $sourceItemTitle) } catch { diff --git a/Start-SPMigrationGUI.ps1 b/Start-SPMigrationGUI.ps1 index 58d0c69..72c7fc8 100644 --- a/Start-SPMigrationGUI.ps1 +++ b/Start-SPMigrationGUI.ps1 @@ -54,6 +54,7 @@ class SettingsManager { LastTargetUrl = "" LastOutputPath = (Join-Path -Path (Get-Location).Path -ChildPath "SPMigrationOutput") LastMappingTablePath = "" + LastColumnDefaultsPath = "" } foreach ($key in $defaults.Keys) { @@ -797,6 +798,14 @@ $script:CurrentMappingMeta = [ordered]@{ GeneratedAtUtc = "" SourceWebUrl = "" } +$script:IsMigrationRunning = $false +$script:MigrationPowerShell = $null +$script:MigrationAsyncResult = $null +$script:MigrationOutput = $null +$script:MigrationTimer = $null +$script:MigrationStreamPositions = @{} +$script:MigrationCompletedHandler = $null +$script:MigrationActionLabel = "" function Get-ObjectPropertyValue { param( @@ -872,7 +881,6 @@ function Write-UILog { $script:txtLog.AppendText($line) $script:txtLog.SelectionStart = $script:txtLog.TextLength $script:txtLog.ScrollToCaret() - [System.Windows.Forms.Application]::DoEvents() } switch ($Level) { @@ -1001,38 +1009,229 @@ function Convert-RecordToLogText { return [string]$Record } +function Set-MigrationUiBusy { + param( + [Parameter(Mandatory = $true)] + [bool]$IsBusy + ) + + $script:IsMigrationRunning = $IsBusy + + if ($null -ne $script:MainForm) { + $script:MainForm.UseWaitCursor = $IsBusy + } + + $controlNames = @( + "btnRunExport", + "btnRunImport", + "btnLoadContainers", + "btnLoadMapping", + "btnSaveMappingGlobal", + "btnBrowseOutputPath", + "btnSelectAllContainers", + "btnClearContainerSelection", + "btnLoadColumnDefaults", + "btnApplyColumnDefaults", + "btnSaveColumnDefaults" + ) + + foreach ($controlName in $controlNames) { + $variable = Get-Variable -Name $controlName -Scope Script -ErrorAction SilentlyContinue + if ($null -eq $variable -or $null -eq $variable.Value) { + continue + } + + $variable.Value.Enabled = -not $IsBusy + } +} + +function Reset-MigrationStreamPositions { + $script:MigrationStreamPositions = @{ + Output = 0 + Error = 0 + Warning = 0 + Verbose = 0 + Debug = 0 + Information = 0 + } +} + +function Write-NewPowerShellRecords { + param( + $Collection, + + [Parameter(Mandatory = $true)] + [string]$PositionKey, + + [ValidateSet("INFO", "WARN", "ERROR")] + [string]$Level = "INFO" + ) + + if ($null -eq $Collection -or -not $script:MigrationStreamPositions.ContainsKey($PositionKey)) { + return + } + + while ($script:MigrationStreamPositions[$PositionKey] -lt $Collection.Count) { + $index = [int]$script:MigrationStreamPositions[$PositionKey] + $script:MigrationStreamPositions[$PositionKey] = $index + 1 + + try { + $record = $Collection[$index] + } + catch { + return + } + + $text = Convert-RecordToLogText -Record $record + if (-not [string]::IsNullOrWhiteSpace($text)) { + Write-UILog -Message $text -Level $Level + } + } +} + +function Write-NewMigrationPowerShellRecords { + if ($null -eq $script:MigrationPowerShell) { + return + } + + Write-NewPowerShellRecords -Collection $script:MigrationOutput -PositionKey "Output" -Level "INFO" + Write-NewPowerShellRecords -Collection $script:MigrationPowerShell.Streams.Warning -PositionKey "Warning" -Level "WARN" + Write-NewPowerShellRecords -Collection $script:MigrationPowerShell.Streams.Error -PositionKey "Error" -Level "ERROR" + Write-NewPowerShellRecords -Collection $script:MigrationPowerShell.Streams.Verbose -PositionKey "Verbose" -Level "INFO" + Write-NewPowerShellRecords -Collection $script:MigrationPowerShell.Streams.Debug -PositionKey "Debug" -Level "INFO" + Write-NewPowerShellRecords -Collection $script:MigrationPowerShell.Streams.Information -PositionKey "Information" -Level "INFO" +} + +function Clear-MigrationRunState { + if ($null -ne $script:MigrationPowerShell) { + $script:MigrationPowerShell.Dispose() + } + + if ($null -ne $script:MigrationOutput) { + try { + $script:MigrationOutput.Dispose() + } + catch { + } + } + + $script:MigrationPowerShell = $null + $script:MigrationAsyncResult = $null + $script:MigrationOutput = $null + $script:MigrationCompletedHandler = $null + $script:MigrationActionLabel = "" + Reset-MigrationStreamPositions +} + +function Complete-MigrationScriptAsync { + if ($null -eq $script:MigrationPowerShell -or $null -eq $script:MigrationAsyncResult) { + return + } + + $actionLabel = $script:MigrationActionLabel + $completedHandler = $script:MigrationCompletedHandler + $success = $false + $errorMessage = $null + + Write-NewMigrationPowerShellRecords + + try { + [void]$script:MigrationPowerShell.EndInvoke($script:MigrationAsyncResult) + Write-NewMigrationPowerShellRecords + $success = $true + } + catch { + Write-NewMigrationPowerShellRecords + $errorMessage = $_.Exception.Message + Write-UILog -Message $errorMessage -Level "ERROR" + } + finally { + Clear-MigrationRunState + Set-MigrationUiBusy -IsBusy $false + } + + if ($success) { + Write-UILog -Message ("{0} abgeschlossen." -f $actionLabel) + + if ($null -ne $completedHandler) { + try { + & $completedHandler + } + catch { + Write-UILog -Message $_.Exception.Message -Level "ERROR" + Show-UiMessage -Message $_.Exception.Message -Caption ("{0} abgeschlossen, Nachbereitung fehlgeschlagen" -f $actionLabel) -Icon ([System.Windows.Forms.MessageBoxIcon]::Warning) + } + } + } + else { + Show-UiMessage -Message $errorMessage -Caption ("{0} fehlgeschlagen" -f $actionLabel) -Icon ([System.Windows.Forms.MessageBoxIcon]::Error) + } +} + +function Ensure-MigrationTimer { + if ($null -ne $script:MigrationTimer) { + return + } + + $script:MigrationTimer = New-Object System.Windows.Forms.Timer + $script:MigrationTimer.Interval = 250 + $script:MigrationTimer.Add_Tick({ + Write-NewMigrationPowerShellRecords + + if ($null -eq $script:MigrationAsyncResult -or -not $script:MigrationAsyncResult.IsCompleted) { + return + } + + $script:MigrationTimer.Stop() + Complete-MigrationScriptAsync + }) +} + function Invoke-MigrationScript { param( [Parameter(Mandatory = $true)] [hashtable]$Parameters, [Parameter(Mandatory = $true)] - [string]$ActionLabel + [string]$ActionLabel, + + [scriptblock]$OnCompleted = $null ) if (-not (Test-Path -LiteralPath $script:MigrationScriptPath)) { throw ("Start-SPMigration.ps1 nicht gefunden: {0}" -f $script:MigrationScriptPath) } - $script:MainForm.UseWaitCursor = $true - [System.Windows.Forms.Application]::DoEvents() + if ($script:IsMigrationRunning) { + throw "Es laeuft bereits ein Export oder Import. Bitte warten, bis der aktuelle Vorgang abgeschlossen ist." + } + + Reset-MigrationStreamPositions + $script:MigrationPowerShell = [powershell]::Create() + $script:MigrationOutput = [System.Management.Automation.PSDataCollection[psobject]]::new() + $script:MigrationCompletedHandler = $OnCompleted + $script:MigrationActionLabel = $ActionLabel try { + [void]$script:MigrationPowerShell.AddCommand($script:MigrationScriptPath) + [void]$script:MigrationPowerShell.AddParameters($Parameters) + + Set-MigrationUiBusy -IsBusy $true Write-UILog -Message ("Starte {0}..." -f $ActionLabel) - & $script:MigrationScriptPath @Parameters *>&1 | ForEach-Object { - $text = Convert-RecordToLogText -Record $_ - if (-not [string]::IsNullOrWhiteSpace($text)) { - Write-UILog -Message $text - } - } - Write-UILog -Message ("{0} abgeschlossen." -f $ActionLabel) + + $inputCollection = [System.Management.Automation.PSDataCollection[psobject]]::new() + $inputCollection.Complete() + $script:MigrationAsyncResult = $script:MigrationPowerShell.BeginInvoke($inputCollection, $script:MigrationOutput) + + Ensure-MigrationTimer + $script:MigrationTimer.Start() } - finally { - $script:MainForm.UseWaitCursor = $false - [System.Windows.Forms.Application]::DoEvents() + catch { + Clear-MigrationRunState + Set-MigrationUiBusy -IsBusy $false + throw } } - function Get-SourceContainers { param( [Parameter(Mandatory = $true)] @@ -1465,7 +1664,7 @@ function Convert-DataTableToObjects { foreach ($columnDefinition in $Schema) { $value = $row[$columnDefinition.Name] - if ($value -eq [System.DBNull]::Value) { + if ($value -is [System.DBNull]) { $value = $null } @@ -1647,6 +1846,305 @@ function Save-MappingTableToPath { Write-UILog -Message ("MappingTable gespeichert: {0}" -f $resolvedPath) } +function Test-ObjectHasProperty { + param( + $Object, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($null -eq $Object) { + return $false + } + + if ($Object -is [System.Collections.IDictionary]) { + return $Object.Contains($PropertyName) + } + + return $null -ne $Object.PSObject.Properties[$PropertyName] +} + +function Get-ColumnDefaultRulesFromObject { + param( + $Template + ) + + if ($null -eq $Template) { + return @() + } + + $rules = Get-ObjectPropertyValue -Object $Template -PropertyName "Rules" -DefaultValue $null + if ($null -ne $rules) { + return @($rules) + } + + $rules = Get-ObjectPropertyValue -Object $Template -PropertyName "ColumnMappings" -DefaultValue $null + if ($null -ne $rules) { + return @($rules) + } + + $metadataColumnMappings = Get-ObjectPropertyValue -Object $Template -PropertyName "MetadataColumnMappings" -DefaultValue $null + if ($null -ne $metadataColumnMappings) { + return @( + @(Get-ObjectPropertyValue -Object $metadataColumnMappings -PropertyName "SystemColumns" -DefaultValue @()) + + @(Get-ObjectPropertyValue -Object $metadataColumnMappings -PropertyName "CustomColumns" -DefaultValue @()) + ) + } + + return @() +} + +function Get-ColumnDefaultRuleMatchPriority { + param( + [Parameter(Mandatory = $true)] + $Rule, + + [Parameter(Mandatory = $true)] + [System.Data.DataRow]$Row + ) + + $ruleSourceInternalName = [string](Get-ObjectPropertyValue -Object $Rule -PropertyName "SourceInternalName") + if ([string]::IsNullOrWhiteSpace($ruleSourceInternalName)) { + return -1 + } + + $rowSourceInternalName = [string]$Row["SourceInternalName"] + $rowCanonicalInternalName = [string]$Row["SourceCanonicalInternalName"] + if ($ruleSourceInternalName -ine $rowSourceInternalName -and $ruleSourceInternalName -ine $rowCanonicalInternalName) { + return -1 + } + + $ruleObjectType = [string](Get-ObjectPropertyValue -Object $Rule -PropertyName "ObjectType" -DefaultValue "*") + $ruleContainerSourceTitle = [string](Get-ObjectPropertyValue -Object $Rule -PropertyName "ContainerSourceTitle" -DefaultValue "*") + $rowObjectType = [string]$Row["ObjectType"] + $rowContainerSourceTitle = [string]$Row["ContainerSourceTitle"] + + if ([string]::IsNullOrWhiteSpace($ruleObjectType)) { + $ruleObjectType = "*" + } + + if ([string]::IsNullOrWhiteSpace($ruleContainerSourceTitle)) { + $ruleContainerSourceTitle = "*" + } + + if ($ruleObjectType -ne "*" -and $ruleObjectType -ine $rowObjectType) { + return -1 + } + + if ($ruleContainerSourceTitle -ne "*" -and $ruleContainerSourceTitle -ine $rowContainerSourceTitle) { + return -1 + } + + $priority = 0 + if ($ruleObjectType -ne "*") { + $priority += 2 + } + + if ($ruleContainerSourceTitle -ne "*") { + $priority += 1 + } + + return $priority +} + +function Get-BestColumnDefaultRule { + param( + [object[]]$Rules = @(), + + [Parameter(Mandatory = $true)] + [System.Data.DataRow]$Row + ) + + $bestRule = $null + $bestPriority = -1 + + foreach ($rule in @($Rules)) { + $priority = Get-ColumnDefaultRuleMatchPriority -Rule $rule -Row $Row + if ($priority -gt $bestPriority) { + $bestPriority = $priority + $bestRule = $rule + } + } + + return $bestRule +} + +function Apply-ColumnDefaultRulesToTable { + param( + [Parameter(Mandatory = $true)] + [System.Data.DataTable]$Table, + + [object[]]$Rules = @() + ) + + $updatedCount = 0 + + foreach ($row in $Table.Rows) { + $rule = Get-BestColumnDefaultRule -Rules $Rules -Row $row + if ($null -eq $rule) { + continue + } + + $rowWasUpdated = $false + + if (Test-ObjectHasProperty -Object $rule -PropertyName "TargetInternalName") { + $targetInternalName = [string](Get-ObjectPropertyValue -Object $rule -PropertyName "TargetInternalName" -DefaultValue "") + if (-not [string]::IsNullOrWhiteSpace($targetInternalName)) { + $row["TargetInternalName"] = $targetInternalName + $rowWasUpdated = $true + } + } + + if (Test-ObjectHasProperty -Object $rule -PropertyName "ImportSupported") { + $row["ImportSupported"] = [bool](Get-ObjectPropertyValue -Object $rule -PropertyName "ImportSupported" -DefaultValue $false) + $rowWasUpdated = $true + } + + if ($rowWasUpdated) { + $updatedCount++ + } + } + + return $updatedCount +} + +function Apply-ColumnDefaultsFromPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Bitte einen ColumnDefaults-Pfad angeben." + } + + if (-not (Test-Path -LiteralPath $Path)) { + throw ("ColumnDefaults nicht gefunden: {0}" -f $Path) + } + + if ([System.IO.Path]::GetExtension($Path) -ine ".json") { + throw "ColumnDefaults muessen als JSON-Datei vorliegen." + } + + $template = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json + $rules = @(Get-ColumnDefaultRulesFromObject -Template $template) + if ($rules.Count -eq 0) { + throw "ColumnDefaults enthalten keine Rules." + } + + $systemCount = Apply-ColumnDefaultRulesToTable -Table $script:SystemColumnsTable -Rules $rules + $customCount = Apply-ColumnDefaultRulesToTable -Table $script:CustomColumnsTable -Rules $rules + $script:txtColumnDefaultsPath.Text = (Get-FullPathSafe -Path $Path) + + Write-UILog -Message ("ColumnDefaults angewendet: {0} System Columns, {1} Custom Columns." -f $systemCount, $customCount) +} + +function New-ColumnDefaultRuleObject { + param( + [Parameter(Mandatory = $true)] + $RowObject, + + [Parameter(Mandatory = $true)] + [bool]$UseWildcardContainer + ) + + $containerSourceTitle = if ($UseWildcardContainer) { "*" } else { [string]$RowObject.ContainerSourceTitle } + + return [PSCustomObject]@{ + ObjectType = [string]$RowObject.ObjectType + ContainerSourceTitle = $containerSourceTitle + SourceInternalName = [string]$RowObject.SourceInternalName + SourceCanonicalInternalName = [string]$RowObject.SourceCanonicalInternalName + TargetInternalName = [string]$RowObject.TargetInternalName + ImportSupported = [bool]$RowObject.ImportSupported + DisplayName = [string]$RowObject.DisplayName + TypeAsString = [string]$RowObject.TypeAsString + } +} + +function Get-ColumnDefaultRowsForExport { + $rows = @( + @(Convert-DataTableToObjects -Table $script:SystemColumnsTable -Schema $script:GridSchemas.SystemColumns) + + @(Convert-DataTableToObjects -Table $script:CustomColumnsTable -Schema $script:GridSchemas.CustomColumns) + ) + + return @($rows | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.SourceInternalName) }) +} + +function New-ColumnDefaultsTemplateFromUi { + $rows = @(Get-ColumnDefaultRowsForExport) + $groups = @{} + + foreach ($row in $rows) { + $key = ("{0}|{1}" -f ([string]$row.ObjectType).Trim(), ([string]$row.SourceInternalName).Trim()).ToLowerInvariant() + if (-not $groups.ContainsKey($key)) { + $groups[$key] = New-Object System.Collections.ArrayList + } + + [void]$groups[$key].Add($row) + } + + $rules = @() + + foreach ($key in $groups.Keys) { + $groupRows = @($groups[$key]) + $targetKeys = @( + $groupRows | ForEach-Object { + "{0}|{1}" -f ([string]$_.TargetInternalName).Trim(), ([bool]$_.ImportSupported) + } | Sort-Object -Unique + ) + + if ($targetKeys.Count -le 1) { + $rules += New-ColumnDefaultRuleObject -RowObject $groupRows[0] -UseWildcardContainer $true + } + else { + foreach ($row in $groupRows) { + $rules += New-ColumnDefaultRuleObject -RowObject $row -UseWildcardContainer $false + } + } + } + + return [PSCustomObject]@{ + SchemaVersion = 1 + TemplateType = "ColumnMappings" + GeneratedAtUtc = [System.DateTime]::UtcNow.ToString("o") + Rules = @($rules | Sort-Object -Property ObjectType, ContainerSourceTitle, SourceInternalName) + } +} + +function Save-ColumnDefaultsToPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Bitte einen Pfad fuer ColumnDefaults angeben." + } + + $resolvedPath = Get-FullPathSafe -Path $Path + Ensure-ParentDirectory -Path $resolvedPath + $template = New-ColumnDefaultsTemplateFromUi + $json = $template | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText($resolvedPath, $json, [System.Text.Encoding]::UTF8) + $script:txtColumnDefaultsPath.Text = $resolvedPath + Write-UILog -Message ("ColumnDefaults gespeichert: {0}" -f $resolvedPath) +} + +function Apply-ColumnDefaultsIfAvailable { + $path = [string]$script:txtColumnDefaultsPath.Text + if ([string]::IsNullOrWhiteSpace($path)) { + return + } + + if (-not (Test-Path -LiteralPath $path)) { + Write-UILog -Message ("ColumnDefaults nicht gefunden und wurden nicht angewendet: {0}" -f $path) -Level "WARN" + return + } + + Apply-ColumnDefaultsFromPath -Path $path +} function Save-UiSettings { $script:SettingsManager.Set("WindowWidth", $script:MainForm.Width) $script:SettingsManager.Set("WindowHeight", $script:MainForm.Height) @@ -1654,6 +2152,7 @@ function Save-UiSettings { $script:SettingsManager.Set("LastTargetUrl", [string]$script:txtTargetUrl.Text) $script:SettingsManager.Set("LastOutputPath", [string]$script:txtOutputPath.Text) $script:SettingsManager.Set("LastMappingTablePath", [string]$script:txtMappingPath.Text) + $script:SettingsManager.Set("LastColumnDefaultsPath", [string]$script:txtColumnDefaultsPath.Text) } function Load-UiSettings { @@ -1661,6 +2160,7 @@ function Load-UiSettings { $script:txtTargetUrl.Text = [string]$script:SettingsManager.Get("LastTargetUrl", "") $script:txtOutputPath.Text = [string]$script:SettingsManager.Get("LastOutputPath", (Join-Path -Path (Get-Location).Path -ChildPath "SPMigrationOutput")) $script:txtMappingPath.Text = [string]$script:SettingsManager.Get("LastMappingTablePath", "") + $script:txtColumnDefaultsPath.Text = [string]$script:SettingsManager.Get("LastColumnDefaultsPath", "") $script:MainForm.Width = [int]$script:SettingsManager.Get("WindowWidth", 1400) $script:MainForm.Height = [int]$script:SettingsManager.Get("WindowHeight", 940) Sync-MappingPathWithOutputPath -Force @@ -1692,7 +2192,6 @@ function Invoke-LoadContainers { } $script:MainForm.UseWaitCursor = $true - [System.Windows.Forms.Application]::DoEvents() try { Write-UILog -Message ("Lese Listen und Bibliotheken aus: {0}" -f $sourceUrl) @@ -1706,7 +2205,6 @@ function Invoke-LoadContainers { } finally { $script:MainForm.UseWaitCursor = $false - [System.Windows.Forms.Application]::DoEvents() } } @@ -1749,13 +2247,17 @@ function Invoke-ExportFromGui { $parameters.IncludeHiddenLists = $true } - Invoke-MigrationScript -Parameters $parameters -ActionLabel "Export" - $mappingPath = Join-Path -Path $outputPath -ChildPath "MappingTable.json" - if (Test-Path -LiteralPath $mappingPath) { - Load-MappingTableFromPath -Path $mappingPath - $script:MainTabControl.SelectedTab = $script:tabMapping - } + $onCompleted = { + if (Test-Path -LiteralPath $mappingPath) { + Load-MappingTableFromPath -Path $mappingPath + Apply-ColumnDefaultsIfAvailable + Save-MappingTableToPath -Path $mappingPath + $script:MainTabControl.SelectedTab = $script:tabMapping + } + }.GetNewClosure() + + Invoke-MigrationScript -Parameters $parameters -ActionLabel "Export" -OnCompleted $onCompleted } catch { Write-UILog -Message $_.Exception.Message -Level "ERROR" @@ -1823,19 +2325,21 @@ $mainLayout = New-Object System.Windows.Forms.TableLayoutPanel $mainLayout.Dock = [System.Windows.Forms.DockStyle]::Fill $mainLayout.RowCount = 3 $mainLayout.ColumnCount = 1 -[void]$mainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 110))) +[void]$mainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 146))) [void]$mainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent, 100))) [void]$mainLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 230))) $workspaceGroup = ([GroupBoxBuilder]::new("Arbeitsbereich")).Build() $workspaceLayout = New-Object System.Windows.Forms.TableLayoutPanel $workspaceLayout.Dock = [System.Windows.Forms.DockStyle]::Fill -$workspaceLayout.RowCount = 2 -$workspaceLayout.ColumnCount = 4 +$workspaceLayout.RowCount = 3 +$workspaceLayout.ColumnCount = 5 [void]$workspaceLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 105))) [void]$workspaceLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Percent, 100))) [void]$workspaceLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 110))) [void]$workspaceLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 110))) +[void]$workspaceLayout.ColumnStyles.Add((New-Object System.Windows.Forms.ColumnStyle([System.Windows.Forms.SizeType]::Absolute, 110))) +[void]$workspaceLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 36))) [void]$workspaceLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 36))) [void]$workspaceLayout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute, 36))) @@ -1858,6 +2362,18 @@ $script:txtMappingPath.Margin = New-Object System.Windows.Forms.Padding(6) $btnLoadMapping = ([ButtonBuilder]::new("Laden...")).SetSize(95, 28).Build() $btnLoadMapping.Margin = New-Object System.Windows.Forms.Padding(6) +$lblColumnDefaultsPath = ([LabelBuilder]::new("ColumnDefaults")).SetTextAlign([System.Drawing.ContentAlignment]::MiddleLeft).Build() +$lblColumnDefaultsPath.Dock = [System.Windows.Forms.DockStyle]::Fill +$script:txtColumnDefaultsPath = New-Object System.Windows.Forms.TextBox +$script:txtColumnDefaultsPath.Dock = [System.Windows.Forms.DockStyle]::Fill +$script:txtColumnDefaultsPath.Margin = New-Object System.Windows.Forms.Padding(6) +$btnApplyColumnDefaults = ([ButtonBuilder]::new("Anwenden")).SetSize(95, 28).Build() +$btnApplyColumnDefaults.Margin = New-Object System.Windows.Forms.Padding(6) +$btnLoadColumnDefaults = ([ButtonBuilder]::new("Laden...")).SetSize(95, 28).Build() +$btnLoadColumnDefaults.Margin = New-Object System.Windows.Forms.Padding(6) +$btnSaveColumnDefaults = ([ButtonBuilder]::new("Speichern")).SetSize(95, 28).Build() +$btnSaveColumnDefaults.Margin = New-Object System.Windows.Forms.Padding(6) + [void]$workspaceLayout.Controls.Add($lblOutputPath, 0, 0) [void]$workspaceLayout.Controls.Add($script:txtOutputPath, 1, 0) [void]$workspaceLayout.Controls.Add($btnBrowseOutputPath, 2, 0) @@ -1865,6 +2381,11 @@ $btnLoadMapping.Margin = New-Object System.Windows.Forms.Padding(6) [void]$workspaceLayout.Controls.Add($lblMappingPath, 0, 1) [void]$workspaceLayout.Controls.Add($script:txtMappingPath, 1, 1) [void]$workspaceLayout.Controls.Add($btnLoadMapping, 2, 1) +[void]$workspaceLayout.Controls.Add($lblColumnDefaultsPath, 0, 2) +[void]$workspaceLayout.Controls.Add($script:txtColumnDefaultsPath, 1, 2) +[void]$workspaceLayout.Controls.Add($btnApplyColumnDefaults, 2, 2) +[void]$workspaceLayout.Controls.Add($btnLoadColumnDefaults, 3, 2) +[void]$workspaceLayout.Controls.Add($btnSaveColumnDefaults, 4, 2) $workspaceGroup.Controls.Add($workspaceLayout) $script:MainTabControl = ([TabControlBuilder]::new()).SetDock("Fill").Build() @@ -2139,6 +2660,58 @@ $btnLoadMapping.Add_Click({ } }) +$btnApplyColumnDefaults.Add_Click({ + try { + Apply-ColumnDefaultsFromPath -Path ([string]$script:txtColumnDefaultsPath.Text) + } + catch { + Write-UILog -Message $_.Exception.Message -Level "ERROR" + Show-UiMessage -Message $_.Exception.Message -Caption "ColumnDefaults konnten nicht angewendet werden" -Icon ([System.Windows.Forms.MessageBoxIcon]::Error) + } +}) + +$btnLoadColumnDefaults.Add_Click({ + try { + $dialog = [DialogBuilder]::OpenFile().SetTitle("ColumnDefaults laden").SetFilter("JSON (*.json)|*.json|Alle Dateien (*.*)|*.*").SetCheckFileExists($true) + $initialDirectory = Get-ParentDirectorySafe -Path $script:txtColumnDefaultsPath.Text + if ([string]::IsNullOrWhiteSpace($initialDirectory)) { + $initialDirectory = Get-ParentDirectorySafe -Path $script:txtMappingPath.Text + } + if ([string]::IsNullOrWhiteSpace($initialDirectory)) { + $initialDirectory = Get-ParentDirectorySafe -Path $script:txtOutputPath.Text + } + if (-not [string]::IsNullOrWhiteSpace($initialDirectory)) { + $dialog.SetInitialDirectory($initialDirectory) | Out-Null + } + + if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + Apply-ColumnDefaultsFromPath -Path $dialog.GetPath() + } + } + catch { + Write-UILog -Message $_.Exception.Message -Level "ERROR" + Show-UiMessage -Message $_.Exception.Message -Caption "ColumnDefaults konnten nicht geladen werden" -Icon ([System.Windows.Forms.MessageBoxIcon]::Error) + } +}) + +$btnSaveColumnDefaults.Add_Click({ + try { + $currentDefaultsPath = [string]$script:txtColumnDefaultsPath.Text + $dialog = [DialogBuilder]::SaveFile().SetTitle("ColumnDefaults speichern").SetFilter("JSON (*.json)|*.json|Alle Dateien (*.*)|*.*").SetOverwritePrompt($true) + if (-not [string]::IsNullOrWhiteSpace($currentDefaultsPath)) { + $dialog.SetInitialDirectory((Get-ParentDirectorySafe -Path $currentDefaultsPath)) | Out-Null + $dialog.SetFileName([System.IO.Path]::GetFileName($currentDefaultsPath)) | Out-Null + } + + if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + Save-ColumnDefaultsToPath -Path $dialog.GetPath() + } + } + catch { + Write-UILog -Message $_.Exception.Message -Level "ERROR" + Show-UiMessage -Message $_.Exception.Message -Caption "ColumnDefaults konnten nicht gespeichert werden" -Icon ([System.Windows.Forms.MessageBoxIcon]::Error) + } +}) $script:tvContainers.Add_AfterCheck({ param($sender, $e) @@ -2185,6 +2758,14 @@ $script:MainForm.Add_Shown({ }) $script:MainForm.Add_FormClosing({ + param($sender, $e) + + if ($script:IsMigrationRunning) { + $e.Cancel = $true + Show-UiMessage -Message "Bitte warten, bis der laufende Export oder Import abgeschlossen ist." -Caption "Vorgang laeuft" -Icon ([System.Windows.Forms.MessageBoxIcon]::Warning) + return + } + Save-UiSettings }) diff --git a/column-mapping-defaults.sample.json b/column-mapping-defaults.sample.json new file mode 100644 index 0000000..102ba72 --- /dev/null +++ b/column-mapping-defaults.sample.json @@ -0,0 +1,34 @@ +{ + "SchemaVersion": 1, + "TemplateType": "ColumnMappings", + "GeneratedAtUtc": "2026-06-29T00:00:00.0000000Z", + "Rules": [ + { + "ObjectType": "*", + "ContainerSourceTitle": "*", + "SourceInternalName": "SecurityClearance", + "TargetInternalName": "GMNSecurityClearance", + "ImportSupported": true, + "DisplayName": "Security Clearance", + "TypeAsString": "TaxonomyFieldType" + }, + { + "ObjectType": "DocumentLibrary", + "ContainerSourceTitle": "*", + "SourceInternalName": "Department", + "TargetInternalName": "GMNDepartment", + "ImportSupported": true, + "DisplayName": "Department", + "TypeAsString": "Text" + }, + { + "ObjectType": "List", + "ContainerSourceTitle": "Projektaufgaben", + "SourceInternalName": "DueDate", + "TargetInternalName": "DueDate", + "ImportSupported": true, + "DisplayName": "Faellig am", + "TypeAsString": "DateTime" + } + ] +}