Planner.ps1

function Get-GraphPlan           {
    <#
      .Synopsis
        Gets information about plans used in the Planner app.
      .Example
        >Get-GraphTeam -Plans | where title -eq "team planner" | get-graphplan -FullTasks
        Gets the Plan(s) for the current user's team(s), and isolates those with the name "Team Planner" ;
        for each of these plans gets the tasks, expanding the name, bucket name, and assignee names
    #>

    [cmdletbinding(DefaultParameterSetName="None")]
    param   (
        #The ID of the plan or a plan object with an ID property. if omitted the current users planner will be assumed.
        [Parameter( ValueFromPipeline=$true,Position=0)]
        $Plan,
        #If Specified returns only the details of the plan
        [Parameter(Mandatory=$true, ParameterSetName="Details")]
        [switch]$Details,
        #If specified returns a list of plan tasks.
        [Parameter(Mandatory=$true, ParameterSetName="Tasks")]
        [switch]$Tasks,
        #If specified gets a list of plan buckets which tasks can be assigned to
        [Parameter(Mandatory=$true, ParameterSetName="Buckets")]
        [switch]$Buckets,
        #If specified fills in the plan name, Assignee Name(s) and bucket name for each task.
        [Parameter(Mandatory=$true, ParameterSetName="FullTask")]
        [switch]$FullTasks
    )
    process {
        Connect-MSGraph
        if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
        $webParams = @{Method = "Get"
                       Headers = $Script:DefaultHeader
        }
        if ($Plan.title)    {$planTitle = $Plan.title}
        if ($Plan.id)       {$Plan = $Plan.id}
        if ($Plan)          {$Uri  = "https://graph.microsoft.com/v1.0/planner/plans/$Plan" }
        else                {$Uri  = "https://graph.microsoft.com/v1.0/me/planner/"}
        Write-Verbose -Message "Geting information from $uri"
        if     ($Tasks)     {
            $results = Invoke-RestMethod @webParams -Uri "$uri/Tasks"
            $t       = $results.value | Sort-Object -Property orderHint
            foreach ($task in $t) {$task.pstypenames.add("GraphTask")}
              if ($planTitle) {
                $t = $t  | Add-Member -PassThru   -MemberType NoteProperty -Name PlanTitle -value $planTitle
            }
            return $t
        }
        elseif ($FullTasks) {
            $results = (Invoke-RestMethod @webParams -Uri "$uri/tasks"   ).value | Sort-Object -Property orderHint
            if ($planTitle) {
                $results  = $results  | Add-Member -PassThru  -MemberType NoteProperty -Name PlanTitle -value $planTitle
            }
            $results |  Expand-GraphTask
        }
        elseif ($Details -and
                $Plan)      {  Invoke-RestMethod @webParams -Uri "$uri/Details" }
        elseif ($Buckets -and
                $Plan)      {
            $results =  Invoke-RestMethod @webParams -Uri "$uri/Buckets"
            $b       = $results.value | Sort-Object -Property orderHint
            foreach ($bucket in $b) {
                $bucket.pstypenames.add("GraphBucket")
                if ($planTitle) {Add-Member -InputObject $bucket -MemberType NoteProperty -Name PlanTitle -value $planTitle     }
            }
            return $b
        }
        elseif ($Buckets  -or
                $Details)   {  Write-Warning -Message "You need to specify a Plan when using -Buckets or -Details"}
        elseif ($plan)      {
            $result =  Invoke-RestMethod @webParams -Uri "$uri`?`$expand=details"
            $result.pstypenames.add("GraphPlan")
            if ($result.owner) {
                $owner = (Invoke-RestMethod  @webparams -Uri "https://graph.microsoft.com/v1.0/directoryobjects/$($result.owner)").displayname
                Add-Member -InputObject $result -MemberType NoteProperty -Name OwnerName -Value $owner
            }
            if ($result.createdBy.user.id -and $result.createdBy.user.id  -eq $result.owner) {
                Add-Member -InputObject $result -MemberType NoteProperty -Name CreatorName -Value $owner
            }
            elseif ($result.createdBy.user.id) {
                $creator = (Invoke-RestMethod  @webparams -Uri "https://graph.microsoft.com/v1.0/directoryobjects/$($result.createdBy.user.id)").displayname
                Add-Member -InputObject $result -MemberType NoteProperty -Name CreatorName -Value $creator
            }
            return $result
        }
        else                {
            $result =  Invoke-RestMethod @webParams -Uri  $uri
            $result.pstypenames.add("GraphPlan")
            return $result
        }
    }
}

function New-GraphTeamPlan       {
    <#
      .Synopsis
        Creates new a plan for a team.
    #>

    [cmdletbinding(SupportsShouldProcess=$true)]
    param   (
        #The ID of the team
        [parameter(ValueFromPipeline=$true, Mandatory=$true, Position=0)]
        $Team,
        #Name(s) of the plan(s) to add to this team.
        [parameter(Mandatory=$true, Position=1)]
        $PlanName,
        #If Specified the plan will be added without confirmation
        [Switch]$Force
    )
    begin   {
        Connect-MSGraph
    }
    process {
        if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
        if     ($Team.id)           {$settings =  @{owner = $team.id} }
        elseif ($Team -is [string]) {$settings =  @{owner = $team.id} }
        foreach ($p in $PlanName) {
            $settings["title"] = $p
            $webParams = @{Method      = "Post"
                           Headers     = $Script:DefaultHeader
                           URI         = "https://graph.microsoft.com/beta/planner/plans"
                           Contenttype = "application/json"
                           Body        = (ConvertTo-Json $settings)
            }
            Write-Debug $webParams.Body
            if ($Force -or  $PSCmdlet.ShouldProcess($P,"Add Team Planner")) {
                $result = Invoke-RestMethod @webParams -ErrorAction Stop
                $result.pstypenames.add("GraphPlan")
                Add-Member -InputObject $result -MemberType NoteProperty -Name Team -Value $Team
                return $result
            }
        }
    }
}

