<# Copy-PlannerPlanWithAttachments.ps1 - Creates NEW plan in TargetGroup with NewPlanTitle - Copies plan categories (labels) (categoryDescriptions) safely - Copies plan background (via /beta, if present) - Copies buckets (incl. orderHint; invalid hints normalized) - Copies tasks (base fields + categories + filtered assignments + orderHint + dates/priority/progress) - Patches task details: - description - checklist (NO splitting; stable order; skips empty titles; truncates titles to 97 + "..." = 100; max 20) - references (attachments): - resolves SharePoint/OneDrive file links via /shares and copies them to target Team SharePoint folder - rewires references to copied.webUrl (encoded key) - does NOT copy previewPriority / lastModifiedBy etc (avoids invalid PATCH payload) Notes: - Planner comments/conversations are group conversation threads; Planner API does not support migrating them. Requirements: - PowerShell 7 - Install-Module Microsoft.Graph -Scope CurrentUser - Delegated scopes: Tasks.ReadWrite, Group.Read.All, User.Read.All, Sites.ReadWrite.All #> param( [string]$SourcePlanId = "", [string]$TargetGroupId = "", [string]$NewPlanTitle = "", [string]$TargetAttachmentsFolder = "", [switch]$VerboseTasks, [switch]$VerboseAttachments ) $ErrorActionPreference = "Stop" $JsonDepth = 100 function Fail($msg) { throw $msg } function Invoke-WithRetry { param( [Parameter(Mandatory)] [scriptblock]$Action, [int]$MaxAttempts = 8, [int]$BaseDelaySeconds = 2, [string]$Context = "action" ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { return & $Action } catch { $m = $_.Exception.Message $isTransient = $m -match "\b429\b|\b502\b|\b503\b|\b504\b" if (-not $isTransient) { throw } if ($attempt -eq $MaxAttempts) { Fail "Retry limiet bereikt bij $Context. Laatste fout: $m" } $delay = [Math]::Min(60, $BaseDelaySeconds * [Math]::Pow(2, ($attempt - 1))) $jitter = Get-Random -Minimum 0 -Maximum 3 Start-Sleep -Seconds ([int]$delay + $jitter) } } } function Invoke-GraphJson { param( [Parameter(Mandatory)] [ValidateSet("GET","POST","PATCH","DELETE")] [string]$Method, [Parameter(Mandatory)] [string]$Uri, [object]$Body = $null, [hashtable]$Headers = $null, [string]$Context = "Graph call" ) Invoke-WithRetry -Context $Context -Action { $json = $null if ($null -ne $Body) { $json = ($Body | ConvertTo-Json -Depth $JsonDepth) } if ($null -ne $Headers) { return Invoke-MgGraphRequest -Method $Method -Uri $Uri -Headers $Headers -Body $json -ContentType "application/json" } return Invoke-MgGraphRequest -Method $Method -Uri $Uri -Body $json -ContentType "application/json" } } function Get-AllPages { param( [Parameter(Mandatory)] [string]$Uri, [string]$Context="paging" ) $items = @() $next = $Uri while ($next) { $res = Invoke-GraphJson -Method GET -Uri $next -Context $Context if ($res.value) { $items += @($res.value) } $next = $res.'@odata.nextLink' } return @($items) } function Enumerate-Dictionary { param($obj) $result = @() if ($null -eq $obj) { return $result } if ($obj -is [System.Collections.IDictionary]) { foreach ($e in $obj.GetEnumerator()) { $result += @{ Key = $e.Key; Value = $e.Value } } return $result } foreach ($p in $obj.PSObject.Properties) { $result += @{ Key = $p.Name; Value = $p.Value } } return $result } function Normalize-AppliedCategories { param($appliedCategories) if ($null -eq $appliedCategories) { return $null } $out = @{} if ($appliedCategories -is [System.Collections.IDictionary]) { foreach ($kv in $appliedCategories.GetEnumerator()) { $k = [string]$kv.Key if ($k -match '^category\d+$') { if ([bool]$kv.Value) { $out[$k] = $true } } } } else { foreach ($p in $appliedCategories.PSObject.Properties) { if ($p.Name -match '^category\d+$' -and [bool]$p.Value) { $out[$p.Name] = $true } } } if ($out.Count -eq 0) { return $null } return $out } function Normalize-OrderHint { param( [string]$Hint, [int]$FallbackIndex ) # Planner accepts only certain formats; error shows it requires space or '!' presence. if (-not [string]::IsNullOrWhiteSpace($Hint) -and ($Hint -match '[ !]')) { return $Hint } return ("{0:D6} !" -f $FallbackIndex) } function Ensure-TargetFolder { param( [Parameter(Mandatory)] [string]$TargetGroupId, [Parameter(Mandatory)] [string]$FolderPath ) $site = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/groups/$TargetGroupId/sites/root" -Context "Get target root site" $siteId = $site.id $drive = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/sites/$siteId/drive" -Context "Get target site drive" $driveId = $drive.id $parts = $FolderPath -split "/" | Where-Object { $_ -and $_.Trim() -ne "" } $parentId = "root" foreach ($p in $parts) { $children = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/drives/$driveId/items/$parentId/children?`$select=id,name,folder" -Context "List folder children" $existing = $children.value | Where-Object { $_.name -eq $p -and $_.folder } | Select-Object -First 1 if ($existing) { $parentId = $existing.id; continue } $created = Invoke-GraphJson -Method POST -Uri "https://graph.microsoft.com/v1.0/drives/$driveId/items/$parentId/children" -Context "Create folder" -Body @{ name = $p folder = @{} "@microsoft.graph.conflictBehavior" = "rename" } $parentId = $created.id } return @{ driveId = $driveId; folderItemId = $parentId } } function Try-ResolveDriveItemFromUrl { param([Parameter(Mandatory)] [string]$Url) try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Url) $b64 = [Convert]::ToBase64String($bytes).TrimEnd("=") -replace "\+","-" -replace "/","_" $shareId = "u!$b64" return Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/shares/$shareId/driveItem" -Context "Resolve share to driveItem" } catch { return $null } } function Make-SafeFileName { param([string]$TaskTitle, [string]$OriginalName) $safeTitle = ($TaskTitle -replace '[\\/:*?"<>|]', '').Trim() if ($safeTitle.Length -gt 40) { $safeTitle = $safeTitle.Substring(0,40) } $orig = ($OriginalName -replace '[\\/:*?"<>|]', '').Trim() if (-not $orig) { $orig = "attachment" } $suffix = [Guid]::NewGuid().ToString("N").Substring(0,8) return "$safeTitle-$suffix-$orig" } function Encode-UrlAsReferenceKey { param([Parameter(Mandatory)][string]$Url) $k = $Url $k = $k -replace '%','%25' $k = $k -replace '\.','%2E' $k = $k -replace ':','%3A' $k = $k -replace '@','%40' $k = $k -replace '#','%23' return $k } function Decode-ReferenceKeyToUrl { param([Parameter(Mandatory)][string]$Key) return [System.Uri]::UnescapeDataString($Key) } function Build-ExternalReference { param($meta) $ref = @{ "@odata.type" = "#microsoft.graph.plannerExternalReference" } if ($null -ne $meta) { if ($null -ne $meta.alias -and $meta.alias -ne "") { $ref["alias"] = [string]$meta.alias } if ($null -ne $meta.type -and $meta.type -ne "") { $ref["type"] = [string]$meta.type } } return $ref } function Build-AssignmentsSafe { param( $Assignments, [Parameter(Mandatory)] $TargetMemberIds ) $out = @{} $i = 1 foreach ($entry in (Enumerate-Dictionary -obj $Assignments)) { $uid = [string]$entry.Key if (-not $uid) { continue } if (-not $TargetMemberIds.Contains($uid)) { continue } $orderHint = $null try { $orderHint = [string]$entry.Value.orderHint } catch {} $orderHint = Normalize-OrderHint -Hint $orderHint -FallbackIndex $i $out[$uid] = @{ "@odata.type" = "#microsoft.graph.plannerAssignment" orderHint = $orderHint } $i++ } return $out } function Build-ChecklistRebuilt { param( $srcChecklist, [string]$TaskTitle = "", [switch]$VerboseTasks ) $out = @{} if ($null -eq $srcChecklist) { return $out } $srcItems = @() $seq = 0 foreach ($entry in (Enumerate-Dictionary -obj $srcChecklist)) { $v = $entry.Value if ($null -eq $v) { continue } $title = [string]$v.title if ([string]::IsNullOrWhiteSpace($title)) { continue } if ($title.Length -gt 100) { $title = $title.Substring(0,97) + "..." if ($VerboseTasks) { Write-Host ("Checklist item afgekapt (>100 chars) in task '{0}'" -f $TaskTitle) -ForegroundColor DarkYellow } } $srcItems += [pscustomobject]@{ Seq = $seq Title = $title.Trim() IsChecked = [bool]$v.isChecked SrcOrder = [string]$v.orderHint } $seq++ } if ($srcItems.Count -eq 0) { return $out } $srcItems = $srcItems | Sort-Object ` @{Expression="SrcOrder"; Ascending=$true}, ` @{Expression="Seq"; Ascending=$true} if ($srcItems.Count -gt 20) { if ($VerboseTasks) { Write-Host ("Checklist >20 items in task '{0}', afgekapt naar 20" -f $TaskTitle) -ForegroundColor DarkYellow } $srcItems = $srcItems | Select-Object -First 20 } $i = 1 foreach ($it in $srcItems) { $id = [Guid]::NewGuid().ToString("N") $out[$id] = @{ "@odata.type" = "#microsoft.graph.plannerChecklistItem" title = $it.Title isChecked = $it.IsChecked orderHint = ("{0:D6} !" -f $i) } $i++ } return $out } function Copy-DriveItemToTargetFolder { param( [Parameter(Mandatory)] [string]$SourceDriveId, [Parameter(Mandatory)] [string]$SourceItemId, [Parameter(Mandatory)] [string]$TargetDriveId, [Parameter(Mandatory)] [string]$TargetFolderItemId, [Parameter(Mandatory)] [string]$NewName ) $copyUri = "https://graph.microsoft.com/v1.0/drives/$SourceDriveId/items/$SourceItemId/copy" $body = @{ parentReference = @{ driveId = $TargetDriveId; id = $TargetFolderItemId } name = $NewName } $resp = Invoke-WithRetry -Context "DriveItem copy POST (Graph)" -Action { Invoke-MgGraphRequest -Method POST -Uri $copyUri -Body ($body | ConvertTo-Json -Depth 50) -ContentType "application/json" -OutputType HttpResponseMessage } if (-not $resp.IsSuccessStatusCode -or [int]$resp.StatusCode -ne 202) { $content = $null try { $content = $resp.Content.ReadAsStringAsync().Result } catch {} Fail "DriveItem copy POST failed (status $([int]$resp.StatusCode)): $content" } $monitorUrl = $resp.Headers.Location.AbsoluteUri if (-not $monitorUrl) { Fail "DriveItem copy: geen monitor URL (Location header)." } $completed = $false for ($i = 0; $i -lt 180; $i++) { Start-Sleep -Seconds 2 if ($VerboseAttachments -and (($i % 10) -eq 0)) { Write-Host (" wachten op copy ({0})" -f $i) -ForegroundColor DarkGray } $m = Invoke-WithRetry -Context "DriveItem copy monitor (Graph)" -Action { Invoke-MgGraphRequest -Method GET -Uri $monitorUrl -OutputType PSObject } if ($m -and $m.status) { if ($m.status -eq "completed") { $completed = $true; break } if ($m.status -eq "failed") { Fail "DriveItem copy monitor failed: $($m | ConvertTo-Json -Depth 10)" } } } if (-not $completed) { Fail "DriveItem copy monitor timeout voor $NewName" } $children = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/drives/$TargetDriveId/items/$TargetFolderItemId/children?`$select=id,name,webUrl" -Context "List target folder after copy" $found = $children.value | Where-Object { $_.name -eq $NewName } | Select-Object -First 1 if (-not $found) { Fail "Gekopieerd item niet gevonden in target folder (naam=$NewName)." } return $found } function Copy-PlannerCategories { param( [Parameter(Mandatory)] [string]$SourcePlanId, [Parameter(Mandatory)] [string]$TargetPlanId ) Write-Host " Categorieen kopieren..." -ForegroundColor DarkCyan $srcDetails = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/planner/plans/$SourcePlanId/details" -Context "Get source plan details" $tgtDetails = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/planner/plans/$TargetPlanId/details" -Context "Get target plan details" $etag = $tgtDetails.'@odata.etag' if (-not $etag) { Fail "Geen etag op target plan details" } $catOut = @{} $cd = $srcDetails.categoryDescriptions if ($cd -is [System.Collections.IDictionary]) { foreach ($kv in $cd.GetEnumerator()) { $k = [string]$kv.Key $v = [string]$kv.Value if ($k -match '^category\d+$' -and -not [string]::IsNullOrWhiteSpace($v)) { $catOut[$k] = $v.Trim() } } } elseif ($null -ne $cd) { foreach ($p in $cd.PSObject.Properties) { if ($p.Name -match '^category\d+$' -and -not [string]::IsNullOrWhiteSpace([string]$p.Value)) { $catOut[$p.Name] = ([string]$p.Value).Trim() } } } if ($catOut.Count -eq 0) { Write-Host " (Geen benoemde categorieen gevonden in bronplan)" -ForegroundColor DarkGray return } foreach ($k in ($catOut.Keys | Sort-Object)) { Write-Host (" {0}: {1}" -f $k, $catOut[$k]) -ForegroundColor DarkGreen } Invoke-GraphJson -Method PATCH ` -Uri "https://graph.microsoft.com/v1.0/planner/plans/$TargetPlanId/details" ` -Headers @{ "If-Match" = $etag } ` -Context "Patch target plan categories" ` -Body @{ categoryDescriptions = $catOut } | Out-Null } # -------------------- MAIN -------------------- Write-Host "=== Script gestart ===" -ForegroundColor Cyan $scopes = @("Tasks.ReadWrite","Group.Read.All","User.Read.All","Sites.ReadWrite.All") Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Connect-MgGraph -Scopes $scopes | Out-Null $ctx = Get-MgContext if (-not $ctx -or -not $ctx.Account) { throw "Geen actieve Graph sessie na Connect-MgGraph" } Write-Host ("Connected as {0} | AuthType={1}" -f $ctx.Account, $ctx.AuthType) -ForegroundColor Green Write-Host "1/6 Target folder voorbereiden..." $targetFolderInfo = Ensure-TargetFolder -TargetGroupId $TargetGroupId -FolderPath $TargetAttachmentsFolder $targetDriveId = $targetFolderInfo.driveId $targetFolderItemId = $targetFolderInfo.folderItemId Write-Host (" Target folder OK: /{0}" -f $TargetAttachmentsFolder) -ForegroundColor DarkGreen Write-Host "2/6 Target groepsleden ophalen (voor assignments-filter)..." $members = Get-AllPages -Uri "https://graph.microsoft.com/v1.0/groups/$TargetGroupId/members?`$select=id" -Context "Get target group members" $targetMemberIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($m in $members) { if ($m.id) { [void]$targetMemberIds.Add($m.id) } } Write-Host (" Members loaded: {0}" -f $targetMemberIds.Count) -ForegroundColor DarkGreen Write-Host "3/6 Nieuw plan aanmaken..." $newPlan = Invoke-GraphJson -Method POST -Uri "https://graph.microsoft.com/v1.0/planner/plans" -Context "Create planner plan" -Body @{ owner = $TargetGroupId title = $NewPlanTitle } $TargetPlanId = $newPlan.id Write-Host (" Nieuw plan aangemaakt: {0}" -f $TargetPlanId) -ForegroundColor DarkGreen Copy-PlannerCategories -SourcePlanId $SourcePlanId -TargetPlanId $TargetPlanId # Background via beta (als aanwezig) try { $srcBeta = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/planner/plans/$SourcePlanId/details" $tgtBeta = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/planner/plans/$TargetPlanId/details" $etagBeta = $tgtBeta.'@odata.etag' $bgPatch = @{} if ($srcBeta.backgroundImage) { $bgPatch["backgroundImage"] = $srcBeta.backgroundImage } if ($srcBeta.backgroundColor) { $bgPatch["backgroundColor"] = $srcBeta.backgroundColor } if ($bgPatch.Count -gt 0 -and $etagBeta) { Invoke-MgGraphRequest -Method PATCH ` -Uri "https://graph.microsoft.com/beta/planner/plans/$TargetPlanId/details" ` -Headers @{ "If-Match" = $etagBeta } ` -Body ($bgPatch | ConvertTo-Json -Depth 10) ` -ContentType "application/json" | Out-Null Write-Host " Background gekopieerd (beta)." -ForegroundColor DarkGreen } else { Write-Host " (Geen background velden gevonden of niet beschikbaar in beta.)" -ForegroundColor DarkGray } } catch { Write-Host (" (Background kopie overgeslagen: {0})" -f $_.Exception.Message) -ForegroundColor DarkGray } Write-Host "4/6 Buckets kopieren..." $sourceBuckets = Get-AllPages -Uri "https://graph.microsoft.com/v1.0/planner/plans/$SourcePlanId/buckets" -Context "Get source buckets" # Preserve order: sort by orderHint (lexicographically), then name $sourceBuckets = $sourceBuckets | Sort-Object ` @{Expression="orderHint"; Ascending=$true}, ` @{Expression="name"; Ascending=$true} $bucketMap = @{} $bucketIdx = 1 foreach ($b in $sourceBuckets) { $body = @{ planId = $TargetPlanId name = $b.name } # Normalize orderHint to acceptable format if needed $body["orderHint"] = Normalize-OrderHint -Hint ([string]$b.orderHint) -FallbackIndex $bucketIdx $bucketIdx++ $nb = Invoke-GraphJson -Method POST -Uri "https://graph.microsoft.com/v1.0/planner/buckets" -Context "Create target bucket" -Body $body $bucketMap[$b.id] = $nb.id if ($VerboseTasks) { Write-Host (" Bucket: {0} (orderHint={1})" -f $b.name, $body.orderHint) -ForegroundColor DarkCyan } } Write-Host (" Buckets gekopieerd: {0}" -f $bucketMap.Count) -ForegroundColor DarkGreen Write-Host "5/6 Tasks kopieren (basis + details + attachments)..." $sourceTasks = Get-AllPages -Uri "https://graph.microsoft.com/v1.0/planner/plans/$SourcePlanId/tasks" -Context "Get source tasks" # Preserve task order in UI: sort by orderHint $sourceTasks = $sourceTasks | Sort-Object ` @{Expression="orderHint"; Ascending=$true}, ` @{Expression="title"; Ascending=$true} $tasksDone = 0 foreach ($t in $sourceTasks) { if ($VerboseTasks) { Write-Host ("Task: {0}" -f $t.title) -ForegroundColor Cyan } $newAssignments = Build-AssignmentsSafe -Assignments $t.assignments -TargetMemberIds $targetMemberIds $cats = Normalize-AppliedCategories -appliedCategories $t.appliedCategories $taskBody = @{ planId = $TargetPlanId title = $t.title bucketId = $bucketMap[$t.bucketId] priority = $t.priority percentComplete = $t.percentComplete dueDateTime = $t.dueDateTime startDateTime = $t.startDateTime } # Normalize task orderHint too (some tenants store compact values) $taskBody["orderHint"] = Normalize-OrderHint -Hint ([string]$t.orderHint) -FallbackIndex ($tasksDone + 1) if ($newAssignments.Count -gt 0) { $taskBody["assignments"] = $newAssignments } if ($null -ne $cats) { $taskBody["appliedCategories"] = $cats } $nt = Invoke-GraphJson -Method POST -Uri "https://graph.microsoft.com/v1.0/planner/tasks" -Context "Create target task" -Body $taskBody $srcDetails = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/planner/tasks/$($t.id)/details" -Context "Get source task details" $tgtDetails = Invoke-GraphJson -Method GET -Uri "https://graph.microsoft.com/v1.0/planner/tasks/$($nt.id)/details" -Context "Get target task details" $tgtEtag = $tgtDetails.'@odata.etag' if (-not $tgtEtag) { Fail "Geen @odata.etag op target task details voor task $($nt.id)" } $patchBody = @{} if ($null -ne $srcDetails.description -and $srcDetails.description -ne "") { $patchBody["description"] = $srcDetails.description } $checklistOut = Build-ChecklistRebuilt -srcChecklist $srcDetails.checklist -TaskTitle $t.title -VerboseTasks:$VerboseTasks if ($checklistOut.Count -gt 0) { $patchBody["checklist"] = $checklistOut } $refsOut = @{} foreach ($entry in (Enumerate-Dictionary -obj $srcDetails.references)) { $refKeyEncoded = [string]$entry.Key if (-not $refKeyEncoded) { continue } $refUrl = Decode-ReferenceKeyToUrl $refKeyEncoded $refMeta = $entry.Value $driveItem = Try-ResolveDriveItemFromUrl -Url $refUrl if ($driveItem -and $driveItem.parentReference -and $driveItem.parentReference.driveId) { if ($VerboseAttachments) { Write-Host (" Kopieer attachment: {0}" -f $driveItem.name) -ForegroundColor DarkYellow } $newName = Make-SafeFileName -TaskTitle $t.title -OriginalName $driveItem.name $copied = Copy-DriveItemToTargetFolder ` -SourceDriveId $driveItem.parentReference.driveId ` -SourceItemId $driveItem.id ` -TargetDriveId $targetDriveId ` -TargetFolderItemId $targetFolderItemId ` -NewName $newName if ($VerboseAttachments) { Write-Host (" Gekopieerd: {0}" -f $copied.webUrl) -ForegroundColor DarkGreen } $newKeyEncoded = Encode-UrlAsReferenceKey $copied.webUrl $refsOut[$newKeyEncoded] = (Build-ExternalReference -meta $refMeta) } else { $refsOut[$refKeyEncoded] = (Build-ExternalReference -meta $refMeta) } } if ($refsOut.Count -gt 0) { $patchBody["references"] = $refsOut } if ($patchBody.Keys.Count -gt 0) { Invoke-GraphJson -Method PATCH -Uri "https://graph.microsoft.com/v1.0/planner/tasks/$($nt.id)/details" -Context "Patch target task details" -Headers @{ "If-Match" = $tgtEtag } -Body $patchBody | Out-Null } $tasksDone++ if (($tasksDone % 10) -eq 0) { Write-Host (" ... {0} / {1} tasks gekopieerd" -f $tasksDone, $sourceTasks.Count) -ForegroundColor DarkGray } } Write-Host "6/6 Klaar." -ForegroundColor Green Write-Host (" TargetPlanId : {0}" -f $TargetPlanId) Write-Host (" Attachments : SharePoint target team /{0}" -f $TargetAttachmentsFolder) Write-Host (" Buckets : {0}" -f $bucketMap.Count) Write-Host (" Tasks : {0}" -f $tasksDone) Write-Host "" Write-Host "Let op: Comments/conversaties van Planner-taken migreren niet via de Planner API." -ForegroundColor DarkGray