Duplicate Microsoft Planner Plans

One of the most requested features on the UserVoice forum for Microsoft Planner is the ability to create Microsoft Planner Templates and make new plans from them. Microsoft is working on this feature, but the ETA is still unknown. I got the same request and started working on a PowerShell script that allows you to duplicate plans. At the end of this article, you will find the complete script, but first I will walk you through the steps.

We are going to use PowerShell and Microsoft Graph API to duplicate the plan. I use a custom connector to connect with Graph. If you haven’t used Graph with PowerShell before, make sure you read this step-by-step guide first Microsoft Graph with PowerShell here.

What is required to duplicate a plan

To duplicate a Microsoft Planner Plan we need to go through a lot of steps:

  • Get the Template Plan and New Plan id
  • Copy the template plan categories (for the labels)
  • Get all the buckets
  • Get all the task for each bucket
  • Adding the new buckets in the right order
  • Adding the task to each bucket in the right order
  • Add the descriptions for each task
  • Add checklists if they exist

I will go through every step so you can modify it to your own needs.

Creating Microsoft Planner Templates

I assume you have already connected to Microsoft Graph. If not make sure you read the step-by-step guide mentioned earlier. One thing you will see a lot in the steps below is the if-match header. Every time a plan, bucket, or task is created or updated, the ETag token is changed. So before we can update something or add tasks to a bucket, we need to get the latest ETag value.

Get the plan id’s

We need the template plan and the new plan id before we can do anything. The new plan can be created manually, with PowerShell, or come with a new Office 365 group. I won’t go into detail about how you can create the plan.

There are two ways to get a plan id, the most simple way to copy it from the address bar. Just open a plan and look at the address bar. At the end of the URL you will find the planId:

https://tasks.office.com/~/Planner/#/plantaskboard?groupId=e54430ef-ABCD-43a6-1234-4c2f9d0987c3&planId=123ABCnzhHdE6zfvZkZIXpYAFPB_

Another option would be to use PowerShell to get the Unified Group by name and lookup the Group ID. Make sure you connect to Exchange Online first.

# Get Group ID from newly created Office 365 Group
$GroupId = Get-UnifiedGroup -Identity $groupName | select ExternalDirectoryObjectId

With the GroupID we can request all the available plans for this group:

$url = https://graph.microsoft.com/v1.0/groups/{group-id-with-plan}/planner/plans
$plans = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method GET -ContentType 'application/json'

Copy the plan categories

We got both plan id’s, the first thing we are going to do is copying the categories or also know as labels from the template plan. First, we get the template plan details:

# Get plan Details
$url = "https://graph.microsoft.com/v1.0/planner/plans/$templatePlanID/details"
$planDetails = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method 'GET' -ContentType 'application/json'

And we need the eTag value from the new plan, so we request the details from the new plan as well:

# New plan details for eTag token
$url = "https://graph.microsoft.com/v1.0/planner/plans/$newPlanID/details"
$newPlanDetails = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method 'GET' -ContentType 'application/json'

If you look in the $planDetails.categoryDescriptions you will see a JSON object with all the labels.  We are going to update our new plan with those category descriptions. First, we create the headers with the correct if-match header and make a JSON body. Then with a PATCH method, we can update the new plan.

# Update plan details
$headers = @{
			Authorization = "Bearer $accessToken"
			ContentType = 'application/json'
			'if-match' = $newPlanDetails.'@odata.etag'
		}

$categories = $planDetails.categoryDescriptions | convertTo-Json

$planDetailBody = @"
	{
	  "categoryDescriptions": $($categories)
	}
"@

$url = "https://graph.microsoft.com/v1.0/planner/plans/$newPlanID/details"
Invoke-RestMethod -Headers $headers -Uri $url -Method PATCH -body $planDetailBody -ContentType 'application/json'

If you look at your new plan, you will now see that all the labels have the correct description.

Get all the buckets and tasks

The tasks are not listed under a bucket but are part of the plan. So you can’t just get the task for one bucket, but you will have to request all the task for the plan.

# Get all the buckets
$url = "https://graph.microsoft.com/v1.0/planner/plans/$templatePlanID/buckets"
$buckets = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method 'GET' -ContentType 'application/json'

# Get all tasks
$url = "https://graph.microsoft.com/v1.0/planner/plans/$templatePlanID/tasks"
$allTasks = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method 'GET' -ContentType 'application/json'

Creating buckets in the new plan

The buckets and task are sorted based on the orderHint value. This isn’t a logic integer or alphabetic value, but it’s a string based on the previous and next object. If you get all the buckets and start adding them to a new plan, you will notice that they are added in reverse order. Same goes for the tasks. Now you can’t simply use the orderHint from the template plan, because the orderHint is calculated by the service.

