Get Folders and Files Recursively with ASObjC

Just to make it clear: I don’t intend to wage a language war. No point in that. But I’ll mention alternative scripting solutions if see them. Like in this case. I know that people feel more at ease in one language than in another – I started programming in Fortran, did some Basic, Pascal, C, Perl, the occasional bout in PHP, JavaScript, SQL – nearly every language has something going for it. AppleScript though was never properly defined, and since Apple didn’t add functionality, it’s now sorely lacking: string methods, array methods, regular expressions, to name but a few. I’m well aware that AS aficionados find ways around these shortcomings. But to me, they still look like band-aid, that wouldn’t be necessary with a modern language like Python or JavaScript.

Yes sure there are more powerful languages than AppleScript. But we discuss all this in the context of Mac automation.

What good Python is for if application functionality isn’t exposed to Python?

As to JavaScript: definitely powerful - but not modern. In my opinion, it cannot be considered truly modern until it incorporates named parameters in some form, similar to other modern languages.

Afaik, there are some projects taking care of that.

You can use objects for that, if you want.

Given my background, I never miss named parameters, though.

I did a bit more research on packages and found some Apple documentation here. Apple defines and discusses packages as:

A package is any directory that the Finder presents to the user as if it were a single file… Packages provide one of the fundamental abstractions that makes macOS easy to use. If you look at an application or plug-in on your computer, what you are actually looking at is a directory. Inside the package directory are the code and resource files needed to make the application or plug-in run. When you interact with the package directory, however, the Finder treats it like a single file. This behavior prevents casual users from making changes that might adversely affect the contents of the package. For example, it prevents users from rearranging or deleting resources or code modules that might prevent an application from running correctly.

So, just in general, it seems best that a script not descend into packages and that a script return a package as a file. Both of my scripts in post 1 work in this fashion. However, if a script only needs to work on regular files and not packages or folders, my script in post 17 might be considered for its relative brevity, although it is marginally slower.

BTW, it’s easy to view the packages on a computer by running the script in post 17 but first replacing NSURLIsRegularFileKey with NSURLIsPackageKey. In my home folder, the only packages were:

  • A lot of AppleScript applications.
  • A few library packages (e.g. “Music Library.musiclibrary”).
  • A few RTFD packages containing documents for the Stickies app.

This script returns every regular file that was modified during the current day in the user’s locale. The search is recursive, and the script returns a list of file references or POSIX paths. The timing result with a test folder containing 85 folders and 393 files was 30 milliseconds.

use framework "Foundation"
use scripting additions

set theFolder to POSIX path of (choose folder)
set todaysFiles to getTodaysFiles(theFolder)

