Planner.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
using namespace Microsoft.Graph.PowerShell.Models
using namespace System.Management.Automation

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 {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        if (-not $Plan)         {$Plan = Invoke-GraphRequest -Uri "$GraphUri/me/planner/plans" -ValueOnly -AsType ([MicrosoftGraphPlannerPlan]) -ExcludeProperty '@odata.etag' | Select-Object -First 1 }
        if ($Plan.title)        {$planTitle = $Plan.title}
        if ($Plan.id)           {$Plan      = $Plan.id}
        if ($Plan -is [string]) {$Uri       = "$GraphUri/planner/plans/$Plan" }
        else                    {
            Write-Warning "Could not get a plan ID from the information provided"
        }
        if     ($Tasks -or
                $FullTasks)     {
            #we need @odata.etag for changing items, but it isn't in the object definition ... grrr.
            $response = Invoke-GraphRequest  -Uri "$uri/Tasks" -ValueOnly | Sort-Object -Property orderHint
            $result   = foreach ($r in $response) {
                $etag = $r.'@odata.etag'
                $null = $r.remove( '@odata.etag') , $r.remove( '@odata.id')
                $taskobj = New-Object -Property $r -TypeName MicrosoftGraphPlannerTask
                if ($planTitle) { Add-Member -InputObject $taskobj -NotePropertyName  PlanTitle -NotePropertyValue $planTitle}

                Add-Member -InputObject $taskobj -NotePropertyName  etag -NotePropertyValue $etag -PassThru
            }
            if ($FullTasks) {$result | Expand-GraphTask }
            else            {$result}
        }
        elseif ($Details )      {
            Invoke-GraphRequest  -Uri "$uri/Details" -AsType ([MicrosoftGraphPlannerPlanDetails]) -ExcludeProperty '@odata.etag','@odata.context'
        }
        elseif ($Buckets)       {
            #we need @odata.etag for changing items, but it isn't in the object definition ... grrr.
            Invoke-GraphRequest   -Uri "$uri/Buckets" -ValueOnly | Sort-Object -Property orderHint | ForEach-Object {
                $etag = $_.'@odata.etag'
                $null = $_.remove('@odata.etag'), $_.remove('@odata.id')
                $bucketobj = New-object -Property $_ -TypeName MicrosoftGraphPlannerBucket |
                    Add-Member -PassThru -NotePropertyName  etag -NotePropertyValue $etag
                if ($planTitle) {Add-Member -PassThru -InputObject $bucketobj -NotePropertyName PlanTitle -NotePropertyValue $planTitle     }
                else            {$bucketobj}
            }
        }
        else                    {
            #we need @odata.etag for changing items, but it isn't in the object definition ... grrr.
            $result    =  Invoke-GraphRequest  -Uri "$uri`?`$expand=details"
            $etag      =  $result.'@odata.etag'
            $odatakeys =  $result.Keys.Where({$_ -match "@odata\."})
            foreach ($k in $odatakeys) {[void]$result.Remove($k)}
            $planObj = New-Object  -Property $result -TypeName MicrosoftGraphPlannerPlan |
                Add-Member -PassThru -NotePropertyName  etag -NotePropertyValue $etag

            if ($planObj.owner) {
                $owner = (Invoke-GraphRequest  -Uri "$GraphUri/directoryobjects/$($planObj.owner)").displayname
                Add-Member -InputObject $planObj -NotePropertyName OwnerName -NotePropertyValue $owner
            }
            if ($planObj.createdBy.user.id -and $planObj.createdBy.user.id  -eq $planObj.owner) {
                Add-Member -InputObject $planObj -MemberType NoteProperty -Name CreatorName -Value $owner
            }
            elseif ($planObj.createdBy.user.id) {
                $creator = (Invoke-GraphRequest  -Uri "$GraphUri/directoryobjects/$($planObj.createdBy.user.id)").displayname
                Add-Member -InputObject $planObj -MemberType NoteProperty -Name CreatorName -Value $creator
            }
            return $planObj
        }
    }
}

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,ValueFromPipeline=$true)]
        $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
    )
    process {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        if ($Plan.id) {$detailsURI = "$GraphUri/planner/plans/$($plan.id)/details" ; $planTitle = $Plan.Title}
        else          {$detailsURI = "$GraphUri/planner/plans/$plan/details"       ; $planTitle = "."   }
        try {
            $tag = (Invoke-GraphRequest   -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 "SET-GRAPHPLANDETAILS 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 specify a setting to change "}
        else {$Settings = @{"categoryDescriptions" = $CategorySettings} }
        $webParams = @{ Method      = "Patch"
                        URI         = $detailsURI
                        Headers     = @{"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-GraphRequest @webParams }
    }
}

function Remove-GraphPlan        {
    <#
      .synopsis
        Removes a plan from a plan the
    #>

    [CmdletBinding(SupportsShouldProcess,ConfirmImpact='High')]
    param   (
        #The ID of the plan or a plan object with an ID property.
        [Parameter( ValueFromPipeline=$true,Position=0)]
        $Plan,
        #If specified the plan will be removed without prompting for confirmation; by default confirmation IS requested.
        [switch]$Force
    )
    process {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        if (-not $Plan)         {$Plan = Invoke-GraphRequest -Uri "$GraphUri/me/planner/plans" -ValueOnly -AsType ([MicrosoftGraphPlannerPlan]) -ExcludeProperty '@odata.etag' | Select-Object -First 1 }

        if ($Plan.Title )   {$target = $Plan.Title}
        if ($Plan.etag)     {$tag    = $Plan.etag}
        if ($Plan.id )      {$Plan   = $Plan.ID}
        $uri =  "$GraphUri/planner/Plans/$Plan"
        if (-not $tag)  {
            $plandetails   = Invoke-GraphRequest  -Uri $uri
            $tag           = $plandetails.'@odata.etag'
            $target        = $plandetails.title
        }
        if (-not $target)  {$target=$plan}
        if($Force -or $PSCmdlet.ShouldProcess($target,'Delete Plan')) {
            Invoke-GraphRequest -Method Delete -Uri $uri -Headers @{'If-Match' = $tag}
        }
    }
}

function Add-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, ValueFromPipeline=$true)]
        $Plan,
        #The Name of the new bucket.
        [Parameter(Mandatory=$true,Position=1)]
        $Name,
        #If Specified the bucket will be added without confirmation
        [switch]$Force
    )
    begin {
        $webParams = @{ 'Method'      = "Post"
                        'URI'         = "$GraphUri/planner/buckets"
                        'Contenttype' = "application/json"

        }
        $orderHint = " !"
    }
    process {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        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)")){
            $result    = Invoke-GraphRequest @webParams -Body $json
            $etag = $result.'@odata.etag'
            $null = $result.remove('@odata.etag')  , $result.remove('@odata.context'), $result.remove('@odata.id')
            $bucketobj = New-object -Property $result -TypeName MicrosoftGraphPlannerBucket |
                Add-Member -PassThru -NotePropertyName  etag -NotePropertyValue $etag
            if ($plan.Title) {Add-Member -PassThru -InputObject $bucketobj -NotePropertyName PlanTitle -NotePropertyValue $plan.Title     }
            else             {$bucketobj}
            }
        }
    }
}

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 = "$GraphUri/planner/buckets/$($Bucket.id)"}
    else              {$uri = "$GraphUri/planner/buckets/$Bucket"  }
    if ($Bucket.etag) {$tag =  $Bucket.etag}
    else              {$tag = (Invoke-GraphRequest  -URI $uri ).'@odata.etag' }

    $body    = "{ ""name"": ""$NewName"" }"
    if ($Force -or $PSCmdlet.ShouldProcess($NewName,'Apply new name to bucket')) {
        Invoke-GraphRequest -Method Patch -URI $uri  -Headers @{'If-Match'=$tag} -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 {
    }
    process {
        if ($Bucket.name )  {$target = $Bucket.name}
        if ($Bucket.etag)   {$tag    = $Bucket.etag}
        if ($Bucket.id )    {$Bucket = $Bucket.ID}
        $uri =  "$GraphUri/planner/buckets/$Bucket"
        if (-not $tag)  {
            $bucketdetails = Invoke-GraphRequest  -Uri $uri
            $tag           = $bucketdetails.'@odata.etag'
            $target        = $bucketdetails.name
        }
        if (-not $target)  {$target=$Bucket}
        if($Force -or $PSCmdlet.ShouldProcess($target,'Delete Plan Bucket')) {
            Invoke-GraphRequest -Method Delete -Uri $uri -Headers @{'If-Match' = $tag}
        }

    }
}

