Applescript to generate a txt file ASCII tree diagram

Hello DEVONthink Community,

I thought I’d share this Applescript with you that I have been working on for some time to get right…because Applescript is so very helpful when it throws errors ;).
What started as an attempt to give AI better context when asking for help (re)structuring my databases, turned into an equally useful tool as a nicely formatted Table of Contents in the root of each database (of course not as a substitute for the actual file tree), but it is complete with item links to corresponding records, or use as snapshot versioning tool to help return state if a move classify automation ever goes awry or for nothing more than pure joy in watching your databases grow over time with automated snapshots.

Note: If your database is large, it twill take time to run, and possibly prohibitive for larger databases on slower machines (I’m not sure). For me, running on my M4, on a database with 3651 items in 342 groups took 2m 45 sec. Just so you don’t throw out the script before it finishes generating nice output (maybe I’m biased). In any case, I welcome anyone’s feedback on improvements, especially on efficiency.

Cheers,
Jamie

(*
=================================================================
  DEVONthink Database Tree Generator
  -----------------------------------
  Generates an ASCII tree structure of any open DEVONthink database
  and saves it as a timestamped plain text file in the database root.

  Compatible with: DEVONthink 3 / DEVONthink 4
  Author: Jamie Forrest (https://github.com/drjforrest)
  Date: 2026-02-27

  Usage:
    - Run from Script Editor, Keyboard Maestro, or DEVONthink's Script menu
    - A dialog will prompt you to choose which open database to map
    - The tree is saved as a plain text record in the database root
    - Filename includes ISO 8601 timestamp for versioning

  Output format:
    📁 Database Name
    ├── 📁 Group A
    │   ├── 📄 Document 1.md
    │   ├── 📄 Document 2.pdf
    │   └── 📁 Subgroup
    │       └── 📄 Nested Doc.txt
    ├── 📄 Root Document.md
    └── 📁 Group B
        └── 📄 Another Doc.pdf

  Notes:
    - Excludes Trash and Tags system groups by default
    - Uses Unicode box-drawing characters for clean tree rendering
    - Record types are indicated with emoji prefixes:
        📁 = group/folder
        🔍 = smart group
        📄 = document (all other types)
    - Large databases may take time to process
=============================================================================
*)

use AppleScript version "2.4"
use scripting additions

-- ============================================================
-- CONFIGURATION
-- ============================================================
property excludeTrash : true
property excludeTags : true
property excludeSmartGroups : false
property showRecordType : true
property showItemCount : true
property maxDepth : 50 -- safety limit for recursion depth

-- ============================================================
-- MAIN SCRIPT
-- ============================================================
on run
	tell application id "DNtp"
		-- Gather open database names
		set dbList to name of every database
		
		if (count of dbList) is 0 then
			display dialog "No databases are currently open in DEVONthink." buttons {"OK"} default button "OK" with icon stop
			return
		end if
		
		-- Prompt user to select a database
		set chosenDB to choose from list dbList with prompt ¬
			"Select a database to generate a tree structure:" with title ¬
			"DEVONthink Tree Generator" default items {item 1 of dbList}
		
		if chosenDB is false then return
		set dbName to item 1 of chosenDB
		
		-- Get the database reference
		set theDB to database dbName
		set rootRecord to root of theDB
		
		-- Generate timestamp (ISO 8601)
		set theDate to current date
		set dateStamp to my formatDate(theDate)
		
		-- Build header
		set treeOutput to "============================================================" & linefeed
		set treeOutput to treeOutput & "  DEVONthink Database Tree Structure" & linefeed
		set treeOutput to treeOutput & "  Database: " & dbName & linefeed
		set treeOutput to treeOutput & "  Generated: " & dateStamp & linefeed
		set treeOutput to treeOutput & "============================================================" & linefeed & linefeed
		
		-- Start tree with database root
		set treeOutput to treeOutput & "📁 " & dbName & linefeed
		
		-- Get children of root and process recursively
		set rootChildren to children of rootRecord
		set itemTotal to 0
		set groupTotal to 0
		set {treeBody, itemTotal, groupTotal} to my buildTree(rootChildren, "", true, 0, 0, 0)
		set treeOutput to treeOutput & treeBody
		
		-- Append summary footer
		set treeOutput to treeOutput & linefeed
		set treeOutput to treeOutput & "============================================================" & linefeed
		set treeOutput to treeOutput & "  Summary" & linefeed
		set treeOutput to treeOutput & "  Total items:  " & itemTotal & linefeed
		set treeOutput to treeOutput & "  Total groups: " & groupTotal & linefeed
		set treeOutput to treeOutput & "============================================================" & linefeed
		
		-- Create the record name with timestamp
		set recordName to "Tree_" & dbName & "_" & my filenameSafeDate(theDate)
		
		-- Create a plain text record in the database root
		set newRecord to create record with {name:recordName, type:txt, plain text:treeOutput} in rootRecord
		
		-- Notify user
		set dialogMsg to "Tree structure generated successfully!" & linefeed & linefeed & ¬
			"📄 " & recordName & ".txt" & linefeed & ¬
			"📊 " & itemTotal & " items in " & groupTotal & " groups" & linefeed & linefeed & ¬
			"Saved to root of \"" & dbName & "\" database."
		
		display dialog dialogMsg buttons {"Open Record", "OK"} default button "OK" with title "Tree Generator Complete"
		
		if button returned of result is "Open Record" then
			open tab for record newRecord in think window 1
		end if
		
	end tell
end run

-- ============================================================
-- RECURSIVE TREE BUILDER
-- ============================================================
(*
  Builds the ASCII tree string recursively.

  Parameters:
    records     - list of records to process
    prefix      - the current indentation prefix string
    isRoot      - whether this is the root level call
    depth       - current recursion depth
    itemCount   - running total of items processed
    groupCount  - running total of groups processed

  Returns: {treeString, itemCount, groupCount}
*)

on buildTree(recList, prefix, isRoot, depth, itemCount, groupCount)
	if depth > maxDepth then return {"", itemCount, groupCount}
	
	set treeOutput to ""
	
	-- Build filtered list
	set filteredRecords to {}
	tell application id "DNtp"
		repeat with i from 1 to count of recList
			set thisRecord to item i of recList
			set recName to name of thisRecord
			set recType to type of thisRecord as string
			set shouldExclude to false
			if excludeTrash and recName is "Trash" then set shouldExclude to true
			if excludeTags and recName is "Tags" then set shouldExclude to true
			if excludeSmartGroups and recType is "smart group" then set shouldExclude to true
			if not shouldExclude then
				set end of filteredRecords to thisRecord
			end if
		end repeat
	end tell
	
	set filteredCount to count of filteredRecords
	
	repeat with i from 1 to filteredCount
		
		-- Fetch all DEVONthink properties upfront
		tell application id "DNtp"
			set thisRecord to item i of filteredRecords
			set recName to name of thisRecord
			set recType to type of thisRecord as string
			set recFilename to filename of thisRecord
			set groupChildren to {}
			set smartCount to 0
			if recType is "group" then
				set groupChildren to children of thisRecord
			else if recType is "smart group" then
				set smartCount to count of children of thisRecord
			end if
		end tell
		
		-- All logic outside the tell block
		set isLastItem to (i = filteredCount)
		
		if isLastItem then
			set connector to "└── "
			set childPrefix to prefix & "    "
		else
			set connector to "├── "
			set childPrefix to prefix & "│   "
		end if
		
		if recType is "group" then
			set typeIcon to "📁 "
		else if recType is "smart group" then
			set typeIcon to "🔍 "
		else
			set typeIcon to "📄 "
		end if
		
		set itemCount to itemCount + 1
		set thisLine to prefix & connector & typeIcon & recName
		
		if showRecordType then
			if recType is not "group" and recType is not "smart group" then
				if recFilename is missing value or recFilename is "" then
					set thisLine to thisLine & " [" & recType & "]"
				end if
			end if
		end if
		
		set treeOutput to treeOutput & thisLine & linefeed
		
		if recType is "group" then
			set groupCount to groupCount + 1
			if (count of groupChildren) > 0 then
				set {childOutput, itemCount, groupCount} to my buildTree(groupChildren, childPrefix, false, depth + 1, itemCount, groupCount)
				set treeOutput to treeOutput & childOutput
			end if
		else if recType is "smart group" then
			set groupCount to groupCount + 1
			if showItemCount and smartCount > 0 then
				set treeOutput to treeOutput & childPrefix & "└── (" & smartCount & " matching items)" & linefeed
			end if
		end if
		
	end repeat
	
	return {treeOutput, itemCount, groupCount}
end buildTree

-- ============================================================
-- DATE FORMATTING HELPERS
-- ============================================================

-- Returns ISO 8601 formatted date string: "2026-02-27 18:22:15 PST"
on formatDate(theDate)
	set y to year of theDate as string
	set m to my zeroPad(month of theDate as integer)
	set d to my zeroPad(day of theDate)
	set h to my zeroPad(hours of theDate)
	set min to my zeroPad(minutes of theDate)
	set s to my zeroPad(seconds of theDate)
	return y & "-" & m & "-" & d & " " & h & ":" & min & ":" & s
end formatDate

-- Returns filename-safe date string: "2026-02-27_182215"
on filenameSafeDate(theDate)
	set y to year of theDate as string
	set m to my zeroPad(month of theDate as integer)
	set d to my zeroPad(day of theDate)
	set h to my zeroPad(hours of theDate)
	set min to my zeroPad(minutes of theDate)
	set s to my zeroPad(seconds of theDate)
	return y & "-" & m & "-" & d & "_" & h & min & s
end filenameSafeDate

-- Zero-pads a number to 2 digits
on zeroPad(n)
	set s to n as string
	if (count of s) < 2 then set s to "0" & s
	return s
end zeroPad

3 Likes

On macOS 26 Tahoe? The AppleScript performance on this macOS version seems to be a lot worse.

Yes. Tahoe 26.3

Nice. I converted it to JavaScript, just for the fun of it:

const excludeTrash = true;
const excludeTags = true;
const excludeSmartGroups = false;
const showRecordType = true;
const showItemCount = true;
const maxDepth = 50;

(() => {
  const app = Application("DEVONthink")
  app.includeStandardAdditions = true;
  const dbList = app.databases.name().sort();
  if (!dbList.length) {
    app.displayDialog("No databases currently open in DEVONthink.",
      {buttons: ["OK"], withIcon: "stop"});
    return;  
  }
  const chosenDB = app.chooseFromList(dbList, {
    withPrompt: "Select a database to generate a tree structure",
    withTitle: "DEVONthink Tree Generator",
    defaultItems: dbList[0]});
  if (!chosenDB) {
    return;
  }
  const dbName = chosenDB[0];
  const db = app.databases[dbName];

  const separator = "=".repeat(70);
  const dateStamp = new Date().toISOString();
  const treeOutput = [separator, 
    "DEVONthink Database Tree Structure",
    "Database " + dbName,
    "Generated " + dateStamp,
    separator + '\n',
    '📁  ' + dbName
  ];
  
  const [buildTreeOutput, itemTotal, groupTotal] = buildTree(db,db.root.children(),"", 0, 0, 0 );
  treeOutput.push(...buildTreeOutput);
  treeOutput.push(
    '\n' + separator,
    " Summary",
    " Total items: " + itemTotal,
    " Total groups: " + groupTotal,
    separator
  )
  const recordName = `Tree_${dbName}_${dateStamp.replace("T","_").replaceAll(/[Z:]/g,"").replace(/\.\d+$/,"")}`;
  
  const newRecord = app.createRecordWith({name: recordName, 
    "record type": "txt",
    "plain text": treeOutput.join('\n')}, {in: db.root});
  const dialogMsg = "Tree structure generated successfully!\n\n" +
              `📄 ${recordName}.txt\n`+
              `📊 ${itemTotal} items in ${groupTotal} groups\n\n` +
               `Saved to root of "${dbName}" database.`;
  const reply = app.displayDialog(dialogMsg, {buttons: ["Open record", "Dismiss"], 
    defaultButton: "Dismiss",
    withTitle: "Tree Generator complete"
  });
  if (reply.buttonReturned === "Open record") {
    app.openTabFor({record: newRecord, in: app.thinkWindows[0]});
  }
/*  console.log(treeOutput.join('\n'));
  console.log(`Saving to record name "${recordName}"`);
  */
})()

function buildTree(db, recList, prefix, depth, itemCount, groupCount) {
  if (depth > maxDepth) {
    return [[], itemCount, groupCount]
  }
  const filteredList = recList.filter(r => {
    const uuid = r.uuid();
    const exclude = ((db.trashGroup.uuid() === uuid && excludeTrash) ||
    (excludeTags && db.tagsGroup.uuid() === uuid)) ||
    (excludeSmartGroups && r.recordType() === "smart group");
    return !exclude;
  })
  let connector = "├── ";
  let childPrefix = prefix + "|  ";
  const lastItem = filteredList.length-1;
  const treeOutput = [];
  filteredList.forEach((r,i) => {
    const recType = r.recordType();
    itemCount++;
    if (i === lastItem) {
      let connector = "└── ";
      let childPrefix = prefix + "   ";      
    }
    let typeIcon = "📄 ";
    if (recType === "group") {
      typeIcon = "📁 ";
      groupCount++;
    } else if (recType === "smart group") {
      typeIcon = "🔍 ";
      groupCount++;
    } 
    let thisLine = prefix + connector + typeIcon + r.name();
    if (showRecordType 
      && recType !== "group" 
      && recType !== "smart group" 
      && !(r.filename && r.filename())) {
      thisLine += ` [${recType}]`;
    }
    treeOutput.push(thisLine);
    if (recType === "group" && r.children().length > 0) {
      [childOutput, itemCount, groupCount] = buildTree(db, r.children(), childPrefix, depth+1, itemCount, groupCount);
      treeOutput.push(...childOutput);
    } else if (recType === "smart group" && showItemCount && r.children().length) {
      treeOutput.push(`${childPrefix}└── (${r.children().length} matching items)`);
    }
  })
  return [treeOutput, itemCount, groupCount];
}

Not astonishingly, it is considerably shorter (also because I didn’t bother to comment it :wink:
Noticeable differences:

  • I use an Array (list in AppleScript) to select all the output lines in treeOutput and only add linefeeds between the array elements at the very end. Less typing, and fewer linefeeds obscuring the important parts
  • The database names are presented in alphabetical order in the list for the user to chose from. That’s a single call in JavaScript.
  • The functions to create an ISO date string and to create a filename-safe datestamp are missing: toISOString is a built-in, and removing the unwanted characters from the datestring is easily done with regular expressions.
  • I’m not happy with the negative flags (exclude…). They leed to double negations just to include something, and I’d have preferred to use include… flags instead.

As to speed: Took about 3 seconds for a database with 1730 documents and 86 groups, on Sequoia. The original version took about 2 seconds. These are just rough measurements, and the time difference is not relevant for me. But I’ll refrain from upgrading to Tahoe for the time being.

Some questions/remarks regarding the original.

if showRecordType then
			if recType is not "group" and recType is not "smart group" then
				if recFilename is missing value or recFilename is "" then

Why not and all these conditions together?

And when do you expect the filename to be missing or empty if the document is neither a group nor a smart group? I ran a search on my databases for documents with empty filenames and the only ones ever coming up where exactly (smart) groups. Or perhaps I’m misunderstanding the conditions?

isRoot in the parameter list of buildTree is defined but never used. It is, I think, pointless anyway isRoot is true if and only if depth is 0.

I’d group together the various tests for recType instead of repeating them. Also, I’d move the default definition of connector and childPrefix out of the loop. There’s no point in setting them on n-1 iterations of the loop to the same value. I did all of that in my script.

Finally, there’s the test for Trash and tags: Testing for the name is possibly locale-dependent. IMO, it would be more robust to compare the UUIDs of the trash and the tags group with the UUID of the current record. That’s what I did in my version