Created
December 30, 2018 07:26
-
-
Save brookinc/88d6a7fdf0f3e3a8ac4c98a53b0e26b4 to your computer and use it in GitHub Desktop.
Apply macOS file system tags based on EXIF metadata.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/swift | |
// ExifTag.swift | |
// Scans files in the given directory and any subdirectories for EXIF metadata and applies | |
// macOS tags based on the results. | |
// | |
// Usage: | |
// swift ExifTag.swift [options] path | |
// or... | |
// chmod +x ExifTag.swift | |
// ExifTag.swift [options] path | |
// | |
// Options: | |
// -p=REGEX: uses REGEX to filter which files are scanned (default filter is defined below) | |
// -v: enable verbose mode | |
// -d: enable dry-run mode (preview which files will be affected, but don't actually apply any tags) | |
// | |
// Relies on: | |
// http://owl.phy.queensu.ca/~phil/exiftool/ | |
// https://github.com/jdberry/tag | |
// | |
// Documentation links: | |
// exiftool options / flags: http://owl.phy.queensu.ca/~phil/exiftool/exiftool_pod.html | |
// exiftool tag names: http://owl.phy.queensu.ca/~phil/exiftool/TagNames/EXIF.html | |
import Foundation | |
// customize these variables as needed | |
let modelNames = ["iPhone 7", "Canon EOS 7D"] | |
let tagToApply = "Downloaded" | |
let defaultFilter = "\\.jpg|\\.mov" | |
extension CommandLine { | |
// credit: https://stackoverflow.com/a/50035059/5673556 | |
static func run(_ command: String) -> String { | |
let task = Process() | |
task.launchPath = "/bin/bash" | |
task.arguments = ["-c", command] | |
let pipe = Pipe() | |
task.standardOutput = pipe | |
task.launch() | |
let data = pipe.fileHandleForReading.readDataToEndOfFile() | |
let output = String(data: data, encoding: .utf8)! | |
return output | |
} | |
} | |
extension String { | |
func trimmed() -> String { | |
return self.trimmingCharacters(in: .whitespacesAndNewlines) | |
} | |
} | |
// check requirements | |
if CommandLine.run("which exiftool").isEmpty { | |
print("This script relies on `exiftool` -- please install it: http://owl.phy.queensu.ca/~phil/exiftool/") | |
exit(1) | |
} else if CommandLine.run("which tag").isEmpty { | |
print("This script relies on `tag` -- please install it: https://github.com/jdberry/tag") | |
exit(1) | |
} | |
// parse arguments | |
var filenamePattern = try? NSRegularExpression(pattern: defaultFilter, options: .caseInsensitive) | |
var isVerbose = false | |
var isDryRun = false | |
var targetDirectory: URL? | |
// Note: items named `IMG_E1234.JPG` or `IMG_1234E.JPG` that don't have a model EXIF tag are usually edited | |
// screenshots -- not camera photos per se, but they aren't downloaded and shouldn't be tagged as such | |
var screenshotPattern = try? NSRegularExpression(pattern: "IMG_\\d\\d\\d\\dE\\.JPG|IMG_E\\d\\d\\d\\d\\.JPG", | |
options: .caseInsensitive) | |
// as usual, any paths that contain spaces must be either quoted or escaped to be parsed correctly here | |
for argument in CommandLine.arguments { | |
if argument.hasPrefix("-p=") { | |
let startIndex = argument.index(after: argument.firstIndex(of: "=")!) | |
// Note: we don't support whitespace here yet (even escaped), or quoted patterns (ie. any quotes will be included) | |
let pattern = String(argument.suffix(from: startIndex)) | |
filenamePattern = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) | |
guard filenamePattern != nil else { | |
print("Error: filename pattern `\(pattern)` is invalid.") | |
exit(1) | |
} | |
} else if argument.hasPrefix("-v") { | |
isVerbose = true | |
print("Verbose output enabled") | |
} else if argument.hasPrefix("-d") { | |
isDryRun = true | |
print("Dry-run mode enabled") | |
} else if !argument.hasSuffix(".swift") { | |
// otherwise, we assume the argument is a target directory path | |
targetDirectory = URL(fileURLWithPath: argument) | |
let targetExists = FileManager.default.fileExists(atPath: argument) | |
guard targetDirectory != nil && targetExists else { | |
print("Error: target directory `\(argument)` is invalid.") | |
exit(1) | |
} | |
} | |
} | |
func processFiles(path: URL) { | |
var doProcess = true | |
if let regex = filenamePattern { | |
let filename = path.lastPathComponent | |
let filenameNS = NSString(string: path.lastPathComponent) | |
if regex.firstMatch(in: String(filename), range: NSRange(location: 0, length: filenameNS.length)) == nil { | |
// we still want to recurse on non-matching directories, so just clear the flag here instead of returning | |
doProcess = false | |
} | |
} | |
if doProcess && !path.hasDirectoryPath { | |
let exifOutput = CommandLine.run("exiftool -S -T -model \"\(path.path)\"") | |
let exifData = exifOutput.trimmed().split(separator: "\t") | |
guard exifData.count == 1 else { | |
print("Error: Couldn't parse model name from file `\(path.path)`") | |
exit(1) | |
} | |
let model = exifData[0] | |
var isDownloaded = true | |
for modelName in modelNames where modelName == model { | |
isDownloaded = false | |
break | |
} | |
if isDownloaded && model == "-" { | |
if let regex = screenshotPattern { | |
let filename = path.lastPathComponent | |
let filenameNS = NSString(string: path.lastPathComponent) | |
if regex.firstMatch(in: String(filename), range: NSRange(location: 0, length: filenameNS.length)) != nil { | |
if isVerbose { | |
print("Skipping `\(path.path)` because it appears to be a screenshot...") | |
} | |
isDownloaded = false | |
} | |
} | |
} | |
if isDownloaded { | |
var output = "" | |
if isDryRun { | |
output += "Would apply tag `\(tagToApply)` to file `\(path.path)`...\n" | |
} else { | |
output += "Applying tag `\(tagToApply)` to file `\(path.path)`...\n" | |
let tagOutput = CommandLine.run("tag -a \"\(tagToApply)\" \"\(path.path)\"").trimmed() | |
output += tagOutput | |
} | |
print(output, terminator: "") | |
} else if isVerbose { | |
print("\(path.path) -> \(exifOutput.trimmed())") | |
} | |
} | |
if path.hasDirectoryPath { | |
var contents = try? FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) | |
if contents != nil { | |
contents!.sort { | |
return $0.lastPathComponent.lowercased() <= $1.lastPathComponent.lowercased() | |
} | |
for item in contents! { | |
processFiles(path: item) | |
} | |
} | |
} | |
} | |
guard let path = targetDirectory else { | |
print("Error: No target directory specified.") | |
exit(1) | |
} | |
print("Processing files in `\(path.path)` with pattern `\(filenamePattern?.pattern ?? "nil")`...") | |
processFiles(path: path) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment