AppleScript Devonthink Tasks in Markdown File to Omnifocus

Hi, my main 2 Apps on the Mac are Devonthink (4 Beta2) and Omnifocus 4. I switched to writing all my notes of meetings, thoughts, … in markdown in Devonthink. I use tasks “- ” Syntax for ToDo Items. I wanted an automated script to get all the Tasks of a markdown file as:

  • Single ToDos in Omnifocus.
  • Enter a backlink to Devonthink in the note section of Omnifocus task
  • make a (OF) entry at the end of the task in Devonthink markdown file, so I know if it is already sent to Omnifocus
  • Don’t make another entry for these marked Tasks in Omnifocus when running the script again.

Didn’t get it on the first try, but thanks to ChatGPT the Applescript is now working.

Anybody interested in the script?

You are free to post it, if you feel it’s beneficial.

You are of course free to post scripts. But in my experience, AI generated AppleScript code is badly written and convoluted. It may do what it should do, but it’s often not a good example for people to learn how to code.

But if it’s working, then it’s a useful draft for people familiar with AppleScript and saving time. AI has its pros and cons, like everything in life :wink:

Maybe it is useful for someone…

How do I post a script file in the forum?

Just add the source code to your post and enclose it in ```

Here is the Applescript:

-- AppleScript: DEVONthink 4 → OmniFocus 4 (robust, detects indented tasks)

-- Helper: Remove leading spaces and tabs
on trimLeadingSpaces(t)
	do shell script "echo " & quoted form of t & " | sed -E 's/^[[:space:]]*//'"
end trimLeadingSpaces

-- Helper: Extract task text after "- [ ]"
on extractTaskText(lineText)
	do shell script "echo " & quoted form of lineText & " | sed -E 's/^[[:space:]]*- \\[ \\] ?//'"
end extractTaskText

-- 1. Get the currently selected Markdown document from DEVONthink
tell application "DEVONthink"
	set theSelection to the selection
	if theSelection is {} then
		display dialog "No document selected in DEVONthink." buttons {"OK"} default button "OK"
		return
	end if
	
	set theRecord to item 1 of theSelection
	set theText to get plain text of theRecord
	set theLink to reference URL of theRecord
end tell

-- 2. Collect lines that are tasks (start with - [ ] and not already sent to OmniFocus)
set taskList to {}
set originalLines to paragraphs of theText

repeat with aLine in originalLines
	set trimmedLine to my trimLeadingSpaces(aLine)
	if trimmedLine starts with "- [ ]" and aLine does not contain "(OF)" then
		set end of taskList to aLine
	end if
end repeat

if (count of taskList) = 0 then
	display dialog "No new tasks found." buttons {"OK"} default button "OK"
	return
end if

-- 3. Create tasks in OmniFocus and mark lines in Markdown
set updatedLines to originalLines

tell application "OmniFocus"
	tell default document
		repeat with t in taskList
			-- Extract the clean task text from Markdown
			set taskName to my extractTaskText(t)
			
			-- Create a new inbox task with the document link
			set newTask to make new inbox task with properties {name:taskName, note:"Link to DEVONthink document: " & theLink}
			
			-- Mark the task line with (OF) in the Markdown
			repeat with i from 1 to count of updatedLines
				set currentLine to item i of updatedLines
				set trimmedCurrent to my trimLeadingSpaces(currentLine)
				set currentContent to my extractTaskText(trimmedCurrent)
				
				if currentContent is equal to taskName and currentLine does not contain "(OF)" then
					set item i of updatedLines to currentLine & " (OF)"
					exit repeat
				end if
			end repeat
		end repeat
	end tell
end tell

-- 4. Write updated Markdown content back to DEVONthink
set AppleScript's text item delimiters to linefeed
set finalText to updatedLines as string
set AppleScript's text item delimiters to ""

tell application "DEVONthink"
	set plain text of theRecord to finalText
end tell

display dialog "Tasks have been created in OmniFocus and marked in the Markdown document." buttons {"OK"} default button "OK"
2 Likes

As expected…
For example:

  • every line of the text is trimmed
  • Then every line is checked for the task markers at the start of it

The first step were not needed if one would simply check for optional space at beginning of line followed by the task marker

The lines are then trimmed again later, btw, in the disguise of updatedLines.

Again: I’m not saying that the code is not working. It’s just overly contrived and difficult to understand.

1 Like

I had the problem that in my Code only Spaces were found but no tabs… that was where I started asking AI for help….

Here is a non-AI, human-written, pure AppleScript with the core function of getting the tasks from the document and passing them to a handler where you can do whatever with them…

tell application id "DNtp"
	if (selected records) is {} then return
	
	repeat with theRecord in (selected records)
		if (record type of theRecord is markdown) then
			set src to (plain text of theRecord)
			set recordID to (reference URL of theRecord)
			set od to AppleScript's text item delimiters
			set theTasks to {}
			repeat with theParagraph in (paragraphs of src)
				if theParagraph contains "- [ ]" then
					set AppleScript's text item delimiters to "[ ] "
					set theTask to text item -1 of (text items of theParagraph)
					if theTask is not "" then copy {|name|:theTask, |note|:"Link to DEVONthink document: ", |URL|:recordID} to end of theTasks
				end if
			end repeat
			set AppleScript's text item delimiters to od
			if theTasks is not {} then
				my processtasks(theTasks)
			else
				log message "No tasks were found in this document." record theRecord
			end if
		end if
	end repeat
end tell

on processtasks(theTasks)
	repeat with theTask in theTasks
		display alert "" & theTask's |name| & return & theTask's |note| & return & theTask's |URL|
	end repeat
end processtasks

PS: This is DEVONthink 4 using record type. type would work in DEVONthink 3.


Quick example

Here is a simple practical use of the handler using Cultured Code’s Things…

on processtasks(theTasks)
	tell application "Things3"
		repeat with theTask in theTasks
			make new to do with properties {name:theTask's |name|, notes:(|note| of theTask & theTask's |url|) as string}
		end repeat
	end tell
end processtasks
1 Like

Thanks, that code looks a lot nicer…

I tried the script with this markdown Document

## Testing again

Hier ist der normale Text

- [ ] Eine Aufgabe zum testen

It returns “No tasks were found in this document”

There’s a typo in the script… Do you see it? :wink:

repeat with theParagraph in (paragraphs of src)
	if theParagraph contains "-[ ]" then

Thanks. Script adjusted. I added the - quickly after the fact and missed by space.

You’re welcome and for the most part it’s simple.
The most advanced thing is adding an AppleScript record with piped variables (†), e.g., |name| to the list of possible matches…

if theTask is not "" then copy {|name|:theTask, |note|:"Link to DEVONthink document: ", |URL|:recordID} to end of theTasks

Building the list could have been handled differently but this is a valid form and one some people may not be aware is even possible. It also expedites creating a more complete list to pass to a handler.

(†) Note the pipes are required in this case because name and URL mean something to DEVONthink; they’re reserved terms. Bookending the term with the pipes lets it function as a variable. (|note| isn’t reserved but I used the same syntax to appear uniform.) I could just as easily have used e.g.,…

{theName:theTask, theNote:"Link to DEVONthink document: ", theURL:recordID} to end of theTasks

or

 {theTask,"Link to DEVONthink document: ",recordID}`