function Set-GraphPlanDetails    {
    <#
    .Synopsis
        Sets the category labels on a Plan
    #>

    [cmdletbinding(SupportsShouldProcess=$true)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification="Detail would be incorrect")]
    Param(
        #The ID of the Plan or a Plan object with an ID property.
        [Parameter(Mandatory=$true, Position=0)]
        $Plan,
        #Label for category 1
        [AllowNull()]
        [string]
        $Category1 ,
        #Label for category 2
        [AllowNull()]
        [string]
        $Category2 ,
        #Label for category 3
        [AllowNull()]
        [string]
        $Category3 ,
        #Label for category 4
        [AllowNull()]
        [string]
        $Category4 ,
        #Label for category 5
        [AllowNull()]
        [string]
        $Category5 ,
        #Label for category 6
        [AllowNull()]
        [string]
        $Category6,
        #If specified the plan will updated without confirmation
        [switch]$Force
    )
    Connect-MSGraph
    if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
    if ($Plan.id) {$detailsURI = "https://graph.microsoft.com/v1.0/planner/plans/$($plan.id)/details" ; $planTitle = $Plan.Title}
    else          {$detailsURI = "https://graph.microsoft.com/v1.0/planner/plans/$plan/details"       ; $planTitle = "."   }
    try {
        $tag = (Invoke-RestMethod -Method Get -Headers $Script:DefaultHeader -Uri $detailsURI -ErrorAction Stop ).'@odata.etag'
    }
    catch          {throw "Failed to get tag from $detailsURI" ; return }
    if (-not $tag) {throw "Failed to get tag from $detailsURI" ; return }
    Write-Verbose -Message "Details uri is $detailsURI will match etag of $tag"

    $CategorySettings = @{}
    foreach ($x in (1..6)) {
        if ($PSBoundParameters.ContainsKey("Category$x")) {
            $CategorySettings["category$x"] = $PSBoundParameters["category$x"]
        }
    }
    if ($CategorySettings.Count -eq 0) {throw "You need to specifiy "}
    else {$Settings = @{"categoryDescriptions" = $CategorySettings} }
    $webParams = @{ Method      = "Patch"
                    URI         = $detailsURI
                    Headers     = @{Authorization = $Script:AuthHeader; "If-Match" = $tag}
                    Contenttype = "application/json"
                    body        =  ((ConvertTo-Json $settings) -replace '""','null')

    }
    write-Debug   $webParams.body
    if ($Force -or $PSCmdlet.ShouldProcess($PlanTitle,"Update Plan Details")) {Invoke-RestMethod @webParams }
}

function New-GraphPlanBucket     {
    <#
      .Synopsis
        Creates a task-bucket in an exsiting plan
      .Example
        > New-GraphPlanBucket -Plan $NewTeamplan -Name 'Backlog', 'To-Do','Not Doing'
        Creates 3 buckets in the same plan.
    #>

    [cmdletbinding(SupportsShouldProcess=$true)]
    param   (
        #The ID of the Plan or a Plan object with an ID property.
        [Parameter(Mandatory=$true,Position=0)]
        $Plan,
        #The Name of the new bucket.
        [Parameter(Mandatory=$true,Position=1, ValueFromPipeline=$true)]
        $Name,
        #If Specified the bucket will be added without confirmation
        [switch]$Force
    )
    begin {
        Connect-MSGraph
        $webParams = @{ 'Method'      = "Post"
                        'URI'         = "https://graph.microsoft.com/v1.0/planner/buckets"
                        'Headers'     = $Script:DefaultHeader
                        'Contenttype' = "application/json"

        }
        $orderHint = " !"
    }
    process {
        if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
        if     ($Plan.id)           {$Planid = $plan.id}
        elseif ($Plan -is [String]) {$planid = $Plan}
        else   {Write-Warning 'Could not get the plan ID' ; return }
        foreach ($bucketName in $name) {
            $json      = (ConvertTo-Json ([ordered]@{"planId"=$Planid; "name"=$bucketName; "orderHint"= $orderHint}))
            Write-Debug $json
            if ($force -or $PSCmdlet.ShouldProcess($Name,"Add Bucket to plan $($Plan.title)")){
            $bucket    = Invoke-RestMethod @webParams -Body $json
            $bucket.pstypenames.add("GraphBucket")
            $orderHint = " " + $orderHint + "!"

            $bucket
            }
        }
    }
}

