Skip to content

Instantly share code, notes, and snippets.

@brookinc
Created December 30, 2018 07:26
Show Gist options
  • Save brookinc/88d6a7fdf0f3e3a8ac4c98a53b0e26b4 to your computer and use it in GitHub Desktop.
Save brookinc/88d6a7fdf0f3e3a8ac4c98a53b0e26b4 to your computer and use it in GitHub Desktop.
Apply macOS file system tags based on EXIF metadata.
#!/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