diff --git a/.gitignore b/.gitignore index 6240da8b..730172f7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* - +script.log* # environment variables .env diff --git a/src/content/docs/BACaudit/AuditReport:BAC.md b/src/content/docs/BACaudit/AuditReportBAC.md similarity index 100% rename from src/content/docs/BACaudit/AuditReport:BAC.md rename to src/content/docs/BACaudit/AuditReportBAC.md diff --git a/src/content/docs/report-scripts/Planner_Pull.ps1 b/src/content/docs/report-scripts/Planner_Pull.ps1 new file mode 100644 index 00000000..cb65bb5f --- /dev/null +++ b/src/content/docs/report-scripts/Planner_Pull.ps1 @@ -0,0 +1,883 @@ +# Script: Planner_pull.ps1 +# Description: Pulls tasks from a Microsoft Planner plan and exports them to a CSV, JSON, or Markdown file. +# Version: 3.3 +# Author: Ibitope Fatoki + +[CmdletBinding()] +param ( + [string]$ConfigPath = ".\config.json" +) + +# --- Script-level configuration --- +# Set logging level. Options: DEBUG, INFO, WARN, ERROR +$Script:LogLevel = 'INFO' + +# --- Function Definitions --- + +function Write-Log { + param( + [string]$Message, + [string]$Level = "INFO", + [string]$LogPath = ".\logs\script.log" + ) + + $logLevels = @{ + 'DEBUG' = 1 + 'INFO' = 2 + 'WARN' = 3 + 'ERROR' = 4 + } + + if ($logLevels[$Level.ToUpper()] -ge $logLevels[$Script:LogLevel.ToUpper()]) { + try { + $logDirectory = Split-Path -Path $LogPath -Parent + if (-not (Test-Path -Path $logDirectory -PathType Container)) { + New-Item -Path $logDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null + } + $logEntry = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$($Level.ToUpper())] $Message" + Add-Content -Path $LogPath -Value $logEntry + } + catch { + Write-Warning "Could not write to log file at '$LogPath'. Error: $_" + } + } +} + +function Select-TableColumns { + param($Data) + Write-Log -Level DEBUG -Message "Entering Select-TableColumns function." + + if (-not $Data) { + Write-Log -Level WARN -Message "Select-TableColumns called with no data." + return $null + } + + $availableColumns = $Data[0].PSObject.Properties.Name + Write-Log -Level DEBUG -Message "Available columns for selection: $($availableColumns -join ', ')" + Write-Host "`nSelect the columns to include in the Markdown report:" + for ($i = 0; $i -lt $availableColumns.Count; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $availableColumns[$i]) + } + + $choice = Read-Host -Prompt "Enter the column numbers, separated by commas (e.g., 1,3,4), or press Enter for all" + Write-Log -Level INFO -Message "User column selection: '$choice'" + + if ([string]::IsNullOrWhiteSpace($choice)) { + Write-Log -Level DEBUG -Message "User selected all columns." + return $availableColumns + } + + $selectedColumns = @() + $choices = $choice -split ',' | ForEach-Object { $_.Trim() } + + foreach ($c in $choices) { + if ($c -match "^\d+$" -and [int]$c -ge 1 -and [int]$c -le $availableColumns.Count) { + $selectedIndex = [int]$c - 1 + if ($selectedColumns -notcontains $availableColumns[$selectedIndex]) { + $selectedColumns += $availableColumns[$selectedIndex] + } + } else { + Write-Log -Level WARN -Message "Invalid column selection: '$c'. Aborting export." + Write-Host "Invalid selection: '$c'. Aborting export." + return $null + } + } + + Write-Log -Level DEBUG -Message "Returning selected columns: $($selectedColumns -join ', ')" + return $selectedColumns +} + +function ConvertTo-MarkdownTable { + param ($Data) + Write-Log -Level DEBUG -Message "Entering ConvertTo-MarkdownTable function." + + if (-not $Data) { + Write-Log -Level WARN -Message "ConvertTo-MarkdownTable called with no data." + return "" + } + + $headers = $Data[0].PSObject.Properties.Name + $headerLine = "| $($headers -join ' | ') |" + $separatorArray = $headers | ForEach-Object { '-' * $_.Length } + $separatorLine = "| $($separatorArray -join ' | ') |" + + $dataRows = $Data | ForEach-Object { + $row = $_ + $values = $headers | ForEach-Object { + $value = $row.$_ + if ($null -eq $value) { + $value = "" + } + $sanitizedValue = $value.ToString() -replace '(\r|\n)', ' ' -replace '\|', '|' + $sanitizedValue + } + "| $($values -join ' | ') |" + } + + # Flatten the array of lines before joining to prevent nested array issues. + $allLines = @($headerLine, $separatorLine) + $dataRows + $markdown = $allLines -join [System.Environment]::NewLine + + Write-Log -Level DEBUG -Message "Exiting ConvertTo-MarkdownTable function. Markdown length: $($markdown.Length)" + return $markdown +} + +function Connect-ToGraph { + Write-Log -Level DEBUG -Message "Entering Connect-ToGraph function." + Write-Host "Importing Required Microsoft.Graph sub modules..." + try { + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + Import-Module Microsoft.Graph.Planner -ErrorAction Stop + Import-Module Microsoft.Graph.Users -ErrorAction Stop + } + catch { + Write-Log -Level "ERROR" -Message "Failed to import required modules: $_" + Write-Error "Failed to import required modules. Please ensure Microsoft.Graph is installed correctly and is up to date." + exit 1 + } + + Write-Host "Authenticating to Microsoft Graph..." + try { + $context = Get-MgContext + if ($context -and $context.AuthContext) { + try { + # Actively test the token to ensure it's valid, as cached tokens can become stale. + Write-Log -Level DEBUG -Message "Existing context found. Verifying token with a test call..." + Get-MgUser -UserId "me" -Property "Id" -ErrorAction Stop | Out-Null + + $tokenExpiry = [datetime]$context.AuthContext.ExpiresOn + $timeToExpiry = $tokenExpiry - (Get-Date) + + if ($timeToExpiry.TotalMinutes -gt 5) { + Write-Log -Level INFO -Message "Already authenticated as $($context.Account). Token is valid." + Write-Host "Already authenticated as $($context.Account)" + return $true + } + else { + Write-Log -Level WARN -Message "Token is expired or expiring soon. Re-authenticating." + Write-Host "Your session is expired or expiring soon. Re-authenticating..." + Disconnect-MgGraph + } + } + catch { + Write-Log -Level WARN -Message "Token validation failed or session is invalid. Re-authenticating. Error: $_" + Disconnect-MgGraph + } + } + + Connect-MgGraph -Scopes @("Tasks.Read", "Tasks.ReadWrite", "User.Read", "User.ReadBasic.All") -Audience "organizations" + $context = Get-MgContext + if (-not $context) { + throw "Failed to establish connection" + } + Write-Host "Authentication successful as $($context.Account)" + Write-Log -Level INFO -Message "Authentication successful as $($context.Account)" + return $true + } + catch { + Write-Log -Level "ERROR" -Message "Authentication failed: $_" + Write-Error "Authentication failed: $_" + exit 1 + } +} + +function Get-Configurations { + param ([string]$Path) + Write-Log -Level DEBUG -Message "Entering Get-Configurations function for path: $Path" + if (Test-Path $Path) { + try { + $content = Get-Content -Path $Path -Raw + if ([string]::IsNullOrWhiteSpace($content)) { + Write-Log -Level INFO -Message "Config file is empty. Returning empty array." + return @() + } + $data = $content | ConvertFrom-Json + Write-Log -Level DEBUG -Message "Successfully read and parsed config file." + return @($data) + } + catch { + Write-Log -Level "ERROR" -Message "Failed to read or parse config file '$Path': $_" + Write-Error "Could not read or parse config file '$Path'. It might be corrupted." + return @() + } + } + Write-Log -Level INFO -Message "Config file not found at '$Path'. Returning empty array." + return @() +} + +function Save-Configurations { + param ([string]$Path, $ConfigData) + Write-Log -Level DEBUG -Message "Entering Save-Configurations function for path: $Path" + try { + $jsonOutput = if ($ConfigData -is [array] -and $ConfigData.Count -eq 1) { + Write-Log -Level DEBUG -Message "Saving single-item array with comma-prefix for backwards compatibility." + ,$ConfigData | ConvertTo-Json -Depth 5 + } else { + Write-Log -Level DEBUG -Message "Saving multi-item array or empty array." + $ConfigData | ConvertTo-Json -Depth 5 + } + $jsonOutput | Set-Content -Path $Path + Write-Log -Level INFO -Message "Successfully saved configuration to '$Path'." + } + catch { + $errorMessage = $_.Exception.Message + Write-Log -Level "ERROR" -Message "Failed to save config file '$Path': $errorMessage" + Write-Error "Failed to save configuration to '$Path'. Reason: $errorMessage" + } +} + +function Set-Configurations { + param ([string]$ConfigPath) + Write-Log -Level INFO -Message "Entering plan management utility." + + while ($true) { + [array]$savedPlans = Get-Configurations -Path $ConfigPath + + Write-Host "`n--- Manage Saved Plans ---" + if ($savedPlans.Count -eq 0) { + Write-Host "No saved plans found." + } else { + for ($i = 0; $i -lt $savedPlans.Count; $i++) { + Write-Host ("{0}. {1} ({2})" -f ($i + 1), $savedPlans[$i].name, $savedPlans[$i].planId) + } + } + + Write-Host "--------------------------" + Write-Host "A. Add a new plan" + Write-Host "D. Delete a plan" + Write-Host "Q. Quit to main menu" + + $choice = Read-Host -Prompt "Enter your choice" + Write-Log -Level INFO -Message "User selected management option: '$choice'" + + switch ($choice.ToUpper()) { + 'A' { + Write-Host "`nNeed help finding the id? Look no further https://youtu.be/KdOdRppqxCk" + $newPlanId = Read-Host -Prompt "Please enter the new Plan ID" + $planName = Read-Host -Prompt "Please enter a name for this plan" + + if (-not $newPlanId -or -not $planName) { + Write-Log -Level WARN -Message "User tried to add a plan with empty ID or name." + Write-Host "Plan ID and Plan Name cannot be empty." + continue + } + Write-Log -Level INFO -Message "Adding new plan: Name='$planName', ID='$newPlanId'" + $newPlan = @{ planId = $newPlanId; name = $planName } + $savedPlans += $newPlan + Save-Configurations -Path $ConfigPath -ConfigData $savedPlans + Write-Host "Plan '$planName' added successfully." + } + 'D' { + if ($savedPlans.Count -eq 0) { + Write-Host "No plans to delete." + continue + } + + Write-Host "`nWhich plan would you like to delete?" + for ($i = 0; $i -lt $savedPlans.Count; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $savedPlans[$i].name) + } + + $delChoice = Read-Host -Prompt "Enter the number of the plan to delete" + if ($delChoice -match "^\d+$" -and [int]$delChoice -ge 1 -and [int]$delChoice -le $savedPlans.Count) { + $selectedIndex = [int]$delChoice - 1 + $planNameToDelete = $savedPlans[$selectedIndex].name + Write-Log -Level INFO -Message "User chose to delete plan #$delChoice ('$planNameToDelete')." + $confirmation = Read-Host "Are you sure you want to delete '$planNameToDelete'? (y/n)" + if ($confirmation.ToUpper() -eq 'Y') { + Write-Log -Level INFO -Message "User confirmed deletion." + $savedPlans = $savedPlans | Where-Object { $_.planId -ne $savedPlans[$selectedIndex].planId } + Save-Configurations -Path $ConfigPath -ConfigData $savedPlans + Write-Host "'$planNameToDelete' has been deleted." + } else { + Write-Log -Level INFO -Message "User cancelled deletion." + } + } else { + Write-Log -Level WARN -Message "User entered invalid number for deletion: '$delChoice'" + Write-Host "Invalid number." + } + } + 'Q' { + Write-Log -Level INFO -Message "Exiting plan management utility." + return + } + default { + Write-Log -Level WARN -Message "User entered invalid management option: '$choice'" + Write-Host "Invalid option." + } + } + } +} + +function Select-PlannerPlan { + param ([string]$ConfigPath) + Write-Log -Level DEBUG -Message "Entering Select-PlannerPlan function." + [array]$savedPlans = Get-Configurations -Path $ConfigPath + $planId = $null + + if ($savedPlans.Count -gt 0) { + Write-Host "Please choose a saved Plan or enter a new one:" + Write-Host "`nNeed help finding the id? Look no further https://youtu.be/KdOdRppqxCk" + for ($i = 0; $i -lt $savedPlans.Count; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $savedPlans[$i].name) + } + Write-Host "N. Enter a new Plan" + + $choice = Read-Host -Prompt "Select a plan" + Write-Log -Level INFO -Message "User plan selection: '$choice'" + + if ($choice -eq 'N' -or $choice -eq 'n') { + $planId = $null + } + elseif ($choice -match "^\d+$" -and [int]$choice -ge 1 -and [int]$choice -le $savedPlans.Count) { + $selectedIndex = [int]$choice - 1 + $planId = $savedPlans[$selectedIndex].planId + } + else { + Write-Log -Level WARN -Message "Invalid plan selection. Exiting." + Write-Host "Invalid selection. Exiting." + exit + } + } + + if (-not $planId) { + Write-Log -Level INFO -Message "User chose to enter a new plan." + $newPlanId = Read-Host -Prompt "Please enter the new Plan ID" + $planName = Read-Host -Prompt "Please enter a name for this plan (for your reference)" + + if (-not $newPlanId -or -not $planName) { + Write-Log -Level WARN -Message "New plan details were empty. Exiting." + Write-Host "Plan ID and Plan Name cannot be empty. Exiting." + exit + } + + $newPlan = @{ planId = $newPlanId; name = $planName } + $savedPlans += $newPlan + Save-Configurations -Path $ConfigPath -ConfigData $savedPlans + Write-Host "New Plan saved: $planName" + $planId = $newPlanId + } + + Write-Log -Level INFO -Message "Using Plan ID: $planId" + Write-Host "Using Plan ID: $planId" + return $planId +} + +function Select-PlannerBucket { + param ([string]$PlanId) + Write-Log -Level DEBUG -Message "Entering Select-PlannerBucket for Plan ID: $PlanId" + Write-Host "Fetching buckets for plan..." + try { + $buckets = Get-MgPlannerPlanBucket -PlannerPlanId $PlanId -ErrorAction Stop + } + catch { + Write-Log -Level "ERROR" -Message "Failed to get buckets for plan '$PlanId': $_" + Write-Error "Failed to get buckets for plan '$PlanId': $_" + return $null + } + + if ($buckets.Count -eq 0) { + Write-Log -Level WARN -Message "No buckets found in plan '$PlanId'." + Write-Host "No buckets found in this plan." + return $null + } + + Write-Host "Please choose a bucket:" + for ($i = 0; $i -lt $buckets.Count; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $buckets[$i].Name) + } + + $choice = Read-Host -Prompt "Select a bucket" + if ($choice -match "^\d+$" -and [int]$choice -ge 1 -and [int]$choice -le $buckets.Count) { + $selectedIndex = [int]$choice - 1 + $selectedBucket = $buckets[$selectedIndex] + Write-Log -Level INFO -Message "User selected bucket: Name='$($selectedBucket.Name)', ID='$($selectedBucket.Id)'" + return $selectedBucket.Id + } + else { + Write-Log -Level WARN -Message "User entered invalid bucket selection: '$choice'" + Write-Host "Invalid selection. No bucket filter will be applied." + return $null + } +} + +function Select-TaskStatus { + Write-Log -Level DEBUG -Message "Entering Select-TaskStatus function." + Write-Host "Filter by task status:" + Write-Host "1. Not Started" + Write-Host "2. In Progress" + Write-Host "3. Completed" + + $choice = Read-Host -Prompt "Enter your choice" + Write-Log -Level INFO -Message "User status filter selection: '$choice'" + switch ($choice) { + '1' { return "Not Started" } + '2' { return "In Progress" } + '3' { return "Completed" } + default { + Write-Log -Level WARN -Message "Invalid status selection." + Write-Host "Invalid selection. No status filter will be applied." + return $null + } + } +} + +function Get-TaskReferences { + param ($TaskDetails) + Write-Log -Level DEBUG -Message "Entering Get-TaskReferences function." + + if (-not $TaskDetails.References -or -not $TaskDetails.References.AdditionalProperties) { + return "No references" + } + + $foundUrls = @() + foreach ($key in $TaskDetails.References.AdditionalProperties.Keys) { + $url = [System.Net.WebUtility]::UrlDecode($key) + $alias = $TaskDetails.References.AdditionalProperties[$key].alias + + $urlIsGithub = $url -like "*github.com*" + $aliasIsGithub = $alias -like "*github.com*" + + if ($urlIsGithub -and $aliasIsGithub) { + if ($url -eq $alias) { $foundUrls += $url } else { $foundUrls += "$alias ($url)" } + } elseif ($urlIsGithub) { + $foundUrls += $url + } elseif ($aliasIsGithub) { + $foundUrls += $alias + } + } + + if ($foundUrls.Count -gt 0) { + $result = $foundUrls -join "; " + Write-Log -Level DEBUG -Message "Found $($foundUrls.Count) GitHub links." + return $result + } else { + return "" # No GitHub links found + } +} + +function Get-PlannerTasks { + param ( + [string]$PlanId, + [string]$Selection, + $StartDate, + $EndDate, + [string]$BucketId, + [string]$Status + ) + Write-Log -Level INFO -Message "Starting Get-PlannerTasks for Plan ID '$PlanId'. Selection: $Selection, BucketId: $BucketId, Status: $Status" + + Write-Host "Fetching tasks from plan..." + try { + $allTasks = Get-MgPlannerPlanTask -PlannerPlanId $PlanId -ErrorAction Stop + if (-not $allTasks) { + Write-Log -Level WARN -Message "No tasks found in the plan." + Write-Warning "No tasks found in the plan." + return @() + } + Write-Log -Level INFO -Message "Fetched $($allTasks.Count) total tasks from plan." + + $tasksToProcess = $allTasks + if ($BucketId) { + $tasksToProcess = $tasksToProcess | Where-Object { $_.BucketId -eq $BucketId } + Write-Log -Level INFO -Message "Filtered by Bucket ID '$BucketId'. $($tasksToProcess.Count) tasks remain." + } + if ($Status) { + switch ($Status) { + "Not Started" { $tasksToProcess = $tasksToProcess | Where-Object { $_.PercentComplete -eq 0 } } + "In Progress" { $tasksToProcess = $tasksToProcess | Where-Object { $_.PercentComplete -gt 0 -and $_.PercentComplete -lt 100 } } + "Completed" { $tasksToProcess = $tasksToProcess | Where-Object { $_.PercentComplete -eq 100 } } + } + Write-Log -Level INFO -Message "Filtered by Status '$Status'. $($tasksToProcess.Count) tasks remain." + } + + if ($tasksToProcess.Count -eq 0) { + Write-Log -Level WARN -Message "No tasks match the specified filter criteria." + Write-Warning "No tasks match the specified filter criteria." + return @() + } + + $buckets = Get-MgPlannerPlanBucket -PlannerPlanId $PlanId -ErrorAction Stop + $bucketNameLookup = @{} + foreach ($bucket in $buckets) { $bucketNameLookup[$bucket.Id] = $bucket.Name } + Write-Log -Level DEBUG -Message "Created lookup for $($buckets.Count) buckets." + } + catch { + $errorMessage = $_.Exception.Message + Write-Log -Level "ERROR" -Message "Failed to get tasks or buckets for plan '$PlanId': $errorMessage" + if ($errorMessage -like "*DeviceCodeCredential*") { + Write-Error "An authentication error occurred while fetching data. This can sometimes be resolved by restarting the script to force re-authentication. Error: $errorMessage" + } + else { + Write-Error "Failed to get tasks or buckets: $errorMessage" + } + exit 1 + } + + $totalTasks = $tasksToProcess.Count + $processedCount = 0 + + $taskData = foreach ($task in $tasksToProcess) { + $processedCount++ + Write-Progress -Activity "Processing Tasks" -Status "Task $processedCount of $totalTasks" -PercentComplete (($processedCount / $totalTasks) * 100) + Write-Log -Level DEBUG -Message "Processing task: ID=$($task.Id), Title='$($task.Title)'" + + $bucketName = $bucketNameLookup[$task.BucketId] + + try { + if (-not $task.Assignments) { + if ($selection -eq '1') { + $taskDetails = Get-MgPlannerTaskDetail -PlannerTaskId $task.Id + $attachments = Get-TaskReferences -TaskDetails $taskDetails + + [PSCustomObject]@{ Name = "Unassigned"; Role = ""; Task = $task.Title; Bucket = $bucketName; Attachments = $attachments; Status = switch ($task.PercentComplete) { 0 { "Not Started" } 100 { "Completed" } default { "In Progress" } } } + } + continue + } + + $taskDetails = Get-MgPlannerTaskDetail -PlannerTaskId $task.Id + $attachments = Get-TaskReferences -TaskDetails $taskDetails + + $assignmentKeys = $task.Assignments.AdditionalProperties.Keys + $assignments = $task.Assignments.AdditionalProperties + + if ($assignmentKeys.Count -gt 0) { + $sortedKeys = $assignmentKeys | Sort-Object { [datetime]$assignments[$_].assignedDateTime } + $mainContributorKey = $sortedKeys[0] + $reviewerKey = if ($sortedKeys.Count -gt 1) { $sortedKeys[-1] } else { $null } + + foreach ($userId in $assignmentKeys) { + $assignment = $assignments[$userId] + $assignedDateTime = [datetime]$assignment.assignedDateTime + + if ($selection -eq '3' -and ($assignedDateTime -lt $StartDate -or $assignedDateTime -ge $EndDate)) { + Write-Log -Level DEBUG -Message "Skipping user '$userId' in task '$($task.Title)' due to date filter." + continue + } + + $role = if ($userId -eq $mainContributorKey) { "Main Contributor" } elseif ($userId -eq $reviewerKey) { "Reviewer" } else { "" } + + try { + $user = Get-MgUser -UserId $userId -ErrorAction Stop + $userName = $user.DisplayName + } + catch { + Write-Log -Level "WARN" -Message "Could not get user details for ID '$userId': $_" + $userName = "$userId (Unable to get name)" + } + + [PSCustomObject]@{ Name = $userName; Role = $role; Task = $task.Title; Bucket = $bucketName; Attachments = $attachments; Status = switch ($task.PercentComplete) { 0 { "Not Started" } 100 { "Completed" } default { "In Progress" } } } + } + } + } + catch { + Write-Log -Level "WARN" -Message "Failed to process task '$($task.Title)' (ID: $($task.Id)). Error: $_" + Write-Warning "Failed to process task: $($task.Title). Error: $_" + [PSCustomObject]@{ Name = "Error Processing"; Role = ""; Task = $task.Title; Bucket = $bucketName; Attachments = "Error retrieving details"; Status = "Unknown" } + } + } + Write-Progress -Activity "Processing Tasks" -Completed + Write-Log -Level INFO -Message "Finished processing tasks. Returning $($taskData.Count) records." + return $taskData +} + +function Export-Data { + param ($TaskData) + Write-Log -Level DEBUG -Message "Entering Export-Data function." + + if (-not $TaskData) { + Write-Log -Level WARN -Message "No data passed to Export-Data." + Write-Host "No data to export." + return + } + + $sortedData = $TaskData | Sort-Object Name + + Write-Host "`nPreview of exported data:" + $previewColumns = @('Name', 'Task', 'Bucket', 'Status') + $sortedData | Format-Table -Property $previewColumns -AutoSize + + $formatChoice = Read-Host -Prompt "Choose export format: (1) CSV, (2) JSON, or (3) Markdown" + Write-Log -Level INFO -Message "User chose export format: $formatChoice" + + # --- Column Selection for applicable formats --- + $reportData = $sortedData + if ($formatChoice -eq '1' -or $formatChoice -eq '3') { # CSV or Markdown + $formatName = if ($formatChoice -eq '1') { "CSV" } else { "Markdown" } + $selectedColumns = Select-TableColumns -Data $sortedData -FormatName $formatName + if (-not $selectedColumns) { + Write-Log -Level WARN -Message "User cancelled or failed column selection. Aborting export." + return + } + + if ($selectedColumns.Count -ne $sortedData[0].PSObject.Properties.Name.Count) { + $reportData = $sortedData | Select-Object -Property $selectedColumns + Write-Log -Level INFO -Message "User filtered columns for export: $($selectedColumns -join ', ')" + } + } + + # --- File Export Logic --- + switch ($formatChoice) { + '1' { # CSV + $outputFile = Read-Host -Prompt "Enter the desired name for the CSV file" + if (-not ($outputFile.EndsWith(".csv"))) { $outputFile = "$outputFile.csv" } + Write-Log -Level INFO -Message "Exporting to CSV at '$outputFile'" + try { + $reportData | Export-Csv -Path $outputFile -NoTypeInformation -Force + Write-Host "Tasks exported successfully to $outputFile" + } + catch { + Write-Log -Level "ERROR" -Message "Failed to export CSV to '$outputFile': $_" + Write-Error "Failed to export CSV file: $_" + } + } + '2' { # JSON + $outputFile = Read-Host -Prompt "Enter the desired name for the JSON file" + if (-not ($outputFile.EndsWith(".json"))) { $outputFile = "$outputFile.json" } + Write-Log -Level INFO -Message "Exporting to JSON at '$outputFile'" + try { + $reportData | ConvertTo-Json -Depth 10 | Set-Content -Path $outputFile + Write-Host "Tasks exported successfully to $outputFile" + } + catch { + Write-Log -Level "ERROR" -Message "Failed to export JSON to '$outputFile': $_" + Write-Error "Failed to export JSON file: $_" + } + } + '3' { # Markdown + $outputFile = Read-Host -Prompt "Enter the desired name for the MD file" + if (-not ($outputFile.EndsWith(".md"))) { $outputFile = "$outputFile.md" } + Write-Log -Level INFO -Message "Exporting to Markdown at '$outputFile'" + try { + $markdownTable = ConvertTo-MarkdownTable -Data $reportData + Set-Content -Path $outputFile -Value $markdownTable + Write-Host "Tasks exported successfully to $outputFile" + + $convertChoice = Read-Host -Prompt "Convert GitHub PR links to clickable Markdown hyperlinks? (y/n)" + if ($convertChoice.ToUpper() -eq 'Y') { + Convert-GitHubLinksToMarkdown -Path $outputFile + Write-Host "GitHub links have been converted in $outputFile" + } + } + catch { + Write-Log -Level "ERROR" -Message "Failed to export Markdown to '$outputFile': $_" + Write-Error "Failed to export Markdown file: $_" + } + } + default { + Write-Log -Level WARN -Message "User entered invalid export format: $formatChoice" + Write-Host "Invalid format selection. Export cancelled." + } + } +} + +function Convert-GitHubLinksToMarkdown { + param ( + [string]$Path + ) + Write-Log -Level INFO -Message "Starting GitHub link conversion for '$Path'." + try { + $content = Get-Content -Path $Path -Raw + + # Regex to find GitHub pull request URLs. + # It captures the whole URL and then the PR number. + $regex = '(https?://github\.com/[^/|]+/[^/|]+/pull/(\d+)[^|; ]*)' + + $newContent = $content + $foundMatches = $content | Select-String -Pattern $regex -AllMatches + + if ($foundMatches) { + # Get unique matches to avoid processing the same URL multiple times + $uniqueMatches = $foundMatches.Matches | Select-Object -Property Value, @{N='PR';E={$_.Groups[2].Value}} -Unique + + foreach ($match in $uniqueMatches) { + $url = $match.Value + $pr = $match.PR + $markdownLink = "[PR#$pr]($url)" + # Use simple string replacement. + $newContent = $newContent.Replace($url, $markdownLink) + } + } + + $newContent | Set-Content -Path $Path + Write-Log -Level INFO -Message "Finished GitHub link conversion for '$Path'." + } + catch { + Write-Log -Level "ERROR" -Message "Failed to convert GitHub links in '$Path'. Error: $_" + Write-Error "An error occurred during link conversion: $_" + } +} + +function Convert-CsvToMarkdown { + Write-Log -Level INFO -Message "Entering CSV to Markdown conversion utility." + + $csvPath = $null # Initialize to null + + # Find CSV files in the script's directory ($PSScriptRoot is an automatic variable) + $localCsvFiles = Get-ChildItem -Path $PSScriptRoot -Filter *.csv + + if ($localCsvFiles.Count -gt 0) { + Write-Host "`nFound CSV files in the current directory:" + for ($i = 0; $i -lt $localCsvFiles.Count; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $localCsvFiles[$i].Name) + } + Write-Host "M. Enter a file path manually" + + $choice = Read-Host -Prompt "Select a file or choose manual entry" + + if ($choice -match "^\d+$" -and [int]$choice -ge 1 -and [int]$choice -le $localCsvFiles.Count) { + $selectedIndex = [int]$choice - 1 + $csvPath = $localCsvFiles[$selectedIndex].FullName + Write-Log -Level INFO -Message "User selected local CSV file: $csvPath" + } + elseif ($choice.ToUpper() -ne 'M') { + Write-Host "Invalid selection. Returning to main menu." + Write-Log -Level WARN -Message "User made an invalid selection: $choice" + return + } + # If choice is 'M', $csvPath remains null and we fall through to the manual prompt. + } + + # If no local files were found, or if user chose manual entry + if (-not $csvPath) { + $csvPath = Read-Host -Prompt "Please enter the path to the input CSV file" + } + + # Validate the final path + if (-not (Test-Path -Path $csvPath) -or -not ($csvPath.EndsWith(".csv"))) { + Write-Log -Level WARN -Message "Invalid CSV path provided: $csvPath" + Write-Host "Error: The specified file does not exist or is not a .csv file." + return + } + + $markdownPath = Read-Host -Prompt "Please enter the name for the output Markdown file" + if (-not ($markdownPath.EndsWith(".md"))) { + $markdownPath = "$markdownPath.md" + } + + try { + $csvData = Import-Csv -Path $csvPath + + if (-not $csvData) { + Write-Log -Level WARN -Message "CSV file '$csvPath' is empty or could not be read." + Write-Host "Warning: CSV file is empty or could not be read. No output generated." + return + } + + $markdownTable = ConvertTo-MarkdownTable -Data $csvData + Set-Content -Path $markdownPath -Value $markdownTable + Write-Log -Level INFO -Message "Successfully converted '$csvPath' to '$markdownPath'." + Write-Host "Successfully converted CSV to Markdown: $markdownPath" + + # Offer to convert GitHub links + $convertChoice = Read-Host -Prompt "Convert GitHub PR links to clickable Markdown hyperlinks? (y/n)" + if ($convertChoice.ToUpper() -eq 'Y') { + Convert-GitHubLinksToMarkdown -Path $markdownPath + Write-Host "GitHub links have been converted in $markdownPath" + } + } + catch { + Write-Log -Level "ERROR" -Message "Failed to convert CSV '$csvPath' to Markdown. Error: $_" + Write-Error "An error occurred during CSV to Markdown conversion: $_" + } +} + +# --- Main Script --- + +# --- Pre-flight Checks --- + +# Check for PowerShell 7+ +if ($PSVersionTable.PSVersion.Major -lt 7) { + Write-Error "This script requires PowerShell 7 or higher. You are running version $($PSVersionTable.PSVersion). Please start the script using PowerShell 7 (pwsh.exe)." + if ($Host.Name -eq "ConsoleHost") { + Read-Host "Press Enter to exit" + } + exit 1 +} + +# Check for module dependency +if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) { + Write-Host "Microsoft.Graph module is not installed. Please install it by running: Install-Module Microsoft.Graph -Scope CurrentUser -AllowClobber -Force" + exit +} + +#clear the previous connection for better reliability +Disconnect-MgGraph + +Write-Log -Message "Script started." + +while ($true) { + Write-Host "`n--- Microsoft Planner Task Exporter ---" + Write-Host "1. Pull all tasks (including unassigned)" + Write-Host "2. Pull only tasks with assigned users" + Write-Host "3. Pull tasks of assigned users based on a date range" + Write-Host "4. Pull tasks from a specific bucket" + Write-Host "5. Pull tasks by completion status" + Write-Host "6. Manage saved plans" + Write-Host "7. Convert CSV to Markdown" + Write-Host "Q. Quit" + $selection = Read-Host -Prompt "Enter your choice" + + if ($selection.ToUpper() -eq 'Q') { + break + } + + # Handle standalone options first + if ($selection -eq '7') { + Convert-CsvToMarkdown + Read-Host "Press Enter to return to the main menu" + continue + } + + if ($selection -notin '1', '2', '3', '4', '5', '6', '7') { + Write-Log -Level WARN -Message "User selected invalid main menu option: $selection" + Write-Host "Invalid selection." + continue + } + + # The rest of the options require Graph connection + Connect-ToGraph + + if ($selection -eq '6') { + Set-Configurations -ConfigPath $ConfigPath + continue + } + + # The rest of the options require a Plan ID + $planId = Select-PlannerPlan -ConfigPath $ConfigPath + if (-not $planId) { continue } + + $startDate = $null + $endDate = $null + $bucketId = $null + $status = $null + + if ($selection -eq '3') { + try { + $startDateStr = Read-Host -Prompt "Enter the start date (YYYY-MM-DD)" + $startDate = [datetime]::ParseExact($startDateStr, 'yyyy-MM-dd', $null) + $endDateStr = Read-Host -Prompt "Enter the end date (YYYY-MM-DD)" + $endDate = [datetime]::ParseExact($endDateStr, 'yyyy-MM-dd', $null).AddDays(1) + Write-Log -Level INFO -Message "Date filter selected. Start: $startDate, End: $endDate" + } + catch { + Write-Log -Level WARN -Message "User entered invalid date format $startDateStr and $endDateStr." + Write-Host "Invalid date format. Please use YYYY-MM-DD." + continue + } + } + elseif ($selection -eq '4') { + $bucketId = Select-PlannerBucket -PlanId $planId + } + elseif ($selection -eq '5') { + $status = Select-TaskStatus + } + + $taskData = Get-PlannerTasks -PlanId $planId -Selection $selection -StartDate $startDate -EndDate $endDate -BucketId $bucketId -Status $status + Export-Data -TaskData $taskData + + Read-Host "Press Enter to return to the main menu" +} +Write-Log -Message "Script finished." +Write-Host "Script made by Ibitope Fatoki. Github ibi420." +Write-Host "Exiting." diff --git a/src/content/docs/report-scripts/Planner_Pull_Documentation.md b/src/content/docs/report-scripts/Planner_Pull_Documentation.md new file mode 100644 index 00000000..2af3ba37 --- /dev/null +++ b/src/content/docs/report-scripts/Planner_Pull_Documentation.md @@ -0,0 +1,173 @@ +--- +title: Planner Task Puller Script Documentation +--- + +## Overview + +This PowerShell script connects to Microsoft Graph to retrieve tasks from a specified Microsoft Planner plan. It is a flexible, menu-driven tool that allows for powerful filtering and multiple export formats. + +The script can store connection details for multiple plans in a `config.json` file, allowing for easy switching between them. It fetches task details, including bucket names, completion status, attachments (specifically looking for GitHub links), and assigned users with their roles (Main Contributor, Reviewer). The collected data can then be exported to a **CSV**, **JSON**, or **Markdown** file. + +For CSV and Markdown exports, the script allows you to interactively select which data columns to include, making it ideal for generating custom reports. + +## Key Features + +* **Multiple Filtering Options:** Pull tasks by assignment, date range, completion status, or specific bucket. +* **Multiple Export Formats:** Export your data to CSV, JSON, or a report-ready Markdown table. +* **CSV to Markdown Utility:** A dedicated tool to convert existing CSV files into Markdown tables, with an easy-to-use file picker for local files. +* **Automatic Link Conversion:** For Markdown exports, automatically convert raw GitHub pull request URLs into formatted, clickable Markdown hyperlinks. +* **Customizable Columns:** For CSV and Markdown exports, you can choose exactly which columns to include. +* **Plan Management:** An interactive utility to add, delete, and view your saved Planner plans. +* **Robust Logging:** Creates a detailed log file in the `./logs` directory, with a configurable log level for easier debugging. +* **Environment Checks:** Automatically checks for the required PowerShell version (7+) and `Microsoft.Graph` module to prevent errors. + +## Prerequisites + +* **PowerShell 7 or higher.** The script will not run on older versions like Windows PowerShell 5.1. You can get PowerShell 7 from the [Microsoft Page](https://learn.microsoft.com/en-gb/powershell/scripting/install/installing-powershell?view=powershell-7.5) or [GitHub](https://github.com/PowerShell/PowerShell). +* An internet connection. +* A Microsoft 365 account with access to Microsoft Planner. +* The `Microsoft.Graph` PowerShell module. + +## Setup + +Before running the script for the first time, you need to install the `Microsoft.Graph` module. Open a PowerShell 7 terminal and run the following command: + +```powershell +Install-Module Microsoft.Graph -Scope CurrentUser -AllowClobber -Force +``` + +## Finding Your Plan ID + +The Plan ID is required to fetch tasks from a specific plan. You can find the Plan ID in the URL of your Planner board. + +1. Go to [Microsoft Planner](https://tasks.office.com/). +2. Open the plan you want to use. +3. Look at the URL in your browser's address bar. It will look something like this: + `https://tasks.office.com/your-tenant.com/en-US/Home/Plan?planId=YOUR_PLAN_ID&ownerId=...` +4. The `planId` is the alphanumeric string that follows `planId=`. Copy this value. + +### Video Tutorial + +For a visual guide on how to find the Plan ID, please watch this video (covers two methods): + +[Plan ID Tutorial](https://youtu.be/KdOdRppqxCk) +If you don't want to do that, here are the steps transcribed: + +# How to Find Your Planner ID + +This guide outlines two methods for finding your Planner ID, as described in the video "How to Find your Planner ID." + +### Method 1: Scraping from the URL + +This method is simpler but less reliable. + +1. **Open your Planner:** Go to the specific plan in your web browser. +2. **Locate the ID:** Look at the address bar. The **Planner ID** is the string of characters right after `"plan/"`. +3. **Copy the ID:** This alphanumeric string is your Planner ID. + +### Method 2: Using Microsoft Graph Explorer + +This method is more reliable but more complex. + +1. **Go to Graph Explorer:** Open `developer.microsoft.com/graph/graph-explorer` in your browser. + +#### Option A: If you have a task assigned to the plan + +1. In the left sidebar, scroll down and expand **Users**. +2. Find and click on **all my planner tasks**. +3. Click the **Run query** button. +4. In the response area, find **planId**. The value next to it is your Planner ID. + +#### Option B: If you do not have a task assigned to the plan + +**First, get the Group ID:** +1. In the left sidebar, scroll up to **Groups**. +2. Click on **all groups I belong to**. +3. Click **Run query**. +4. Use your browser's find function (Ctrl+F or Cmd+F) to search for the name of the group associated with your plan. +5. Locate the **id** field next to the group name. This is your **Group ID**. Copy it. + +**Next, get the Planner ID using the Group ID:** +1. In the left sidebar, scroll down to **Planner**. +2. Click on **all Planner plans associated with a group**. +3. In the query bar, replace the placeholder with the Group ID you just copied. +4. Click **Run query**. +5. In the response, find the plan by its **title**. The **id** field associated with that title is your **Planner ID**. + +## How to Run the Script + +1. Open a **PowerShell 7** terminal (`pwsh.exe`). +2. Navigate to the directory where the script is located. +3. Execute the script by running: `.\Planner_pull.ps1` +4. Follow the on-screen prompts. + +### Main Menu + +The script presents a main menu with the following options: + +* **1-5 (Data Pulling Options):** Choose how you want to filter the tasks you retrieve: + 1. Pull all tasks (including unassigned) + 2. Pull only tasks with assigned users + 3. Pull tasks of assigned users based on a date range + 4. Pull tasks from a specific bucket + 5. Pull tasks by completion status (Not Started, In Progress, Completed) +* **6. Manage saved plans:** Enter a utility menu to add or delete plans from your `config.json` file. +* **7. Convert CSV to Markdown:** A tool to convert a CSV file to a Markdown table. +* **Q. Quit:** Exit the script. + +### Data Export Workflow + +After you select a data pulling option (1-5) and retrieve the tasks: + +1. **Data Preview:** A preview of the data is shown in the console. +2. **Choose Export Format:** You will be prompted to choose an export format: **CSV**, **JSON**, or **Markdown**. +3. **Select Columns (for CSV/Markdown):** If you choose CSV or Markdown, a menu will appear listing all available data columns. You can then enter the numbers of the columns you wish to keep in your report (e.g., `1,3,5`). +4. **Enter Filename:** Provide a name for the output file. +5. **Convert Links (for Markdown):** If you chose Markdown, you will be asked if you want to convert GitHub pull request links into clickable Markdown hyperlinks. + +### CSV to Markdown Conversion + +This utility provides a convenient way to create a Markdown report from a CSV file. This is useful if you prefer to export your data to CSV, make manual edits, and then generate a final Markdown table. + +When you select this option from the main menu: + +1. **File Selection:** The script will first look for any `.csv` files in its current directory and display them as a numbered list. You can simply select a number to choose your file. +2. **Manual Path:** If your file is in a different location, you can choose the option to enter the file path manually. +3. **Output:** You will be prompted to provide a name for the new Markdown file. +4. **Link Conversion:** After the Markdown file is created, the script will ask if you want to perform the final step of converting any GitHub URLs into clickable hyperlinks. + +## How it Works + +1. **Pre-flight Checks:** The script first checks for two things: + * That it is being run in **PowerShell 7 or higher**. + * That the `Microsoft.Graph` module is installed. +2. **User Selection:** It prompts the user to choose an action from the main menu. This can be a data pull, plan management, or a utility like the CSV to Markdown converter. +3. **Authentication:** For data pulling and plan management, it connects to the Microsoft Graph API using a device code authentication flow. +4. **Plan ID Configuration (`config.json`):** + * The script reads the `config.json` file to find any saved plans. This file is created automatically. + * If saved plans are found, it displays them in a menu for the user to select. + * The **Manage saved plans** option provides a dedicated utility for adding and deleting plans from this file. +5. **Data Retrieval & Processing:** + * It fetches tasks, buckets, and user details from the Graph API based on the user's filter selections. + * **Attachments:** It intelligently scans task references for GitHub links. + * **User Roles:** It determines user roles ("Main Contributor" or "Reviewer") based on the order of assignment. +6. **Output:** + * The script prompts the user for an export format and an output filename. + * For CSV and Markdown, it allows the user to select specific columns. + * If the user exports to Markdown, it offers an additional step to automatically format any found GitHub PR links into proper Markdown hyperlinks. + * It compiles all the processed data and exports it to the specified file. +7. **Logging:** All operations, user choices, and errors are logged to a file in the `./logs` directory for easy debugging. + +## Output Columns + +The following data columns are available for export: + +* **Name:** The display name of the user assigned to the task. +* **Role:** The role of the user for that task (Main Contributor, Reviewer, or blank). +* **Task:** The title of the Planner task. +* **Bucket:** The name of the bucket the task belongs to. +* **Attachments:** A semicolon-separated list of GitHub URLs found in the task's references. In Markdown exports, these can be automatically converted to clickable hyperlinks (e.g., `[PR#123](...)`). +* **Status:** The completion status of the task (Not Started, In Progress, or Completed). + +**Date of Creation:** 22/08/2025 +**Author:** Ibitope Fatoki \ No newline at end of file diff --git a/src/content/docs/report-scripts/config.json b/src/content/docs/report-scripts/config.json new file mode 100644 index 00000000..d3144bdd --- /dev/null +++ b/src/content/docs/report-scripts/config.json @@ -0,0 +1,10 @@ +[ + { + "planId": "njykIFLDn0iAY1at7tACfcgADgBS", + "name": "Ontrack Plan id" + }, + { + "planId": "mIelcQoIgkqhbM8WaPS3sMgAEmyV", + "name": "Splashkit Plan id" + } +] diff --git a/src/content/docs/report-scripts/logs/script.log b/src/content/docs/report-scripts/logs/script.log new file mode 100644 index 00000000..69009dcb --- /dev/null +++ b/src/content/docs/report-scripts/logs/script.log @@ -0,0 +1,57 @@ +2025-08-23 18:43:37 [INFO] Script started. +2025-08-23 18:43:43 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:43:43 [INFO] Entering plan management utility. +2025-08-23 18:43:45 [INFO] User selected management option: 'a' +2025-08-23 18:43:51 [WARN] User tried to add a plan with empty ID or name. +2025-08-23 18:44:15 [INFO] User selected management option: 'q' +2025-08-23 18:44:15 [INFO] Exiting plan management utility. +2025-08-23 18:44:18 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:44:21 [INFO] User plan selection: '2' +2025-08-23 18:44:21 [INFO] Using Plan ID: mIelcQoIgkqhbM8WaPS3sMgAEmyV +2025-08-23 18:44:48 [INFO] Date filter selected. Start: 07/01/2025 00:00:00, End: 08/24/2025 00:00:00 +2025-08-23 18:44:48 [INFO] Starting Get-PlannerTasks for Plan ID 'mIelcQoIgkqhbM8WaPS3sMgAEmyV'. Selection: 3, BucketId: , Status: +2025-08-23 18:44:49 [INFO] Fetched 120 total tasks from plan. +2025-08-23 18:45:20 [INFO] Finished processing tasks. Returning 120 records. +2025-08-23 18:45:24 [INFO] User chose export format: 1 +2025-08-23 18:45:34 [INFO] Exporting to CSV at 'test.csv' +2025-08-23 18:46:35 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:46:39 [INFO] User plan selection: '2' +2025-08-23 18:46:39 [INFO] Using Plan ID: mIelcQoIgkqhbM8WaPS3sMgAEmyV +2025-08-23 18:47:06 [INFO] Date filter selected. Start: 07/01/2025 00:00:00, End: 08/24/2025 00:00:00 +2025-08-23 18:47:06 [INFO] Starting Get-PlannerTasks for Plan ID 'mIelcQoIgkqhbM8WaPS3sMgAEmyV'. Selection: 3, BucketId: , Status: +2025-08-23 18:47:06 [INFO] Fetched 120 total tasks from plan. +2025-08-23 18:47:39 [INFO] Finished processing tasks. Returning 120 records. +2025-08-23 18:47:43 [INFO] User chose export format: 2 +2025-08-23 18:47:46 [INFO] Exporting to JSON at 'test2.json' +2025-08-23 18:48:10 [INFO] Script finished. +2025-08-23 18:50:43 [INFO] Script started. +2025-08-23 18:50:46 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:50:49 [INFO] User plan selection: '2' +2025-08-23 18:50:49 [INFO] Using Plan ID: mIelcQoIgkqhbM8WaPS3sMgAEmyV +2025-08-23 18:51:11 [INFO] Date filter selected. Start: 07/01/2025 00:00:00, End: 08/02/2025 00:00:00 +2025-08-23 18:51:11 [INFO] Starting Get-PlannerTasks for Plan ID 'mIelcQoIgkqhbM8WaPS3sMgAEmyV'. Selection: 3, BucketId: , Status: +2025-08-23 18:51:12 [INFO] Fetched 120 total tasks from plan. +2025-08-23 18:51:40 [INFO] Finished processing tasks. Returning 44 records. +2025-08-23 18:51:51 [INFO] User chose export format: 3 +2025-08-23 18:52:14 [INFO] User column selection: '1,2,3,5' +2025-08-23 18:52:20 [INFO] Exporting to Markdown at 'test123.md' with columns: Name, Role, Task, Attachments +2025-08-23 18:52:50 [INFO] Script finished. +2025-08-23 18:56:54 [INFO] Script started. +2025-08-23 18:57:11 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:57:14 [INFO] User plan selection: '1' +2025-08-23 18:57:14 [INFO] Using Plan ID: njykIFLDn0iAY1at7tACfcgADgBS +2025-08-23 18:57:42 [INFO] Date filter selected. Start: 07/01/2025 00:00:00, End: 08/24/2025 00:00:00 +2025-08-23 18:57:42 [INFO] Starting Get-PlannerTasks for Plan ID 'njykIFLDn0iAY1at7tACfcgADgBS'. Selection: 3, BucketId: , Status: +2025-08-23 18:57:43 [INFO] Fetched 400 total tasks from plan. +2025-08-23 18:57:58 [INFO] Script started. +2025-08-23 18:58:02 [INFO] Already authenticated as s223739207@deakin.edu.au +2025-08-23 18:58:04 [INFO] User plan selection: '2' +2025-08-23 18:58:04 [INFO] Using Plan ID: mIelcQoIgkqhbM8WaPS3sMgAEmyV +2025-08-23 18:58:19 [INFO] Date filter selected. Start: 07/01/2025 00:00:00, End: 08/23/2025 00:00:00 +2025-08-23 18:58:19 [INFO] Starting Get-PlannerTasks for Plan ID 'mIelcQoIgkqhbM8WaPS3sMgAEmyV'. Selection: 3, BucketId: , Status: +2025-08-23 18:58:20 [INFO] Fetched 120 total tasks from plan. +2025-08-23 18:58:55 [INFO] Finished processing tasks. Returning 119 records. +2025-08-23 18:58:59 [INFO] User chose export format: 1 +2025-08-23 18:59:10 [INFO] User column selection: '' +2025-08-23 18:59:16 [INFO] Exporting to CSV at 'test.csv' +2025-08-23 18:59:27 [INFO] Script finished.