function Rename-GraphPlanBucket  {
    [CmdletBinding(SupportsShouldProcess)]
    <#
      .Synopsis
        Renames a bucket in a plan
      .Example
        Get-GraphPlan $teamplanner -Buckets | where name -eq "wish list" | Rename-GraphPlanBucket -NewName "Wish-List"
        Gets a list of a buckets and finds the one named "Wish list" and reanmes is.
    #>

    Param(
        #Bucket to update either as an ID or a Bucket object with an ID
        [Parameter(ValueFromPipeline=$true,Mandatory=$true, Position=0)]
        $Bucket,
        #The new name for the Bucket.
        [Parameter(Mandatory=$true, Position=1)]
        $NewName,
        #If specified the bucket will be renamed without prompting for confirmation; this is the default unless $ConfirmPreference is set
        [Switch]$Force
    )

    if ($Bucket.id) { $uri = "https://graph.microsoft.com/v1.0/planner/buckets/$($Bucket.id)"}
    else             {$uri = "https://graph.microsoft.com/v1.0/planner/buckets/$Bucket"       }
    if ($Bucket.'@odata.etag') {$tag = $Bucket.'@odata.etag'}
    else                       {$tag = (Invoke-RestMethod -Method Get -URI $uri -Headers $Script:DefaultHeader).'@odata.etag' }

    $headers = @{'If-Match'=$tag} + $Script:DefaultHeader
    $body    = "{ ""name"": ""$NewName"" }"
    if ($Force -or $PSCmdlet.ShouldProcess($NewName,'Apply new name to bucket')) {
        Invoke-RestMethod -Method Patch -URI $uri  -Headers $headers -Body $body -ContentType 'application/json'
    }
}

