A Smart Rule Script to Move Items by Tags

For once I don’t have a question but a script I’d like to share. A Smart Rule script that distributes items by tag.

A Use Case for the Script

First I’ll explain how I am working with tags. I use, so to speak, two different kind of tags with two different purposes.

The first kind is the common one, the »topic tag«. These tags connect items of any kind by a topic, say “19th century novel”. A search for this tag shows every item tagged with it, wether they are in the same group, database, or maybe even outside of DEVONthink. These tags are (meant to be) permanent.

The second kind is the “destination tag”. I tag items outside from DEVONthink with these tags to tell DT where they belong as soon as they get into it. When the file is coming from macOS or iOS/iPadOS they can be tagged directly if the app they are created in supports the OS tags (I try to avoid apps that don’t).

If you use DT’s Convert Hashtags to Tags feature, either generally or in a Smart Rule, you could use tags even with files from non-Apple systems, at least when they contain text. So tags cover items with quite a large number of origins and kinds. Destination tags are non-permanent, they get deleted right after the item has reached its destination.

I use a central collecting place for items from outside of DEVONthink, which naturally is the Global Inbox as it can be accessed from the outside.

A Smart Rule watches the Global Inbox and uses the script to distribute the incoming items to my databases.

The Script

-- Distribute by Tag

on performSmartRule(theRecords)
	tell application id "DNtp"
		
		set theDestinationTags to {"A Group", "A Sub-Group", "Naughty"}
		set theDestinationPaths to {"/A Group", "/A Group/A Sub-Group", "/Notebooks/My Secret Diary"}
		set theDestinationDatabases to {"Database 1", "Database 1", "Highly Encrypted Database"}
		set theDefaultDestination to root of database "Database 1" -- or inbox, if you prefer
		
		repeat with theRecord in theRecords
			
			set theTags to the tags of theRecord
			set duplicated to false
			
			repeat with theTag in theTags
				
				set theDestinationPosition to my getPositionOfItemInList((theTag as string), theDestinationTags)
				
				if (theDestinationPosition is not 0) then
					
					set theDestinationPath to item theDestinationPosition of theDestinationPaths
					set theDestinationDatabase to item theDestinationPosition of theDestinationDatabases
					set theDestination to create location theDestinationPath in database theDestinationDatabase
					set theTags to my removeItemfromList((theTag as string), theTags)
					
					set theDuplicatesTags to theTags
					
					repeat with theDestinationTag in theDestinationTags
						set theDuplicatesTags to my removeItemfromList((theDestinationTag as string), theDuplicatesTags)
					end repeat
					
					repeat with theDestinationTag in theDestinationDatabases
						set theDuplicatesTags to my removeItemfromList((theDestinationTag as string), theDuplicatesTags)
					end repeat
					
					set the tags of theRecord to theDuplicatesTags
					duplicate record theRecord to theDestination
					set duplicated to true
					set the tags of theRecord to theTags
					
				else
					
					if theDestinationDatabases contains theTag then
						
						set theDestinationDatabase to theTag
						set theTags to my removeItemfromList((theTag as string), theTags)
						
						set theDuplicatesTags to theTags
						
						repeat with theDestinationTag in theDestinationTags
							set theDuplicatesTags to my removeItemfromList((theDestinationTag as string), theDuplicatesTags)
						end repeat
						
						repeat with theDestinationTag in theDestinationDatabases
							set theDuplicatesTags to my removeItemfromList((theDestinationTag as string), theDuplicatesTags)
						end repeat
						
						set the tags of theRecord to theDuplicatesTags
						duplicate record theRecord to root of database theDestinationDatabase -- or inbox, if you prefer
						set duplicated to true
						set the tags of theRecord to theTags
						
					end if
					
				end if
				
			end repeat
			
			if duplicated then
				delete record theRecord
			else
				move record theRecord to theDefaultDestination -- optional
			end if
			
		end repeat
	end tell
end performSmartRule


on getPositionOfItemInList(theItem, theList)
	repeat with n from 1 to count of theList
		if item n of theList is theItem then return n
	end repeat
	return 0
end getPositionOfItemInList


on removeItemfromList(theItem, theList)
	set theNewList to {}
	repeat with n from 1 to count of theList
		set theNewItem to item n of theList
		if theNewItem is not theItem then set theNewList to theNewList & theNewItem
	end repeat
	return theNewList
end removeItemfromList

What Does the Script Do and How to Customize It?

The Basics

There is a list, theDestinationTags, which contains every destination tag.

There is a corresponding list, theDestinationPaths, which contains every POSIX path of the destination.

There is a second corresponding list, theDestinationDatabases, which contains every destination database.

Meaning: If an item is tagged with the first item of theDestinationTags, it will be moved to the first item in theDestinationPaths in the first item of theDestinationDatabases. And so forth.

TheDefaultDestination is the destination for every item that has no destination tag.

Just expand and customize these lists to your needs.

The Actions

When an item has arrived the script checks it for destination tags and moves it to the respective destinations. Yes, plural. The script will send an item to more than one destination if it has more than one destination tag.

It does also look for tags named after destination databases. If there are any the script will send the item to the root folder of those databases. If you prefer the inbox you can change that of course.

