Initial commit

This commit is contained in:
Alex E. Tafoya
2026-09-17 15:15:45 -05:00
commit 87fe7927c4
36 changed files with 5113 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
// 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<from>,<to> - substring
// :r<search>,<replace> - replace
// :t - trim
// :u / :l - upper / lower case
import Foundation
enum FilenamePattern {
private static let variableRegex = try! NSRegularExpression(
pattern: #"\$\{(?<variable>[^:}]+)[:]?(?<parameters>[^}]*)\}"#)
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..<end])
} else {
value = ""
}
default:
break
}
}
return value
}
/// Splits parameters on "," but keeps quoted sections together.
private static func split(_ parameters: String) -> [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))
}
}