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