Managing temporal data

When managing personal data, the foundational question is what type of data it is. I use three products for data management: DEVONthink as the authoritative data repository, Archi to visualize relationships among structural data in an ArchiMate model, and Aeon Timeline to visualize relationships among temporal data. Aeon Timeline becomes the display surface, with temporal data elements imported via .csv files, assisted by scripting and Claude AI.

If this sounds interesting, I’ve written a series of essays on this subject which are available on Substack and the DEVONthink forum, where they engendered a lively discussion. Submitted for your consideration:

Substack: The Architected Self
DEVONthink forum: The Architected Self Discussion

Hi,

Thank you for sharing these articles, and thank you for mentioning Aeon Timeline in Part 2 of your series. It’s always interesting to see how people integrate Aeon Timeline into a broader personal knowledge management workflow.

One point that particularly resonated with us was the distinction between structural information and temporal information. Separating what something is (such as people, places, or organisations) from when things happen (events and milestones) is a powerful way to organise complex information, and it’s an approach that many Aeon Timeline users find works well as their projects grow.

One of the biggest challenges when starting any timeline project is deciding on an appropriate taxonomy- working out which concepts should become item types, relationships, or properties. There’s rarely a single “correct” answer, and it’s something that often evolves as a project develops.

We’d love to see some examples of how you’re visualising those structures in Aeon Timeline, or to hear about any challenges you’ve encountered while bringing your framework into a temporal view. I’m sure other community members would also find that discussion valuable.

Thank you again for sharing your work with the community!

Kind regards,

Riaan
Aeon Timeline Support

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:

  1. creates a dated group in a DEVONthink database,
  2. copies that group’s item link to the clipboard, and
  3. 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.

Wonderful curation of thoughts! Bookmarked for later review.

For a while now I’ve thought a writing plan, particularly for nonfiction or technical work, should include a repository of facts. Those don’t have any narrative order.

You also need an outline, which draws from the facts and creates the narrative, and a time based view is a cool thing, too.

I lack your Applescript skills. The way I’ve handled export from Devonthink to Aeon is with DT’s metadata export. Extra metadata fields for Aeon things like Start, Participants, etc, which Aeon will directly import.

That works great, but it’s not a sync.