Streamlining Task Management with Todoist and Outlook

Struggling to sync multiple calendars with Todoist? I was too—until I built a PowerShell script that pulls Outlook meetings straight from the desktop client and syncs them with my Todoist task list. No Azure setup. No OAuth headaches. Just simple, local automation to bridge the gap.

Streamlining Task Management with Todoist and Outlook
Photo by Ed Hardie / Unsplash

I have been using Todoist to manage all my day-to-day tasks and meetings. It's an integral part of my routine, and to maximize its utility, I've been incorporating all my calendar events and meetings into it. Like many people, I use a Google calendar for personal and family events and an Outlook calendar for work-related events. While Google calendar easily syncs with Todoist, the challenge arose when I wanted to sync my Outlook calendar with Todoist, as it only allows syncing with one calendar at a time. Some workarounds involve subscribing to different calendars within one platform, but this wasn't an ideal solution for me.

Extracting Meetings from Outlook

The first hurdle was figuring out how to extract meetings from Outlook. The official Microsoft Graph API provides some endpoints for working with Outlook. However, it requires setting up an Azure Application, which I don't have the access to do at work (though I did manage to set it up in my personal environment).

After some research, I found an old article that explained how to use PowerShell to access the Outlook desktop client through the Outlook API. This method is a bit outdated and necessitates the use of the Outlook desktop client, but it avoids the need for Azure App permissions or the need to deal with OAuth just to extract your meetings.

The PowerShell Script

I've developed a PowerShell script to streamline this process. Although it's a work in progress and could use some polishing, it's been effectively serving its purpose. The script achieves the following:

  • Connects to your Outlook desktop client.
  • Retrieves all your upcoming meetings for the next 7 days.
  • Connects to Todoist.
  • Pulls all your tasks for the next 7 days.
  • Compares each Outlook meeting with Todoist tasks, and if a task with the same name as the Outlook meeting is not found, it creates one in Todoist.
# Clear the screen
Clear-Host

# Setup some arrays to hold our data
$meetings = @()
$apps = @()
 

#========= TODOIST Stuff =============
# Find your Todoist API Token at https://app.todoist.com/app/settings/integrations/developer
$token = ConvertTo-SecureString -String "repace-with-your-Todoist-token" -AsPlainText -Force

# The ID of the project where your tasks will be created (for example, https://app.todoist.com/app/project/{{projectID}})
$projectID = "repalce-with-your-project-id"
#=========== End TODOIST Stuff ========

# Set start date to tomorrow
$Start = (Get-Date).AddDays(1).Date.ToShortDateString()

# Set end date to 8 days from now (Really seven days, since the start date is tomorrow)
$End = (Get-Date).AddDays(8).Date.ToShortDateString()

# This is the Outlook calendar folder
$olFolderCalendar = 9

# Connect to Outlook
$ol = New-Object -ComObject Outlook.Application
$ns = $ol.GetNamespace('MAPI')

# Filter to get only appointments that fall between $Start and $End dates
$Filter = "[MessageClass]='IPM.Appointment' AND [Start] >= '$Start' AND [End] < '$End'"

# Get all appointments
$appointments = $ns.GetDefaultFolder($olFolderCalendar).Items

# Include Recurrences -- not sure if this is necessary
$appointments.IncludeRecurrences = $true

# Sort by start date
$appointments.Sort("[Start]")

# Get appointments that fall between $Start and $End
$apps = $appointments.Restrict($Filter)  

foreach ($m in $apps) {
	$meetings += [PSCustomObject]@{
		Subject           = $m.Subject
		Start             = $m.Start
		End               = $m.End
		Duration          = $m.Duration
		Created           = $m.CreationTime
		LastModified      = $m.LastModificationTime
		Organizer         = $m.Organizer
		RequiredAttendees = $m.RequiredAttendees
		OptionalAttendees = $m.OptionalAttendees
		Body              = $m.Body
	}
}