function Get-GraphBucketTaskList {
    [CmdletBinding()]
    Param(
        #Bucket to query either as an ID or a Bucket object with an ID
        [Parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true,Mandatory=$true, Position=0)]
        [Alias('ID')]
        $Bucket,
        #If specified IDs will be updated to their names, and extended properties (e.g. Checklist) will be added
        [Alias('FullTasks')]
        [Switch]$Expand
    )
    process {
        if ($Bucket.id) {$Bucket = $Bucket.ID}
        #we need etag for chaning items, but it isn't in the object definition ... grrr.
        $response      = Invoke-GraphRequest  -URI "$GraphUri/planner/buckets/$Bucket/tasks"
        $result        = $response.value
        while ($response.'@odata.nextLink') {
            $response  = Invoke-GraphRequest  -URI $response.'@odata.nextLink'
            $result   += $response.value
        }
        $taskObjs = foreach ($r in $result) {
            $etag      =  $r.'@odata.etag'
            $null      =  $r.remove( "@odata.etag"), $r.remove( "@odata.id") ;
            New-Object -Property $r -TypeName MicrosoftGraphPlannerTask |
                Add-Member -PassThru -NotePropertyName  etag -NotePropertyValue $etag
        }
        if ($Expand) { $taskObjs | Expand-GraphTask }
        else         { $taskobjs }
    }
}

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}

        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         = "$GraphUri/planner/tasks"
                        Contenttype = "application/json"
        }
    }
    process {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        $settings =  [ordered]@{"planId"=$Plan; "title"=$title}

        if ($Bucket) {
            if     ($Bucket.id)                              {$settings["bucketId"]=$Bucket.Id}
            elseif ($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 ($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-GraphRequest   -Uri "$GraphUri/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'
            $result  = Invoke-GraphRequest @webParams -body $Json
            if     ($Description -and $Checklist) {Set-GraphTaskDetails -PSC $PSCmdlet -Task $result -Description $Description -CheckList $Checklist }
            elseif ($Description )                {Set-GraphTaskDetails -PSC $PSCmdlet -Task $result -Description $Description  }
            elseif ($Checklist   )                {Set-GraphTaskDetails -PSC $PSCmdlet -Task $result -CheckList $Checklist }
            if     ($Links)                       {Set-GraphTaskDetails -PSC $PSCmdlet -Task $result -Links $Links }
            Write-Progress -Activity 'Adding Task' -Completed
            if ($Passthru) {
                $etag      =  $result.'@odata.etag'
                $odatakeys =  $result.Keys.Where({$_ -match "@odata\."})
                foreach ($k in $odatakeys) {[void]$result.Remove($k)}
                New-Object -Property $result -TypeName  MicrosoftGraphPlannerTask |
                        Add-Member -NotePropertyName  etag -NotePropertyValue $etag -PassThru
            }
        }
    }
}

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
        [Alias('FullTasks')]
        [Switch]$Expand
    )
    process {
        if ($Task.ID)   {$Task = $Task.ID}
        #we need odata.etag for changing items, but it isn't in the object definition ... grrr.
        $result    = Invoke-GraphRequest  -URI "$GraphUri/planner/tasks/$Task"
        $etag      =  $result.'@odata.etag'
        $odatakeys =  $result.Keys.Where({$_ -match "@odata\."})
        foreach ($k in $odatakeys) {[void]$result.Remove($k)}
        $taskobj  = New-Object -Property $result -TypeName  MicrosoftGraphPlannerTask |
                        Add-Member -NotePropertyName  etag -NotePropertyValue $etag -PassThru
        if ($Expand) { $taskobj | Expand-GraphTask}
        else         {$taskobj}
    }
}

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)]
        [alias('ID')]
        $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   {
        $planHash = @{}
    }
    process {
        ContextHas -WorkOrSchoolAccount -BreakIfNot
        #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.etag)          {$tag         = $Task.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.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     = "$GraphUri/planner/tasks/$Task"
                    Headers     = @{'If-Match' = $tag ; 'Prefer' = 'return=representation'  }
                    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-GraphRequest -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) {
            $etag      =  $UpdatedTask.'@odata.etag'
            $odatakeys =  $UpdatedTask.Keys.Where({$_ -match "@odata\."})
            foreach ($k in $odatakeys) {[void]$UpdatedTask.Remove($k)}
            New-Object -Property $UpdatedTask -TypeName  MicrosoftGraphPlannerTask |
                            Add-Member -NotePropertyName  etag -NotePropertyValue $etag -PassThru
        }

    }
}

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   {
    }
    process {
        if ($Task.title )        {$target = $Task.title}
        if ($Task.etag)          {$tag    = $Task.etag}
        if ($Task.id )           {$Task   = $Task.ID}
        $uri =  "$GraphUri/planner/Tasks/$Task"
        if (-not $tag)  {
            $Taskdetails = Invoke-GraphRequest   -Uri $uri
            $tag           = $Taskdetails.'@odata.etag'
            $target        = $Taskdetails.title
        }
        if (-not $target)  {$target=$Task}
        if($Force -or $PSCmdlet.ShouldProcess($target,'Delete Plan Task')) {
            Invoke-GraphRequest -Method Delete -Uri $uri -Headers  @{'If-Match' = $tag}
        }
    }
}

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   {
        $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-GraphRequest  -Uri "$GraphUri/planner/plans/$P" ).title
            Invoke-GraphRequest   -Uri "$GraphUri/planner/plans/$p/buckets"  -ValueOnly |
                ForEach-Object  {$bucketHash[$_.id] = $_.name}
        }
        Write-Progress -Activity "Getting task details" -Status "Getting name(s) for assignee ID(s)"
        $userIDs = $allTasks.Assignments.Keys | Sort-object -unique
        foreach ($u in $userIDs)  {
            $uData = Invoke-GraphRequest  -Uri  "$GraphUri/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) {
            if ($t.Assignments.keys) {$assignees = $t.assignments.keys |  foreach-object {$userhash[$_]} }
            $details   = Invoke-GraphRequest  -Uri "$GraphUri/planner/tasks/$($t.id)/details"
            $expandedTask = $t | Select-Object -Property * -ExcludeProperty keys,values,additionalproperties,count   |
                Add-Member -Force -PassThru -NotePropertyName Assignees   -NotePropertyValue ($assignees -join ", ") |
            Add-Member -Force -PassThru -NotePropertyName Bucketname  -NotePropertyValue  $buckethash[$t.bucketId]   |
            Add-Member -Force -PassThru -NotePropertyName PlanTitle   -NotePropertyValue  $planhash[$t.planID]       |
            Add-Member -Force -PassThru -NotePropertyName DetailTag   -NotePropertyValue  $details.'@odata.etag'     |
            Add-Member -Force -PassThru -NotePropertyName References  -NotePropertyValue  $details.references        |
            Add-Member -Force -PassThru -NotePropertyName Checklist   -NotePropertyValue  $details.checklist         |
            Add-Member -Force -PassThru -NotePropertyName Description -NotePropertyValue  $details.description       |
            Add-Member -Force -PassThru -NotePropertyName PreviewType -NotePropertyValue  $details.previewType
            $expandedTask.pstypeNames.Add("GraphExtendedTask")
            $i += 100 #To give percentage
            Write-Progress -Activity "Getting task details" -Status "Extending Tasks" -PercentComplete ($i/$allTasks.count)
            $expandedTask
        }
        Write-Progress -Activity "Getting task details" -Completed

    }
}

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 = "$GraphUri/planner/tasks/$($task.id)/details" ; $taskTitle =$Task.title}
    else           {$detailsURI = "$GraphUri/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-GraphRequest   -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-GraphRequest   -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 "SET-GRAPHPLANDETAILS 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     = @{"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-GraphRequest @webParams | Out-Null
    }
    Write-Progress -Activity "Updating task" -Completed
}