function Remove-GraphPlanBucket  {
    <#
      .synopsis
        Removes a bucket from a plan in planner
    #>

    [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')]
    param (
        #The bucket to remove
        [parameter(ValueFromPipeline=$true,Mandatory=$true,Position=0)]
        $Bucket,
        #If specified the bucket will be removed without prompting for confirmation; by default confirmation IS requested.
        [switch]$Force
    )
    begin {
        Connect-MSGraph
    }
    process {
        if ($Bucket.name )         {$target = $Bucket.name}
        if ($Bucket.'@odata.etag') {$tag    = $Bucket.'@odata.etag'}
        if ($Bucket.id )           {$Bucket = $Bucket.ID}
        $uri =  "https://graph.microsoft.com/v1.0/planner/buckets/$Bucket"
        if (-not $tag)  {
            $bucketdetails = Invoke-RestMethod -Method Get -Headers $Script:DefaultHeader -Uri $uri
            $tag           = $bucketdetails.'@odata.etag'
            $target        = $bucketdetails.name
        }
        if (-not $target)  {$target=$Bucket}
        $headers = @{'If-Match' = $tag} + $Script:DefaultHeader
        if($Force -or $PSCmdlet.ShouldProcess($target,'Delete Plan Bucket')) {
            Invoke-RestMethod -Method Delete -Uri $uri -Headers $headers
        }

    }
}

function Get-GraphBucketTaskList {
    [CmdletBinding()]
    Param(
        #Bucket to query either as an ID or a Bucket object with an ID
        [Parameter(ValueFromPipelineByPropertyName=$true,Mandatory=$true, Position=0)]
        $Bucket,
         #If specified IDs will be updated to their names, and extended properties (e.g. Checklist) will be added
        [Switch]$Expand
    )
    if ($Bucket.id) {$Bucket = $BucketID}
    $response = Invoke-RestMethod -Method Get -URI "https://graph.microsoft.com/v1.0/planner/buckets/$Bucket/tasks" -Headers $Script:DefaultHeader
    $value    = $response.value
    while ($response.'@odata.nextLink') {
        $response = Invoke-RestMethod -Method Get -URI $response.'@odata.nextLink' -Headers $Script:DefaultHeader
        $value += $response.value
    }
    if ($Expand) {$value | Expand-GraphTask}
    else {
        foreach ($v in $value) { $v.pstypenames.add("GraphTask")}
        return $value
    }
}

function Add-GraphPlanTask       {
    <#
      .Synopsis
        Adds a task to an exsiting plan
      .Description
        Multiple items may be piped in, to be added to the same plan.
    #>

    [cmdletbinding(SupportsShouldProcess=$true)]
    param   (
        #The ID of the Plan or a Plan object with an ID property.
        [Parameter(Mandatory=$true, Position=0)]
        $Plan,
        #The title of the new task.
        [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
        $Title,
        #Longer description of the task
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [string]$Description,
        #User(s) to assign the task to either as a UPN name (bob@contoso.com) or ID
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $AssignTo,
        #Bucket to place the task in - it must exist already
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $Bucket,
        #Start date for the task
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [Nullable[datetime]] $StartDate,
        #Date by when task should be completed
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [Nullable[datetime]]$DueDate,
        #Percentage complete (note the planner app doesn't show percentages, only "Not started", "In Progress", and "Complete")
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [ValidateRange(0,100)]
        [int]$PercentComplete,
        #Category tabs by number (1=Magenta, 2=Red, 3=Orange, 4=Green, 5=Teal, 6=Cyan)
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        # [ValidateRange(1,6)] #doesn't work if piped and values are null.
        [AllowNull()]
        [int[]]$CategoryNumbers,
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        #A single item, or an array of items to display as a list with check boxes on the task
        [string[]]$Checklist,
        #HyperLinks (a.k.a. references): a single item, a string with items seperated with ';' an array of strings or as a hash table of URI=Label.
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $Links,
        #if specified the task will be added without confirmation. (This is the default unless $confirmPreference has been changed)
        [switch]$Force,
        #By default, the task is added without returning a result. -Passthru specifies the new task should be returned.
        [Alias('PT')]
        [switch]$Passthru
    )
    begin   {
        if ($Plan.owner)  {$owner = $plan.owner}
        if ($Plan.id)     {$Plan = $Plan.id}

        Connect-MSGraph
        try {
            Write-Progress -Activity 'Adding Task' -Status 'Getting buckets and team memmbers for this plan'
            if (-not $owner) {$owner = (Get-GraphPlan -Plan $plan).owner }
            $PlanUserHash = @{}
            Get-GraphTeam -Team $owner -Members | ForEach-Object {$PlanUserHash[$_.Mail]=$_.ID}

            $planBucketshash = @{}
            Get-GraphPlan -Buckets -Plan $Plan  | ForEach-Object {$planBucketshash[$_.Name]=$_.ID}
        }
        catch { throw "An error occured while get information about the plan" ; return }

        $webParams = @{ Method      = "Post"
                        URI         = "https://graph.microsoft.com/v1.0/planner/tasks"
                        Headers     =  $Script:DefaultHeader
                        Contenttype = "application/json"
        }
    }
    process {
        if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
        $settings =  [ordered]@{"planId"=$Plan; "title"=$title}

        if ($Bucket) {
            if     ($planBucketshash.ContainsValue($Bucket)) {$settings["bucketId"]=$Bucket}
            elseif ($planBucketshash[$Bucket])               {$settings["bucketId"]=$planBucketshash[$Bucket]}
            else   {throw "$Bucket is not a valid bucket name or ID"}
        }

        if ($AssignTo) {
            $settings["assignments"] = @{}
            ForEach ($a in $AssignTo) {
                if     ($a -match "\w+@\w+")           {$assigneeID = $PlanUserHash[$a]}
                elseif ($PlanUserHash.ContainsKey($a)) {$assigneeID = $a }
                else   {throw "User $a is not a user of this plan "; return}
                $settings.assignments[$assigneeID] = @{'@odata.type'= "#microsoft.graph.plannerAssignment"; 'orderHint'= " !" }}
        }

        if ($DueDate )               {$settings["dueDateTime"]   =   $DueDate.ToUniversalTime().tostring("yyyy-MM-ddTHH:mm:ssZ")  } # 'o' for ISO date format may work here
        if ($StartDate)              {$settings["startDateTime"] = $StartDate.ToUniversalTime().tostring("yyyy-MM-ddTHH:mm:ssZ")  }

        If ($PercentComplete -ge 0) { #need to use this to catch Percent complete being 0
                                $settings["percentComplete"] = $PercentComplete
        }
        if ($AssignTo) {
            $settings["assignments"] = @{}
            ForEach ($a in $AssignTo) {
                try {
                    if ($a -match "\w+@\w+") {
                    Write-Progress -Activity 'Adding Task' -Status 'Getting system ID for user' -CurrentOperation $a
                    $a = (Invoke-RestMethod -Method Get -headers $Script:DefaultHeader -Uri "https://graph.microsoft.com/v1.0/users/$a" -ErrorAction stop).id}
                }
                catch {throw "Couldn't resolve user $a"; return}
                $settings.assignments[$a] = @{'@odata.type'= "#microsoft.graph.plannerAssignment"; 'orderHint'= " !" }}
        }
        if ($CategoryNumbers) {
            $Settings["appliedCategories"] = @{}
            foreach ($n in $CategoryNumbers) {
               if ($n -lt 1-or $n -gt 6) {throw "$n is not a valid category - valid numbers are 1..6"; return}
               else {$settings.appliedCategories["category$n"] = $true}
            }
        }
        $json =  (ConvertTo-Json $settings)
        Write-Debug $json
        if ($Force -or $PSCmdlet.ShouldProcess($Title,"Add Task") ) {
            Write-Progress -Activity 'Adding Task' -Status 'Saving new task'
            $task  = Invoke-RestMethod @webParams -body $Json
            if     ($Description -and $Checklist) {Set-GraphTaskDetails -PSC $PSCmdlet -Task $task -Description $Description -CheckList $Checklist }
            elseif ($Description )                {Set-GraphTaskDetails -PSC $PSCmdlet -Task $task -Description $Description  }
            elseif ($Checklist   )                {Set-GraphTaskDetails -PSC $PSCmdlet -Task $task -CheckList $Checklist }
            if     ($Links)                       {Set-GraphTaskDetails -PSC $PSCmdlet -Task $task -Links $Links }
            Write-Progress -Activity 'Adding Task' -Completed
            if ($Passthru) {
                $task.pstypenames.add("GraphTask")

                $task
            }
        }
    }
}

function Get-GraphPlanTask       {
    <#
      .Synopsis
        Gets a task from a plan in planner, and optionally expands IDs to names and fetches extended properties
    #>

    [cmdletbinding()]
    param (
        #The Task to get, either an ID or a Task object with an ID property.
        [Parameter(ValueFromPipeline=$true,Position=0,Mandatory=$true)]
        $Task,
        #If specified IDs will be updated to their names, and extended properties (e.g. Checklist) will be added
        [Switch]$Expand

    )

    if ($Task.ID)   {$Task = $Task.ID}
    $response = Invoke-RestMethod -Method Get -URI "https://graph.microsoft.com/v1.0/planner/tasks/$Task" -Headers $Script:DefaultHeader
    if ($Expand) {$response | Expand-GraphTask }
    else         {
        $response.pstypenames.add("GraphTask")
        return $response
    }
}

function Set-GraphPlanTask       {
    <#
      .Synopsis
        Update an a existing task in a planner plan
    #>

    [cmdletbinding(SupportsShouldProcess=$true)]
    param   (
        #The Task to update, either an ID or a Task object with an ID property.
        [Parameter(ValueFromPipelineByPropertyName=$true, Mandatory=$true, Position=0)]
        $Task,
        #The new title of for task.
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $Title,
        #Longer description of the task
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [string]$Description,
        #User(s) to assign the task to either as a UPN name (bob@contoso.com) or ID. They must already be part of the team.
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $AssignTo,
        #Bucket to place the task in - it must exist already
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $Bucket,
        #Start date for the task
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [Nullable[datetime]] $StartDate,
        #Date by when task should be completed
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [Nullable[datetime]]$DueDate,
        #Percentage complete (note the planner app doesn't show percentages, only "Not started", "In Progress", and "Complete")
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [ValidateRange(0,100)]
        [int]$PercentComplete,
        #Category tabs by number (1=Magenta, 2=Red, 3=Orange, 4=Green, 5=Teal, 6=Cyan)
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        # [ValidateRange(1,6)] #doesn't work if piped and values are null.
        [AllowNull()]
        [int[]]$CategoryNumbers,
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        #If specified, any existing check-list will be removed
        [switch]$ClearList,
        #A single item, A string with items seperated with ";" or an array of items to display as a list with check boxes on the task.
        $Checklist,
        #If specified, any existing links will be removed
        [switch]$ClearLinks,
        #HyperLinks (a.k.a. references): a single item, a string with items seperated with ';' an array of strings or as a hash table of URI=Label.
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        $Links,
        #Specified no confirmation will occur
        [switch]$Force,
        #If Specified returns the modified task.
        [Alias('PT')]
        [switch]$Passthru
    )
    begin   {
        Connect-MSGraph
        $planHash = @{}
    }
    process {
        if (-not $Script:WorkOrSchool) {Write-Warning   -Message "This command only works when you are logged in with a work or school account." ; return    }
        #Did we get a task object with an ID , a title, a Plan ID and an etag ? Or and ID with the need to look up the others up
        $tag = $plan = $promptTitle = $null
        if ($Task.planID)        {$plan        = $Task.planID}
        if ($task.'@odata.etag') {$tag         = $Task.'@odata.etag'}
        if ($Task.title)         {$promptTitle = $Task.title}
        if ($Task.ID)            {$Task        = $Task.ID}
        if (-not ($tag -and $plan -and $promptTitle) ) {
            Write-Progress -Activity "Updating task" -Status 'Getting task information'
            try {$taskobj =   Get-GraphPlanTask -Task $Task }
            catch { throw "Could not get the task: Server response code was $($_.exception.response.statuscode.value__)" ; return }
            $plan        = $taskobj.planId
            $tag         = $taskobj.'@odata.etag'
            $promptTitle = $taskobj.title
        }
        #If we have not seen this Plan before get its users and buckets
        if (-not $planHash[$plan] ) {
            try {
                Write-Progress -Activity "Updating task" -Status 'Getting team members'
                $owner = (Get-GraphPlan -Plan $plan).owner
                $PlanUserHash = @{}
                Get-GraphTeam -Team $owner -Members | ForEach-Object {$PlanUserHash[$_.Mail]=$_.ID}

                Write-Progress -Activity "Updating task" -Status 'Getting plan buckets'
                $planBucketshash = @{}
                Get-GraphPlan -Buckets -Plan $Plan  | ForEach-Object {$planBucketshash[$_.Name]=$_.ID}

                $planHash[$Plan] = $true
            }
            catch { throw "An error occured while get information about the plan" ; return }
        }

        #Build up a hash table of the settings, and then convert it to JSON. Some people would rather wrangle JSON text ...
        $settings =  [ordered]@{}
        #start by adding bucket and assigned to - if they are not in the plan already, bail out.
        if ($Bucket)   {
            if     ($planBucketshash.Containsvalue($Bucket)) {$settings["bucketId"]=$Bucket}
            elseif ($planBucketshash[$Bucket])               {$settings["bucketId"]=$planBucketshash[$Bucket]}
            else   {throw ("$Bucket is not a valid bucket name or ID; Names are: '" + ($planBucketshash.Keys -join "', '") + "'" )}
        }

        if ($AssignTo) {
            $settings["assignments"] = @{}
            ForEach ($a in $AssignTo) {
                if     ($a -match "\w+@\w+")             {$assigneeID = $PlanUserHash[$a]}
                elseif ($PlanUserHash.ContainsValue($a)) {$assigneeID = $a }
                else   {throw "User $a is not a user of this plan "; return}
                $settings.assignments[$assigneeID] = @{'@odata.type'= "#microsoft.graph.plannerAssignment"; 'orderHint'= " !" }}
        }
        #Add category numbers next. If outside the range 1..6, bail out.
        if ($CategoryNumbers) {
            $Settings["appliedCategories"] = @{}
            foreach ($n in $CategoryNumbers) {
               if   ($n -lt 1-or $n -gt 6) {throw "$n is not a valid category - valid numbers are 1..6"; return}
               else {$settings.appliedCategories["category$n"] = $true}
            }
        }
        #Now everything else, dates become strings in a specific format. All the names are case sensitive BTW.
        if ($Title)                  {$settings["title"]           = $title}
        if ($DueDate )               {$settings["dueDateTime"]     = $DueDate.ToUniversalTime().tostring("yyyy-MM-ddTHH:mm:ssZ")  }
        if ($StartDate)              {$settings["startDateTime"]   = $StartDate.ToUniversalTime().tostring("yyyy-MM-ddTHH:mm:ssZ")  }
        If ($PSBoundParameters.ContainsKey('PercentComplete')) {
                                      $settings["percentComplete"] = $PercentComplete
        }

        $json =  (ConvertTo-Json $settings)
        Write-Debug $json
        $webParams = @{ URI     = "https://graph.microsoft.com/v1.0/planner/tasks/$Task"
                    Headers     = @{'If-Match' = $tag ; 'Prefer' = 'return=representation'  } + $Script:DefaultHeader
                    Contenttype = 'application/json'
                    body        = $json
        }
        if (($settings.count -gt 0) -and ($Force -or $PSCmdlet.ShouldProcess($promptTitle,"Update Task")) ) {
            Write-Progress -Activity "Updating task" -Status 'Updating Task'
            #by specifying a 'return' preference in the headers we get the task back, and we can use that when calling set-graphtaskDetails, and return it if asked to.
            $UpdatedTask = Invoke-RestMethod -Method Patch @webParams
        }
        #The only warnings we get from Set-GraphTaskDetails are 'This check list item/ This link' is already there' - supress those because if we have a changed task, that's expected.
        if     ($Description -and $Checklist) {Set-GraphTaskDetails -Task $UpdatedTask -PSC $PSCmdlet -CheckList   $Checklist   -WarningAction SilentlyContinue -ClearList:$ClearList  -Description $Description }
        elseif ($Checklist   )                {Set-GraphTaskDetails -Task $UpdatedTask -PSC $PSCmdlet -CheckList   $Checklist   -WarningAction SilentlyContinue -ClearList:$ClearList  }
        elseif ($Description )                {Set-GraphTaskDetails -Task $UpdatedTask -PSC $PSCmdlet -Description $Description}
        if     ($Links)                       {Set-GraphTaskDetails -Task $UpdatedTask -PSC $PSCmdlet -Links       $Links       -WarningAction SilentlyContinue -ClearLinks:$ClearLinks}
        Write-Progress -Activity "Updating task" -Completed
        if ($Passthru) {
            $updatedtask.pstypenames.add('GraphTask')
            return $UpdatedTask
        }

    }
}

function Remove-GraphPlanTask    {
    <#
      .synopsis
        Removes a task from a plan in planner
    #>

    [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')]
    param   (
        #The task to remove, either as an ID, or as a Task object containing an ID.
        [parameter(ValueFromPipeline=$true,Mandatory=$true,Position=0)]
        $Task,
        #If specified the Task will be removed without prompting for confirmation; by default confirmation IS requested.
        [switch]$Force
    )
    begin   {
        Connect-MSGraph
    }
    process {
        if ($Task.title )        {$target = $Task.title}
        if ($Task.'@odata.etag') {$tag    = $Task.'@odata.etag'}
        if ($Task.id )           {$Task   = $Task.ID}
        $uri =  "https://graph.microsoft.com/v1.0/planner/Tasks/$Task"
        if (-not $tag)  {
            $Taskdetails = Invoke-RestMethod -Method Get -Headers $Script:DefaultHeader -Uri $uri
            $tag           = $Taskdetails.'@odata.etag'
            $target        = $Taskdetails.title
        }
        if (-not $target)  {$target=$Task}
        $headers = @{'If-Match' = $tag} + $Script:DefaultHeader
        if($Force -or $PSCmdlet.ShouldProcess($target,'Delete Plan Task')) {
            Invoke-RestMethod -Method Delete -Uri $uri -Headers $headers
        }

    }
}

function Expand-GraphTask        {
    <#
      .Synopsis
        Adds Assignees, buckname, plan name. Checklist, links, Preview and description fields in an existing task
      .Description
        This is not exported - it is called in Get-GraphPlan -FullTasks and Get-GraphPlanTask -Expand
    #>

    param   (
        #ID of a task or a task object contining an ID
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
        $Task
    )
    begin   {
        Connect-MSGraph
        $webParams  = @{Method = "Get"
                        Headers = $Script:DefaultHeader
        }
        $allTasks   = @()
        $planhash   = @{}
        $bucketHash = @{}
        $userHash   = @{}
    }
    process {
        $allTasks += $Task
    }
    end     {
        Write-Progress -Activity "Getting task details" -Status "Getting plan and bucket names"
        $planids      = $allTasks.planid | Sort-Object -Unique
        foreach ($p  in $planids) {
            $planhash[$p] = (Invoke-RestMethod @webParams -Uri "https://graph.microsoft.com/v1.0/planner/plans/$P" ).title
            (Invoke-RestMethod @webParams -Uri "https://graph.microsoft.com/v1.0/planner/plans/$p/buckets" ).value |
                ForEach-Object  {$bucketHash[$_.id] = $_.name}
        }
        Write-Progress -Activity "Getting task details" -Status "Getting name(s) for assignee ID(s)"
        $userIDs = $allTasks | ForEach-Object {$_.assignments.psobject.Properties} | Select-Object -ExpandProperty Name | Sort-object -unique
        foreach ($u in $userIDs)  {
            $uData = Invoke-RestMethod @webParams -Uri  "https://graph.microsoft.com/v1.0/users/$u"
            if ($uData) {$userHash[$uData.id]=$uData.displayname}
        }
        $i = 0 #Counter for progress bar.
        Write-Progress -Activity "Getting task details" -Status "Extending Tasks" -PercentComplete 0
        foreach ($t in $allTasks) {
            $details   = Invoke-RestMethod @webParams -Uri "https://graph.microsoft.com/v1.0/planner/tasks/$($t.id)/details"
            $assignees = $t.assignments.psobject.Properties | Select-Object -ExpandProperty Name | foreach-object {$userhash[$_]}
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name Assignees    -Value ($assignees -join ", ")
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name Bucketname   -Value $buckethash[$t.bucketId]
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name PlanTitle    -Value $planhash[$t.planID]
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name DetailTag    -Value $details.'@odata.etag'
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name references   -Value $details.references
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name checklist    -Value $details.checklist
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name description  -Value $details.description
            Add-Member -Force -InputObject $t -MemberType NoteProperty -Name previewType  -Value $details.previewType
            $t.pstypeNames.Add("GraphExtendedTask")
            $i += 100 #To give percentage
            Write-Progress -Activity "Getting task details" -Status "Extending Tasks" -PercentComplete ($i/$allTasks.count)
        }
        Write-Progress -Activity "Getting task details" -Completed
        return $allTasks
    }
}

function Set-GraphTaskDetails    {
    <#
      .Synopsis
        Adds Checklist, links, Preview and/or description to an existing task
      .Description
        This is not exported - it is called in Add-GraphPlanlTasks and Set-GraphPlanTask
 
    #>

    [CmdletBinding(SupportsShouldProcess=$true)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification="Detail would be incorrect")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification="False positives when initializing variable in begin block")]

    param (
        #ID of a task or a task object contining an ID
        [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
        $Task ,
        #Task description field
        [string]$Description,
        #Preview style for the task
        [ValidateSet("automatic", "noPreview", "checklist", "description", "reference")]
        $PreviewType,
        #If specified, any existing check-list will be removed
        [switch]$ClearList,
        #A single item, A string with items seperated with ";" or an array of items to display as a list with check boxes on the task.
        $CheckList,
        #If specified, any existing links will be removed
        [switch]$ClearLinks,
        #HyperLinks (a.k.a. references): a single item, a string with items seperated with ';' an array of strings or as a hash table of URI=Label.
        $Links,
        #If specified the tasks will be updated without prompting
        [Switch]$Force,
        #used to pass state should process state from another command.
        $PSC
    )
    #See https://docs.microsoft.com/en-us/graph/api/plannertaskdetails-update?view=graph-rest-1.0

    $referencesHash = $checklistHash = $null
    if (-not $psc) {$psc = $PSCmdlet}
    if ($task.id ) {$detailsURI = "https://graph.microsoft.com/v1.0/planner/tasks/$($task.id)/details" ; $taskTitle =$Task.title}
    else           {$detailsURI = "https://graph.microsoft.com/v1.0/planner/tasks/$task/details"       ; $taskTitle = "."       }
    try   {
        if ($task.DetailTag -and -not $ClearChecks -and -not $ClearReferences) {
            $tag            = $task.DetailTag
            $existingChecks = $task.checklist.psobject.Properties.value.title
            $existingRefs   = $task.references.psobject.Properties.name
        }
        else {
            Write-Progress -Activity "Updating task" -Status 'Updating Task' -CurrentOperation 'Fetching suplementary details'
            $taskdetails    = Invoke-RestMethod -Method Get -Headers $Script:DefaultHeader -Uri $detailsURI
            $tag            = $taskdetails.'@odata.etag'
            if ($ClearChecks) {
                               $taskdetails.checklist.psobject.Properties.name |
                                 ForEach-Object -begin {$checklistHash=[ordered]@{} } -Process {$checklistHash[$_] = $null}
                               $existingChecks = @()
            }
            else             { $existingChecks = $taskdetails.checklist.psobject.Properties.value.title}
            if ($ClearLinks) {
                               $taskdetails.checklist.references.Properties.name |
                                 ForEach-Object -begin {$referencesHash=[ordered]@{} } -Process {$referencesHash[$_] = $null}
                               $existingRefs = @()
            }
            else             { $existingRefs = $taskdetails.references.psobject.Properties.name}
        }
    }
    catch {
        if ($_.exception.response.statuscode.value__ -eq 404) {
            Write-Warning "Retrying connection to get taskdetails"
            Start-Sleep -Seconds 5
            $taskdetails    = Invoke-RestMethod -Method Get -Headers $Script:DefaultHeader -Uri $detailsURI
            $tag            = $taskdetails.'@odata.etag'
            $existingChecks = $taskdetails.checklist.psobject.Properties.value.title
        }
        else {  throw "Failed to get tag from $detailsURI" ;  return}
    }
    if (-not $tag) {throw "Failed to get detail tag " ; return }
    Write-Verbose -Message "Details uri is $detailsURI will match etag of $tag"

    #build up settings which will be converted into JSON later
    $Settings = @{}

    if ($CheckList) {
        if (-not $checklistHash) {$checklistHash=[ordered]@{} }
        #if Checklist is a single string with items split with ; split at the ; and include spaces either side of it.
        if     ($Checklist -is [string] )     {$Checklist = $Checklist -split '\s*;\s*'}
        foreach ($c in $CheckList) {
            if ($c -notin $existingChecks) {
                $guid = (New-Guid) -as [string]
                $checklistHash[$guid] = @{'@odata.type' = 'microsoft.graph.plannerChecklistItem' ;  'title'= $c;  }
            }
        }
        if (-not $PreviewType) { $settings["previewType"] = "checklist" }
    }
    if ($checklistHash.count -gt 0) {$settings["checklist"] = $checklistHash}

    #see https://docs.microsoft.com/en-us/graph/api/resources/plannerexternalreferences?view=graph-rest-1.0
    if     ($Links -is [hashtable] -or $links -is  [System.Collections.Specialized.OrderedDictionary]) {
        if (-not $referencesHash) {$referencesHash=[ordred]@{} }
        $orderhint = " !"
        foreach ($key in $links.keys) {
            $l = $links[$Key]  -replace "%","%25" -replace ":","%3A" -replace "\.","%2E"
            if ($l -notin $existingRefs ){
                $referencesHash[$l] = @{
                    '@odata.type'        = 'microsoft.graph.plannerExternalReference'
                    "previewPriority"    =  $orderhint
                    "alias"              =  $key
                }
                $orderhint = " $orderhint!"
            }
            else {Write-Warning -Message "$($Links[$key]) is already part of the task"}
        }
    }
    elseif ($Links)       {
        if ($Links -is [string]) {$Links = $Links -split "\s*;\s*"}  #Support semi-colon seperated list; remove any spaces adjacent to the semi-colon
        $referencesHash=[Ordered]@{}
        $orderhint = " !"
        foreach ($link in $Links) {
            #property names in Open Types cannot contain the following characters: ., :, % so they need to be encoded.
            $l = $link  -replace "%","%25" -replace ":","%3A" -replace "\.","%2E"
            if ($l -notin $existingRefs ){
                $referencesHash[$l] = @{
                    '@odata.type' = 'microsoft.graph.plannerExternalReference'
                    "previewPriority" =  $orderhint
                }
                $orderhint = " $orderhint!"
            }
            else {Write-Warning -Message "$link is already part of the task"}
        }
    }
    if ($referencesHash.Count -gt 0) {$settings["references"] = $referencesHash}
    if ($Description) {
        $settings["description"] = $Description
        if (-not $PreviewType) { $settings["previewType"] = "description"}
    }
    if ($PreviewType) { $settings["previewType"] = $PreviewType}

    #Now send a PATCH to the details URI with the if-match header and the settings in JSON Form
    $webParams = @{ Method      = "Patch"
                    URI         = $detailsURI
                    Headers     = @{Authorization = $Script:AuthHeader; "If-Match" = $tag}
                    Contenttype = "application/json"
                    body        = (ConvertTo-Json $settings)}
    Write-Debug $webParams.body
    if (($Settings.Count -gt 0 ) -and  ($Force -or $PSC.ShouldProcess($taskTitle,"Set details on task"))) {
        Write-Progress -Activity "Updating task" -Status 'Updating Task' -CurrentOperation 'Updating suplementary details'
        Invoke-RestMethod @webParams | Out-Null
    }
    Write-Progress -Activity "Updating task" -Completed
}

function Add-GraphPlannerTab     {
    <#
      .Synopsis
        Adds a planner tab to a team-channel for a pre-existing plan
      .Description
        This posts to https://graph.microsoft.com/v1.0/teams/{id}/channels/{id}/tabs
        which requires consent to use the Group.ReadWrite.All scope.
      .Example
        >
        >$channel = Get-GraphTeam -ByName accounts -Channels -ChannelName 'year-end'
        >$plan = Get-GraphTeam -ByName accounts -Plans | where title -Like "year end*"
        >Add-GraphPlannerTab -Plan $plan -Channel $channel -TabLabel "Planner"
        The first line gets the 'year-end' channel for the accounts team
        The second gets a plan with tile which matches 'year end'
        and the third creates a tab labelled 'Planner' in the channel for that plan.
    #>

    [CmdletBinding(SupportsShouldProcess=$true)]
    param(
        #An ID or Plan object for a plan within the team
        [Parameter(Mandatory=$true,Position=0)]
        $Plan,
        #An ID or Channel object for a channel (which may contain the team ID)
        [Parameter(Mandatory=$true,Position=1)]
        $Channel,
        #A team ID, or a team object, if not specified as part of the channel
        $Team,
        #The label for the tab.
        $TabLabel,
        #Normally the tab is added 'silently'. If passthru is specified, an object describing the new tab will be returned.
        $PassThru,
        #If Specified the tab will be added without confirming
        $Force
    )

    #We got a team ID use it. If the the channel had a team, use that. If we didn't get a team, throw an error.
    if       ($Team.id)      {$Team = $Team.id}
    elseif   ($Channel.Team) {$Team = $Channel.Team}
    if ( -not $Team)         {throw 'Can not determine the team from the channel; please specify it explicitly' }
    if ((-not $TabLabel) -and $Plan.Title) {
        Write-Verbose -Message "No Tab label was specified, using the Plan title '$($Plan.Title)'"
        $TabLabel = $Plan.Title
    }
    #If Plan and/or channel were objects with IDs use the ID
    if       ($Channel.id) {$Channel = $Channel.id}
    if       ($Plan.id)    {$Plan    = $Plan.id}
    $tabURI = "https://tasks.office.com/{0}/Home/PlannerFrame?page=7&planId={1}" -f $Script:TenantId , $Plan

    $webparams = @{'Method'      = 'Post';
                   'Uri'         = "https://graph.microsoft.com/beta/teams/$team/channels/$channel/tabs" ;
                   'Headers'     =  $Script:DefaultHeader;
                   'ContentType' = 'application/json'
    }

    $json = ConvertTo-Json ([ordered]@{
                'name'          = $TabLabel
                'TeamsAppId'    = 'com.microsoft.teamspace.tab.planner'
                'configuration' = [ordered]@{
                   'entityId'   = $plan
                   'contentUrl' = $tabURI
                   'websiteUrl' = $tabURI
                   'removeUrl'  = $tabURI
                }
            })
    Write-Debug $json
    if ($Force -or $PSCmdlet.ShouldProcess($TabLabel,"Add Tab")) {
        $result = Invoke-RestMethod @webParams -body $json
        if ($PassThru) {
            $result.pstypeNames.add('GraphTab')
            #Giving a type name formats things nicely, but need to set the name to be used when the tab is displayed
            Add-Member -InputObject $result -MemberType NoteProperty -Name teamsAppName -Value 'Planner'
            return $result
        }
    }
}