Skip to content

Instantly share code, notes, and snippets.

@scottwils
Last active April 14, 2025 09:00
Show Gist options
  • Save scottwils/256b5658a094a295b88585c1215c12f4 to your computer and use it in GitHub Desktop.
Save scottwils/256b5658a094a295b88585c1215c12f4 to your computer and use it in GitHub Desktop.
Apple script to Copy Exchange events to other calendar using Apple Calendar app
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.yourname.calendar.copy</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/osascript</string>
<string>/Users/youruser/Documents/Scripts/CopyCalendarEvents.scpt</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardErrorPath</key>
<string>/Users/youruser/tmp/calendar.err</string>
<key>StandardOutPath</key>
<string>/Users/youruser/tmp/calendar.out</string>
<key>StartInterval</key>
<integer>1800</integer> <!-- Seconds (3600 = 1 hour) -->
</dict>
</plist>
tell application "Calendar"
-- Define the source and destination calendars
set sourceCalendar to calendar "SourceCalendar"
set destCalendar to calendar "DestinationCalendar"
-- Set the log file path
set logFile to (path to desktop as text) & "calendar_copy_log.txt"
-- Script version
set scriptVersion to "1.4.1"
-- Enable or disable logging
set enableLogging to true
-- Enable or disable alerts (alarms)
set enableAlerts to true
-- Set the default alarm trigger (minutes before event; negative value)
set defaultAlarmMinutes to -15
-- Set date range (today to 1 year ahead)
set currentDate to current date
set startOfToday to currentDate - (time of currentDate) -- Midnight today
set endOfRange to currentDate + (365 * days)
-- Log script start with version
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Starting calendar copy script (Version " & scriptVersion & ")")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
-- Check if this is the first run (destination calendar is empty)
set destEvents to events of destCalendar
set isFirstRun to ((count of destEvents) = 0)
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - First run: " & isFirstRun)
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
-- Fetch source events based on run type
if isFirstRun then
set allSourceEvents to events of sourceCalendar
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Fetching all events for first run")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
else
set allSourceEvents to (events of sourceCalendar whose start date ≥ startOfToday)
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Fetching events starting today or later")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end if
-- Filter events to include those with occurrences in the date range
set sourceEvents to {}
repeat with srcEvent in allSourceEvents
set eventStart to start date of srcEvent
set isRecurring to (recurrence of srcEvent is not missing value)
if isRecurring then
set recurrenceRule to recurrence of srcEvent
if my eventOccursInRange(eventStart, recurrenceRule, startOfToday, endOfRange) then
set end of sourceEvents to srcEvent
end if
else if (eventStart ≥ startOfToday and eventStart ≤ endOfRange) then
set end of sourceEvents to srcEvent
end if
end repeat
-- Build a deduplicated list of source UIDs
set sourceUIDs to {}
repeat with srcEvent in sourceEvents
set eventUID to uid of srcEvent
if eventUID is not in sourceUIDs then
set end of sourceUIDs to eventUID
end if
end repeat
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Source UIDs: " & (my joinList(sourceUIDs, ", ")))
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
-- Copy events
repeat with sourceEvent in sourceEvents
try
set eventSummary to summary of sourceEvent
set eventStartDate to start date of sourceEvent
set eventEndDate to end date of sourceEvent
set eventUID to uid of sourceEvent
set copiedMarker to "copied-from:" & eventUID
set isRecurring to (recurrence of sourceEvent is not missing value)
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Processing event: " & eventSummary & " (UID: " & eventUID & ")")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
-- Check for existing events with this UID
set existingEvents to (events of destCalendar whose url is copiedMarker)
if (count of existingEvents) > 0 then
-- Check if the existing event's date matches the source
set existingEvent to first item of existingEvents
set existingStartDate to start date of existingEvent
if existingStartDate is not equal to eventStartDate then
-- Date changed; delete the old event and recopy
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Date changed from " & existingStartDate & " to " & eventStartDate & "; deleting old event")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
delete existingEvent
set shouldCopy to true
else
-- No change; skip
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Skipped (already copied, date unchanged)")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
set shouldCopy to false
end if
else
-- No existing event; copy it
set shouldCopy to true
end if
if shouldCopy then
set eventProps to {summary:eventSummary, start date:eventStartDate, end date:eventEndDate, url:copiedMarker}
try
if description of sourceEvent is not missing value then
set description of eventProps to description of sourceEvent
end if
end try
tell destCalendar
set newEvent to make new event with properties eventProps
end tell
if allday event of sourceEvent then
set allday event of newEvent to true
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Event is all-day")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end if
if isRecurring then
try
set recurrence of newEvent to recurrence of sourceEvent
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Recurrence copied successfully")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
on error errMsg
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Failed to copy recurrence: " & errMsg)
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end try
end if
if enableAlerts and not (allday event of sourceEvent) then
try
tell newEvent
make new display alarm at end with properties {trigger interval:defaultAlarmMinutes}
end tell
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Added alarm " & (-defaultAlarmMinutes) & " minutes before event")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
on error errMsg
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Failed to add alarm: " & errMsg)
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end try
else
if enableLogging then
if not enableAlerts then
set logMsg to quoted form of (((current date) as text) & " - Skipped alarm (alerts disabled)")
else
set logMsg to quoted form of (((current date) as text) & " - Skipped alarm for all-day event")
end if
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end if
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Event copied successfully")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end if
on error errMsg
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Error processing event '" & eventSummary & "': " & errMsg)
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end try
end repeat
-- Delete events from destCalendar not in source
set destEvents to (events of destCalendar whose start date ≥ startOfToday and start date ≤ endOfRange)
repeat with destEvent in destEvents
try
set destURL to url of destEvent
if destURL is not missing value and destURL starts with "copied-from:" then
set origUID to text 13 thru -1 of destURL
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Checking dest event: " & (summary of destEvent) & " with URL: " & destURL & " (extracted UID: " & origUID & ")")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
if origUID is not in sourceUIDs then
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Deleting event from destCalendar: " & (summary of destEvent) & " (UID: " & origUID & ")")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
delete destEvent
end if
end if
on error errMsg
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Error checking event for deletion: " & errMsg)
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end try
end repeat
if enableLogging then
set logMsg to quoted form of (((current date) as text) & " - Script completed")
do shell script "echo " & logMsg & " >> " & quoted form of POSIX path of logFile
end if
end tell
-- Helper to check if a recurring event occurs in the date range
on eventOccursInRange(eventStart, recurrenceRule, rangeStart, rangeEnd)
if eventStart > rangeEnd then return false
set AppleScript's text item delimiters to ";"
set ruleParts to text items of recurrenceRule
set AppleScript's text item delimiters to ""
set freq to ""
set interval to 1
set untilDate to missing value
repeat with part in ruleParts
if part starts with "FREQ=" then
set freq to text 6 thru -1 of part
else if part starts with "INTERVAL=" then
set interval to (text 10 thru -1 of part) as integer
else if part starts with "UNTIL=" then
set untilStr to text 7 thru -1 of part
set untilYear to text 1 thru 4 of untilStr
set untilMonth to text 5 thru 6 of untilStr
set untilDay to text 7 thru 8 of untilStr
set untilDate to date (untilMonth & "/" & untilDay & "/" & untilYear & " 00:00:00")
end if
end repeat
if untilDate is not missing value and untilDate < rangeStart then return false
set currentOccurrence to eventStart
set timeDelta to 0
if freq is "DAILY" then
set timeDelta to interval * days
else if freq is "WEEKLY" then
set timeDelta to interval * weeks
else if freq is "MONTHLY" then
set timeDelta to interval * 30 * days
else if freq is "YEARLY" then
set timeDelta to interval * 365 * days
end if
if timeDelta = 0 then return false
repeat while currentOccurrence ≤ rangeEnd
if currentOccurrence ≥ rangeStart then return true
set currentOccurrence to currentOccurrence + timeDelta
if untilDate is not missing value and currentOccurrence > untilDate then exit repeat
end repeat
return false
end eventOccursInRange
-- Helper to join list items with a delimiter
on joinList(theList, delim)
set AppleScript's text item delimiters to delim
set joinedString to theList as text
set AppleScript's text item delimiters to ""
return joinedString
end joinList
@scottwils
Copy link
Author

