Blog Post

Planner Blog
15 MIN READ

Microsoft Planner: How to clone a Plan with Graph

DeletedBrianSmith's avatar
DeletedBrianSmith
Brass Contributor
Mar 06, 2019

First published on MSDN on Feb 17, 2017
*** Update 4/18/2017 - new blog post and refined code for multi-assign and new endpoints - https://techcommunity.microsoft.com/t5/Planner-Blog/Planner-Cloning-a-Plan-with-multiple-assignments/ba-p/362246 - see the blog post for walk-through and explanation of the changes ***

*** Update 4/14/2017 - with the introduction of multi assign and some other recent updates to the API (planner entities are now under beta/planner - for example beta/planner/tasks) this blog post is now out of date.  Most still makes sense but I am attaching here a revised ps1 script and will shortly post a new blog.  I've added the 'previewType' seeting and re-written the checklists part to be less clunky - and of course now added the new multiple assignments - plannerclonemultiassign - is the new zip. This more or less follow the same logic as the old script - but I will be revising this to account for tasks not in buckets, and also keeping other details like relative start/end dates for tasks.  Perhaps even copying over SharePoint content.  The documentation is also updated at https://developer.microsoft.com/en-us/graph/docs/api-reference/beta/resources/planner_overview ***

(This is the zip file of the PowerShell script - plannercloneblog )

One common request we already have in the roadmap for Microsoft Planner is to support templates – but this will be a few months yet.  I wanted to find my way around Graph and what I could do with Plans and tasks – so thought cloning an existing plan might be a good thing to show.  This isn’t production ready code – really just a step-by-step using PowerShell to read and write the various entities in Planner.  This is all based on the Beta Graph for Planner - https://graph.microsoft.io/en-us/docs/api-reference/beta/beta-overview and when this goes to General Availability - hopefully this quarter - I will make the necessary edits.  There may be some slight changes to the endpoints.  Thanks to one of our MVP’s - Jakob Gottlieb Svendsen - mailto:jgs@coretech.dk http://blog.coretech.dk/jgs as I stole used some of his code from his example Graph scripts published at https://www.powershellgallery.com/packages/MicrosoftGraphAPI/0.1.3/Content/MicrosoftGraphAPI.psm1 to get the authentication tokens.

The first part of the walk-through shows creating a simple Plan – then I’ll move on to the cloning.  Follow along with the documentation linked above – so that the endpoints and requests make more sense.

