Hazel question for the experts

I would like to seek some help on a Hazel question. I know this might be better posted on the Hazel forums, which of course I can do, but since I frequent here and not there, I decided to through this out here first.

Background:

I have a generic inbox folder (creatively named _Inbox; the underscore for sort ordering) into which downloads, scans, and pretty much anything that needs later filing goes.

I have a rather large set of Hazel rules that detect many of the files that wind up their, use their contents to figure out what they are, rename and possible tag the files, and then ultimately move them to a target destination. For example, the contents of a downloaded electric bill allows Hazel to detect it, rename the file to the format <billing_date>_electric_bill.pdf and then move it to the Bills/Electric folder.

The moving part is what I am trying to modify for several reasons. First, what I like to do is have the file renamed and tagged, but then sit in the _Inbox for some time period before being moved in case I need to work with it shortly after the download, scan, etc. Therefore, since there is no Hazel action (that I am aware of) where you can place an intentional delay in the action rules processing, this requires a separate rule for each type of file, one that does the rename initially and a second one that has an added condition of “file added not in the last XX minutes” which then does the move.

Secondly, it does arise from time to time that I want to relocate the target folders of one or more type of file, and that means a lot of rule editing. This is even worse at times when I migrate to a new Mac, which I do more often than is reasonable.

So: my thought was that it would be ideal for me to have one rule that does all of the file moving. This rule would take the file name patterns (eg it would have the ability to recognize _electric_bill.pdf and _gas_bill.pdf) and would have a table that matched patterns to the target folder and then do the move to the target folder.

On the surface, the Hazel pattern matching capability that allows you to create a table would seem to be perfect for this purpose, with a pattern column providing the match pattern and a destination folder providing the place to move the file. I could easily maintain this data in an external file that could be easily editing with batch replacements and so forth.

My problem: as far as I have been able to determine, the table pattern matching in Hazel does not allow you to actually insert a pattern into the cells of the table, only fixed text, and therefore there is no way to do pattern matching of any sort - and since these files will pretty much always have the date in the file name which is obviously not going to be a fixed string, it does not look like I can accomplish this with the built in Hazel tables.

I was wondering if anyone knew something that I have not been able to find that could help here?

If not, it is not all that difficult for me to create a script that can do the same, but it’s a bit of a pain. To return the destination folder to Hazel (I would prefer Hazel do the file move rather than doing so in the script, although perhaps not for any good reason) the script needs to be in AppleScript as the only way for scripts to pass data back into Hazel tokens. However, doing regex or pattern matching in AppleScript is not what I would call convenient, so I will need to embed a script in another language (probably Python) to do the lookup in the file. Not the most difficult thing in the world, but the kludge level grows, and I would hate to miss a solution internal to Hazel that I am not aware of.

(BTW: I know that the AppleScript could use a shell command to grep the file that associates patterns to target folders, but that is backwards of how grep works, because the lines in the file contain the regex patterns which I am matching against the string of the filename, which is backwards. I am sure this could be down with something like awk, but it willl take 10 minutes to write the Python script, so I might was well.)

Instead of moving the file to the destination, copy it to the destination. Then add one new rule that trashes files older than some number of days.

If you want to check for files that match no rules, add a rule at the end that just tags the file. This rule won’t be executed if any earlier rule matches. Then have the trashing rule only work for files without the tag.

1 Like

What about, after renaming and tagging you moved it into a subfolder. Then you could have rules on the subfolder that included the trigger DATE ADDED is not in the LAST xxxxx.

Or something like that

1 Like

I don’t use Hazel for anything g this complex, but it sounds like you want a something like configuration file.

Can a Hazel rule read from a file? If so, you could save the locations/paths for specific rules in a plain text file (even as simple as a single line of text), and have Hazel read that file to retrieve the destination path.

Alternatively, is Hazel scriptable? If so, you could periodically run a script to change the destinations in each Hazel action. If you have a tool like Keyboard Maestro, you could set this script to run each day before you wake up, and/or at login; you could also remember to run it when you change the destination for a particular kind of file.

