Add column mapping defaults functionality and example JSON file

This commit is contained in:
Torsten Brendgen
2026-06-29 13:37:18 +02:00
parent 396caa2ca4
commit 0702baf7f1
4 changed files with 762 additions and 29 deletions

View File

@@ -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

View File

@@ -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 = "<Where><Eq><FieldRef Name='$FieldInternalName' /><Value Type='Text'>$escapedValue</Value></Eq></Where>"
$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 {

View File

@@ -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
})

View File

@@ -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"
}
]
}