1 Like

The thing is, that the code is even worse than I thought. It actually does do the right thing in extractTaskText, namely ignoring any leading spaces (and that should include tab characters, since it’s using [[:space:]]). And it does that on lines that have been trimmed before.

Well, it’s only AI. So it has no idea what it is doing.

Although @BLUEFROG beat me to it, here’s my take on the task, in JavaScript. The script is modelled after the original A"I" one. Its idea is to use regular expression’s capturing group in the forEach loop. It thus saves the part after the checkbox as name for later use, as well as the current index in the list of paragraphs. Thus, this information is readily available when the OF note is created and the original paragraphs must be updated. The script thus does a bit more than the one by @BLUEFROG :wink:

The code is much shorter than the original version, it doesn’t rely on external command line tools like sed, it doesn’t repeat itself unnecessarily, and it is (of course :wink: ) a lot cleaner, too. IMO. However, it is not tested completely as I do not have OmniFocus to do so. I only checked it against the simple example @hmartin posted, and that was correctly found as well as updated.

(() => {
/* define instances for DT, OF, and current application */
	const dtApp = Application('DEVONthink');
	const currentApp = Application.currentApplication();
	currentApp.includeStandardAdditions = true;
	//const ofApp = Application('OmniFocus');
	
	/* Get the selected markdown records, bail out if none selected */
	const records = dtApp.selectedRecords().filter(r => r.recordType() === 'markdown');
	if (!records || records.length === 0) {
		currentApp.displayAlert('No markdown records selected');
		return;
	}
	/* Get first of selected record and its text and URL */
	const record = records[0];
	const txt = record.plainText();
	const link = record.referenceURL();
	
	/* Split the text into paragraphs, stored in an Array */
	const lines = txt.split(`\n\n`);
	const taskList = [];
	
	/* Find all tasks not added to OF yet and save their index and name to an element of taskList */
  lines.forEach((l,i) => {
		const match = l.match(/^\s*- \[ \]\s?(.*)/) ;
		if (match && !l.includes('(OF)')) {
			taskList.push({index: i, name: match[1]});
		}
	})
	/* Bail out if no pending tasks found */
	if (taskList.length === 0) {
		currentApp.displayAlert('No new tasks found');
		return;
	}
	/* Add each task to OF */
	taskList.forEach(t => {
		inbox = ofApp.defaultDocument.inboxTasks;
		task = of.InboxTask({ 
			name: t.name,
			note: `Link to DEVONthink document: ${link}`
		});
		inbox.push(task);
		/* Append ' (OF)' to the text of the task just added. Use the previously saved index to find the correct line */
		lines[t.index] += ' (OF)';
		})
	/* Update the record's plainText */
	record.plainText = lines.join('\n\n');
	currentApp.displayAlert('Tasks have been created in OmniFocus and marked in the Markdown document.')
})()
1 Like

The Example for Things3 should be possible with “parse tasks into it with transport text” in Omnifocus?

I think there is a middleground @chrillek

I suspect using an advanced model such as Claude 3.7 you could give instructions regarding how to write or refactor the code - such as do not use command-line apps, use a modular design with functions rather than repetition, use the pipe feature to reference variables, etc.

If you then turned those into .rules and updated them after prompting AI for a few more scripts, the result would likely be much improved.

Or alternatively - if your .rules included a few example scripts written with optimal techniques, you could then instruct AI to “Write a JXA script to do _____ in the style of @chrillek .”

It’s no different than prose- AI is pretty good at mimicking writting style from a 5-year-old child to a Southern preacher to a PhD scientist. You just have to tell it what you want.

I can’t say for sure as I don’t use OmniFocus here. However, I’m quite sure it’s possible to use the code and modify the handler for use with OF.

Basically, I’ll tell the AI how I’d write the code. That might teach the AI, true.

But with that level of detail, I could easily write the code myself. And I don’t see myself as teacher for a system that freeloads to learn and then charges others to put what it learned to work.

2 Likes