on getTodaysFiles(theFolder) -- theFolder required a POSIX path
	set dateFormatter to current application's NSDateFormatter's new()
	dateFormatter's setDateStyle:(current application's NSDateFormatterShortStyle)
	set theDate to (dateFormatter's stringFromDate:(current application's NSDate's now()))
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set fileManager to current application's NSFileManager's defaultManager()
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects()
	set regularFileKey to current application's NSURLIsRegularFileKey
	set dateKey to current application's NSURLContentModificationDateKey
	set todaysFiles to current application's NSMutableArray's new()
	repeat with anItem in folderContents
		set {theResult, theModificationDate} to (anItem's getResourceValue:(reference) forKey:dateKey |error|:(missing value))
		set modificationDate to (dateFormatter's stringFromDate:theModificationDate)
		if (theDate's isEqualToString:modificationDate) is true then
			set {theResult, aRegularFile} to (anItem's getResourceValue:(reference) forKey:regularFileKey |error|:(missing value))
			if aRegularFile as boolean is true then (todaysFiles's addObject:anItem)
		end if
	end repeat
	return todaysFiles as list -- a list of file references
	-- return (todaysFiles's valueForKey:"path") as list -- a list of POSIX paths
end getTodaysFiles

Or in a one-liner for terminal

find . -mtime -24h | sed -e "s|\.|file \"$PWD|" -e 's/$/",/' -e '1s/^/{ /' -e '$s/,$/ }/'

I tried that with a local tree where the script and this gave the same result with two differences:

  • The script returns HFS paths
  • and those begin with the root volume’s name

The shell command returns POSIX paths with / as root directory, which is normal for POSIX paths.

The script returns an AppleScript list as a string, in the form of { file "...", file "...}. That is not directly usable in AppleScript, I think. Therefore, simply using find . -mtime -24h might be more appropriate: That will return one line for every matching file.

I’m not saying that this is better than the ASObjC variant. I’m simply a stickler for short code.

chrillek. Thanks for looking at my script. A few comments:

  • As written my script returns a list of file objects, which can be used directly with most macOS apps. These file objects are easily coerced to an HFS path with the as-text operator.

  • My script will return a list of POSIX paths by enabling the last line of the handler. Modifying my script to return a string of POSIX paths with one line for every matching file is a trivial matter.

  • My script returns files modified during the current day in the user’s locale, which was the stated purpose of my script. Your script returns files modified within the prior 24 hours.

  • Your script returns hidden files which in most circumstances seems a bad idea.

I didn’t know about the file object. When I run the script in Script Editor, the result seemed to be a list of … well, apparently files. If one needs files or paths probably depends on the situation, though.

The script indeed does return hidden files. Why that is “a bad idea”, I don’t know. If one is interested in files modified within a certain time span, it depends on the application if hidden files are relevant or not. In any case, they can be weeded out by adding stuff to the find call.

The following is a rewrite and repurposing of the script in post 2. It returns every regular file that was modified during the specified number of 24-hour periods prior to the current date and time. The search is recursive, and the script returns a list of file references but is easily edited to return POSIX paths. A few comments:

  • To search by hours rather than days change -86400 to -3600.
  • Change 1 to -1 to return older rather than newer files.
  • Change NSURLContentModificationDateKey to NSURLAddedToDirectoryDateKey to filter by the date files were added to their containing folder rather than by their modification date.
  • The timing result with a test folder containing 395 files in 85 folders was 21 milliseconds.
use framework "Foundation"
use scripting additions

set theFolder to POSIX path of (choose folder)
set theDays to 3
set theFiles to getFiles(theFolder, theDays)

on getFiles(theFolder, theDays) -- theFolder requires a POSIX path
	set fileManager to current application's NSFileManager's defaultManager()
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set dateKey to current application's NSURLContentModificationDateKey
	set regularFileKey to (current application's NSURLIsRegularFileKey) -- does not include packages
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects() -- excludes hidden files and package contents
	set filterDate to current application's NSDate's dateWithTimeIntervalSinceNow:(-86400 * theDays)
	set theFiles to current application's NSMutableArray's new()
	repeat with anItem in folderContents
		set {theResult, aDate} to (anItem's getResourceValue:(reference) forKey:dateKey |error|:(missing value))
		if (aDate's compare:filterDate) = 1 then -- use -1 to return older files
			set {theResult, aRegularFile} to (anItem's getResourceValue:(reference) forKey:regularFileKey |error|:(missing value))
			if aRegularFile as boolean is true then (theFiles's addObject:anItem)
		end if
	end repeat
	return theFiles as list -- a list of file objects
	-- return (theFiles's valueForKey:"path") as list -- a list of POSIX paths
end getFiles
2 Likes

Was spending time here just looking for things that caught my interest. Your script here in post 35 was one of those. Works perfectly! Any chance this could be modified to produce either a text or pdf document on the users desktop?

Homer712. I edited my script in post 35 to save the matching files to a text file on the desktop. Saving them as a PDF is not easily done. I also edited the script to sort the files by their containing path and then by their file name.

As written, the text file simply contains the matching files including their paths. If the number of files is large, the script should probably be edited to save the files in whatever format is desired.

use framework "Foundation"
use scripting additions

set theFolder to POSIX path of (choose folder)
set theDays to 3
set theFiles to getFiles(theFolder, theDays)
writeFile(theFiles)

on getFiles(theFolder, theDays) -- theFolder requires a POSIX path
	set fileManager to current application's NSFileManager's defaultManager()
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set dateKey to current application's NSURLContentModificationDateKey
	set regularFileKey to (current application's NSURLIsRegularFileKey) -- does not include packages
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects() -- excludes hidden files and package contents
	set filterDate to current application's NSDate's dateWithTimeIntervalSinceNow:(-86400 * theDays)
	set theFiles to current application's NSMutableArray's new()
	repeat with anItem in folderContents
		set {theResult, aDate} to (anItem's getResourceValue:(reference) forKey:dateKey |error|:(missing value))
		if (aDate's compare:filterDate) = 1 then -- use -1 to return older files
			set {theResult, aRegularFile} to (anItem's getResourceValue:(reference) forKey:regularFileKey |error|:(missing value))
			if aRegularFile as boolean is true then (theFiles's addObject:anItem)
		end if
	end repeat
	set descriptorOne to current application's NSSortDescriptor's sortDescriptorWithKey:"stringByDeletingLastPathComponent" ascending:true selector:"localizedStandardCompare:" -- sort by path of containing folder
	set descriptorTwo to current application's NSSortDescriptor's sortDescriptorWithKey:"lastPathComponent" ascending:true selector:"localizedStandardCompare:" -- sort by file name
	return ((theFiles's valueForKey:"path")'s sortedArrayUsingDescriptors:{descriptorOne, descriptorTwo})
end getFiles

on writeFile(theFiles)
	set theString to theFiles's componentsJoinedByString:linefeed
	set theFolder to (current application's NSHomeDirectory()'s stringByAppendingPathComponent:"Desktop")
	set theFile to theFolder's stringByAppendingPathComponent:"New Files.txt"
	theString's writeToFile:theFile atomically:true encoding:(current application's NSUTF8StringEncoding) |error|:(missing value)
end writeFile

Works very well, and, as I’m only using it for information, the text file is all that I need. Thank you.

Hi @peavine.

Wouldn’t it be simpler just to sort the files’ full paths?

Thanks Nigel for looking at my script.

I think the results are different if you sort by path only. I edited my script to replace the last 3 lines of the getFiles handler with:

return ((theFiles's valueForKey:"path")'s sortedArrayUsingSelector:"localizedStandardCompare:")

The edited script returned the following with a test folder:

/Volumes/Store/Test/BB/AA.txt
/Volumes/Store/Test/BB/BB.txt
/Volumes/Store/Test/BB/CC.txt
/Volumes/Store/Test/BB/DD.txt
/Volumes/Store/Test/BB/DD/AA.txt
/Volumes/Store/Test/BB/DD/BB.txt
/Volumes/Store/Test/BB/DD/CC.txt
/Volumes/Store/Test/BB/DD/DD.txt
/Volumes/Store/Test/BB/DD/EE.txt
/Volumes/Store/Test/BB/DD/EE/AA.txt
/Volumes/Store/Test/BB/DD/EE/BB.txt
/Volumes/Store/Test/BB/DD/EE/CC.txt
/Volumes/Store/Test/BB/DD/EE/DD.txt
/Volumes/Store/Test/BB/DD/EE/EE.txt
/Volumes/Store/Test/BB/DD/EE/FF.txt
/Volumes/Store/Test/BB/DD/FF.txt
/Volumes/Store/Test/BB/EE.txt
/Volumes/Store/Test/BB/FF.txt

My existing script returned:

/Volumes/Store/Test/BB/AA.txt
/Volumes/Store/Test/BB/BB.txt
/Volumes/Store/Test/BB/CC.txt
/Volumes/Store/Test/BB/DD.txt
/Volumes/Store/Test/BB/EE.txt
/Volumes/Store/Test/BB/FF.txt
/Volumes/Store/Test/BB/DD/AA.txt
/Volumes/Store/Test/BB/DD/BB.txt
/Volumes/Store/Test/BB/DD/CC.txt
/Volumes/Store/Test/BB/DD/DD.txt
/Volumes/Store/Test/BB/DD/EE.txt
/Volumes/Store/Test/BB/DD/FF.txt
/Volumes/Store/Test/BB/DD/EE/AA.txt
/Volumes/Store/Test/BB/DD/EE/BB.txt
/Volumes/Store/Test/BB/DD/EE/CC.txt
/Volumes/Store/Test/BB/DD/EE/DD.txt
/Volumes/Store/Test/BB/DD/EE/EE.txt
/Volumes/Store/Test/BB/DD/EE/FF.txt

Am I missing something?

Ah. I see. I think both approaches are legitimate depending on what’s needed, but yours produces results that are easier to to read. :grin:

FWIW, I added a formatFiles handler to my script in post 35. The text file now shows the path to the parent folder followed by the names of matching files in that folder. The timing result when run on my smallish home folder with a setting of 3 days was 60 milliseconds.

-- revised 2023.11.18

use framework "Foundation"
use scripting additions

set theDays to 3
set theFolder to POSIX path of (choose folder)
set theFiles to getFiles(theFolder, theDays)
set theFiles to formatFiles(theFiles) -- disable if desired
writeFile(theFiles)

on getFiles(theFolder, theDays)
	set fileManager to current application's NSFileManager's defaultManager()
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set dateKey to current application's NSURLContentModificationDateKey
	set regularFileKey to (current application's NSURLIsRegularFileKey) -- does not include packages
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects() -- excludes hidden files and package contents
	set filterDate to current application's NSDate's dateWithTimeIntervalSinceNow:(-86400 * theDays)
	set theFiles to current application's NSMutableArray's new()
	repeat with anItem in folderContents
		set {theResult, aDate} to (anItem's getResourceValue:(reference) forKey:dateKey |error|:(missing value))
		if (aDate's compare:filterDate) is 1 then -- use -1 to return older files
			set {theResult, aRegularFile} to (anItem's getResourceValue:(reference) forKey:regularFileKey |error|:(missing value))
			if aRegularFile as boolean is true then (theFiles's addObject:anItem)
		end if
	end repeat
	if theFiles's |count|() = 0 then display dialog "No matching files" buttons {"OK"} cancel button 1 default button 1
	set pathDescriptor to current application's NSSortDescriptor's sortDescriptorWithKey:"stringByDeletingLastPathComponent" ascending:true selector:"localizedStandardCompare:" -- sort by path of containing folder
	set nameDescriptor to current application's NSSortDescriptor's sortDescriptorWithKey:"lastPathComponent" ascending:true selector:"localizedStandardCompare:" -- sort by file name
	return ((theFiles's valueForKey:"path")'s sortedArrayUsingDescriptors:{pathDescriptor, nameDescriptor})
end getFiles

on formatFiles(theFiles)
	set filePaths to theFiles's valueForKey:"stringByDeletingLastPathComponent"
	set fileNames to theFiles's valueForKey:"lastPathComponent"
	set formattedFiles to current application's NSMutableArray's arrayWithArray:{filePaths's objectAtIndex:0, fileNames's objectAtIndex:0}
	repeat with i from 1 to ((theFiles's |count|()) - 1)
		if ((filePaths's objectAtIndex:i)'s isEqualToString:(filePaths's objectAtIndex:(i - 1))) is false then
			(formattedFiles's addObjectsFromArray:{"", filePaths's objectAtIndex:i, fileNames's objectAtIndex:i})
		else
			(formattedFiles's addObject:(fileNames's objectAtIndex:i))
		end if
	end repeat
	return formattedFiles
end formatFiles

on writeFile(theFiles)
	set theString to theFiles's componentsJoinedByString:linefeed
	set theFolder to (current application's NSHomeDirectory()'s stringByAppendingPathComponent:"Desktop")
	set theFile to theFolder's stringByAppendingPathComponent:"New Files.txt"
	theString's writeToFile:theFile atomically:true encoding:(current application's NSUTF8StringEncoding) |error|:(missing value)
end writeFile
1 Like

This makes reading the results of the script a whole lot easier, thanks for the tweak.

FWIW, I tested various approaches that will do a deep search of a folder and return files and packages. The testing was done with Script Geek on my 2023 Mac mini, and the results ranged from 58 to 85 milliseconds. The fastest was:

use framework "Foundation"
use scripting additions

set theFolder to POSIX path of (choose folder)
set theFiles to getFiles(theFolder)

on getFiles(theFolder)
	set fileManager to current application's NSFileManager's defaultManager()
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set folderKey to current application's NSURLIsDirectoryKey
	set packageKey to current application's NSURLIsPackageKey
	set booleanTrue to current application's NSNumber's numberWithBool:true -- thanks KniazidisR
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects() -- no hidden files or package contents
	set theFiles to folderContents's mutableCopy()
	repeat with anItem in folderContents
		set {theResult, aFolder} to (anItem's getResourceValue:(reference) forKey:folderKey |error|:(missing value))
		if aFolder is booleanTrue then
			set {theResult, aPackage} to (anItem's getResourceValue:(reference) forKey:packageKey |error|:(missing value))
			if aPackage is not booleanTrue then (theFiles's removeObject:anItem)
		end if
	end repeat
	return theFiles as list -- returns a list of file objects
	-- return (theFiles's valueForKey:"path") as list -- returns a list of POSIX paths
end getFiles

I also tested scripts that return folders only, and the timing results ranged from 52 to 73 milliseconds. The fastest script was:

use framework "Foundation"
use scripting additions

set theFolder to POSIX path of (choose folder)
set theFolders to getFiles(theFolder)

on getFiles(theFolder)
	set fileManager to current application's NSFileManager's defaultManager()
	set theFolder to current application's |NSURL|'s fileURLWithPath:theFolder
	set folderKey to current application's NSURLIsDirectoryKey
	set packageKey to current application's NSURLIsPackageKey
	set booleanTrue to current application's NSNumber's numberWithBool:true -- thanks KniazidisR
	set folderContents to (fileManager's enumeratorAtURL:theFolder includingPropertiesForKeys:{} options:6 errorHandler:(missing value))'s allObjects() -- no hidden files or package contents
	set theFolders to current application's NSMutableArray's new()
	repeat with anItem in folderContents
		set {theResult, aFolder} to (anItem's getResourceValue:(reference) forKey:folderKey |error|:(missing value))
		if aFolder is booleanTrue then
			set {theResult, aPackage} to (anItem's getResourceValue:(reference) forKey:packageKey |error|:(missing value))
			if aPackage is not booleanTrue then (theFolders's addObject:anItem)
		end if
	end repeat
	return theFolders as list -- returns a list of file objects
	-- return (theFolders's valueForKey:"path") as list -- returns a list of POSIX paths	
end getFiles

Hi, this script gave me a result of:

 «class ocid» id «data optr00000000B06EC50600600000»

on a big folder (couple thousand files and folders), under Monterey 12.7.1. --fyi

kerflooey. Thanks for looking at my script.

I believe the issue is that you are running the script in Script Editor (which is a perfectly reasonable thing to do). I’ve edited my script to return a list of file objects, and these should display as expected with Script Editor. I’ve also added but commented out a line that will cause the script to return a list of POSIX paths.