A script to move markdown documents with assets

I like to share an AppleScript that might also be useful to others.

The script is able to move selected markdown documents from the /Inbox group of a DEVONthink source database to a destination DEVONthink database, including assets that are stored in /Assets. The script is tested on DEVONthink version 4.2.2.

My knowledge of AppleScript is limited, so the script is written and corrected entirely by Claude Code from Anthropic. For this, all credits go to Claude Code.

Using the script

  1. Open your source and destination database
  2. Select the markdown formatted documents in /Inbox you want to move
  3. Select the script from the DEVONthink script menu and run. (for convenience, the script remembers your last selection for source and destination databases).

Assumptions

The script assumes (for both source and destination databases)

  • documents are stored in /Inbox,
  • assets are stored in /Assets,
  • documents are of type markdown,
  • references to assets are of the kind /Assets/filename i.e. !\[\](/Assets/picture.png)

Q: What is the limit of markdown documents that you can you select in one go?
A: I don’t know. The maximum I tried so far is 300.

A bit of context: Why this script?

Via the “Clip to DEVONthink” browser plugin, I clip (newspaper) articles that I want to keep (for personal use only) to read later or reference. I store those articles in markdown format.
Clip to DEVONthink does a great job, but often clipped articles need some extra attention such adding information that the clip plug missed and removing “see also” links. Most of the time those links have a small image that accompanies it. The problem is that you can remove those “see also” links, but the images remain in the database. If you keep doing this long enough, you end up with a database that takes about half an hour to sync to DEVONthink to Go. That is what happened to me.

I have now changed my workflow a bit. When I clip a newspaper article, it is send in markdown format to a DEVONthink “sink” database. This sink database is were, I clean up articles. By using my AppleScript, cleaned up articles are then moved with assets to my DEVONthink newspaper archive database. When I am done, I empty the /Assets group.
When my newspaper archive becomes becomes to large, I can use the same script to move articles to a another database.

To give you an idea about the asset pollution; five clipped articles easily put about 150 images into the “sink” database. When I move those cleaned articles to my newspaper archive database, only zero to one image assets are moved with the articles. Quite a difference!

I use my newspaper archive database now for about ten years. At the time I have declared it unsyncable, it got more then 29k of images in the /Assets group and a few thousand articles. I now realize that most of those assets are extra weight that is never used. Unfortunately, I did not find an out-of-the-box solution to move my newspaper articles with their assets to another database.
With this script, I can.

Installing the script

Open Script Editor and copy ‘n paste the script code. Save the script as AppleScript (I named mine “Move markdown documents.scpt” and copy the script somewhere under the DEVONthink script folder.

The script code

=================================================================
  DEVONthink Document Mover  -  Version 1.1
=================================================================
  Moves selected Markdown articles -- and their locally-stored
  image assets -- from one DEVONthink database to another.

  In the DESTINATION database:
    * Articles land in  /Inbox
    * Images   land in  /Assets  (auto-created if missing)

  Your database choices are remembered between runs.
-----------------------------------------------------------------
  HOW TO USE
  1. Open both DEVONthink databases.
  2. Select the Markdown articles you want to move.
  4. Run this script.
=================================================================
*)

-- Domain used by macOS to store your database choices between runs
property kPrefDomain : "com.user.dtDocumentMover"
property kSourceKey : "sourceDatabase"
property kDestKey : "destinationDatabase"

