Other Versions
Find Any File is Shareware
You may try it out without buying first. Simply download it.
If you keep using it you are expected to pay for it, though.
Find Any File (FAF)
Key Features
- Convenient folder and icon views for results
- Can search in other users' home folders ("root" mode)
- Searches can be saved for easy re-use
- Can be launched with a self defined keyboard shortcut
Links
New in version 2.5:
- Adds a Name without Extension rule.
- Faster search on Synology and QNAP, as well as on Windows shares with Everything.
- New script for finding duplicate files.
- See the Version History for a detailed list of changes.
Find Any File Scripts
FAF's Matching Scripts are an extension for FAF that lets you add search methods that FAF doesn't inherently offer.
How to install the examples below
Note: This method requires FAF 2.5 or later.
- Choose a script from the Example Scripts at the bottom of this page.
- Click on the script description so that it expands.
- Click on the "Install" button at the top right of the script code.
- FAF will open and insert the script rule for you.
- From now on, the script is available in the rules menu under "Script", so you can choose it any time you need it.
How to install the examples manually
- Copy the script code (that's the text inside the light blue box) and paste it into a text editor, such as BBEdit or TextEdit, as a plain text file (ending in .txt). If you use TextEdit, make sure to open the Format menu and choose “Make Plain Text” if it's available, or you'll use Rich text, which won't work.
- Save the file somewhere convenient.
- Once saved, change the file's extension (in Finder) to “.lua” for Lua scripts, or to “.js” for Javascripts (the script's introductory text will indicate which type it is).
- Open Find Any File (FAF), hold down the option (⌥) key and click on the rules pop-up menu (usually titled “Name”) and then choose "Scripts" from the menu. This will set the rule to “Script matches any”.
- Click on “any” and choose “Open Scripts Folder”.
- Move your script into that folder (the folder's name is “Matching”).
- Back in FAF, click on “any” and choose your newly added script.
More details for power users
Starting with FAF version 2.4, FAF's search rules can be extended by scripts (programming code) in the Lua and JavaScript (short: JS) languages.
This makes it possible to create very specific and complex search rules. If you don't feel comfortable with writing script code, look below for a list of readily available scripts that you can install, or ask me for assistance.
(I am also planning to add more scripting features, such as for displaying custom columns and more information about listed files and folders, and offering more commands in the contextual menu for found items.)
Installing scripts
FAF's AppSupport folder (which you can open by opening the menu bar, under Help, and choosing Open the AppSupport folder) contains a Scripts folder, which in turn contains sub folders for the different types of scripts:
- Matching – These are used as search rules, which you can then choose from once you select the Script matches rule.
You can also reach the scripts folder by clicking on the rightmost pop-up menu for the Script rule:
(Note: In v2.4 and 2.4.1, the Script rule does not appear in the pop-up menu unless you hold down the option (⌥) key before clicking on it!)
Place the script into the appropriate sub folder (i.e. Matching), and make sure its name ends in either .lua, .js or .javascript.
Then click on the right-most pop-up menu again, where you will now find the newly installed script. Choose the script from the menu to use it with the Script rule.
Writing new scripts
Scripts for matching during search
These are scripts that are placed into the Matching folder. For instance, if you have a file named test.lua in that folder, you can choose it in the Find window as shown:
A simple script that imitates the "Name contains good" rule would look like this:
Lua:
function match(diskItem)
if string.find(diskItem.name, "good") then
return true
end
end
JavaScript:
function match(diskItem) {
if (diskItem.name.includes("good")) {
return true;
}
}
Basically, you have to implement a function named match
that takes one argument, and returns either true
or false
(returning nothing is fine, too, and is the same as returning false
). The one argument is an object providing access to many properties of the to-be-checked item (i.e. either a file or a folder). The properties will be listed in a section further below.
A script's lifetime and storing data persistently
The script's context, i.e. where it stores its global properties, gets re-initialized after each search operation. That means a script can't keep its values across multiple searches.
It could store some information in FAF's preferences, but if your script attempts this, it should take care to use a unique name for the preference key, ideally based on the name of the script, or on a reverse domain name you own. And the data stored should be kept small; a few 100 bytes is okay, a path list of all found items would not be, as that could easily become megabytes of data, which the prefs system is not suited for.
Another option would be to store data in a text file, using the faf.fileLoad
and faf.fileSave
functions. If you do not pass an absolute path but only a file name, the file will be located inside a folder named "Storage" next to the script file. That way, you won't have to figure out where best to store the data for the script; just pass a resonable file name.
Configuration options
If the source code has a single-line comment containing "_FAF_Config_" in the form
_FAF_Config_ ( option1, option2 )
then the one or more listed options will be used to configure the rule's appearance and/or behavior.
The following options are available:
input
orinput=single
– will add a single-line input field to the rule in the Find window. The input value can be queried by the script usingcurrentSearch.input
, which will be a string. The rule will only be usable if the user has input something into the text field.input=multi
– will add a multi-line input field.currentSearch.input
will return an array of strings in this case. An empty input will disable the rule.inputOptional
– The rule doesn't require input, i.e. even with no text in the input field the rule will be run. Only applies ifinput
is also declared.persistentContext
– The lifetime (see above) will be extended as long as possible, meaning that it won't create a new context for each search (but will still renew the context if the script was stopped due to an error or when clicking Stop infaf.showAlert()
or whenever the script's source file gets updated).minAppVersion=N
– Declares that this script requires a certain FAF version (e.g. when using functions introduced later). N is the app's integer version that's shown in parentheses, such as367
inversion 2.4.2 (367.1)
. This works since appVersion 365 (i.e. it works in v2.5 and later).
Debugging
If you use a script, and if the script has an issue, FAF will display a message about the encounted syntax or runtime error in the Find window, and abort the search or otherwise stop using the script until you edit it.
JavaScript
Debugging your JS scripts is fairly easy thanks to Apple having provided the JavaScriptCore engine to run the scripts and linking it with Safari's Web Inspector:
For this to work, you need a special version of FAF that allows the debugger to be enabled. You should find this special version either here for release versions and here for beta versions. These versions have a reduced safety state (i.e. they're not notarized). This means that you cannot launch these versions with a double click after downloading them, or macOS will tell you that the app is damaged. Instead, you first need to remove the quarantine state from the app by issuing this command in Terminal.app: xattr -d com.apple.quarantine /path/to/FindAnyFile.app
(you need to enter the correct path to the app, of course). Then ctrl-click on the app's icon and choose "Open" from the menu, and confirm to open it (you'll have to do this only once). After that, this version will work just like the regular version, with the added benefit of being able to use the JavaScript debugger.
But note that this debug-enabled version can't automatically be updated by FAF's self-update mechanism. To update FAF, you will need to run the regular version instead. Then, if you want to debug JavaScript again, you need to download the new special version manually again, as explained above.
Now, to enable JS debugging (which includes support for the console.log()
command), launch Safari, open its Preferences window, switch to the Advanced tab and check the "Show Developer menu in menu bar" option. With that, you'll find a new Develop item in the menu bar. In that, find the row titled after your Mac's name (3rd from the top, usually), and check the options "Automatically Show Web Inspector for JSContexts" and "Automatically Pause Connecting to JSContexts" inside the submenu.
Now, if you use a Script in FAF, Safari will open a Web Inspector window in which you can see the Console output, view the source code, set breakpoints into the match
function, view the diskInfo object's properties and their values, and single-step through the code to see what it does.
Turn the options in the Develop menu off again once you're finished with debugging your script, or the Web Inspector windows will keep popping up when FAF runs any scripts.
Lua
Unfortunately, there's no advanced debugging support for Lua with FAF. Therefore, consider using JavaScript and Safari's Web Inspector if you want to write more complex scripts, as it'll probably make it easier.
You can, however, use the logInfo function as in faf.logInfo("file name:"..diskItem.name)
to write a line of text to the FAF.log file, which you can view by opening the menu bar, under Help, and choose Show Log
Objects and operations (events) provided to the scripts
function match (diskItem)
The optional match
function, when implemented by the script, is called during a search repeatedly, being passed every disk item (file or directory) that has been left after matching by any other previous rules in the search. The function then has to return true to keep the item matched. If all rules agree that an item matches, it will be added to the "found results". Since the scripts usually run a bit slower than FAF's built-in rules, scripts will therefore always be invoked last (but before file content rules), in order to sort out any misses (non-matches) more quickly before invoking the script with the remaining items that the other rules have matched. If the match
function is not declared in the script, then its rule will always match.
function searchHasStarted ()
The optional searchHasStarted
function, when implemented by the script, is called once a search starts. The global variable currentSearch
will be set at this point (and remains available until after the search has ended). The function gets a ruleInfo
passed, which has information about the rule the script is running under.
function searchHasEnded (completed, foundItems)
The optional searchHasEnded
function, when implemented by the script, receives two arguments: The first is a boolean stating whether the search was completed (as opposed to being stopped by the user), and the second provides all the found disk items so far in an array (this may exclude items that require a content search, though).
Since version 2.4.2 (appVersion 365), currentSearch.addMatch()
may be called from here to add more to the set of found items.
function skipDirectory (diskItem)
The optional skipDirectory
function, when implemented by the script, gets called during a "slow" search for every directory it encounters. If this function returns true
, that directory's contents won't be searched.
function finishedDirectory (diskItem)
The optional finishedDirectory
function, when implemented by the script, gets called during a "slow" and recursive search (set currentSearch.searchMode
to 3 to enable) for every directory it had traversed into.
The "disk item" object
The properties of the disk item objects can be seen in the Web Inspector (see above, Debugging). Here's a list (which is manually collected and thus may be outdated or incorrect):
volumeName: string
name: string // normalized, POSIX format (using ":" in names, not "/")
localizedName: string // as shown by Finder (using "/" in names, not ":")
path: string
canonicalPath: string // normalized & canonical
normalizedPath: string // normalized
parentPath: string // normalized
fileSize: number // combined data and resource fork size
dataSize: number // data fork size only
resourceSize: number
fileType: string // the UTI
kind: string
tagsArray: array of string
typeCode: number
typeCodeString: string
creatorCode: number
creatorCodeString: string
ownerID: number
groupID: number
fileIDNumber: number
inodeNumber: number
creationDate: date
modificationDate: date
lastContentAccessDate: date
addedToDirectoryDate: date
labelNumber: number
localizedLabel: string
finderComment: string
exists: boolean
isDirectory: boolean
isRegularFile: boolean
isSymlink: boolean // true for symlinks only
isAlias: boolean // true for symlinks and Finder Aliases
isHidden: boolean // true only if the item itself is invisible or its name starts with a "."
isItemOrParentItemHidden: boolean // true if hidden or inside a hidden folder
isVolume: boolean // true if it's the root folder of a volume
isPackage: boolean
isTrashed: boolean
isTrashFolder: boolean
isLocked: boolean
isSystemProtected: boolean
isContentOfPackage: boolean // true if inside a package (bundle)
isDataless: boolean // true if the file or folder is offline, i.e. its data is only in the cloud
parentItem: DiskItem // returns the parent object, or nil (undefined) for the root dir
function resourceValueNamed(name): any type
The date
type is actually a number in Lua. You can then use the os.date()
function to extract day and time from it.
For resourceValueNamed()
, pass any of the names listed as Key under NSURLResourceKey.
For now, all these properties are read-only.
The "faf" global
faf
is a global object that provides general functions and properties:
// These log functions write to the "FAF.log" file; see FAF's Help menu
function logInfo(msg)
function logWarning(msg)
function logError(msg)
// Disable the script with an error message, e.g. if requirements are not met.
function fail(msg) // Available since appVersion 365
// Get a diskItem for the given POSIX path.
function diskItem(path) // Available since appVersion 365
// Get and set FAF's preferences
function prefsValue(key): any type of value
function setPrefsValue(key, value)
// Load from and write to text files; passing an empty string to the
// path parameter will use the script's file name, and passing no
// absolute path but only a file name will put the file into a folder
// named "Storage" next to the script file.
function fileLoad(path): string
function fileSave(path, content): boolean
function fileAppend(path, content): boolean
function showAlert(title, subtext)
// Shows a modal dialog with an "OK" and a "Stop" button
function showNotification(title, subtext)
// Shows a notification in the top right screen corner
// (if not disabled in System Preferences by the user)
function beep() // Plays the system alert sound
function playSound(name) // E.g.: faf.playSound("Frog")
scriptFileName: (read-only) // Returns the file name of the script
appVersion: (read-only) // Returns the app's numeric version (integer part only). Available since appVersion 365
// Execute a commandline tool and wait for it to end. For example, in JavaScript:
// let output = faf.runCommandWithArgs("/bin/ls", ["-l","/"])
// or to run a shell with a free command string:
// let cmd = "ls -la ~"
// let output = faf.runCommandWithArgs("/bin/sh", ["-c",cmd])
function runCommandWithArgs(cmd, args): string // args is an array of strings
// Execute a command without waiting for it to finish, and instead providing
// a callback function for its output. For example, in JavaScript:
// faf.runCommandWithArgsDataAsync("/sbin/md5", ["-q", diskItem.path], diskItem, function (diskItem, output) {
// faf.logInfo("-- MD5 of "+diskItem.path+ " is "+output+" --")
// })
// The `data` argument is passed on to the function, which has two parameters: data and the output from the command.
// Available since appVersion 366.
function runCommandWithArgsDataAsync(cmd, args, data, function(data, output)) // args is an array of strings
// The following two functions are needed when using runCommandWithArgsDataAsync()
// inside the searchHasEnded() script handler function, in order to let FAF know that the search
// shall not be finished until all async command calls have been completed.
// `ignorePrematureStop()` needs to be invoked first, and `waitForAsyncTasksToFinish()` at the end
// of the function. Available since appVersion 366.
function ignorePrematureStop()
function waitForAsyncTasksToFinish()
For example, to write a line to the log file, use:
faf.logInfo ("some info")
The "currentSearch" global
This is an object that only exists during a search.
// Set and retrieve named values that persist across
// all involved matching scripts during a search:
function setSharedValue(name, value)
function sharedValue(name): any type of value
currentSearchTarget:
(read-only) A disk item that specifies which volume or folder
is currently searched.
statusMessage:
(string, r/w) When assigning a string to this, the search will be
stopped and the text be shown in the Find window. This can be used
to show an error message as well as to show extra information at
the end of a successful search (when set from `searchHasEnded`).
input:
(read-only) If the script has requested to show an input field
with the rule, this property will contain the text the user has
typed in. It's either a string or an array of strings, depending
on the config value (`input=single` or `input=multi`).
stopped:
(boolean, read-only) Is true once the user stops the search with the Stop button.
If you run a lengthy operation in one of the event functions, you should regularly
check this property and exit the function once its value is true.
Available since appVersion 365.
// These settings are to be applied to all comparisons
// and can be changed with related rules:
caseSensitiveNames (boolean, read-only)
diacriticsSensitiveNames (boolean, read-only)
caseSensitiveContent (boolean, read-only)
negateConditions (boolean, read-only)
// The following properties can only be changed from
// within the `searchHasStarted` function:
searchMode (integer, r/w):
0: Default (prefers fast search).
1: Forced slow mode (no searchfs aka CatalogSearch and no `find` tool).
2: Prefer recursive in slow mode.
3: Force recursive mode (slower than 1 but needed for `finishedDirectory` callback).
This also implictly sets spotlightMode to 0.
4: May use `find` tool in Pro version for searches run on the Mac.
8: May use `find` tool in Pro version for searches on servers via SSH.
spotlightMode (integer, r/w):
0: No Spotlight use.
1: Include Spotlight query.
33: Spotlight only.
calculateFolderSizes (boolean, r/w):
Only used searchMode is 3. Calculates the size of every
searched folder, and can be fetched in the `finishedDirectory`
callback with `f.resourceValueNamed("NSURLTotalFileSizeKey")`.
addMatch(diskItem)
Adds the item to the results.
Example scripts
These and more scripts are downloadable here.
This finds executable files (another, faster, method is to use the rule "Kind is executable").
JavaScript version (save this as "Is executable.js" to the Scripts/Matching
folder):
function match(f) {
if (! f.isRegularFile) { // ignores directories and symlinks
return false
}
isExecutable = f.resourceValueNamed ("NSURLIsExecutableKey")
return isExecutable
}
And the alternative Lua version (save this as "Is executable.lua" to the Scripts/Matching
folder):
function match(f)
if not f.isRegularFile then -- ignores directories and symlinks
return false
end
isExecutable = f.resourceValueNamed ("NSURLIsExecutableKey")
return isExecutable
end
Finder-locked files are those where you can check the "lock" in the "Get Info" window in Finder. Files can also be locked by other methods, e.g. via ACLs, which will not be identified by this script.
Save as "Is locked.lua" to the Scripts/Matching
folder:
function match(f)
return f.isLocked
end
Works on macOS 11 and later. See Which files are purgeable? for details.
Save as "Is purgeable.lua" to the Scripts/Matching
folder:
function match(f)
if not f.isRegularFile then -- sorts out directories and symlinks
return false
end
return f.resourceValueNamed ("NSURLIsPurgeableKey")
end
Save as "Is sparse file.lua" to the Scripts/Matching
folder:
function match(f)
if not f.isRegularFile then -- sorts out directories and symlinks
return false
end
return f.resourceValueNamed ("NSURLIsSparseKey")
end
Save as "Has Extended Attribute.lua" to the Scripts/Matching
folder:
function match(f)
local hasXattr = f.resourceValueNamed ("NSURLMayHaveExtendedAttributesKey")
if hasXattr then
-- This item _may_ have an EA; now we need to check if it really has any
local output = faf.runCommandWithArgs("/usr/bin/xattr", {"-s", f.path})
--faf.logInfo(output)
if output ~= "" then
return true
end
end
end
Here is a lua script that also shows how to specify that an input value is required
Save as "Name length.lua":
-- a Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)
--
--
-- The user may enter a comparator symbol (=, <, >) and then a number to compare against.
--
-- Examples:
-- >80 -- checks for names longer than 80 chars. Same as >=81 (which you cannot use).
-- =12 -- checks for names of exactly 12 chars in length.
-- <5 -- checks for names shorter than 4 chars. Same as <=4 (which you cannot use).
name_length = 0
comparator = ""
function searchHasStarted ()
-- Fetch the user's input and make sure it's valid
local input = currentSearch.input
comparator = string.sub (input, 1, 1)
if not (comparator == "=" or comparator == "<" or comparator == ">") then
currentSearch.statusMessage = "Input must start with =, < or >"
else
name_length = tonumber (string.sub (input, 2, -1))
if name_length <= 0 then
currentSearch.statusMessage = "Input must provide a number > 0"
end
end
end
function match(f)
local s = f.name
if comparator == "=" then
return string.len (s) == name_length
elseif comparator == "<" then
return string.len (s) < name_length
elseif comparator == ">" then
return string.len (s) > name_length
else
currentSearch.statusMessage = "Invalid comparator in script's match function"
end
end
BTW, you could also perform a name length check with a Regex rule:
The above rule finds any name that is at least 25 bytes long (plain "ASCII" characters such as A-Z and digits count as one byte, whereas non-latin characters may count as 2 to 5 bytes, though, so using regex may not work well if you're using non-latin scripts for your file names).
Certain file names cannot be used with OneDrive. To identify them, use this script (save it as "OneDrive Name Issues.lua"):
-- Finds file names that will cause an issue if moved to Microsoft OneDrive.
-- Save this to FAF's Scripts/Matching folder with a name ending in ".lua".
-- For more info, see https://findanyfile.app/scripting.html
function match(f)
name = f.name
firstchar = string.byte (name, 1)
lastchar = string.byte (name, -1)
-- these chars may not appear in file names: /\:"*?
if name:find "[/\\:\"%*%?]" then
return true
end
-- names may not start nor end with blanks
if firstchar == 32 then
return true
end
if lastchar == 32 then
return true
end
-- names may not end with a period
if lastchar == 46 then
return true
end
end
To fix the file names of these items, you may use renamer programs such as NameChanger: Install the program, then select the found items in FAF, open the "Services" menu (e.g. by right-clicking on the selection) and choose "Rename with NameChanger" and then enter various replacement rules for "Original Text" and "New Text", such as replacing each of the invalid chars with an underscore ("_") or a dash ("-") character.
To find folders that contain a minimum, exact, or maximum number of files and/or folders inside, use this script, and adapt it to your needs.
The version below finds folders whose cumulated item count, which means the number of items in a folder and all its decendents, is at least the entered number. You can alter the code to match exact numbers or count only the immediate items in a folder without any deeper folder contents.
Save as "Minimum item count.lua":
-- A Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)
targetItemCount = 0
function searchHasStarted ()
-- Fetch the user's input and make sure it's a sensible number, i.e. > 0
targetItemCount = tonumber (currentSearch.input)
if targetItemCount <= 0 then
currentSearch.statusMessage = "input must be a number > 0"
else
currentSearch.searchMode = 3 -- forces recursive search that performs the folder calculations we need below
if faf.appVersion >= 366 then
currentSearch.countHiddenItems = true -- without this, the counts of hidden items will not be determined
end
end
end
function match(f)
-- Let's not match anything by default (we'll do it below)
return false
end
function finishedDirectory(f)
-- Determine the currently searched folder's item counts
cumulatedItemCount = (f.resourceValueNamed("dirFileCount") or 0) + (f.resourceValueNamed("dirFolderCount") or 0)
if faf.appVersion >= 366 then
-- These additional values require FAF 2.5 or later:
cumulatedHiddenItemCount = (f.resourceValueNamed("dirFileCountHidden") or 0) + (f.resourceValueNamed("dirFolderCountHidden") or 0)
immediateItemCount = (f.resourceValueNamed("dir1FileCount") or 0) + (f.resourceValueNamed("dir1FolderCount") or 0)
immediateHiddenItemCount = (f.resourceValueNamed("dir1FileCountHidden") or 0) + (f.resourceValueNamed("dir1FolderCountHidden") or 0)
end
-- Print the values into the FAF.log file, for debugging:
--faf.logInfo (f.path)
--faf.logInfo (" total: "..cumulatedItemCount.." ("..cumulatedHiddenItemCount.." hidden), in this dir only: "..immediateItemCount.." ("..immediateHiddenItemCount.." hidden)")
-- If the count reaches the input value, then make this folder a match that'll appear in the results.
-- You can change the following line to check against a different count, e.g. to count only visible items,
-- you would use:
-- if cumulatedItemCount-cumulatedHiddenItemCount >= targetItemCount then
-- And to find folder with a maximum of immediate items (ignore those in sub folders):
-- if immediateItemCount <= targetItemCount then
if cumulatedItemCount >= targetItemCount then
currentSearch.addMatch (f)
end
end
Save as "Minimum folder size.lua":
-- a Lua program, see https://findanyfile.app/scripting.html
--
-- _FAF_Config_ (input=single)
targetSize = 0
function searchHasStarted ()
-- Fetch the user's input and make sure it's a sensible number, i.e. > 0
targetSize = tonumber (currentSearch.input)
if targetSize <= 0 then
currentSearch.statusMessage = "input must be a number > 0"
else
currentSearch.searchMode = 3 -- forces recursive search that performs folder calculations
currentSearch.calculateFolderSizes = true -- needed for getting "NSURLTotalFileSizeKey" set
end
end
function match(f)
-- let's not match anything by default (we'll do it below)
return false
end
function finishedDirectory(f)
-- determine the currently searched folder's content size
currentSize = (f.resourceValueNamed("NSURLTotalFileSizeKey") or 0)
-- if the size reaches the input value, then make this folder a match that'll appear in the results
if currentSize >= targetSize then
currentSearch.addMatch (f)
end
-- optional logging of values:
-- faf.logInfo (f.name .. ": " .. tonumber(currentSize))
end
Use this rule as the last rule with other rules that match specifc files. That way, if the other (previous) rules find a match, this script will then drop those matches and instead mark the parent folder as a match.
Save as "Parent folders of matched items.lua":
-- a Lua program, see https://findanyfile.app/scripting.html
function searchHasStarted ()
currentSearch.searchMode = 3 -- forces a recursive search
end
function match(f)
-- if we get here, previous rules have already matched - which means we drop
-- this match and instead match its parent directory
currentSearch.addMatch (f.parentItem)
return false
end
Use this rule as the last rule with other rules that match specifc files. That way, if the other (previous) rules find a match, this script will then drop those matches and remember that the folder had at least one match. It will then instead mark those folder as matched that had no matches inside.
Save as "Folders with no matches.lua":
-- a Lua program, see https://findanyfile.app/scripting.html
function searchHasStarted ()
currentSearch.searchMode = 3 -- forces a recursive search
end
matchStack = {}
function skipDirectory (diskItem)
-- we're entering a new directory - put a fresh "has no match" flag onto the stack
table.insert (matchStack, false)
return false
end
function finishedDirectory (diskItem)
-- directory has been searched - did we have matches inside?
local hadMatch = table.remove (matchStack)
if not hadMatch then
currentSearch.addMatch (diskItem)
end
end
function match (diskItem)
-- if we get here, previous rules have already matched - which means we drop
-- this match and instead remember that this directory had a match
matchStack[#matchStack] = true
return false
end
Use this rule to locate folders that are missing files with the given extensions, any levels deep.
Save as "Folders without extensions.lua":
-- See https://findanyfile.app/scripting.html
--
-- Save as "Folders without extensions.lua" to FAF's "Scripts/Matching" folder.
--
-- FAF script for listing folders that do not contain files with a set of specified extensions.
-- To specify multiple extensions, enter them with a blank (space) as a separator.
--
-- _FAF_Config_ (input=single)
-- "deep" setting: Choose true or false. "false" means to look for the extensions only in the
-- same folder as the matched file, "true" will also search all deeper folders.
deep = true
exts = {}
if deep then
ls_cmd = "find ."
else
ls_cmd = "ls -1"
end
function searchHasStarted ()
-- Build a shell command that lists all files with the given extensions
-- This turns an input like "png jpeg" into the cmd "ls -1 *.png *.jpeg"
local input = currentSearch.input
for ext in input:gmatch("%w+") do table.insert(exts, ext) end
for idx = 1, #exts do
local ext = exts[idx]
if ext ~= "" then
if not startswith(ext, ".") then
ext = "." .. ext
end
if deep then
if idx > 1 then
ls_cmd = ls_cmd .. " -or"
end
ls_cmd = ls_cmd .. " -iname '*" .. ext .. "'"
else
ls_cmd = ls_cmd .. " *" .. ext
end
end
end
-- faf.logInfo ("Script cmd: " .. ls_cmd)
end
visitedDirs = {}
function match (diskItem)
-- if we get here, previous rules have already matched - which means we drop
-- this match and instead remember that this directory if there's none of the exts inside
local parentItem = diskItem.parentItem
local path = parentItem.path
local visited = visitedDirs[path]
if not visited then -- we need to check each dir only once
visitedDirs[path] = true
-- Are there any files of the input's extensions in the same
-- directory as the currently matched file?
local cmd = 'cd "' .. path .. '" ; ' .. ls_cmd .. " 2>/dev/null"
local res = faf.runCommandWithArgs ("/bin/sh", {"-c",cmd})
res = trim(res)
if res == "" then
-- there were no matching files in the dir
currentSearch.addMatch (parentItem)
else
-- there were matching files in the dir
end
end
return false
end
function startswith(text, prefix)
return string.sub(text, 1, #prefix) == prefix
end
function trim(text)
return string.gsub(text, "%s+", "")
end
Use this rule to identify directories (which includes folders and bundles) that
contain no files apart from the insignificant .DS_Store
files or contain
only directories that in turn are determined to be empty as well.
This is effectively the same operation that you can also get when using my free tool Find Empty Folders.
Save as "Is empty directory.lua":
-- "Is empty directory.lua"
--
-- a Lua program, see https://findanyfile.app/scripting.html
function searchHasStarted ()
currentSearch.searchMode = 3 -- forces a recursive search
end
isEmptyDirectoryStack = {} -- an array of booleans for the currently scanned folder and all its parents
-- The calling order of the following functions is as follows:
--
-- match() - can be a file or directory. If it's a directory, then:
-- skipDirectory() if it's a directory
-- recursion (i.e. calling match, skipDirectory, finishedDirectory for all items inside)
-- finishedDirectory() if it's a directory
function match(f)
-- remember whether this directory has a visible item inside
if f.isDirectory then
-- we'll let finishedDirectory() handle this later, after its contents have been checked
else
-- check a file inside the current directory
if f.name == ".DS_Store" then
-- let's ignore this often occuring file inside otherwise empty directories
else
--faf.logInfo("dir not empty bc of file: "..f.path)
isEmptyDirectoryStack[#isEmptyDirectoryStack] = false
end
end
return false
end
function skipDirectory (f)
-- we're entering a new directory - put a fresh "is empty" flag onto the stack
table.insert (isEmptyDirectoryStack, true)
return false
end
function finishedDirectory(f)
-- directory has been searched - is it still marked as empty?
local isEmpty = table.remove (isEmptyDirectoryStack)
if isEmpty then
currentSearch.addMatch (f)
else
-- mark the content of the parent directory as non-empty, too
--faf.logInfo("dir not empty bc of non-empty subdir: "..f.parentItem.path)
isEmptyDirectoryStack[#isEmptyDirectoryStack] = false
end
end
Looking for a "Name doesn't match regex" rule? Use this script and set the rule to [Script] [does not match] [regex]
Save as "regex.js":
// a Javascript program, see https://findanyfile.app/scripting.html
//
// _FAF_Config_ (input=single)
//
//
// Matches on the entered a regular expression.
var regex;
function searchHasStarted () {
regex = new RegExp(currentSearch.input, "i"); // "i": case insensitive
}
function match(f) {
return regex.test (f.name);
}
This script finds identical files, by comparing their contents. It's not very efficient (there are much better apps in the App Store, for instance), but it works, and it's a nice demonstration of what's possible with FAF's scripting feature.
Ideally, you would combine this script with an additional rule that requires a minimum file size, e.g. [File size] [is greater than] [1 M], to have it only check larger files, because checking every tiny file will take a lot of memory and extra time.
It requires FAF v2.5 or later.
See the comments in the script below for more information.
Save as "Duplicates.lua":
-- a Lua program, see https://findanyfile.app/scripting.php
--
-- Latest update: 2 Dec 2024
--
-- This script finds identical files (only checking for identical data forks, not other
-- metadata such as resource forks and extended attributes).
--
-- When it finds files with identical contents, it adds them to the results. It's up to you
-- to see which files are the identical pairs, e.g. by looking at their file sizes (which
-- would be identical unless they also have different-size resource forks, as FAF lists
-- their sizes as a sum of their data and optional resource forks).
--
-- This script should ideally be used with additional rules such as a minimum file size,
-- for performance reasons – if you want it to find any tiny duplicate file on your main
-- volume with millions of files on it, this could take many hours!)
-- So add a rule such as "File system is greater than 10m" to have it only check files
-- above 10 MB in size. It might still take many minutes for it to finish the process,
-- because it'll have to read each file (it's calculating an md5 checksum for each).
--
-- You can see the groups of identical files also in the log, which you can open from
-- FAF's Help menu.
--
-- _FAF_Config_ ( persistentContext, minAppVersion=366 )
--
if faf.appVersion < 366 then
faf.fail ("The script '" .. faf.scriptFileName .. "' doesn't work with this outdated version of FAF.")
end
filesBySize = {} -- a dictionary with every file's size (if over the threshold size) as the key
filesByMD5 = {} -- a dictionary with every file's MD5 hash as the key and an array of the file objects as its value
md5ByFile = {} -- a dictionary with every file's know MD5 hash value (useful if the search is repeated, whereby the script retains previously collected values)
threshold = 32768 -- hashes of files smaller that this value are collected immediately, others are collected at the end
function searchHasStarted ()
-- Clear some of our globals because FAF keeps their values in memory due to the
-- "persistentContext" config setting above.
filesBySize = {}
filesByMD5 = {}
-- However, we do not clear the possibly collected hashes from previous searches,
-- instead re-using them (though, if the file contents changed since then, we won't
-- notice this). To have FAF forget the previous hashes, FAF needs to be quit first.
end
function match (f)
if not f.isDirectory then
local size = f.dataSize
if size > 0 then
if size > threshold then
-- Skip md5 calculation for now for larger files, in order to make this faster.
-- Instead, we only record their size. Then, in searchHasEnded(), we look at
-- each recorded size and see if we have multiple files of that sizes, and only
-- then we need to check their content to see if they have also an identical
-- md5 checksum value.
local files = filesBySize[size]
if files == null then
files = {}
filesBySize[size] = files
end
files[f] = true -- adds this file to the list of files for this particular size
else
getMD5 (f) -- for smaller files, we get the checksum right away, as it'll be quite fast.
end
end
end
return false
end
function addMD5 (f, md5)
local dupes = filesByMD5[md5]
if dupes == nil then
dupes = {}
filesByMD5[md5] = dupes
end
dupes[f] = true
end
function getMD5 (f)
local md5 = md5ByFile[f.path]
if md5 then
addMD5 (f, md5)
else
faf.runCommandWithArgsDataAsync ("/sbin/md5", {"-q",f.path}, f,
function (f, result)
local md5 = trim (result)
if md5 ~= "" then
md5ByFile[f.path] = md5
addMD5 (f, md5)
end
end
)
end
end
function trim (s)
return s:match "^%s*(.-)%s*$"
end
function searchHasEnded (completed, foundItems)
if not completed then
return -- user has stopped the search
end
faf.logInfo("-- Collecting MD5 of larger files… --")
for size, files in pairs(filesBySize) do
-- we need to get the hash only if there's more than one file with the same size
local first
local handledFirst = false
for f, v in pairs(files) do
if currentSearch.stopped then
return
end
if not first then
first = f
else
if not handledFirst then
handledFirst = true
getMD5 (first)
end
getMD5 (f)
end
end
end
faf.logInfo("-- Waiting for MD5 collection to finish… --")
faf.waitForAsyncTasksToFinish()
faf.logInfo("-- Listing duplicates… --")
-- now that we have all checksums, let's see which checksums are used by more than one file
for md5, files in pairs(filesByMD5) do
local first
local handledFirst = false
for f, v in pairs(files) do
if currentSearch.stopped then
return
end
if not first then
first = f
else
if not handledFirst then
handledFirst = true
faf.logInfo("\t" .. first.path)
currentSearch.addMatch(first)
end
faf.logInfo("\t" .. f.path)
currentSearch.addMatch(f)
end
end
end
faf.logInfo("-- Finished --")
end
See the comments in the script below for more information.
Save as "Duplicate Names.lua":
-- a Lua program, see https://findanyfile.app/scripting.php
--
-- This script finds files with identical names.
--
-- When it finds files with identical names, it adds them to the results. It's up to you
-- to see which files are the identical pairs, e.g. by sorting them by name.
--
-- You can also see all duplicates listed in the log file (see FAF's Help menu).
--
-- _FAF_Config_ ( persistentContext )
--
caseSensitive = false
includeDirectoryNames = false -- set to true if you want to include folder names as well
filesByName = {} -- a dictionary with every file's name as the key and an array of the file objects as its value
function match (f)
if includeDirectoryNames or not f.isDirectory then
local name = f.name
if not caseSensitive then
name = string.lower(name)
end
local dupes = filesByName[name]
if dupes == nil then
dupes = {}
filesByName[name] = dupes
end
table.insert (dupes, f)
end
return false
end
function searchHasEnded (completed, foundItems)
if not completed then
return -- user has stopped the search
end
faf.logInfo("-- Listing duplicates… --")
for name, files in pairs(filesByName) do
if #files > 1 then
faf.logInfo(name .. ":")
for index, f in pairs(files) do
if currentSearch.stopped then
return
end
faf.logInfo("\t" .. f.path)
currentSearch.addMatch(f)
end
end
end
faf.logInfo("-- Finished --")
end
See the instructions in the comments in the file below.
Save as "exiftool.lua":
-- a Lua program, see https://findanyfile.app/scripting.php
--
-- _FAF_Config_ (input=single)
--
-- This script looks for EXIF tags in files.
--
-- It requires that the "exiftool" is installed, see https://exiftool.org/
-- (Install the macOS package - you may have to right-click on the installer and choose
-- "Open" to get around the macOS warning about an unidentified developer).
--
-- To use, enter either just the tag name you seek, or, after a space, its specific value that you seek.
-- Case of the tag name is not significant.
--
-- Example input:
-- Samplerate 44100
-- The above input matches if the the file contains the SampleRate tag and if its value is 44100.
--
-- To learn the name of a tag you seed, you need to run the exiftool command in Terminal.app and add to it a space,
-- "-s", a space, and the path to a file containing the tag (simply drag the file from Finder into the
-- Terminal window to get its path inserted). A complete command line would look like this:
-- exiftool -s /System/Library/CoreServices/Finder.app/Contents/Resources/Invitation.aiff
--
tagName = ""
expectedValue = nil
function trim(s) -- https://stackoverflow.com/a/27455195
return s:match("^%s*(.-)%s*$")
end
function searchHasStarted ()
-- Fetch the user's input and extract the tag name and the optional searched value (which may contain spaces)
local input = trim (currentSearch.input)
local from, to = input:find (" ")
from = from and from-1
tagName = input:sub(1, from)
if to then
expectedValue = trim (input:sub(to+1))
end
-- faf.logInfo("tag:<"..tagName..">, value:<"..expectedValue..">")
end
function match(f)
local output = faf.runCommandWithArgs ("/usr/local/bin/exiftool", {"-S", "-n", "-"..tagName, f.path})
-- faf.logInfo(output)
local from, to, str = output:lower():find(tagName:lower()..": ([^\n]+)")
-- faf.logInfo("->"..str)
if from then
-- tag exists in file
if not expectedValue then
-- we're just looking for files that contain the tag -> this is a match
return true
end
if str == expectedValue:lower() then
-- the tag's value matches
return true
end
end
end