So if we add a new bucket or task, we need to check the orderHint that the service created and use that value to add the next bucket after it. The official documentation explains pretty well how the orderHint works. If you want to add a task behind the previously added task, you only need to add a space and exclamation mark to it  !.

To add the buckets in the right order, we store the orderHint from the last created bucket so we can add the next one to the right of it.

# Create new buckets in the destination plan
$url = "https://graph.microsoft.com/v1.0/planner/buckets"

$lastBucketOrderHint  = ''

foreach ($bucket in $buckets.value) {	
	$body = @"
	{
	  "name": "$($bucket.name)",
	  "planId": "$newPlanID",
	  "orderHint": "$lastBucketOrderHint !"
	}
"@

	$newBucket = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $url -Method 'POST' -body $body -ContentType 'application/json'
	$lastBucketOrderHint = $newBucket.orderHint
}

Creating the tasks for each bucket

Inside the foreach from the buckets we are also going to add the tasks. We have already requested all the tasks from the template plan, now we only need to get the tasks that belong to this bucket. To solve the ordering issue, we can sort the task in a descending order so they are added in the right order.

# Get the task for this bucket - Reverse order to get them in right order
$tasks = $allTasks.value | where bucketId -eq $bucket.id | Sort-Object orderHint -Descending
$createTaskUrl = "https://graph.microsoft.com/v1.0/planner/tasks"

foreach ($task in $tasks) {
	# Create the task
	$taskBody = @"
		{
		        "planId": "$newPlanId",
			"bucketId": "$($newBucket.id)",
			"title": "$($task.title)"
		}
"@

	$newTask = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $createTaskUrl -Method 'POST' -body $taskBody -ContentType 'application/json'
}

Adding the details to the task

The details from a task must be added through a patch method. To update the newly created task, we need the eTag value from it. If we create a task and immediately request the details from it you might get an error that the object doesn’t exist. So to prevent this I added a little delay in the script after we created a new task.

# Add delay - we need to wait until the task is created
Start-Sleep -milliseconds 500

We also need the description from the original task from the template plan:

# Get the task Description
$taskDetailsUrl = "https://graph.microsoft.com/v1.0/planner/tasks/$($task.id)/details"
$taskDetails = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $taskDetailsUrl -Method 'GET' -ContentType 'application/json'

First, we get the etag value from the new task so we can create the necessary headers. We set the previewType and description which we get from the original task. The previewType determines what is displayed in the plan board. It can be the description of the task, reference of a checklist.

# Get the new task details for the etag
$newTaskDetailsUrl = "https://graph.microsoft.com/v1.0/planner/tasks/$($newTask.id)/details"
$newDetails = Invoke-RestMethod -Headers @{Authorization = "Bearer $accessToken"} -Uri $newTaskDetailsUrl -Method 'GET' -ContentType 'application/json'

# Update task with the details
	$headers = @{
		Authorization = "Bearer $accessToken"
		ContentType = 'application/json'
		'if-match' = $newDetails.'@odata.etag'
	}

	# Add the task description
        if ($task.hasDescription -eq $True) {
           
		$taskUpdateDescription = @"
                {
                    "description": "$($taskDetails.description)",
                    "previewType": "$($taskDetails.previewType)" 
                }
"@
            
            $taskUpdateUrl = "https://graph.microsoft.com/v1.0/planner/tasks/$($newTask.id)/details"

            Invoke-RestMethod -Headers $headers -Uri $taskUpdateUrl -Method PATCH -body $taskUpdateDescription -ContentType 'application/json'
}

Adding the checklist Items

The last part of the script is adding the checklist. This one is the most complicated one to recreate. First, we check if the original task has a checklist. Then if we have a checklist, we can start copying this one to our new task.

A checklist item needs a unique client-side generated GUID, which we can create in PowerShell with the following cmd:

$guid = [guid]::newGuid().guid

Next, we create a JSON object with all the checklist Items and add them with a PATCH method to the task:

# Copy the checklist items
        if (![string]::IsNullOrEmpty($taskDetails.checklist)) {

            $checkListItems = @{}
            foreach ($item in $taskDetails.checklist.psobject.Properties) {
                $guid = [guid]::newGuid().guid

                $checkListItem = @{
                    "@odata.type" = "#microsoft.graph.plannerChecklistItem"
                    "title" = "$($item.value.title)"
                }

                $checkListItems | Add-Member -MemberType NoteProperty -Name $guid -value $checkListItem
            }

            $checkListItemsJson = $checkListItems | ConvertTo-Json

            $taskUpdateChecklist = @"
                {
                    "checklist": $($checkListItemsJson)
                }
"@

            Invoke-RestMethod -Headers $headers -Uri $taskUpdateUrl -Method PATCH -body $taskUpdateChecklist -ContentType 'application/json'
        }
	}