function createTodoistTask {
	Param(
		# meeting
		[Parameter(Mandatory = $true)]
		$meeting,

		# Todoist Token
		[Parameter(Mandatory = $true)]
		[securestring]
		$token,

		# Todoist Project ID
		[Parameter(Mandatory = $true)]
		[string]
		$todoistProjectID
	)

	# Todoist Task API
	$taskURL = "https://api.todoist.com/rest/v2/tasks"
	
	# Set up headers
	$headers = @{
		'Content-Type' = "application/json"
	}

	# Check if meeting is required or optional
	if ($meeting.RequiredAttendees -like "*Sloan, Thomas*") {

		# Add a label to the Todoist task
		$labels = @("assistant", "meeting (required)")
	}
	elseif ($meeting.OptionalAttendees -like "*Sloan, Thomas*") {
		# I'm optional, so add the optional label
		$labels = @("assistant", "meeting (optional)")
	}
	else {
		# This ensures we always add at least the "Assistant" label
		$labels = @("assistant")
	}

	# Set up body and convert to JSON
	$body = @{
		content       = $meeting.Subject
		due_datetime  = [Xml.XmlConvert]::ToString(($meeting.Start.ToUniversalTime()), [Xml.XmlDateTimeSerializationMode]::Utc)
		labels        = $labels
		project_id    = $todoistProjectID
		duration      = $meeting.Duration
		duration_unit = "minute"
	} | ConvertTo-Json

	# Create the task
	$response = Invoke-RestMethod -Uri $taskURL -Method Post -Authentication Bearer -Token $token -Headers $headers -Body $body

	#TODO: Check if task was created, for now just assuming it was
	Write-Host "    🎉 Created meeting: $($meeting.Subject) --- $($meeting.Start) to $($meeting.End) ($($meeting.Duration) minutes)"
}

<#
Get all Todoist tasks for the next 7 days and compare them to the Outlook meetings.
Create new tasks for any that don't exist.
#>

# Todoist Task API
$taskURL = "https://api.todoist.com/rest/v2/tasks"

# Append the URL to get Todoist tasks for the next 7 days
$getTodayTasks = $taskURL + "?filter=7 days"

# Get the tasks
$todaysTodoistTasks = Invoke-RestMethod -Uri $getTodayTasks -Method Get -Authentication Bearer -Token $token -Headers $headers

# Check if any meetings were found from Outlook
if ($meetings.Count -gt 0) {

	foreach ($meeting in $meetings) {
		Write-Host "Checking for meeting: " -NoNewline
		Write-Host "$($meeting.Subject)" -ForegroundColor Yellow
		
		# Check if a task has a title that matches the Outlook meeting title
		if ($todaysTodoistTasks.content.Contains($meeting.Subject)) {
			Write-Host "    ✅ Meeting already in Todoist."

			# We found the meeting in Todoist. Lets check if start time was updated
			$todoistTask = $todaysTodoistTasks | Where-Object { $_.content -eq $meeting.Subject }

			# Make sure we have a datetime for the Todoist task we are looking at
			if ($todoistTask.due.datetime) {
				if (($todoistTask.due.datetime.ToUniversalTime()).ToLocalTime() -eq ($meeting.Start.ToUniversalTime()).ToLocalTime()) {
					# We have a task that has the same start time as the meeting. Do nothing.
					Write-Host "    ✅ Meeting start time and task due date match"
				}
				else {
					# We have a task but the times don't match (Todoist task is out of date, Outlook meeting got updated, etc).
					Write-Host "    ❌ Meeting has a start time of $(($meeting.Start.ToUniversalTime()).ToLocalTime()) and it's Todoist task has a start time of $(($todoistTask.due.datetime.ToUniversalTime()).ToLocalTime())" -ForegroundColor Red
				}
			}
		}
		else {
			Write-Host "    ❌ Meeting not found. Creating meeting"

			# Create the task
			createTodoistTask -meeting $meeting -token $token -todoistProjectID $projectID
		}
		Write-Host " "
	}
}
else {
	Write-Host "No meetings found in Outlook between $Start and $End"
}

What's Next?

So far, this script has been performing quite well. I run it at the end of each day to ensure that all meetings from my Outlook calendar are transferred into Todoist. The script also tags these meetings as 'required' or 'optional', and adds an 'assistant' tag to the Todoist tasks, allowing me to quickly filter and identify tasks added by this script.

Moving forward, I plan to enhance the functionality of this script. One such improvement could be enabling the script to create meeting notes in Obsidian linked back to the Todoist tasks. Another potential feature could be the integration with other applications. Stay tuned for more updates!