// FilenamePattern.swift // GreenshotMac // // Port of Greenshot's FilenameHelper: ${variable:parameters} expansion. // // Supported variables: // ${capturetime} - capture timestamp, ${capturetime:d"yyyy-MM-dd HH_mm_ss"} for a custom format // ${now} - current timestamp, same parameter syntax // ${title} - window title of the capture // ${appname} - application name of the captured window // ${user} - short user name // ${hostname} - computer name // ${mode} - capture mode (region / window / fullscreen) // ${NUM} - zero padded incrementing counter, ${NUM:d4} for four digits // Parameters shared by all variables: // :s, - substring // :r, - replace // :t - trim // :u / :l - upper / lower case import Foundation enum FilenamePattern { private static let variableRegex = try! NSRegularExpression( pattern: #"\$\{(?[^:}]+)[:]?(?[^}]*)\}"#) private static let counterKey = "FilenamePatternCounter" private static let maxTitleLength = 80 /// Expands the pattern and returns a filename *without* extension. static func filename(pattern: String, details: CaptureDetails) -> String { let expanded = expand(pattern: pattern, details: details) return makeSafe(expanded.isEmpty ? defaultName(details) : expanded) } /// Expands the pattern and appends the extension of the given format. static func filename(pattern: String, details: CaptureDetails, format: OutputFormat) -> String { filename(pattern: pattern, details: details) + "." + format.rawValue } static func defaultName(_ details: CaptureDetails) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH_mm_ss" return formatter.string(from: details.dateTime) } static func expand(pattern: String, details: CaptureDetails) -> String { let ns = pattern as NSString var result = "" var location = 0 variableRegex.enumerateMatches(in: pattern, range: NSRange(location: 0, length: ns.length)) { match, _, _ in guard let match else { return } result += ns.substring(with: NSRange(location: location, length: match.range.location - location)) let variable = ns.substring(with: match.range(withName: "variable")) let parameters = match.range(withName: "parameters").location == NSNotFound ? "" : ns.substring(with: match.range(withName: "parameters")) result += value(for: variable, parameters: parameters, details: details) location = match.range.location + match.range.length } result += ns.substring(from: location) return result } private static func value(for variable: String, parameters: String, details: CaptureDetails) -> String { var replacement: String switch variable.lowercased() { case "capturetime": replacement = formatted(date: details.dateTime, parameters: parameters) case "now": replacement = formatted(date: Date(), parameters: parameters) case "title": replacement = String(details.title.prefix(maxTitleLength)) case "appname", "application": replacement = details.applicationName ?? "" case "user", "username": replacement = NSUserName() case "hostname", "computername": replacement = Host.current().localizedName ?? ProcessInfo.processInfo.hostName case "mode": replacement = details.captureMode.rawValue case "num": replacement = counter(parameters: parameters) default: replacement = ProcessInfo.processInfo.environment[variable] ?? "" } return apply(parameters: parameters, to: replacement) } private static func formatted(date: Date, parameters: String) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") if let format = parameter(parameters, prefix: "d") { formatter.dateFormat = format } else { formatter.dateFormat = "yyyy-MM-dd HH_mm_ss" } return formatter.string(from: date) } private static func counter(parameters: String) -> String { let defaults = UserDefaults.standard let next = defaults.integer(forKey: counterKey) + 1 defaults.set(next, forKey: counterKey) let digits = Int(parameter(parameters, prefix: "d") ?? "") ?? 1 return String(format: "%0\(max(1, digits))d", next) } /// Extracts a single parameter such as d"yyyy-MM-dd" or d4 from the parameter string. private static func parameter(_ parameters: String, prefix: String) -> String? { for part in split(parameters) where part.hasPrefix(prefix) { var value = String(part.dropFirst(prefix.count)) if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 { value = String(value.dropFirst().dropLast()) } return value } return nil } private static func apply(parameters: String, to input: String) -> String { var value = input for part in split(parameters) { guard let marker = part.first else { continue } let argument = String(part.dropFirst()) switch marker { case "t": value = value.trimmingCharacters(in: .whitespacesAndNewlines) case "u": value = value.uppercased() case "l": value = value.lowercased() case "r": let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) if pieces.count == 2 { value = value.replacingOccurrences(of: unquote(String(pieces[0])), with: unquote(String(pieces[1]))) } case "s": let pieces = argument.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) let from = max(0, Int(pieces.first.map(String.init) ?? "") ?? 0) let to = pieces.count == 2 ? (Int(String(pieces[1])) ?? value.count) : value.count if from < value.count { let start = value.index(value.startIndex, offsetBy: from) let end = value.index(value.startIndex, offsetBy: min(max(to, from), value.count)) value = String(value[start.. [String] { var parts: [String] = [] var current = "" var inQuotes = false for character in parameters { if character == "\"" { inQuotes.toggle() current.append(character) } else if character == "," && !inQuotes { parts.append(current) current = "" } else { current.append(character) } } if !current.isEmpty { parts.append(current) } return parts } private static func unquote(_ value: String) -> String { guard value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") else { return value } return String(value.dropFirst().dropLast()) } /// Strips characters that are illegal in a macOS filename. static func makeSafe(_ filename: String) -> String { var safe = filename for character in [":", "/", "\\", "\0"] { safe = safe.replacingOccurrences(of: character, with: "_") } safe = safe.trimmingCharacters(in: .whitespacesAndNewlines) if safe.hasPrefix(".") { safe = "_" + safe.dropFirst() } if safe.isEmpty { safe = "screenshot" } return String(safe.prefix(200)) } }