Wrapping up

There are a few things I didn’t cover in this post, we created the labels in the beginning but didn’t apply them to the newly created tasks. If you want to this, check out the documentation on plannerAppliedCategories. You need to update a newly created task to add them.

Also, I didn’t copy any external references or users that are assigned to a task. In my case, this wasn’t necessary, but with this guide, you will have a good starting point to add those details also.

The complete script for using Microsoft Planner Templates can be download from my GitHub Repo. If you have any questions, you can always reach out to me. You can also hire me to make you a customized script or to help you with the implementation.

Thanks to Laura Kokkarinen for explaining the bucket and task sorting

15 thoughts on “Duplicate Microsoft Planner Plans”

  1. Hi! The link to the complete script at technet is still not working. Is it possible to provide another one? Thanks!

  2. Sorting checklist items successfully – sort by orderhint and update each item (PATCH is inside the loop)

    # Copy the checklist items
    if (![string]::IsNullOrEmpty($taskDetails.checklist)) {

    $checkListItems = @{}
    $chkListItems = $taskDetails.checklist.psobject.Properties.value | Sort-Object orderHint -Descending

    foreach ($item in $chkListItems) {
    $guid = [guid]::newGuid().guid
    $item
    $checkListItem = @{
    “@odata.type” = “#microsoft.graph.plannerChecklistItem”
    “title” = “$($item.title)”
    }

    $checkListItems | Add-Member -MemberType NoteProperty -Name $guid -value $checkListItem

    $checkListItemsJson = $checkListItems | ConvertTo-Json

    $taskUpdateChecklist = @”
    {
    “checklist”: $($checkListItemsJson)
    }
    “@

    Invoke-RestMethod -Headers $headers -Uri $taskUpdateUrl -Method PATCH -body $taskUpdateChecklist -ContentType ‘application/json’
    }
    }

    • Hi there,
      I implemented this in my script but I noticed that the checklist items end up in the wrong order although they are sorted by their OrderHint.
      I added the Prefer=”return=representation” in the header too since Microsoft says it should be in the header of the Patch request and still they end up wrong.

      Does this work for you still?
      Can this be a bug?

  3. Thanks very much! This post was a life saver for me. Our planner seems to have gotten corrupted and there is no simple way to back up Planner.

  4. Thanks for this, have been working on implementing this using Flow instead of PowerShell, and for the most part have been successful. We now have a form/list in SharePoint that lets our end-users (who can’t create O365 group on their own) request and create new planners based on a selection of pre-existing templates. Greatly appreciate the guidance!

    • Doing this with Flow (Microsoft Automate) is also a good idea. You could also take a look at Azure Runbooks, this allows you to use PowerShell scripts and trigger them from a Flow. I am planning to write an article about that at the beginning of next year.

      • I’ve been working on this technique since you mentioned it, hardest part for me has been converting the scripts so they’ll run as a single imported file. Not sure what to do with the text files that data is stored in.

  5. Copying the buckets and tasks is working great – thanks for the script.

    Checklist items are posing a problem I’m hoping someone can help with. When I try to include an orderHint attribute the PATCH operation fails (400). But without orderHint I don’t know how to get the items in the correct sequence.

    # Copy the checklist items
    if (![string]::IsNullOrEmpty($task.checkListItems)) {

    $checkListItems = @{}
    foreach ($item in (($task.checkListItems | ConvertFrom-Json) | Sort-Object -Property orderHint -Descending )) {
    $guid = [guid]::newGuid().guid

    $checkListItem = @{
    “@odata.type” = “#microsoft.graph.plannerChecklistItem”
    “title” = “$($item.title)”
    # “orderHint” = “$($item.orderHint)” #400 error when trying to include orderhint
    }

    $checkListItems.add($guid,$checkListItem)
    }

    $checkListItemsJson = $checkListItems | ConvertTo-Json

    $taskUpdateChecklist = @”
    {
    “checklist”: $($checkListItemsJson)
    }
    “@

    Invoke-RestMethod -Headers $headers -Uri $taskUpdateUrl -Method PATCH -body $taskUpdateChecklist -ContentType ‘application/json’
    }

    Any suggestions how to order the checklist items?

  6. Hi – Very very interesant post.
    Trying to implement i am reciving error 403 Forbiden in the line Invoke-RestMethod, i am not sure what is wrong, maybe my Azure level Contract??, now is free. I need to update for use Graph or……
    TIA
    Luis

Leave a Comment

14 Shares
Tweet
Pin
Share14
Share