In the last step all destination and database tags get removed. All “normal” a.k.a. topic tags of course are kept untouched.

If an item has no destination tag it will be moved to theDefaultDestination. This is of course optional. If you prefer to keep items without destination tags in their incoming group to later move them manually, why not?

But if so when you are using an interval trigger for the Smart Rule you should consider marking these items for exclusion from the rule, maybe by flagging them, after the script has been executed on them once. If not it will be iterating through their tags every time the Smart Rule kicks in. That would cause unnecessary activity.

Why This Script Or: Can’t Smart Rules With Simple Move Actions Provide This Too?

I have no doubt they can. But using a number of sequenced move action Smart Rules instead of one Smart Rule with this script can get fiddly at least in two ways:

The Proper Sequencing of the Move Action Smart Rules

When you use a fallback Smart Rule, i. e. the Smart Rule that moves an item to the default destination if none of the other move action Smart Rules have detected their conditional destination tags, the fallback Smart Rule must by all means be executed at last. Depending on the number of sequenced move action Smart Rules and on the kind of their triggers the intended execution order of Smart Rules might get broken.

Unless … the fallback Smart Rule comes with a safety net by checking the items for all the destination tags first: If the item is not tagged with destination tag 1, is not tagged with destination tag 2, &c. … only then move the item to the default destination.

Can be done but leads us straight to:

Maintenance

When you allow only one destination tag at a time it’s simple: Tag X is detected, item gets moved to the respective destination, Tag X gets removed.

When you allow multiple destination tags—like this script does—every single move action Smart Rule would not only have to remove its own destination tag but all of them. Plus the database tags.

Now imagine what you have to do when you want to expand your list of destinations:

  1. You have to set up a new move action Smart Rule
  2. You have to take care of the proper sequencing of all the move action Smart Rules.
  3. You have to add the new destination tag to every other move Action Smart Rule for removal.
  4. You have to add the new destination tag to the fallback move action Smart Rule

Whereas in the script you just have to add the new destination tag, its destination path and its destination database once to their respective lists and that’s it.

In Short

When you only have a small number of destinations move action Smart Rules probably will suffice. When it comes to a future proof heavy lifting in my opinion this script provides a more comfortable alternative. Which is why I wrote it in the first place.

5 Likes

this is really great - thank you.

I find this very interesting but have a question - sounds like the tags are created outside of DT, how are the tags generated in the first place? (Sorry if this is a dumb question, I am not a tag user.). I was actually thinking about whether there would be a way to first generate tags for certain recurring types of files, then use a script to put them into their homes.

In general the tags DEVONthink uses are the tags of macOS and iOS/iPadOS. So every app that works with these system tags can exchange files with DEVONthink with the tags intact. Of course the Finder shows them too.

If not one can write hashtags into a text and use DEVONthink’s Convert Hashtags to Tags feature. This can be a general import setting or used in Smart Rules for specific files. I’d recommend the latter because the general setting can lead to many unwanted tags.

While Convert Hashtags to Tags just adds for each hashtag a system tag of the same name my script additionally removes the hashtags.

I wonder whether it is possible to keep the corresponding tag after moving items.

I suppose you could remove the lines set tag ... from the script. Though I’m not sure if that negatively influences the process, too.

Here is my code, I removed some lines, now it works. I only use this to look at Inbox files.

-- Distribute by Tag

on performSmartRule(theRecords)
	tell application id "DNtp"
		
		set theDestinationTags to {"xx"}
		set theDestinationPaths to {"xx"}
		set theDestinationDatabases to {"xx"}
		set theDefaultDestination to root of database "Inbox" -- or inbox, if you prefer
		
		repeat with theRecord in theRecords
			
			set theTags to the tags of theRecord
			set duplicated to false
			
			repeat with theTag in theTags
				
				set theDestinationPosition to my getPositionOfItemInList((theTag as string), theDestinationTags)
				
				if (theDestinationPosition is not 0) then
					
					set theDestinationPath to item theDestinationPosition of theDestinationPaths
					set theDestinationDatabase to item theDestinationPosition of theDestinationDatabases
					set theDestination to create location theDestinationPath in database theDestinationDatabase
					
					duplicate record theRecord to theDestination
					set duplicated to true
					set the tags of theRecord to theTags
				end if
				
			end repeat
			
			if duplicated then
				delete record theRecord
			else
				move record theRecord to theDefaultDestination -- optional
			end if
			
		end repeat
	end tell
end performSmartRule


on getPositionOfItemInList(theItem, theList)
	repeat with n from 1 to count of theList
		if item n of theList is theItem then return n
	end repeat
	return 0
end getPositionOfItemInList


on removeItemfromList(theItem, theList)
	set theNewList to {}
	repeat with n from 1 to count of theList
		set theNewItem to item n of theList
		if theNewItem is not theItem then set theNewList to theNewList & theNewItem
	end repeat
	return theNewList
end removeItemfromList

Sorry for my late reply. But it seems you have made it work already for your purpose. If have not checked your code but on the first glance it seems you removed every calling of the removeItemfromList function.

When I wrote the little script I had considered to add a boolean Remove Tags variable. But then I had written it primarily for myself and I saw no benefit in keeping the tags.

I might update it adding that option and more—my untidy in use version uses tags to attach labels too for example.