-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathomnifocus_tasks_to_calendar.scpt
More file actions
executable file
·425 lines (377 loc) · 21.3 KB
/
Copy pathomnifocus_tasks_to_calendar.scpt
File metadata and controls
executable file
·425 lines (377 loc) · 21.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
-- ┌─────────────────────────────────────────────────────────────────────────────┐
-- │ OVERVIEW │
-- ├─────────────────────────────────────────────────────────────────────────────┤
-- │ Syncs macOS Calendar events from OmniFocus tasks due today or later. │
-- │ Events are matched to tasks via their OmniFocus task ID (stored in the │
-- │ event URL), and only changed properties are updated. New tasks get new │
-- │ events, completed/removed tasks have their events deleted, and unchanged │
-- │ tasks are left alone. │
-- └─────────────────────────────────────────────────────────────────────────────┘
-- ┌─────────────────────────────────────────────────────────────────────────────┐
-- │ USAGE │
-- ├─────────────────────────────────────────────────────────────────────────────┤
-- │ Run from the command line: │
-- │ osascript omnifocus_tasks_to_calendar.scpt │
-- │ │
-- │ Days to look ahead/back are configured in config.json │
-- │ via the daysAhead and daysBack properties. │
-- └─────────────────────────────────────────────────────────────────────────────┘
-- ┌─────────────────────────────────────────────────────────────────────────────┐
-- │ SCRIPT │
-- └─────────────────────────────────────────────────────────────────────────────┘
property default_event_duration : 30 --in minutes
property minimum_config_version : "v2.0.0"
on run
log("The OmniFocus Tasks to Calendar script has started.")
-- Load sync configuration from external JSON file using JavaScript for Automation (JXA)
-- Prefer config.json; fall back to data.json for backwards compatibility
set scriptPath to do shell script "dirname " & quoted form of POSIX path of (path to me)
set jsonPath to scriptPath & "/config.json"
set jsonExists to do shell script "test -f " & quoted form of jsonPath & " && echo 'true' || echo 'false'"
if jsonExists is "false" then
set jsonPath to scriptPath & "/data.json"
set jsonExists to do shell script "test -f " & quoted form of jsonPath & " && echo 'true' || echo 'false'"
if jsonExists is "true" then
log("config.json not found, falling back to data.json")
end if
end if
if jsonExists is "false" then
log("config.json not found at " & jsonPath)
display notification "config.json not found. Please create it from config.example.json." with title "Sync Error"
return
end if
set jsonContent to do shell script "cat " & quoted form of jsonPath
-- Validate config version meets this script's minimum required version
set dataVersion to do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'var j=JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js); j.version || \"\"'"
set lowerVersion to do shell script "printf '%s\\n%s\\n' " & quoted form of minimum_config_version & " " & quoted form of dataVersion & " | sort -V | head -1"
if lowerVersion is not minimum_config_version then
log("config version too old: minimum " & minimum_config_version & ", found " & dataVersion)
display notification "config version too old: minimum " & minimum_config_version & ", found " & dataVersion & ". Please update config.json." with title "Sync Error"
return
end if
log("config.json version: " & dataVersion & ", minimum required: " & minimum_config_version)
set syncCount to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data.length'") as integer
-- Read daysAhead, daysBack, and runtime_logging from config.json
set daysAhead to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).daysAhead || 1'") as integer
set daysBack to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).daysBack || 1'") as integer
set runtimeLogging to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).runtime_logging === true ? \"true\" : \"false\";'") is "true"
log("Global daysAhead: " & daysAhead & ", daysBack: " & daysBack)
-- Start a stopwatch
set stopwatchStart to current date
-- Prevent macOS from automatically terminating Calendar while the script runs.
-- On Apple Silicon Macs, macOS aggressively kills apps with no visible windows.
-- This is the root cause of error -600 ("Application isn't running").
do shell script "defaults write com.apple.iCal NSDisableAutomaticTermination -bool true"
-- Launch Calendar in background (hidden, no focus steal) and wait for it to be responsive
ensureCalendarRunning()
-- Let the user know that the script has started
display notification "OmniFocus is now syncing to Calendar" with title "Syncing..."
-- ********************************* --
-- CALL THE HANDLERS WITH PARAMETERS --
-- ********************************* --
-- Loop through each sync configuration in the JSON and call the handler
set syncCalendars to {}
set syncModes to {}
set syncRuntimes to {}
set syncDaysAheadList to {}
set syncDaysBackList to {}
repeat with i from 0 to syncCount - 1
set syncTags to paragraphs of (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'var d=JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data[" & i & "]; d.tags.join(\"\\n\")'")
set syncMode to do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data[" & i & "].mode'"
set syncCalendar to do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data[" & i & "].calendar'"
set configDaysAhead to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'var d=JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data[" & i & "]; d.daysAhead != null ? d.daysAhead : " & daysAhead & "'") as integer
set configDaysBack to (do shell script "echo " & quoted form of jsonContent & " | osascript -l JavaScript -e 'var d=JSON.parse($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding).js).data[" & i & "]; d.daysBack != null ? d.daysBack : " & daysBack & "'") as integer
-- Compute date range for this config (per-config values override global)
log("Config " & i & " (" & syncCalendar & ") effective daysAhead: " & configDaysAhead & ", daysBack: " & configDaysBack)
set theStartDate to current date - (days * configDaysBack)
set hours of theStartDate to 0
set minutes of theStartDate to 0
set seconds of theStartDate to 0
set theEndDate to current date + (days * (configDaysAhead - 1))
set hours of theEndDate to 23
set minutes of theEndDate to 59
set seconds of theEndDate to 59
set configStart to current date
processOmniFocusTasks(syncTags, syncMode, syncCalendar)
set configRuntime to (current date) - configStart
log("Config " & i & " (" & syncCalendar & ", " & syncMode & ") took " & configRuntime & " seconds.")
set end of syncCalendars to syncCalendar
set end of syncModes to syncMode
set end of syncRuntimes to configRuntime
set end of syncDaysAheadList to configDaysAhead
set end of syncDaysBackList to configDaysBack
end repeat
-- Stop the stopwatch
set stopwatchStop to current date
-- Subtract the two dates
set runtimeSeconds to (stopwatchStop - stopwatchStart)
-- Log runtime to CSV file if runtime_logging is enabled
if runtimeLogging then
set logPath to scriptPath & "/runtime_log.csv"
set logExists to do shell script "test -f " & quoted form of logPath & " && echo 'true' || echo 'false'"
set runTimestamp to do shell script "date '+%Y-%m-%dT%H:%M:%S%z'"
set machineName to do shell script "scutil --get ComputerName"
if logExists is "false" then
do shell script "echo 'timestamp,machine,config_index,calendar,mode,days_ahead,days_back,config_runtime_seconds' > " & quoted form of logPath
end if
-- Write per-config rows
repeat with i from 1 to syncCount
set configCalendar to item i of syncCalendars
set configMode to item i of syncModes
set configRuntime to item i of syncRuntimes
set configDaysAheadLogged to item i of syncDaysAheadList
set configDaysBackLogged to item i of syncDaysBackList
do shell script "echo " & quoted form of (runTimestamp & "," & machineName & "," & (i - 1) & "," & configCalendar & "," & configMode & "," & configDaysAheadLogged & "," & configDaysBackLogged & "," & configRuntime) & " >> " & quoted form of logPath
end repeat
log("Runtime logged to " & logPath)
end if
-- Let the user know that the script has finished
display notification "OmniFocus is finished syncing to Calendar, took " & runtimeSeconds & " seconds" with title "Syncing Complete!"
log("The script has finished. Runtime: " & runtimeSeconds & " seconds.")
end run
--
-- HANDLER :: SMART SYNC OMNIFOCUS TASKS TO CALENDAR EVENTS --
-- Matches existing events by task URL, updates changed events, creates new ones, removes orphaned ones
--
on processOmniFocusTasks(tags_considered,include_or_exclude,calendar_name)
log("Processing tags to " & include_or_exclude & ": " & tags_considered)
global theStartDate, theEndDate
-- Get existing calendar events for smart sync
set matched_urls to {}
ensureCalendarRunning()
tell application "Calendar"
set calendar_element to calendar calendar_name
set existing_events to every event of calendar_element
end tell
tell application "OmniFocus"
tell default document
set task_elements to flattened tasks whose ¬
(completed is false) and ¬
(dropped is false) and ¬
((effective due date ≠ missing value) or (effective planned date ≠ missing value)) and ¬
((effective due date is greater than or equal to theStartDate) or (effective planned date is greater than or equal to theStartDate)) and ¬
((effective due date is less than or equal to theEndDate) or (effective planned date is less than or equal to theEndDate))
repeat with item_ref in task_elements
-- GET OMNIFOCUS TASKS
set the_task to contents of item_ref
set task_tags to tags of the_task
set task_should_sync to false
-- Check if the task should be made into a calendar event
if include_or_exclude is "include" then
repeat with aTag in task_tags
if name of aTag is in tags_considered then
set task_should_sync to true
exit repeat
end if
end repeat
else
repeat with aTag in task_tags
if name of aTag is in tags_considered then
set task_should_sync to false
exit repeat
else
set task_should_sync to true
end if
end repeat
end if
-- If the task should be synced, then sync it to the calendar
if task_should_sync then
set task_due to effective due date of the_task
set task_planned to effective planned date of the_task
set task_name to name of the_task
set task_note to note of the_task
-- Check if the task has a project assigned
try
set task_project to name of containing project of the_task
set has_project to true
on error
set has_project to false
end try
if task_note is missing value or task_note is "" then
if has_project then
set full_task_note to "Project: " & task_project
else
set full_task_note to ""
end if
else
if has_project then
set full_task_note to "Project: " & task_project & return & "----------------------------------------" & return & task_note
else
set full_task_note to task_note
end if
end if
set task_estimate to estimated minutes of the_task
set task_url to "omnifocus:///task/" & id of the_task
set is_flagged to flagged of the_task
if task_estimate is missing value then
set task_estimate to default_event_duration
end if
-- Determine the event's task_end_date and task_start_date
if task_due is not missing value then
set task_end_date to task_due
else if task_planned is not missing value then
set task_end_date to task_planned
else
-- Skip the task if neither due nor planned date is available
log("Skipping task '" & task_name & "' as it has no due or planned date.")
exit repeat
end if
set task_start_date to task_end_date - (task_estimate * minutes)
-- Safety check: ensure task_start_date is before task_end_date
-- (handles edge cases like tasks at 11:50 PM where arithmetic might cause issues)
if task_start_date is greater than or equal to task_end_date then
log("WARNING: Date calculation error for task '" & task_name & "' | Estimate: " & task_estimate & "m | Using fallback of 1 minute duration")
set task_start_date to task_end_date - (1 * minutes)
end if
-- SMART SYNC: Find existing calendar event by task URL
set found_event to missing value
my ensureCalendarRunning()
tell application "Calendar"
repeat with evt in existing_events
try
if url of evt is task_url then
set found_event to contents of evt
exit repeat
end if
end try
end repeat
end tell
set end of matched_urls to task_url
if found_event is not missing value then
-- UPDATE existing event only if properties have changed
my ensureCalendarRunning()
tell application "Calendar"
set needs_update to false
set needs_alarm_recreate to false
-- Compare core properties (tolerant of iCloud/CalDAV sync artifacts)
if summary of found_event is not task_name then set needs_update to true
if not (my normalizeText(description of found_event) is my normalizeText(full_task_note)) then set needs_update to true
if not my datesEqualToMinute(start date of found_event, task_start_date) then set needs_update to true
if not my datesEqualToMinute(end date of found_event, task_end_date) then set needs_update to true
-- Compare alarm state
set has_alarm to (count of display alarms of found_event) > 0
if is_flagged and not has_alarm then
set needs_update to true
end if
if (not is_flagged) and has_alarm then
-- Alarm needs to be removed; Calendar.app cannot reliably delete alarms,
-- so we delete the event and recreate it without an alarm
set needs_alarm_recreate to true
end if
if needs_alarm_recreate then
-- Delete and recreate: most reliable way to remove alarms
log("Recreating event (alarm removal) for task: " & task_name)
delete found_event
tell calendar_element
make new event with properties {summary:task_name, description:full_task_note, start date:task_start_date, end date:task_end_date, url:task_url} at calendar_element
end tell
else if needs_update then
-- Delete and recreate: Calendar.app has no batch/transaction API,
-- so updating properties one-by-one causes visible intermediate
-- states (e.g. a briefly huge event span). Recreating the event
-- ensures the calendar only ever shows the final correct state.
log("Updating event for task: " & task_name)
delete found_event
tell calendar_element
set newEvent to make new event with properties {summary:task_name, description:full_task_note, start date:task_start_date, end date:task_end_date, url:task_url} at calendar_element
end tell
-- Add alarm if flagged
if is_flagged then
tell newEvent
make new display alarm at end with properties {trigger interval:task_estimate}
end tell
end if
end if
end tell
else
-- CREATE new calendar event
log("Creating event for task: " & task_name)
my ensureCalendarRunning()
tell application "Calendar"
tell calendar_element
set newEvent to make new event with properties {summary:task_name, description:full_task_note, start date:task_start_date, end date:task_end_date, url:task_url} at calendar_element
end tell
if is_flagged then
tell newEvent
make new display alarm at end with properties {trigger interval:task_estimate}
end tell
end if
end tell
end if
end if
end repeat
end tell
end tell
-- CLEANUP: Delete orphaned events whose OmniFocus tasks are no longer active
ensureCalendarRunning()
tell application "Calendar"
set events_to_delete to {}
repeat with evt in existing_events
try
set evt_url to url of evt
if evt_url is not missing value and evt_url starts with "omnifocus:///task/" and evt_url is not in matched_urls then
set end of events_to_delete to contents of evt
end if
end try
end repeat
repeat with evt in events_to_delete
log("Deleting orphaned event: " & summary of evt)
delete evt
end repeat
end tell
end processOmniFocusTasks
--
-- HANDLER :: ENSURE CALENDAR IS RUNNING --
-- On Apple Silicon Macs, macOS aggressively terminates apps with no visible windows.
-- Using 'run' alone is insufficient — the process starts but is killed almost immediately.
-- This handler uses 'open -a' via shell (which fully launches with a window) and polls
-- to verify Calendar is actually running before returning.
--
on ensureCalendarRunning()
-- First, try a quick Apple Event ping to see if Calendar is truly responsive
try
tell application "Calendar" to get name
return -- Calendar responded, it's alive
on error
log("Calendar is not responsive, launching...")
end try
-- 'open -a' is the most reliable way to fully launch an app on Apple Silicon
do shell script "open -a Calendar"
-- Poll up to 10 seconds for Calendar to become responsive
set maxAttempts to 10
repeat maxAttempts times
try
tell application "Calendar" to get name
log("Calendar is now running.")
-- Minimize the Calendar window to keep it out of the way
try
tell application "Calendar" to set miniaturized of every window to true
end try
return
end try
delay 1
end repeat
error "Failed to launch Calendar after " & maxAttempts & " seconds"
end ensureCalendarRunning
--
-- HANDLER :: NORMALIZE TEXT --
-- Normalizes line endings and trims trailing whitespace for reliable cross-machine comparison.
-- iCloud/CalDAV sync may convert \r to \n, add/remove trailing whitespace, etc.
--
on normalizeText(txt)
if txt is missing value then return ""
-- Replace \r\n with \n, then remaining \r with \n
set normalized to do shell script "printf %s " & quoted form of txt & " | tr '\r' '\n' | sed 's/[[:space:]]*$//'"
return normalized
end normalizeText
--
-- HANDLER :: DATES EQUAL TO MINUTE --
-- Compares two dates ignoring seconds, since CalDAV/iCloud may strip or round seconds.
--
on datesEqualToMinute(d1, d2)
if d1 is missing value or d2 is missing value then return false
-- Compare year, month, day, hours, minutes (ignore seconds)
set d1m to d1 - (time of d1 mod 60)
set d2m to d2 - (time of d2 mod 60)
return d1m is equal to d2m
end datesEqualToMinute