Last active
April 14, 2025 09:00
-
-
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
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
<?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> |
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
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 |
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.
Version 1.3
- updated all-day logic, so it makes it an all-day event.
- Added Version printing to the log.
Version History
- 1.3 → 1.4 → 1.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.
- Checks if an event with the same UID exists in "destinationCalendar" (
- 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.
- Problem: In Version 1.3, if an event’s
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.
- Problem: Version 1.4 had a typo in the deletion block:
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
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:
No other script combines this level of intelligence, reliability, and customization for macOS Calendar syncing. Sync smarter, not harder!