Most efficient way to get all nested tags (JXA)

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" });
		}
	}
})();