Public/Import-M365PPlan.ps1
|
function Import-M365PPlan { <# .SYNOPSIS Imports a Microsoft 365 Planner plan from a JSON or XML file. .DESCRIPTION The Import-M365PPlan function imports a previously exported Planner plan from a JSON or XML file, recreating the plan structure including buckets, tasks, assignments, and checklist items in a specified Microsoft 365 group. This is useful for restoring backups, migrating plans between tenants, or cloning plan templates. .PARAMETER InputPath The file path of the exported plan file (JSON or XML format). .PARAMETER GroupId The unique identifier (GUID) of the Microsoft 365 group where the plan will be imported. .PARAMETER PlanTitle Optional custom title for the imported plan. If not specified, the original plan title will be used with " (Imported)" suffix. .PARAMETER SkipAssignments If specified, task assignments will not be imported. Useful when importing to a different tenant where the original users don't exist. .PARAMETER SkipDates If specified, start and due dates will not be imported. Tasks will be created without dates. .PARAMETER SkipProgress If specified, task progress (PercentComplete) will not be imported. All tasks will be created with 0% progress (Not Started status). .PARAMETER WhatIf Shows what would happen if the cmdlet runs without actually performing the import. .PARAMETER Confirm Prompts for confirmation before performing the import operation. .EXAMPLE Import-M365PPlan -InputPath ".\backup.json" -GroupId "group123" Imports the plan from backup.json into the specified group with the original title and full task progress. .EXAMPLE Import-M365PPlan -InputPath ".\backup.json" -GroupId "group123" -PlanTitle "Restored Plan" Imports the plan with a custom title, preserving task progress. .EXAMPLE Import-M365PPlan -InputPath ".\backup.xml" -GroupId "group123" -SkipAssignments -SkipDates -SkipProgress Imports the plan structure only, without assignments, dates, or progress. Useful for clean template migration. .NOTES Author: Sergio Cánovas Cardona Version: 1.1.0 Requires: Microsoft.Graph.Planner module Important: - User assignments require that the users exist in the target tenant - The import process may take several minutes for large plans - ETag concurrency is handled automatically with retry logic .LINK Export-M365PPlan Copy-M365PPlan #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true)] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string]$InputPath, [Parameter(Mandatory = $true)] [string]$GroupId, [Parameter(Mandatory = $false)] [string]$PlanTitle, [Parameter(Mandatory = $false)] [switch]$SkipAssignments, [Parameter(Mandatory = $false)] [switch]$SkipDates, [Parameter(Mandatory = $false)] [switch]$SkipProgress ) begin { Write-Verbose "Starting plan import process..." # Helper function to retry operations with exponential backoff function Invoke-WithRetry { param( [ScriptBlock]$ScriptBlock, [int]$MaxRetries = 3, [int]$InitialDelaySeconds = 2 ) $attempt = 0 $delay = $InitialDelaySeconds while ($attempt -lt $MaxRetries) { try { return & $ScriptBlock } catch { $attempt++ if ($attempt -ge $MaxRetries) { throw } if ($_.Exception.Message -match '412|Precondition Failed') { Write-Verbose "Conflict detected (412), retrying in $delay seconds... (Attempt $attempt/$MaxRetries)" Start-Sleep -Seconds $delay $delay *= 2 } else { throw } } } } } process { try { # Step 1: Load and parse the export file Write-Verbose "Loading export file: $InputPath" $extension = [System.IO.Path]::GetExtension($InputPath).ToLower() if ($extension -eq '.json') { $importData = Get-Content -Path $InputPath -Raw | ConvertFrom-Json } elseif ($extension -eq '.xml') { [xml]$xmlData = Get-Content -Path $InputPath -Raw # Convert XML to PSObject (simplified conversion) function ConvertFrom-XmlElement { param($Element) $obj = @{} # Process attributes foreach ($attr in $Element.Attributes) { $obj[$attr.Name] = $attr.Value } # Process child elements foreach ($child in $Element.ChildNodes) { if ($child.NodeType -eq 'Element') { if ($child.Name -eq 'Item') { if (-not $obj.ContainsKey('Items')) { $obj['Items'] = @() } $obj['Items'] += ConvertFrom-XmlElement -Element $child } else { $obj[$child.Name] = ConvertFrom-XmlElement -Element $child } } } if ($obj.Count -eq 0 -and $Element.InnerText) { return $Element.InnerText } return [PSCustomObject]$obj } $importData = ConvertFrom-XmlElement -Element $xmlData.DocumentElement } else { throw "Unsupported file format. Only .json and .xml files are supported." } Write-Verbose "Export file loaded successfully" Write-Verbose "Exported at: $($importData.ExportedAt)" Write-Verbose "Original plan: $($importData.Plan.Title)" # Step 2: Verify group exists Write-Verbose "Verifying target group: $GroupId" try { $group = Get-MgGroup -GroupId $GroupId -ErrorAction Stop Write-Verbose "Target group found: $($group.DisplayName)" } catch { throw "Group with ID '$GroupId' not found or inaccessible: $_" } # Step 3: Determine plan title $newPlanTitle = if ($PlanTitle) { $PlanTitle } else { "$($importData.Plan.Title) (Imported)" } if (-not $PSCmdlet.ShouldProcess("Group: $($group.DisplayName)", "Import plan '$newPlanTitle' with $($importData.Statistics.TotalBuckets) buckets and $($importData.Statistics.TotalTasks) tasks")) { Write-Warning "Import cancelled by user." return } # Step 4: Create the new plan Write-Verbose "Creating plan: $newPlanTitle" $newPlan = Invoke-WithRetry -ScriptBlock { New-MgPlannerPlan -Title $newPlanTitle -Container @{ ContainerId = $GroupId Type = "group" } -ErrorAction Stop } Write-Host "Plan created successfully: $newPlanTitle (ID: $($newPlan.Id))" -ForegroundColor Green # Step 5: Create buckets with mapping Write-Verbose "Creating buckets..." $bucketMapping = @{} # Sort buckets by OrderHint to maintain original order $sortedBuckets = $importData.Buckets | Sort-Object -Property OrderHint # Create buckets in order WITHOUT specifying OrderHint # Graph API will auto-generate OrderHints that preserve creation order foreach ($bucket in $sortedBuckets) { Write-Verbose " Creating bucket: $($bucket.Name)" $bucketParams = @{ Name = $bucket.Name PlanId = $newPlan.Id } # Note: OrderHint is intentionally NOT specified - Graph API auto-generates it $newBucket = Invoke-WithRetry -ScriptBlock { New-MgPlannerBucket @bucketParams -ErrorAction Stop } $bucketMapping[$bucket.Id] = $newBucket.Id Write-Verbose " Original ID: $($bucket.Id) -> New ID: $($newBucket.Id) (OrderHint auto-generated)" } Write-Host "Created $($bucketMapping.Count) bucket(s) in original order" -ForegroundColor Cyan # Step 6: Create tasks Write-Verbose "Creating tasks..." $createdTasks = 0 $skippedTasks = 0 $userCache = @{} foreach ($task in $importData.Tasks) { Write-Verbose " Creating task: $($task.Title)" try { # Build task parameters $taskParams = @{ PlanId = $newPlan.Id BucketId = $bucketMapping[$task.BucketId] Title = $task.Title } # Add dates if not skipped if (-not $SkipDates) { if ($task.StartDateTime) { $taskParams['StartDateTime'] = $task.StartDateTime } if ($task.DueDateTime) { $taskParams['DueDateTime'] = $task.DueDateTime } } # Add priority if ($task.Priority) { $taskParams['Priority'] = [int]$task.Priority } # Add percent complete (progress) if not skipped if (-not $SkipProgress -and ($null -ne $task.PercentComplete)) { $taskParams['PercentComplete'] = [int]$task.PercentComplete } # Add assignments if not skipped if (-not $SkipAssignments -and $task.Assignments) { $assignments = @{} foreach ($assignment in $task.Assignments.PSObject.Properties) { $userId = $assignment.Name # Try to find user in target tenant if (-not $userCache.ContainsKey($userId)) { try { $user = Get-MgUser -UserId $userId -ErrorAction Stop $userCache[$userId] = $user.Id Write-Verbose " User found: $($user.DisplayName)" } catch { Write-Warning " User not found in target tenant: $userId" $userCache[$userId] = $null } } if ($userCache[$userId]) { $assignments[$userCache[$userId]] = @{ "@odata.type" = "microsoft.graph.plannerAssignment" OrderHint = " !" } } } if ($assignments.Count -gt 0) { $taskParams['Assignments'] = $assignments } } # Create the task $newTask = Invoke-WithRetry -ScriptBlock { New-MgPlannerTask @taskParams -ErrorAction Stop } # Update task details (description, checklist) if ($task.Description -or $task.Checklist) { Write-Verbose " Updating task details..." Start-Sleep -Milliseconds 500 # Brief delay to avoid conflicts $detailParams = @{} if ($task.Description) { $detailParams['Description'] = $task.Description } if ($task.Checklist) { $checklistItems = @{} foreach ($item in $task.Checklist.PSObject.Properties) { $checklistItems[$item.Name] = $item.Value } if ($checklistItems.Count -gt 0) { $detailParams['Checklist'] = $checklistItems } } if ($detailParams.Count -gt 0) { # Get current ETag $taskDetail = Get-MgPlannerTaskDetail -PlannerTaskId $newTask.Id Invoke-WithRetry -ScriptBlock { Update-MgPlannerTaskDetail -PlannerTaskId $newTask.Id -IfMatch $taskDetail.AdditionalProperties.'@odata.etag' @detailParams -ErrorAction Stop } } } $createdTasks++ Write-Verbose " Task created successfully" } catch { Write-Warning "Failed to create task '$($task.Title)': $_" $skippedTasks++ } } # Step 7: Summary Write-Host "`nImport Summary:" -ForegroundColor Green Write-Host " Plan Title: $newPlanTitle" -ForegroundColor Cyan Write-Host " Plan ID: $($newPlan.Id)" -ForegroundColor Cyan Write-Host " Buckets Created: $($bucketMapping.Count)" -ForegroundColor Cyan Write-Host " Tasks Created: $createdTasks" -ForegroundColor Cyan if ($skippedTasks -gt 0) { Write-Host " Tasks Skipped: $skippedTasks" -ForegroundColor Yellow } Write-Host " Assignments Included: $(-not $SkipAssignments)" -ForegroundColor Cyan Write-Host " Dates Included: $(-not $SkipDates)" -ForegroundColor Cyan Write-Host " Progress Included: $(-not $SkipProgress)" -ForegroundColor Cyan # Return the created plan return $newPlan } catch { Write-Error "Failed to import plan: $_" throw } } end { Write-Verbose "Import process completed" } } |