None of the processes suggested in The Architected Self would be feasible without scripting. Probably the most interesting thing for readers here are the scripts I use to enter a temporal event that take input and easily create a tagged DEVONthink group linked to an Aeon Timeline event.
create-temporal-item — a DEVONthink ↔ Aeon Timeline event-capture helper
A 4-step macOS dialog wizard that:
- creates a dated group in a DEVONthink database,
- copies that group’s item link to the clipboard, and
- writes a one-row Aeon Timeline CSV “delta” you import to create the matching event
…then you paste the clipboard into the event’s Links field so the timeline points back at your notes/files.
Files
Three files — keep the names, and put all three in the same folder:
| # |
File |
Role |
| 1 |
create-temporal-item |
zsh launcher (chmod +x) |
| 2 |
temporal_event.applescript |
the wizard / DEVONthink + CSV logic |
| 3 |
temporal_write.py |
writes the Aeon CSV delta row |
Edit before use: the AppleScript Configuration block — set your DEVONthink database name (DT_DB_NAME), the in-database group path that holds events (DT_BUCKET), and BASE (where the CSV / archive live).
1. create-temporal-item
zsh launcher — chmod +x, or just run the .applescript directly / from a hotkey.
#!/usr/bin/env zsh
osascript "$HOME/Documents/aeon-capture/scripts/temporal_event.applescript"
2. temporal_event.applescript
-- =============================================================
-- temporal_event.applescript
--
-- Purpose : Create a DEVONthink event group and write a matching
-- row to an Aeon Timeline CSV delta file.
--
-- Workflow : (1) Dialogs collect event data
-- (2) DT group created via "create record" (avoids the
-- AppleScript/DT 'record' keyword collision)
-- (3) DT item link copied to clipboard
-- (4) CSV delta row written by temporal_write.py
-- (5) You import the CSV into Aeon, then paste the link
-- into the event's Links field
--
-- Companion: temporal_write.py (same directory)
-- Deploy : ~/Library/Scripts/ for the Script Menu, or assign a
-- hotkey via the Script Menu preferences.
-- =============================================================
-- ---- Helpers -------------------------------------------------
on findGroupByPath(thePath, theDB)
-- Navigate a /-separated path inside theDB by matching child names.
-- Avoids both the 'record' keyword collision AND the 'with path'
-- boolean-flag misparse (AppleScript reads 'with path' as {path:true}).
set oldDelims to AppleScript's text item delimiters
set AppleScript's text item delimiters to "/"
set allParts to text items of thePath
set AppleScript's text item delimiters to oldDelims
set cleanParts to {}
repeat with p in allParts
if (p as text) is not "" then set end of cleanParts to (p as text)
end repeat
tell application "DEVONthink"
-- Start from the database root (some DT versions don't expose children of a database)
try
set currentNode to root of theDB
on error
set currentNode to theDB
end try
repeat with partName in cleanParts
set nextNode to missing value
repeat with g in (children of currentNode)
if name of g is (partName as rich text) then
set nextNode to g
exit repeat
end if
end repeat
if nextNode is missing value then return missing value
set currentNode to nextNode
end repeat
return currentNode
end tell
end findGroupByPath
-- Simple text replace helper
on replaceText(theText, searchString, replaceString)
set prevDelims to AppleScript's text item delimiters
set AppleScript's text item delimiters to searchString
set parts to text items of theText
set AppleScript's text item delimiters to replaceString
set theText to parts as text
set AppleScript's text item delimiters to prevDelims
return theText
end replaceText
-- Sanitize DT group name by replacing problematic characters
on sanitizeName(s)
set s to s as text
set s to my replaceText(s, ",", "_")
set s to my replaceText(s, "+", "plus")
set s to my replaceText(s, ":", "-")
set s to my replaceText(s, "/", "_")
set s to my replaceText(s, "\"", "'")
return s
end sanitizeName
-- Split a space-separated category string into a list
on splitBySpaces(s)
set s to s as text
if s is "" then return {}
set prevDelims to AppleScript's text item delimiters
set AppleScript's text item delimiters to " "
set parts to text items of s
set AppleScript's text item delimiters to prevDelims
set outList to {}
repeat with p in parts
set t to my trimText(p as text)
if t is not "" then set end of outList to t
end repeat
return outList
end splitBySpaces
-- Logging helper (writes to ~/Library/Logs/temporal_event.log)
on writeLog(msg)
try
set logsDir to (POSIX path of (path to library folder from user)) & "Logs"
do shell script "mkdir -p " & quoted form of logsDir
set logPath to logsDir & "/temporal_event.log"
set timeStamp to (do shell script "date -u +'%Y-%m-%dT%H:%M:%SZ'")
set safe to quoted form of (timeStamp & " " & msg & "
")
do shell script "printf %s " & safe & " >> " & quoted form of logPath
on error
try
-- fallback to /tmp when user Library isn't writable
set logPath to "/tmp/temporal_event.log"
set timeStamp to (do shell script "date -u +'%Y-%m-%dT%H:%M:%SZ'")
set safe to quoted form of (timeStamp & " " & msg & "
")
do shell script "printf %s " & safe & " >> " & quoted form of logPath
end try
end try
end writeLog
on trimText(s)
set s to s as text
repeat while length of s > 0 and (s starts with " " or s starts with tab)
set s to text 2 thru -1 of s
end repeat
repeat while length of s > 0 and (s ends with " " or s ends with tab)
set s to text 1 thru -2 of s
end repeat
return s
end trimText
on isValidDate(d)
-- Accepts YYYYMMDD: 8 digits, month 01-12, day 01-31
if (length of d) is not equal to 8 then return false
repeat with i from 1 to 8
if (character i of d) is not in {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} then return false
end repeat
try
set mm to (text 5 thru 6 of d) as integer
set dd to (text 7 thru 8 of d) as integer
if mm < 1 or mm > 12 then return false
if dd < 0 or dd > 31 then return false
return true
end try
return false
end isValidDate
on toISO(d)
-- YYYYMMDD to YYYY-MM-DD, or YYYY-MM when day is 00
if (text 7 thru 8 of d) is "00" then
return (text 1 thru 4 of d) & "-" & (text 5 thru 6 of d)
end if
return (text 1 thru 4 of d) & "-" & (text 5 thru 6 of d) & "-" & (text 7 thru 8 of d)
end toISO
-- ---- Main ----------------------------------------------------
on run
-- Configuration >>> EDIT THESE LINES <<<
set DT_DB_NAME to "MyLibrary" -- your DEVONthink database name
set DT_BUCKET to "/Library/_data_buckets/::temporal_bucket" -- in-DB group path that holds events
set BASE to (POSIX path of (path to home folder)) & "Documents/aeon-capture" -- where the CSV / archive live
-- (derived; normally no need to edit)
set DELTA_CSV to BASE & "/model/imports/aeon_delta.csv"
set PY_SCRIPT to BASE & "/model/scripts/temporal_write.py"
set ARCHIVE_DIR to BASE & "/model/imports/applied/"
set LINKS_FILE to BASE & "/model/imports/dt_links_pending.md"
-- Early diagnostics
try
my writeLog("Starting temporal_event")
my writeLog("DT_DB_NAME=" & DT_DB_NAME & ", DT_BUCKET=" & DT_BUCKET)
end try
-- Step 1 : Collect input -----------------------------------
-- 1a. Event name
set r to display dialog "Event name:" default answer "" with title "New Temporal Event (1 of 4)" buttons {"Cancel", "Next"} default button "Next"
if button returned of r is "Cancel" then return
set evtName to my trimText(text returned of r)
if evtName is "" then
display dialog "Event name is required." with title "Error" with icon stop buttons {"OK"}
return
end if
-- Auto-append dagger (DT<->Aeon linkage marker)
if evtName does not end with "†" then set evtName to evtName & "†"
-- 1b. Start date (required)
set r to display dialog "Start date:" default answer "" with title "New Temporal Event (2 of 4) YYYYMMDD" buttons {"Cancel", "Next"} default button "Next"
if button returned of r is "Cancel" then return
set startRaw to my trimText(text returned of r)
if not my isValidDate(startRaw) then
display dialog "Invalid date. Required format: YYYYMMDD (e.g. 20260308)." with title "Error" with icon stop buttons {"OK"}
return
end if
-- 1c. End date (optional -- blank for single-day event)
set r to display dialog "End date (leave blank for single-day event):" default answer "" with title "New Temporal Event (3 of 4) YYYYMMDD" buttons {"Cancel", "Next"} default button "Next"
if button returned of r is "Cancel" then return
set endRaw to my trimText(text returned of r)
if endRaw is not "" then
if not my isValidDate(endRaw) then
display dialog "Invalid end date. Use YYYYMMDD or leave blank." with title "Error" with icon stop buttons {"OK"}
return
end if
if (startRaw as integer) ≥ (endRaw as integer) then
display dialog "End date must be after start date. For a single-day event, leave end date blank." with title "Error" with icon stop buttons {"OK"}
return
end if
end if
-- 1d. Categories (optional, space-separated)
set r to display dialog "Categories (space-separated):" default answer "" with title "New Temporal Event (4 of 4) e.g. ::med ::nav" buttons {"Cancel", "Create"} default button "Create"
if button returned of r is "Cancel" then return
set catsRaw to my trimText(text returned of r)
-- Step 2 : Compute DT group name and ISO dates -------------
-- Single-day : YYYYMMDD -- name
-- Multi-day : YYYYMMDD-YYYYMMDD -- name
if endRaw is "" or endRaw is startRaw then
set dtGroupName to startRaw & " -- " & evtName
set isoEnd to my toISO(startRaw)
else
set dtGroupName to startRaw & "-" & endRaw & " -- " & evtName
set isoEnd to my toISO(endRaw)
end if
set isoStart to my toISO(startRaw)
-- Step 3 : Create DT group; copy item link to clipboard ----
set dtLink to ""
tell application "DEVONthink"
-- Locate the database
set dbList to databases whose name is DT_DB_NAME
if dbList is {} then
display dialog "Database " & DT_DB_NAME & " is not open in DEVONthink." with title "Error" with icon stop buttons {"OK"}
return
end if
set theDB to first item of dbList
try
my writeLog("Found DB: " & (name of theDB as rich text) & ", class: " & (class of theDB as rich text))
end try
-- Locate the event bucket group
set theBucket to my findGroupByPath(DT_BUCKET, theDB)
try
my writeLog("Located bucket object; attempting to log its class/name")
try
my writeLog("bucket class: " & (class of theBucket as rich text))
end try
try
my writeLog("bucket name: " & (name of theBucket as rich text))
end try
end try
-- If the path wasn't found, abort rather than creating groups at the DB root
if theBucket is missing value then
my writeLog("Bucket path not found: " & DT_BUCKET & "; aborting")
display dialog "Cannot find bucket at path:" & return & DT_BUCKET & return & return & "Please create this group in DEVONthink before running the script." with title "Error" with icon stop buttons {"OK"}
return
end if
-- Verify the bucket is usable as a container.
-- DT group classes include 'group', 'tag group', and 'smart group';
-- testing children access is more robust than checking the exact class.
my writeLog("Bucket class: " & (class of theBucket as rich text))
try
set _ to children of theBucket
on error containerErr
my writeLog("Bucket not usable as container: " & containerErr & "; aborting")
display dialog "The event bucket was found but cannot be used as a container." & return & return & "Path: " & DT_BUCKET & return & return & "Please verify the bucket in DEVONthink." with title "Error" with icon stop buttons {"OK"}
return
end try
-- make new group: reuse if exists, otherwise attempt to create
set newGroup to missing value
-- check for existing child with same name
repeat with g in (children of theBucket)
try
if (name of g as rich text) is dtGroupName then
set newGroup to g
exit repeat
end if
end try
end repeat
if newGroup is missing value then
try
set newGroup to create record with {name:dtGroupName, type:group} in theBucket
on error createErr
set safeName to my sanitizeName(dtGroupName)
try
set newGroup to create record with {name:safeName, type:group} in theBucket
set dtGroupName to safeName
on error createErr2
display dialog "DEVONthink failed to create group:" & return & dtGroupName & return & createErr2 with title "Error" with icon stop buttons {"OK"}
return
end try
end try
end if
set dtLink to reference URL of newGroup
-- Build mandatory tags: a temporal marker + year tag(s)
set startYear to (text 1 thru 4 of startRaw) as integer
if endRaw is "" or endRaw is startRaw then
set endYear to startYear
else
set endYear to (text 1 thru 4 of endRaw) as integer
end if
set tagNames to {"::tem"}
set y to startYear
repeat while y ≤ endYear
set end of tagNames to ("^" & (y as rich text))
set y to y + 1
end repeat
-- Merge user-supplied category tags
if catsRaw is not "" then
set catTags to my splitBySpaces(catsRaw)
repeat with t in catTags
set end of tagNames to (t as rich text)
end repeat
end if
-- Apply all tags to the group
try
set tags of newGroup to tagNames
my writeLog("Set tags on group: " & (tagNames as rich text))
on error tagErr
my writeLog("Failed to set tags on group: " & tagErr)
end try
end tell
-- DT link to clipboard -- ready to paste into Aeon Links field
set the clipboard to dtLink
-- Append name + link to accumulator for batch Aeon paste
try
set safeEntry to quoted form of (dtGroupName & return & " " & dtLink & return & return)
do shell script "printf %s " & safeEntry & " >> " & quoted form of LINKS_FILE
on error
-- non-fatal; clipboard already holds the link
end try
-- Step 4 : Write Aeon CSV delta row ------------------------
try
do shell script "python3 " & quoted form of PY_SCRIPT & " " & quoted form of DELTA_CSV & " " & quoted form of evtName & " " & quoted form of isoStart & " " & quoted form of isoEnd & " " & quoted form of catsRaw
on error pyErr
display dialog "CSV write failed:" & return & pyErr with title "Error" with icon stop buttons {"OK"}
return
end try
-- Step 5 : Confirm -----------------------------------------
display notification "DT link on clipboard -- import the CSV when ready" with title "Temporal Event" subtitle dtGroupName
set archiveResult to display dialog "Created: " & dtGroupName & return & return & " 1. Import aeon_delta.csv into Aeon." & return & " 2. Paste clipboard into event Links field." & return & return & "Click \"Archive Delta\" after completing the import." with title "Temporal Event Created" buttons {"Later", "Archive Delta"} default button "Later"
if button returned of archiveResult is "Archive Delta" then
try
do shell script "mkdir -p " & quoted form of ARCHIVE_DIR
set archiveFile to ARCHIVE_DIR & (do shell script "date -u +'%Y%m%d%H%M'") & "_aeon_delta.csv"
do shell script "mv " & quoted form of DELTA_CSV & " " & quoted form of archiveFile
my writeLog("Archived delta to: " & archiveFile)
try
do shell script "rm -f " & quoted form of LINKS_FILE
end try
on error archErr
display dialog "Archive failed: " & archErr with title "Warning" with icon caution buttons {"OK"}
end try
end if
end run
3. temporal_write.py
#!/usr/bin/env python3
# temporal_write.py
#
# Replaces the Aeon CSV delta file with a fresh header + single event row.
# Called by temporal_event.applescript.
#
# argv[1] DELTA_CSV path
# argv[2] label (event name with dagger)
# argv[3] d_start (YYYY-MM-DD)
# argv[4] d_end (YYYY-MM-DD)
# argv[5] cats_raw (space-separated ::tags, may be empty)
import csv, os, sys
DELTA = sys.argv[1]
label = sys.argv[2]
d_start = sys.argv[3]
d_end = sys.argv[4]
cats_raw = sys.argv[5]
HEADER = [
'Label', 'Date', 'End Date', 'Duration',
'Person', 'Person (Compact Display)',
'System', 'System (Compact Display)',
'Category', 'Category (Compact Display)',
]
# Aeon CSV multi-value fields use literal newlines within quoted cells
cats = chr(10).join(t.strip() for t in cats_raw.split() if t.strip())
row = [label, d_start, d_end, '0', '', '', '', '', cats, cats]
if os.path.exists(DELTA):
os.remove(DELTA)
with open(DELTA, 'w', newline='', encoding='utf-8') as f:
w = csv.writer(f, quoting=csv.QUOTE_ALL)
w.writerow(HEADER)
w.writerow(row)
How the Aeon side works
- The Python helper writes a 2-line CSV (header + one row) for Aeon’s CSV import. Columns map to Aeon’s Label / Date / End Date / Duration plus Person and Category roles.
- Round-trip: import CSV → Aeon creates the event → paste the clipboard (the DEVONthink group link) into the event’s Links field → the timeline entry now opens straight back to your notes/files.
- The
† auto-appended to event names is just a personal convention marking a DEVONthink<->Aeon-linked event — delete that line if you don’t want it.