-- =============================================================
on run
	-- =============================================================
	
	tell application id "DNtp"
		
		-- 1. We need at least two open databases
		set allDBs to databases
		if (count of allDBs) < 2 then
			display dialog ¬
				¬
					"Please open at least two DEVONthink databases before running this script." buttons {"OK"} default button 1 with icon stop ¬
				with title "DEVONthink Document Mover"
			return
		end if
		
		set dbNames to {}
		repeat with aDB in allDBs
			set end of dbNames to (name of aDB)
		end repeat
		
		-- 2. Choose source database
		set savedSource to my readPref(kSourceKey)
		set srcDefault to my pickDefault(dbNames, savedSource, item 1 of dbNames)
		
		set srcPick to choose from list dbNames ¬
			with prompt ¬
			"Move documents FROM which database?" default items {srcDefault} ¬
			with title "Step 1 of 2 - Source Database"
		if srcPick is false then return
		set sourceName to item 1 of srcPick
		
		-- 3. Choose destination database
		set savedDest to my readPref(kDestKey)
		if savedDest is sourceName then set savedDest to ""
		set destDefault to my pickDefault(dbNames, savedDest, my firstOther(dbNames, sourceName))
		
		set dstPick to choose from list dbNames ¬
			with prompt ¬
			"Move documents TO which database?" default items {destDefault} ¬
			with title "Step 2 of 2 - Destination Database"
		if dstPick is false then return
		set destName to item 1 of dstPick
		
		if destName is sourceName then
			display dialog ¬
				¬
					"Source and destination must be different databases." buttons {"OK"} default button 1 with icon stop ¬
				with title "DEVONthink Document Mover"
			return
		end if
		
		-- Save choices so they are pre-selected next time
		my writePref(kSourceKey, sourceName)
		my writePref(kDestKey, destName)
		
		-- Resolve names to actual database objects
		set sourceDB to missing value
		set destDB to missing value
		repeat with aDB in allDBs
			if (name of aDB) is sourceName then set sourceDB to aDB
			if (name of aDB) is destName then set destDB to aDB
		end repeat
		
		-- 4. Validate the selection
		set sel to selection
		if sel is {} then
			display dialog ¬
				"Nothing is selected." & return & return ¬
				& ¬
				"Please select one or more Markdown articles inside " & quote & sourceName & quote ¬
				& ¬
				" and run the script again." buttons {"OK"} default button 1 with icon caution ¬
				with title "DEVONthink Document Mover"
			return
		end if
		
		-- Keep only Markdown records that belong to the source database
		set docsToMove to {}
		repeat with rec in sel
			if (name of database of rec) is sourceName then
				if type of rec is markdown then
					set end of docsToMove to rec
				end if
			end if
		end repeat
		
		if docsToMove is {} then
			display dialog ¬
				¬
					"None of the selected items are Markdown documents in " & quote & sourceName & quote & "." & return & return ¬
				& ¬
				"Select the Markdown articles inside the source database and try again." buttons {"OK"} default button 1 with icon stop ¬
				with title "DEVONthink Document Mover"
			return
		end if
		
		-- 5. Locate (or create) the destination folders
		-- The inbox of the destination database
		set destInboxGrp to incoming group of destDB
		
		-- /Assets in destination -- created automatically if absent
		set destAssetsGrp to get record at "/Assets" in destDB
		if destAssetsGrp is missing value then
			set destAssetsGrp to create record with {name:"Assets", type:group} in destDB
		end if
		
		-- 6. Process each document
		set movedDocs to 0
		set movedAssets to 0
		set missingList to {}
		
		repeat with rec in docsToMove
			
			-- Read the raw Markdown text so we can find /Assets/ references
			set rawMD to my getRawMarkdown(rec)
			
			-- Extract the filenames of locally-stored images
			set assetNames to my extractAssetNames(rawMD)
			
			-- Move each referenced asset using a direct path lookup.
			-- get record at "/Assets/<name>" in <db> is DEVONthink's own
			-- path resolver -- reliable and does not require iterating children.
			repeat with fname in assetNames
				set fname to fname as rich text -- plain text is required for correct path lookup
				
				-- Look up the file directly by path in the source database
				set srcFile to get record at ("/Assets/" & fname) in sourceDB
				
				if srcFile is not missing value then
					-- Move it only when it is not already in destination /Assets
					if (get record at ("/Assets/" & fname) in destDB) is missing value then
						move record srcFile to destAssetsGrp
						set movedAssets to movedAssets + 1
					end if
				else
					-- Not in source -- check whether it was already moved
					-- earlier in this same run (it would then be in dest already)
					if (get record at ("/Assets/" & fname) in destDB) is missing value then
						-- Genuinely not found anywhere
						if fname is not in missingList then
							set end of missingList to fname
						end if
					end if
				end if
			end repeat
			
			-- Move the article itself to destination /Inbox
			move record rec to destInboxGrp
			set movedDocs to movedDocs + 1
			
		end repeat
		
		-- 7. Show summary
		set summary to "All done!" & return & return ¬
			& "Documents moved : " & movedDocs ¬
			& "  ->  " & destName & " / Inbox" & return ¬
			& "Assets moved    : " & movedAssets ¬
			& "  ->  " & destName & " / Assets"
		
		if (count of missingList) > 0 then
			set summary to summary & return & return ¬
				& "Note: " & (count of missingList) ¬
				& " asset file(s) referenced in the articles" & return ¬
				& "were not found in " & sourceName & "/Assets and were skipped." & return ¬
				& "(They may be external images already removed during cleanup.)"
		end if
		
		display dialog summary ¬
			buttons {"OK"} default button 1 ¬
			with title "DEVONthink Document Mover"
		
	end tell
