Initial commit
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user