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
- Open your source and destination database
- Select the markdown formatted documents in
/Inboxyou want to move - 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/filenamei.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