None of these is simple, but then again, you’re trying to do something pretty complicated. How often do the file locations change?

I only answered part one of your question. For part two, relocating target folders, why not use aliases? The rules point to the alias, and the alias points to the desired folder. No rule editing at all. You just change the aliases in Finder.

2 Likes

Thanks for all the input.

@tomalmy Using an alias for the target is not a bad idea at all. I will look into that. Part of the issue is that different files go into different folders, so one option is a tree of aliases. Another is perhaps the sort into subfolder action. Have to play with this a bit.

I am reluctant to do a copy to destination because I don’t want the duplicate files, even for a short time interval. That also isn’t solving my biggest “want” which is to have the file locations derive from a table rather that being hard coded into each rule. Effectively I am trying to separate the moving logic from the identifying and renaming logic.

Generating a utility to parse a text file which basically has a table of regexes and target locations and match the file name with the proper regex and return the location is simple enough. Took about 15 lines of Swift code. It has to be wrapped in AppleScript to return a value to Hazel, as far as I my understanding extends. I might just go with that for a bit.

Resurrecting with an update…

I decided I wanted to at least experiment with my original concept, which is having table to dispatch file movement for my Inbox folder. My concept is to have a match string and for filenames that match, a folder for the file to be moved to.

Unfortunately, Hazel tables in match rules do not appear to allow for wild cards or regexes (at least not yet), so I could not do what I wanted to do within Hazel’s prebuilt features.

My idea is that the table basically has, on each line, one or more regexes to match a filename (comma separated), a space/tab (or multiple spaces/tabs for alignment), and then the folder to move matching files to.

For example, a line like:
\d{4}-\d{4}_gas_bill.pdf ~/Documents/Bills/GasBills

would allow a file with a name that looked like a date (YYYY-MMDD) and _gas_bill.pdf to be moved to the specified folder under the user’s home folder.

(Obviously the regex could be more sophisticated by making sure the YYYY-MMDD was actually valid for a date; this is just a “for instance” and I don’t really need to make it that complex for my own uses.)

My Hazel rules for my Inbox automatically detect a pdf that has the proper contents for my gas bill and formats the filename into the correct format. Using a continue processing rules action I can then have the file moving rule handle the movement.

The point of this is that I am experimenting with the idea of consolidating the file movement rules into a single text file rather than having them in each individual Hazel rule, which has some benefits, for example if I want to relocate where certain folders are located in the filesystem. This may or may not be worthwhile in the long run, but I wanted to work through the process / challenge anyway.

What I came up with is a rule and action that look like this:

The rule is a “Passes Javascript”, expecting an output variable called “MoveToFolder” that has the path to the folder to move the file to. It looks like this (note that the file that has the regexes and targets is “test.txt” on the Desktop, but of course that has to be changed to where the table file is actually going to be kept when I build it):

var app = Application.currentApplication();
app.includeStandardAdditions = true;

const controlFile = "~l/Desktop/test.txt";

var f = Path(theFile).toString();
var components = f.split('/');
var testFilename = components[ components.length - 1 ];

// If we fall out of the try block because of an exception or because there is no 
// match in the inner loop, we will fall through to return a failure to Hazel
try {
	var lines = app.read( Path( controlFile ), { usingDelimiter: '\n' } );
	for ( var line of lines ) {
		if ( line[0] == "#" ) continue;
		let splitResult = line.split('\t');
		let testRegexes = splitResult[0];
		let moveToPath = splitResult[splitResult.length-1];
		for ( var testRegex of testRegexes.split(',') ) {
			if ( testFilename.match( "^" + testRegex + "$" ) ) {
				return {
						hazelPassesScript: true, 
						hazelOutputAttributes: [ $(moveToPath).stringByStandardizingPath.js ]
					}
			}
		}
			
	}
}
catch {	
}
	
return { hazelPassesScript: false }

Most of the difficulty in creating this had to do with using the alias that Hazel passes in to the javascript to be able to read the file, and how to convert folder paths that start with ‘~’ into the full path name. Lots of Google-fu and experimentation to get that to work.

