Created
October 10, 2025 14:03
-
-
Save MnkyArts/e056759631e347565850a65a42d67761 to your computer and use it in GitHub Desktop.
Outlook Calendar Bidirectional Sync Script it syncs events between two calendars in Outlook
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Outlook Calendar Bidirectional Sync Script | |
| # Syncs events between two calendars in Outlook | |
| # ============================================ | |
| # CONFIGURATION - EDIT THESE VALUES | |
| # ============================================ | |
| # Email addresses of your two accounts | |
| $Email1 = "example@example.com" | |
| $Email2 = "example2@example2.com" | |
| # Sync window (how many days forward and backward to sync) | |
| $DaysBack = 7 | |
| $DaysForward = 90 | |
| # Log file location | |
| $LogFile = "$env:USERPROFILE\Documents\CalendarSync.log" | |
| # ============================================ | |
| # SCRIPT START | |
| # ============================================ | |
| function Write-Log { | |
| param($Message) | |
| $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" | |
| "$timestamp - $Message" | Out-File -FilePath $LogFile -Append | |
| Write-Host "$timestamp - $Message" | |
| } | |
| function Get-Calendar { | |
| param($EmailAddress) | |
| try { | |
| $namespace = $outlook.GetNamespace("MAPI") | |
| $recipient = $namespace.CreateRecipient($EmailAddress) | |
| $recipient.Resolve() | Out-Null | |
| if ($recipient.Resolved) { | |
| $calendar = $namespace.GetSharedDefaultFolder($recipient, 9) # 9 = olFolderCalendar | |
| return $calendar | |
| } else { | |
| Write-Log "ERROR: Could not resolve calendar for $EmailAddress" | |
| return $null | |
| } | |
| } catch { | |
| Write-Log "ERROR: Failed to get calendar for $EmailAddress - $_" | |
| return $null | |
| } | |
| } | |
| function Get-SyncIdentifier { | |
| param($Item) | |
| # Create unique identifier from subject, start time, and location | |
| $subject = if ($Item.Subject) { $Item.Subject } else { "" } | |
| $start = if ($Item.Start) { $Item.Start.ToString("yyyyMMddHHmm") } else { "" } | |
| $location = if ($Item.Location) { $Item.Location } else { "" } | |
| return "$subject|$start|$location" | |
| } | |
| function Find-MatchingEvent { | |
| param( | |
| $SourceItem, | |
| $TargetCalendar | |
| ) | |
| try { | |
| $sourceSubject = if ($SourceItem.Subject) { $SourceItem.Subject.Trim() } else { "" } | |
| $sourceLocation = if ($SourceItem.Location) { $SourceItem.Location.Trim() } else { "" } | |
| # For recurring events, use pattern start date | |
| if ($SourceItem.IsRecurring) { | |
| try { | |
| $recPattern = $SourceItem.GetRecurrencePattern() | |
| $searchStart = $recPattern.PatternStartDate.Date | |
| } catch { | |
| $searchStart = $SourceItem.Start.Date | |
| } | |
| } else { | |
| $searchStart = $SourceItem.Start | |
| } | |
| # Search for matching event in target calendar (wider time window) | |
| $filterStart = $searchStart.AddDays(-2).ToString("g") | |
| $filterEnd = $searchStart.AddDays(2).ToString("g") | |
| $filter = "[Start] >= '$filterStart' AND [Start] <= '$filterEnd'" | |
| $items = $TargetCalendar.Items.Restrict($filter) | |
| foreach ($item in $items) { | |
| $targetSubject = if ($item.Subject) { $item.Subject.Trim() } else { "" } | |
| $targetLocation = if ($item.Location) { $item.Location.Trim() } else { "" } | |
| # Check if subjects match (ignore case) | |
| if ($targetSubject -eq $sourceSubject -or | |
| $targetSubject.ToLower() -eq $sourceSubject.ToLower()) { | |
| # For recurring events, compare recurrence patterns | |
| if ($SourceItem.IsRecurring -and $item.IsRecurring) { | |
| try { | |
| $sourceRec = $SourceItem.GetRecurrencePattern() | |
| $targetRec = $item.GetRecurrencePattern() | |
| # Match if same recurrence type and similar start times | |
| if ($sourceRec.RecurrenceType -eq $targetRec.RecurrenceType -and | |
| $sourceRec.StartTime.ToString("HHmm") -eq $targetRec.StartTime.ToString("HHmm")) { | |
| return $item | |
| } | |
| } catch { | |
| # If can't compare recurrence, fall through to other checks | |
| } | |
| } | |
| # For non-recurring or if recurrence check failed, check start time | |
| $timeDiff = [Math]::Abs(($item.Start - $SourceItem.Start).TotalMinutes) | |
| if ($timeDiff -le 5) { # Within 5 minutes | |
| # If location also matches (or both empty), it's definitely the same event | |
| if ($targetLocation -eq $sourceLocation) { | |
| return $item | |
| } | |
| # Even without location match, if subject and time match closely, treat as duplicate | |
| if ($timeDiff -eq 0) { | |
| return $item | |
| } | |
| } | |
| } | |
| } | |
| return $null | |
| } catch { | |
| Write-Log "WARNING: Error in Find-MatchingEvent for '$($SourceItem.Subject)' - $_" | |
| return $null | |
| } | |
| } | |
| function Copy-CalendarEvent { | |
| param( | |
| $SourceItem, | |
| $TargetCalendar, | |
| $Direction | |
| ) | |
| try { | |
| # Check if matching event already exists (BEFORE checking sync marker) | |
| $existingEvent = Find-MatchingEvent -SourceItem $SourceItem -TargetCalendar $TargetCalendar | |
| if ($existingEvent) { | |
| # Mark both as synced if not already marked | |
| $marked = $false | |
| if ($SourceItem.Categories -notlike "*[SYNCED]*") { | |
| if ($SourceItem.Categories) { | |
| $SourceItem.Categories = "$($SourceItem.Categories),[SYNCED]" | |
| } else { | |
| $SourceItem.Categories = "[SYNCED]" | |
| } | |
| $SourceItem.Save() | |
| $marked = $true | |
| } | |
| if ($existingEvent.Categories -notlike "*[SYNCED]*") { | |
| if ($existingEvent.Categories) { | |
| $existingEvent.Categories = "$($existingEvent.Categories),[SYNCED]" | |
| } else { | |
| $existingEvent.Categories = "[SYNCED]" | |
| } | |
| $existingEvent.Save() | |
| $marked = $true | |
| } | |
| if ($marked) { | |
| Write-Log "MARKED: Event '$($SourceItem.Subject)' already exists in both calendars - marked as synced" | |
| } | |
| return $false | |
| } | |
| # Check if already synced (only skip if marked AND no matching event found above) | |
| if ($SourceItem.Categories -like "*[SYNCED]*") { | |
| return $false | |
| } | |
| # Validate date/time before proceeding | |
| if (-not $SourceItem.Start -or -not $SourceItem.End) { | |
| Write-Log "SKIPPED: Event '$($SourceItem.Subject)' has invalid date/time" | |
| return $false | |
| } | |
| # Create new appointment | |
| $newItem = $TargetCalendar.Items.Add(1) # 1 = olAppointmentItem | |
| # Copy basic properties | |
| $newItem.Subject = $SourceItem.Subject | |
| $newItem.Location = $SourceItem.Location | |
| $newItem.Body = $SourceItem.Body | |
| $newItem.BusyStatus = $SourceItem.BusyStatus | |
| $newItem.AllDayEvent = $SourceItem.AllDayEvent | |
| $newItem.Sensitivity = $SourceItem.Sensitivity | |
| # Copy reminder settings safely | |
| try { | |
| $newItem.ReminderSet = $SourceItem.ReminderSet | |
| if ($SourceItem.ReminderSet) { | |
| $newItem.ReminderMinutesBeforeStart = $SourceItem.ReminderMinutesBeforeStart | |
| } | |
| } catch { | |
| $newItem.ReminderSet = $false | |
| } | |
| # Handle recurring appointments with better error handling | |
| if ($SourceItem.IsRecurring) { | |
| try { | |
| $recPattern = $SourceItem.GetRecurrencePattern() | |
| # Set dates and times BEFORE setting recurrence pattern | |
| $newItem.Start = $recPattern.PatternStartDate.Date.Add($recPattern.StartTime.TimeOfDay) | |
| $newItem.End = $recPattern.PatternStartDate.Date.Add($recPattern.EndTime.TimeOfDay) | |
| $newPattern = $newItem.GetRecurrencePattern() | |
| # Copy recurrence settings | |
| $newPattern.RecurrenceType = $recPattern.RecurrenceType | |
| $newPattern.Interval = $recPattern.Interval | |
| # Copy day of week for weekly patterns | |
| if ($recPattern.RecurrenceType -eq 1) { # olRecursWeekly | |
| $newPattern.DayOfWeekMask = $recPattern.DayOfWeekMask | |
| } | |
| # Copy day of month for monthly patterns | |
| if ($recPattern.RecurrenceType -eq 2 -or $recPattern.RecurrenceType -eq 3) { # olRecursMonthly | |
| try { | |
| $newPattern.DayOfMonth = $recPattern.DayOfMonth | |
| } catch { | |
| # Some patterns don't use DayOfMonth | |
| } | |
| } | |
| # Set pattern dates | |
| $newPattern.PatternStartDate = $recPattern.PatternStartDate | |
| $newPattern.StartTime = $recPattern.StartTime | |
| $newPattern.EndTime = $recPattern.EndTime | |
| # Set end date if pattern has one | |
| if ($recPattern.NoEndDate -eq $false) { | |
| try { | |
| $newPattern.PatternEndDate = $recPattern.PatternEndDate | |
| } catch { | |
| # If setting end date fails, try with occurrences | |
| if ($recPattern.Occurrences -gt 0) { | |
| $newPattern.Occurrences = $recPattern.Occurrences | |
| } | |
| } | |
| } | |
| } catch { | |
| Write-Log "WARNING: Could not copy recurrence pattern for '$($SourceItem.Subject)' - copying as single event. Error: $_" | |
| # Fall back to single event | |
| $newItem.Start = $SourceItem.Start | |
| $newItem.End = $SourceItem.End | |
| } | |
| } else { | |
| # Non-recurring event - simple copy | |
| $newItem.Start = $SourceItem.Start | |
| $newItem.End = $SourceItem.End | |
| } | |
| # Mark as synced | |
| if ($SourceItem.Categories) { | |
| $newItem.Categories = "$($SourceItem.Categories),[SYNCED]" | |
| } else { | |
| $newItem.Categories = "[SYNCED]" | |
| } | |
| $newItem.Save() | |
| # Mark source item as synced too | |
| if ($SourceItem.Categories) { | |
| $SourceItem.Categories = "$($SourceItem.Categories),[SYNCED]" | |
| } else { | |
| $SourceItem.Categories = "[SYNCED]" | |
| } | |
| $SourceItem.Save() | |
| Write-Log "SUCCESS: Copied event '$($SourceItem.Subject)' - $Direction" | |
| return $true | |
| } catch { | |
| Write-Log "ERROR: Failed to copy event '$($SourceItem.Subject)' - $_" | |
| return $false | |
| } | |
| } | |
| function Sync-Calendars { | |
| param( | |
| $Calendar1, | |
| $Calendar2 | |
| ) | |
| $startDate = (Get-Date).AddDays(-$DaysBack) | |
| $endDate = (Get-Date).AddDays($DaysForward) | |
| $filter = "[Start] >= '$($startDate.ToString("g"))' AND [Start] <= '$($endDate.ToString("g"))'" | |
| $synced = 0 | |
| # Sync from Calendar 1 to Calendar 2 | |
| Write-Log "Syncing from $Email1 to $Email2..." | |
| $items1 = $Calendar1.Items.Restrict($filter) | |
| $items1.Sort("[Start]") | |
| foreach ($item in $items1) { | |
| if (Copy-CalendarEvent -SourceItem $item -TargetCalendar $Calendar2 -Direction "$Email1 -> $Email2") { | |
| $synced++ | |
| } | |
| } | |
| # Sync from Calendar 2 to Calendar 1 | |
| Write-Log "Syncing from $Email2 to $Email1..." | |
| $items2 = $Calendar2.Items.Restrict($filter) | |
| $items2.Sort("[Start]") | |
| foreach ($item in $items2) { | |
| if (Copy-CalendarEvent -SourceItem $item -TargetCalendar $Calendar1 -Direction "$Email2 -> $Email1") { | |
| $synced++ | |
| } | |
| } | |
| Write-Log "Sync completed. $synced events synchronized." | |
| } | |
| # ============================================ | |
| # MAIN EXECUTION | |
| # ============================================ | |
| Write-Log "=== Calendar Sync Started ===" | |
| try { | |
| # Create Outlook COM object | |
| $outlook = New-Object -ComObject Outlook.Application | |
| # Get both calendars | |
| $cal1 = Get-Calendar -EmailAddress $Email1 | |
| $cal2 = Get-Calendar -EmailAddress $Email2 | |
| if ($cal1 -and $cal2) { | |
| Sync-Calendars -Calendar1 $cal1 -Calendar2 $cal2 | |
| } else { | |
| Write-Log "ERROR: Could not access one or both calendars" | |
| } | |
| } catch { | |
| Write-Log "ERROR: Script failed - $_" | |
| } finally { | |
| # Clean up | |
| if ($outlook) { | |
| [System.Runtime.Interopservices.Marshal]::ReleaseComObject($outlook) | Out-Null | |
| } | |
| [System.GC]::Collect() | |
| [System.GC]::WaitForPendingFinalizers() | |
| } | |
| Write-Log "=== Calendar Sync Finished ===" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment