I would like to build on your work and offer a complete script that runs quickly for deeply nested tags. If most of the tags are top level tags in the “/Tags” group, the gains may be minimal, but for thousands of tags nested below a dozen top level tags, the progress indicator disappears before I can read it.
The approach is to get names and uuids of all tags/top level tags/nested tags in single Apple Events.
A tree structure of nodes representing the tag hierarchy is then built in JavaScript, and that structure is processed recursively, which is much more efficient than processing a hierarchy of DEVONthink records.
A very similar script is available, with a more detailed explanation, for the ordinary group hierarchy with enclosed documents in: A (much) faster version of Scripts > Export > Listing.
Here’s the JXA script:
// exportTagListing
// pmf: 2026-06-30
// ---------
// variables in the script:
// allTags: all tags in database (allTagsNames, allTagsUuids)
// topLevelTags: tags in "/Tags" (topLevelTagsNames, topLevelTagsUuids)
// nestedTags: tags with a tag parent (nestedTagsNames, nestedTagsUuids)
// ---------
(() => {
"use strict";
// traverse the nodeMap depth first
function traverseDFS(node, theTabs) {
let thisListing = "";
if (!node) return;
thisListing += theTabs + node.name + "\n"; // Process the current node
node.children.forEach(child => { // Recursively visit each child
thisListing += traverseDFS(child, theTabs + "\t");
});
return thisListing
}
// create a hierarchical listing of all tags
function createListing(dt, db) {
// 1. get the top level tags, the children of the "/Tags" group
const topLevelTags = db.tagsGroup.children;
// 2. get all tags in db
const allTags = db.parents.whose({_match: [ObjectSpecifier().tagType, "ordinary tag"]});
const allTagsNames = allTags.name(); // 1 AppleEvent
const allTagsUuids = allTags.uuid(); // 1 AppleEvent
// 3. get nested tags
// the kids of allTags may include nested tags as well as tagged documents,
// but we only want the nested tags:
const nestedTags = allTags.children.whose({_match: [ObjectSpecifier().tagType, "ordinary tag"]})
const nestedTagsNames = nestedTags.name(); // 1 AppleEvent
const nestedTagsUuids = nestedTags.uuid(); // 1 AppleEvent
// Show progress UI
dt.showProgressIndicator(`${allTags.length} tags: (${topLevelTags.length} top + ${nestedTags.length} nested)`, {
cancelButton: true,
steps: topLevelTags.length // the listing is developed for each topLevelTag
});
// 4. build the nodeMap
const nodeMap = new Map();
// one node per tag: set children to [] now;
// fill wth childNodes later
allTagsUuids.forEach((uuid, index) => {
nodeMap.set(uuid, {
uuid: uuid,
name: allTagsNames[index],
children: []
});
});
// 5. update nodemap
// set node.children for those tags that contain nested tags
nestedTagsNames.forEach((nTag,index) => { // nTag is an array of nested tag name(s)
const parentNode = nodeMap.get(allTagsUuids[index]); // the tag parent
if (nTag[0] ) { // nested tag(s) are present
for (let j=0; j<nTag.length; j++) { // process array of nested tag(s)
const childNode = nodeMap.get(nestedTagsUuids[index][j]);
if(childNode) {
parentNode.children.push(childNode);
} else {
throw new Error(`childNode: {${nTag[j]}, ${nestedTagsUuids[index][j]}} not found!`)
}
}
}
});
const topLevelTagsUuids = topLevelTags.uuid(); // 1 AppleEvent
const topLevelTagsNames = topLevelTags.name(); // 1 AppleEvent
let theListing = '';
topLevelTagsUuids.forEach((uuid, index) => {
// check for user cancelled, if not step the indicator
if (dt.cancelledProgress()) {
throw new Error("user cancelled");
} else {
dt.stepProgressIndicator(topLevelTagsNames[index]);
if (uuid) {
theListing += traverseDFS(nodeMap.get(uuid), '');
}
}
});
return(theListing);
}
//=================
// main processing
//=================
const dt = Application("DEVONthink");
dt.includeStandardAdditions = true;
ObjC.import('Foundation'); // Objective-C Foundation framework for file I/O
const db = dt.currentDatabase;
try {
// 1. Check if a database is open
if (!dt.currentDatabase.exists()) {
throw new Error("No database is open.");
}
// 2. Prompt user for the save file location
let theFile;
try {
theFile = dt.chooseFileName({ defaultName: db.name() });
} catch (e) {
// Handle user cancellation (corresponds to error -128)
throw -128;
}
// 3. Generate the listing
const theListing = createListing(dt, db);
// 4. Write the output to a text file
if (theListing !== null) {
let thePath = theFile.toString();
if (!thePath.endsWith(".txt")) {
thePath += ".txt";
}
// write UTF8-encoded text
const str = $.NSString.alloc.initWithUTF8String(theListing + '\n');
const error = Ref();
str.writeToFileAtomicallyEncodingError(thePath, true, $.NSUTF8StringEncoding, error);
}
dt.hideProgressIndicator();
} catch (error) {
dt.hideProgressIndicator();
// If the user cancelled, exit silently. Otherwise, show the alert.
if (error !== -128) {
const msg = error.message || error.toString();
dt.displayAlert("DEVONthink", { message: msg, as: "warning" });
}
}
})();