For files that match, the following “Run Javascript” action accepts uses the MoveToFolder parameter and the file name to do the move, as the Hazel move file action requires the actual folder to be selected and cannot use a token as the target.

var app = Application.currentApplication()
app.includeStandardAdditions = true

var finder = Application("Finder")

finder.move( theFile, { to: Path(inputAttributes[0]) } )

Again the complexity was in figuring out what has to be passed to the finder.move method, as it’s always confusing with Apple’s automation scripting (whether AppleScript or JXA) when to use aliases, MacOS version paths (eg colon separated elements in a string), Path objects in JXA, POSIX style paths, etc.

Anyway, not yet sure where this is going to go, and maybe not worth the time spent, but I might build the control file for at least some of my files and see how it works out. Probably I will add some sort of logging to the action so that I can track what is happening easily which I think would be highly desirable in case the files wind up on the wrong places!

Even though I am apparently the only one on this thread…still…

Here is the final version. I added keeping a log of what the script does in a text file, so that I can see if files are coming up without matches, and also where matched files are being redirected to. That way, if there is a problem I can at least track down misfiled files using the log. I also adding in a very simply variable substitution, so that I can specify, for example, HOME=/Users/nl and DATADIR={HOME}/Data, for example (note that the variable substitution is recursive, and isn’t actually done until the file match is determined so that the variable definitions can actually be in any order), and then a file can be routed to {DATADIR}/gas_bills. That way I don’t have to make sure to type long paths correctly over an over.

Minimal error checking; basically if anything fails, it just tells Hazel to fail the rules.

var app = Application.currentApplication();
app.includeStandardAdditions = true;

const controlFile = "path to control file";
var logFile = "path to log file";

var variables = {}


function setVariable ( input )
{
	var i = input.indexOf( "=" );
	var variableName = input.slice( 0, i );
	var variableValue = input.slice( i+1 );
	variables[variableName] = variableValue;
}


function resolveVariables ( input )
{
	const matchRegex = /{.+?}/g
	var output = input;
	
	while ( output.search( matchRegex ) != -1 )
	{
		output = output.replace( matchRegex, 
			(match) => variables[ match.slice(1, match.length-1) ]
			)
	}
	
	return output;
}


function writeLog( logString )
{
	var output = app.currentDate().toString() + "> " + logString + "\n" ;
	
	var outputFile = app.openForAccess( Path(logFile), { writePermission: true } );
	app.write( output, { to: outputFile, startingAt: app.getEof( outputFile) } );
	app.closeAccess( outputFile );
}


function hazelMatchFile( theFile, inputAttributes )
{

	var f = Path(theFile).toString();
	var components = f.split('/');
	var testFilename = components[ components.length - 1 ];

	// If we fall out of the try block because of an exception or because there is no 
	// match in the inner loop, we will fall through to return a failure to Hazel
	
	try {
	
		var lines = app.read( Path( controlFile ), { usingDelimiter: '\n' } );
		
		for ( var line of lines ) {
		
			if ( line[0] == "#" || line.length == 0 ) continue;
			
			if ( line.indexOf("=") != -1 ) {
				setVariable( line );
				continue;
			}
		
			let splitResult = line.split('\t');			
			let testRegexes = splitResult[0];
			let moveToPath = splitResult[splitResult.length-1];
		
			for ( var testRegex of testRegexes.split(',') ) {
				if ( testFilename.match( "^" + testRegex + "$" ) ) {
					moveToPath = resolveVariables( moveToPath) ;
					moveToPath = $(moveToPath).stringByStandardizingPath.js;
					writeLog( "File " + f + " results in move to " + moveToPath );
					return {
						hazelPassesScript: true, 
						hazelOutputAttributes: [ moveToPath ]
						}
				}
			}
			
		}
	}
	catch {	
	}
	
	writeLog( "Failure during processing for file: " + f );
	return { hazelPassesScript: false }

}
2 Likes