end run


-- =============================================================
-- Read the raw Markdown text from a DEVONthink record.
-- Reads the actual file on disk first (most reliable);
-- falls back to DEVONthink's built-in plain text property.
-- =============================================================
on getRawMarkdown(rec)
	tell application id "DNtp"
		try
			set fp to path of rec
			if fp is not missing value and fp is not "" then
				set raw to do shell script "cat " & quoted form of fp & " 2>/dev/null || true"
				if raw is not "" then return raw
			end if
		end try
		return plain text of rec
	end tell
end getRawMarkdown


-- =============================================================
-- Parse Markdown text and return a list of decoded filenames
-- that appear as local  /Assets/...  image references.
-- =============================================================
on extractAssetNames(mdText)
	set found to {}
	set marker to "/Assets/"
	set mLen to length of marker
	set tLen to length of mdText
	set i to 1
	
	repeat while i ≤ (tLen - mLen + 1)
		-- Check whether /Assets/ starts at position i
		if (text i thru (i + mLen - 1) of mdText) is marker then
			-- Walk forward and collect the filename characters
			set j to i + mLen
			set raw to ""
			repeat while j ≤ tLen
				set ch to character j of mdText
				-- Stop at any Markdown delimiter or whitespace
				if ch is in {")", "]", "\"", " ", tab, return, linefeed} then exit repeat
				set raw to raw & ch
				set j to j + 1
			end repeat
			
			-- URL-decode (%20 -> space, etc.) and add to list
			if raw is not "" then
				set decoded to my urlDecode(raw)
				if decoded is not in found then set end of found to decoded
			end if
			set i to j -- jump past this match
		else
			set i to i + 1
		end if
	end repeat
	
	return found
end extractAssetNames


-- =============================================================
-- Decode a URL-encoded string  (%20 -> space, etc.)
-- Uses Python 3 (always present on macOS) for full accuracy.
-- =============================================================
on urlDecode(s)
	try
		return do shell script ¬
			¬
				"python3 -c \"import sys,urllib.parse; print(urllib.parse.unquote(sys.argv[1]),end='')\" " & quoted form of s
	on error
		-- Manual fallback for the most common encoded characters
		set r to s
		repeat with pair in {{"%20", " "}, {"%21", "!"}, {"%27", "'"}, ¬
			{"%28", "("}, {"%29", ")"}, {"%2C", ","}, {"%2E", "."}, ¬
			{"%2F", "/"}, {"%3A", ":"}, {"%40", "@"}}
			set r to my replaceAll(r, item 1 of pair, item 2 of pair)
		end repeat
		return r
	end try
end urlDecode


-- =============================================================
-- Return target if it exists in lst; otherwise return fallback.
-- =============================================================
on pickDefault(lst, target, fallback)
	if target is "" or target is missing value then return fallback
	repeat with anItem in lst
		if (anItem as text) is target then return target
	end repeat
	return fallback
end pickDefault


-- =============================================================
-- Return the first item in lst that is not equal to excluded.
-- =============================================================
on firstOther(lst, excluded)
	repeat with anItem in lst
		if (anItem as text) is not excluded then return anItem as text
	end repeat
	return item 1 of lst as text
end firstOther


-- =============================================================
-- Replace every occurrence of findStr in str with replStr.
-- =============================================================
on replaceAll(str, findStr, replStr)
	set AppleScript's text item delimiters to findStr
	set parts to text items of str
	set AppleScript's text item delimiters to replStr
	set result to parts as text
	set AppleScript's text item delimiters to ""
	return result
end replaceAll


-- =============================================================
-- Read / write persistent preferences via macOS user defaults.
-- =============================================================
on readPref(key)
	try
		return do shell script ¬
			"defaults read " & quoted form of kPrefDomain ¬
			& " " & quoted form of key & " 2>/dev/null || true"
	on error
		return ""
	end try
end readPref

on writePref(key, value)
	try
		do shell script ¬
			"defaults write " & quoted form of kPrefDomain ¬
			& " " & quoted form of key ¬
			& " " & quoted form of value
	end try
end writePref