scottwils commented Mar 12, 2025

CopyCalendarEvents.scpt

This AppleScript is an unparalleled calendar-syncing powerhouse for macOS! It effortlessly copies events from one calendar (e.g., "Exchange Calendar") to another (e.g., "Google Calendar"), with features no other script can match:

  • Use with Apple Calendar. Tested on macOS Sequoia 15.31. With Exchange Calendars, Google Calendar, and iCloud. All calendars that have write permission in the Apple Calendar App will work.
  • Future-Focused Precision: Seamlessly filters out past events, copying only events from today onward, including recurring events with future instances—something most scripts miss.
  • Duplicate Protection: Uses unique UID-based markers to prevent duplicates, keeping your calendar clean and efficient.
  • Recurring Event Mastery: Handles complex recurring events (daily, weekly, etc.) with grace, copying their patterns where others fail, even from shared or Exchange calendars.
  • Smart All-Day Handling: Detects and adjusts all-day events for perfect compatibility, avoiding common errors.
  • Robust Logging: Outputs detailed logs to calendar_copy_log.txt, giving you full visibility into every action—past skips, successes, or rare hiccups.
  • Schedule-Ready: Designed for automation with launchd, making it a set-it-and-forget-it solution.

No other script combines this level of intelligence, reliability, and customization for macOS Calendar syncing. Sync smarter, not harder!