For any application to talk to Graph it will need some permissions – and these are controlled by creating an AppId in Azure and setting the required access levels – then this AppId is passed in when requesting the authentication token.  There are a couple of ways of doing this – one through the Admin portal of Office 365 and then Admin Centers and Azure AD – but the one I will walk through is directly in the Azure portal ( https://portal.azure.com ).  Either way you should be able to follow the steps.

In the Azure Portal select Azure Active Directory, then App Registrations and you should end up with something like this (you may or may not see existing App Registrations):





I’m going to click Add – then enter my details and click Create:



This just takes few seconds then I can see my AppId:



Clicking on the BlogAppId takes me to the details and I can then set myself as the owner, and add the Required Permissions.  While I’m on that page I can also copy the Application ID as I will need that in my PowerShell script:



I’ll skip the screenshots adding me as owner – that is pretty straightforward – and go to Required Permissions.  One permission is already set – sign in and read user profile, you need to select the additional permissions of read and write all groups and read and write directory data – then click Save.



Once these permissions are saved you can Grant them – using the Grant Permissions option in the header:



That’s all we need to do for the Azure side of the house – now we can get on to the more interesting stuff and open the Microsoft Azure Active Directory Module for PowerShell ISE.  I already have the AAD module loaded and the MSOL stuff.  I’ll walk through the script and explain some of it as step through and show results – but the full script is attached too.  No error detection and probably will fail if you run it multiple times as I don’t initialize everything – but just meant to help you find your way around Planner using Graph.

The first thing I do after making sure that my call to Jakob’s Get-GraphAuthToken is in scope to set some variable and get my token:

# Blog Client ID - my Application ID from Azure
$clientId = '50d344ab-fd8a-4cbe-93a7-29cdb8949a71'

#myId - you can pull this from Graph
$myId =  "cf091cb1-dc23-4e12-8f30-b26085eab810"

$tenant = "brismithpjo.onmicrosoft.com"

$token = Get-GraphAuthToken -AADTenant "brismithpjo.onmicrosoft.com" -ClientId $clientid -RedirectUri " http://brismithpjo.sharepoint.com" -Credential (get-credential)


This pops up a login – so I log in to my Contoso demonstration tenant.

To create a new Plan – first I need to create a Group, then add myself as a member of that Group and then I can can create the Plan with the Group as the owner of the Plan.  For the group creation I will make a POST call to https://graph.microsoft.com/beta/groups with a request containing the required properties in json format, and a header containing the authorization (with the access token from the earlier call) as well as the content type and content length.

#Create a Group

$Request = @"
{
"description": "BlogGroup",
"displayName": "BlogGroup",
"groupTypes": [
"Unified"
],
"mailEnabled": true,
"mailNickname": "BlogGroup",
"securityEnabled": false
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$group = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/groups" -Method Post -Body $Request -Headers $headers


I’m returning my $group object – and this contains stuff I need when adding myself as a member and also creating the plan.  By selecting $group in the ISE and executing I see the following:

PS C:\> $group
StatusCode        : 201
StatusDescription : Created
Content           : {"@odata.context":" https://graph.microsoft.com/beta/ $metadata#groups/$entity","id":"a496
8242-6b41-4afa-a93c-bd0a49beda86","classification":null,"createdDateTime":"2017-02-17T23
:20:28Z","description":"...
RawContent        : HTTP/1.1 201 Created
Transfer-Encoding: chunked
request-id: 0ca2fc60-c744-4adc-9a09-be35b5a5ef3b
client-request-id: 0ca2fc60-c744-4adc-9a09-be35b5a5ef3b
x-ms-ags-diagnostic: {"ServerInfo":{"DataCe...
Forms             : {}
Headers           : {[Transfer-Encoding, chunked], [request-id, 0ca2fc60-c744-4adc-9a09-be35b5a5ef3b],
[client-request-id, 0ca2fc60-c744-4adc-9a09-be35b5a5ef3b], [x-ms-ags-diagnostic,
{"ServerInfo":{"DataCenter":"West Central
US","Slice":"SliceB","ScaleUnit":"002","Host":"AGSFE_IN_1","ADSiteName":"WCU"}}]...}
Images            : {}
InputFields       : {}
Links             : {}
ParsedHtml        : mshtml.HTMLDocumentClass
RawContentLength  : 702


The status code should be checked in production code to ensure the right response was received.  As I am working in PowerShell I found it easier to handle PowerShell objects than json – so the following code enabled me to get the returned content into something more manageable.  I use this technique for most of the calls where I want to use the content.  In this case I’m creating my PowerShell object $grouContent and then getting a couple of the properties for later use – the ID and also the displayname.

$groupContent = $group.Content | ConvertFrom-Json

$groupId = $groupContent.id
$groupDisplayName = $groupContent.displayName


To add myself as a member of the group I will be using $myId (hard coded at the top of the script) in the request – and then the $groupId variable is part of the $uri endpoint for the call to https://graph.microsoft.com/beta/groups/{id}/members/$ref – note the need to ‘escape’ the $ref with the ` character.  Again, this is a POST.

$Request = @"
{
"@odata.id": " https://graph.microsoft.com/beta/directoryObjects/ $myId"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/groups/" + $groupId + "/members/`$ref"

Invoke-WebRequest -Uri $uri -Method Post -Body $Request -Headers $headers


Once I have the Group and am a member I can create my new Plan.  The $groupId is the owner of the Plan – and I am using the same name for the Plan as the Group.  We will be supporting multiple Plans per Group at some point – in the way it is already implemented in Teams – but for now this is 1:1.  Nothing much different in this call to https://graph.microsoft.com/beta/plans – again a POST with the request and header set as you can see.  I’m pulling the Content of the returned object into a PowerShell object again – and pulling out the $planId as I will need that when I add my buckets.  One thing to note here is that if you do this too quickly after adding yourself as a member of the group you may get a 403 rather than the desired 201 as the status code – which indicates that it doesn’t yet know that you are a member.

$Request = @"
{
"owner": "$groupId",
"title": "$groupDisplayName"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$plan = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/plans" -Method Post -Body $Request -Headers $headers
$planContent = $plan.Content | ConvertFrom-Json
$planId = $planContent.id


Now we have a Plan (always good to have a Plan) so we can add a bucket.  Nothing new here – apart from the different endpoint – and you can see I used the $planId in the request.  The orderHint is a string that Planner uses to position things in lists.

$Request = @"
{
"name": "BlogBucket",
"planId": "$planId",
"orderHint": "BlogBucket"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$bucket = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/buckets" -Method Post -Body $Request -Headers $headers
$bucketContent = $bucket.Content | ConvertFrom-Json
$bucketId = $bucketContent.id


Next I can add a task to the bucket – using the $bucketId from the previous call, and I am also setting the assignedTo to $myId – so that I am assigned to the task.  I get the Task ID in case I want to do other things with the task – but for now this is all I’m going to do with this Plan.

$Request = @"
{
"assignedTo": "$myId",
"planId": "$planId",
"bucketId": "$bucketId",
"title": "Blog Task",
"orderHint": "Blog Task"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$task = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/tasks" -Method Post -Body $Request -Headers $headers
$taskContent = $task.Content | ConvertFrom-Json
$taskId = $taskContent.id


Going in to Planner I can see my new Plan, with its bucket and task – assigned to me.  So far so good!



Next we can try a clone.  For this I created a Plan called ‘Template’ and set buckets, tasks, assignments, descriptions, checklist and categories.  The aim is to create a new plan that has all these same values set.  In this case I’m not picking up any dates – but in the real world you could potentially choose a start date and use the date relationships in your ‘template’ to drive the new dates.  Here is my template:



My first piece of PowerShell reads through all my Plans and finds the one called “Template”.  This uses a GET and has no request set.  I iterate through my collection of Plans by getting the returned $plans.Content into a PowerShell object as before – then the collection is the .Value property of the Content.  Then I’m just comparing the $plan.title to find what I’m looking for.  If you have many plans you might need to consider that the returned $plans would be paged – I’m ignoring that here as I know I don’t have that many plans.  If you have many plans there is probably a better way to find your template.

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$plans = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/me/plans" -Method Get -Headers $headers
$plansContent = $plans.Content | ConvertFrom-Json
$planValue = $plansContent.Value
ForEach($plan in $planValue){
If($plan.title -eq "Template"){
$templateId=$plan.id
$groupId=$plan.owner
Break
}
}


I have my $templateId so I can read my plan and also the $groupId which I will need for my members.  The next piece of code is a bug chuck – basically reading out all the bits of my plan I am interested in.  These are all using GET’s and anything that I put in a Value variable is a collection.  In some cases I show some of the values – just so you can see what is going on.  For task details I am creating an array so I can keep track of the details like checklists inside each task in my collection.  Again, if you were coding in something different than PowerShell (or just know more than I do) then just keeping the json might be easier.  I did also pull the checklists into arrays – but found later that just using the json when re-creating was the easier way.  Finally I get the members from the group – as I want to add the same members to my new group.

#################################################
# Read Template
# Get buckets
#################################################

$uri = " https://graph.microsoft.com/beta/plans/" + $templateId + "/buckets"

$buckets = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers
$bucketsContent = $buckets.Content | ConvertFrom-Json
$bucketsValue = $bucketsContent.value

#################################################
# Get tasks
#################################################

$uri = " https://graph.microsoft.com/beta/plans/" + $templateId + "/tasks"

$tasks = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers
$tasksContent = $tasks.Content | ConvertFrom-Json
$tasksValue = $tasksContent.value

$tasksValue[6].appliedCategories | Get-Member
$tasksValue[6].bucketId

#################################################
# Get task details
#################################################

Clear-Variable [array]$taskDetailsContent

ForEach($task in $tasksValue){
$uri = " https://graph.microsoft.com/beta/tasks/" + $task.id + "/details"

$taskDetails = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers
[array]$taskDetailsContent += $taskDetails.Content | ConvertFrom-Json
}

$taskDetailsContent[6].checklist

#################################################
# Just for reference - not using the arrays returned
#################################################

ForEach($clist in ($taskDetailsContent[6].checklist | Get-Member -MemberType NoteProperty)){
[array]$checklistNames +=$clist
}
ForEach($itemName in $checklistNames){
[array]$checklistItems += $taskDetailsContent[6].checklist.($itemName.Name.ToString())
}

#$taskDetailsContent[6].checklist | Get-Member -MemberType NoteProperty

#################################################
# Get plan details
#################################################

$uri = " https://graph.microsoft.com/beta/plans/" + $templateId + "/details"

$planDetails = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers
$planDetailsContent = $planDetails.Content | ConvertFrom-Json

#################################################
#Get Group Members
#################################################

$uri = " https://graph.microsoft.com/beta/groups/" + $groupId + "/members"

$members = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers
$membersContent = $members.Content | ConvertFrom-Json
$membersValue = $membersContent.value


So that is the reading part done – next is the writing – and this starts of as before – create a Group, Add the members (which must include me) and then wait for a bit… and then create the Plan.  Once the Plan exists I add the plan details – basically the categories – which are the coloured fly-outs.  This uses a PATCH call and there is a new element in the header too - $headers.Add('If-Match', $planContent.'@odata.etag' ) so it knows what I am updating.  From there it is just a bunch of loops going through and adding the buckets, adding the tasks in the buckets and the details in the tasks – such as the applied categories, the description and checklist items.  For the checklist I swapped out the GUID for a new one – but this isn’t imperative.  Just habit – and also I was proud I’d found a way to swap out the GUID using REGEX and wasn’t about to leave it out after all that effort!  As you are running the code yourselves you can look at the objects to see what they contain – this blog will get a bit long if I try to show every detail.  Scroll down to the bottom to see how this all ended up.

#################################################
#Create our clone
#################################################
# First create the Group and add all members
#################################################

$Request = @"
{
"description": "BlogClone",
"displayName": "Blog Clone",
"groupTypes": [
"Unified"
],
"mailEnabled": true,
"mailNickname": "BlogClone",
"securityEnabled": false
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$group = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/groups" -Method Post -Body $Request -Headers $headers
$groupContent = $group.Content | ConvertFrom-Json

$groupId = $groupContent.id
$groupDisplayName = $groupContent.displayName

#################################################
# Adding members
#################################################

ForEach($member in $membersValue){

$newId=$member.id

$Request = @"
{
"@odata.id": " https://graph.microsoft.com/beta/directoryObjects/ $newId"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/groups/" + $groupId + "/members/`$ref"

$result = Invoke-WebRequest -Uri $uri -Method Post -Body $Request -Headers $headers
}

# The member addition takes some time to be available to graph - might get a 403
Start-Sleep -s 30

#################################################
# Create the new plan
#################################################

$Request = @"
{
"owner": "$groupId",
"title": "$groupDisplayName"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$plan = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/plans" -Method Post -Body $Request -Headers $headers
$planContent = $plan.Content | ConvertFrom-Json
$planId = $planContent.id

#################################################
# Add the plan details - categories (later)
#################################################

$cat0 = $planDetailsContent.category0Description
$cat1 = $planDetailsContent.category1Description
$cat2 = $planDetailsContent.category2Description
$cat3 = $planDetailsContent.category3Description
$cat4 = $planDetailsContent.category4Description
$cat5 = $planDetailsContent.category5Description
$Request = @"
{
"sharedWith": {
},
"category0Description": "$cat0",
"category1Description": "$cat1",
"category2Description": "$cat2",
"category3Description": "$cat3",
"category4Description": "$cat4",
"category5Description": "$cat5"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $planContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/plans/" + $planId + "/details"

Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers

#################################################
# Iterate through the buckets - creating each
#################################################

ForEach($newBucket in $bucketsValue){
$newBucketName = $newBucket.name
$newBucketOrderHint = $newBucket.orderHint

$Request = @"
{
"name": "$newBucketName",
"planId": "$planId",
"orderHint": "$newBucketOrderHint"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$bucket = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/buckets" -Method Post -Body $Request -Headers $headers
$bucketContent = $bucket.Content | ConvertFrom-Json
$bucketId = $bucketContent.id

$newBucket.id
$newBucket.name

Start-Sleep -s 3

ForEach($newTask in $tasksValue){

# Checking if the task is in this bucket

If($newTask.bucketId -eq $newBucket.id){

$newTaskAssignedTo = $newTask.assignedTo
$newTaskTitle = $newTask.title
$newTaskOrderHint = $newTask.orderHint
$newTaskPreviewType = $newTask.previewType
If(!$newTaskAssignedTo){
$Request = @"
{
"planId": "$planId",
"bucketId": "$bucketId",
"title": "$newTaskTitle",
"orderHint": "$newTaskOrderHint"
}
"@
} else{
$Request = @"
{
"assignedTo": "$newTaskAssignedTo",
"planId": "$planId",
"bucketId": "$bucketId",
"title": "$newTaskTitle",
"orderHint": "$newTaskOrderHint"
}
"@
}

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$task = Invoke-WebRequest -Uri " https://graph.microsoft.com/beta/tasks" -Method Post -Body $Request -Headers $headers
$taskContent = $task.Content | ConvertFrom-Json
$taskId = $taskContent.id

Start-Sleep -s 3

#################################################
# Set Applied Categories for the tasks
#################################################

$taskAppliedCategories = $newTask.appliedCategories |ConvertTo-Json

$Request = @"
{
"appliedCategories": $taskAppliedCategories
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $planContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/tasks/" + $taskId

Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers

Start-Sleep -s 3

#################################################
# Set the description and checklist for the task - if present
#################################################
# Getting the index of the task - to find the right items

$ndx = [array]::IndexOf($taskDetailsContent.id,$newTask.id)

If($taskDetailsContent[$ndx].description){

$taskDescription = $taskDetailsContent[$ndx].description

$Request = @"
{
"description": "$taskDescription"
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $planContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/tasks/" + $taskId + "/details"

Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers

}

$taskChecklist = $taskDetailsContent[$ndx].checklist | ConvertTo-Json

If($taskChecklist.Length -gt 6){

# Swap out first GUID for a new one (also need to remove the read-only properties...)

$clNew = new-object system.text.stringBuilder

$pattern = "`{`"[a-fA-F0-9]{8}-([a-fA-F0-9]{4}-){3}[a-fA-F0-9]{12}"

$lastStart = 0
$null = ([regex]::matches($taskChecklist, $pattern) | %{
$clNew.Append($taskChecklist.Substring($lastStart, $_.Index - $lastStart))
$guid = [system.guid]::newguid()
$clNew.Append("{`"" + $guid)
$lastStart = $_.Index + $_.Length
})
$clNew.Append($taskChecklist.Substring($lastStart))

$taskChecklist = $clNew.ToString()

# Remove the read only fields from the checklist json

$clNew = new-object system.text.stringBuilder

$pattern = "`"lastModifiedBy"

$lastStart = 0
$null = ([regex]::matches($taskChecklist, $pattern) | %{
$clNew.Append($taskChecklist.Substring($lastStart, $_.Index - $lastStart - 52))
$lastStart = $_.Index + $_.Length + 145
})
$clNew.Append($taskChecklist.Substring($lastStart))

$taskChecklist = $clNew.ToString()
$Request = @"
{
"checklist": $taskChecklist
}
"@

$headers = @{}
$headers.Add('Authorization','Bearer ' + $Token.AccessToken)
$headers.Add('If-Match', $planContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/beta/tasks/" + $taskId + "/details"

Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers

# Start-Sleep -s 2

}
}
}
}


My finished clone – I haven’t set the same items to ‘show on card’ but you can see from the following screenshot that all the details are there – and I am seeing some issues with the ordering of tasks – I think we have a bug there – but a great way to use templates until we have in product support!



Enjoy!  And any questions just let me know.  The following gif shows the diffsync in Planner reflecting the updates as the cloning script runs.


Updated Oct 02, 2019
Version 2.0
No CommentsBe the first to comment