@scottwils
Copy link
Author

scottwils commented Mar 13, 2025

versiosn 1.2
updated logic

  • disable the logging option
  • Sets a definable alert (alarm) time for imported events (Default 15 minutes).
  • It does not set them for all-day events. (You can alternatively do alerts from the Google Calendar.
  • There is a setting to disable alerts (alarms).
  • It only looks backward from an initial population of recurring events the first time it is run on an empty calendar. Otherwise, it assumes it has been run before and does not look back. It keeps it more efficient.

@scottwils
Copy link
Author

Version 1.3

  • updated all-day logic, so it makes it an all-day event.
  • Added Version printing to the log.

@scottwils
Copy link
Author

scottwils commented Mar 13, 2025

Version History

  • 1.31.41.4.1: Enhancements for Dynamic Event Updates and Bug Fixes

Changes from 1.3 to 1.4

  • Feature Added: Event Date Update Handling
    • Problem: In Version 1.3, if an event’s start date changed in the source calendar ("SourceCalendar"), the script wouldn’t update the corresponding event in "destinationCalendar". It skipped events already copied (based on UID) and didn’t delete outdated ones, leaving stale data.
    • Solution: Introduced logic to detect date changes:
      • Checks if an event with the same UID exists in "destinationCalendar" (events of destCalendar whose url is copiedMarker).
      • Compares the start date of the existing event with the source event.
      • If different, deletes the old event and recopies the updated event from "SourceCalendar".
      • Example: "Meeting" moved from March 14 to March 21 now updates correctly.
    • Log Enhancements:
      • Added "Date changed from [old date] to [new date]; deleting old event" when an update occurs.
      • Updated "Skipped (already copied)" to "Skipped (already copied, date unchanged)" for clarity when no update is needed.

Changes from 1.4 to 1.4.1

  • Bug Fix: Syntax Error in Deletion Block
    • Problem: Version 1.4 had a typo in the deletion block: start date ≥ startOfToday.Concurrent (with an erroneous .Concurrent), causing a syntax error ("Expected ‘,’ but found unknown token").
    • Solution: Corrected to start date ≥ startOfToday, restoring proper event filtering for deletion (events from today to one year ahead).
    • Impact: Ensures the script runs without errors and correctly processes event deletions when UIDs are missing from the source.

Summary of Key Improvements

  • 1.3: Static copying with version logging and all-day event fixes.
  • 1.4: Added dynamic updates for changed event dates.
  • 1.4.1: Fixed syntax error, ensuring robust execution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment