|
|
import Foundation
|
|
|
|
|
|
/// Ergebnis eines einzelnen Parse-Vorgangs. Trägt den Originalnamen mit,
|
|
|
/// damit eine Fehlerliste in der UI ohne Zusatzkontext aufgebaut werden kann.
|
|
|
public struct FileNameParseResult: Equatable {
|
|
|
public let fileName: String
|
|
|
public let outcome: Outcome
|
|
|
|
|
|
public enum Outcome: Equatable {
|
|
|
case parsed(ParsedFileName)
|
|
|
case skipped(SkipReason)
|
|
|
case rejected(RejectionReason)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// Erfolgreich zerlegter Dateiname nach dem Schema
|
|
|
/// `[Projekt]-[Format]-[Version]-[Sprache].[ext]`, wobei das Format-Segment
|
|
|
/// (`9x16`) als Anker dient und nicht die Position. Als Trenner zählen `-`,
|
|
|
/// `_` und Leerzeichen; sie bleiben in der Zielfassung zeichengleich erhalten.
|
|
|
public struct ParsedFileName: Equatable {
|
|
|
/// Wörtlich wie übergeben — wird nicht aus den Segmenten rekonstruiert,
|
|
|
/// damit ein Rundtrip-Fehler (Groß-/Kleinschreibung, Sonderzeichen) nie
|
|
|
/// den gespeicherten Originalnamen verfälscht.
|
|
|
public let originalFileName: String
|
|
|
public let projectName: String
|
|
|
/// Das Format-Segment, wie es im Original vorgefunden wurde (z.B. "9X16").
|
|
|
/// Leer, wenn das Format per `FileNameParser.appendingTargetFormat` erst
|
|
|
/// angehängt wird.
|
|
|
public let sourceFormatToken: String
|
|
|
/// Alles nach dem Format-Segment ohne den Trenner davor, zeichengleich,
|
|
|
/// z.B. "v3-DE" oder "v3-EN-US". Darf leer sein.
|
|
|
public let remainder: String
|
|
|
/// Ohne führenden Punkt, unverändert übernommen (Groß-/Kleinschreibung inklusive).
|
|
|
public let fileExtension: String
|
|
|
/// Alles vor bzw. nach dem Format-Segment, inklusive der Trenner.
|
|
|
let head: String
|
|
|
let tail: String
|
|
|
|
|
|
/// Zieldateiname mit ausschließlich dem Format-Segment ersetzt (immer
|
|
|
/// klein geschrieben als "4x5"). Alle anderen Zeichen bleiben gleich.
|
|
|
public var targetFileName: String {
|
|
|
let ext = fileExtension.isEmpty ? "" : ".\(fileExtension)"
|
|
|
return "\(head)\(FileNameParser.targetFormatToken)\(tail)\(ext)"
|
|
|
}
|
|
|
}
|
|
|
|
|
|
public enum SkipReason: Equatable {
|
|
|
/// Format-Segment ist bereits "4x5" — nichts zu tun.
|
|
|
case alreadyTargetFormat
|
|
|
}
|
|
|
|
|
|
public enum RejectionReason: Equatable {
|
|
|
/// Dateistamm endet auf ein Finder-Duplikat-Suffix wie " (1)".
|
|
|
case finderDuplicate
|
|
|
/// Kein nutzbares Format-Segment gefunden: weder "9x16" mit einem
|
|
|
/// Projektnamen davor noch "4x5". Ausweg: `appendingTargetFormat`.
|
|
|
case formatSegmentMissing
|
|
|
}
|
|
|
|
|
|
/// Zerlegt und baut Dateinamen nach der Namenskonvention. Komplett UI-frei
|
|
|
/// und zustandslos — reine Zeichenketten rein, reine Zeichenketten (bzw.
|
|
|
/// Ergebnistypen) raus.
|
|
|
public enum FileNameParser {
|
|
|
fileprivate static let targetFormatToken = "4x5"
|
|
|
|
|
|
/// Format-Token als eigenständiges Segment: `9x16`, `9X16`, `9×16`, auch
|
|
|
/// mit Leerzeichen um das x. Davor und danach darf kein Buchstabe und keine
|
|
|
/// Ziffer stehen — `-`, `_`, Leerzeichen, Klammern oder Namensanfang/-ende.
|
|
|
private static let sourceFormatPattern = #"(?<![\p{L}\p{N}])9 ?[xX×] ?16(?![\p{L}\p{N}])"#
|
|
|
private static let targetFormatPattern = #"(?<![\p{L}\p{N}])4 ?[xX×] ?5(?![\p{L}\p{N}])"#
|
|
|
private static let separators = CharacterSet(charactersIn: "-_ ()[]")
|
|
|
|
|
|
public static func parse(_ fileName: String) -> FileNameParseResult {
|
|
|
let (stem, fileExtension) = splitExtension(fileName)
|
|
|
|
|
|
// Finder-Duplikat zuerst prüfen: ist eine Datei sowohl Duplikat als
|
|
|
// auch strukturell kaputt, ist "falsche Kopie erwischt" die
|
|
|
// handlungsleitende Meldung, nicht die technische Detailursache.
|
|
|
if isFinderDuplicate(stem) {
|
|
|
return FileNameParseResult(fileName: fileName, outcome: .rejected(.finderDuplicate))
|
|
|
}
|
|
|
|
|
|
if let range = lastRange(of: sourceFormatPattern, in: stem) {
|
|
|
let head = String(stem[..<range.lowerBound])
|
|
|
let tail = String(stem[range.upperBound...])
|
|
|
let projectName = head.trimmingCharacters(in: separators)
|
|
|
if !projectName.isEmpty {
|
|
|
let parsed = ParsedFileName(
|
|
|
originalFileName: fileName,
|
|
|
projectName: projectName,
|
|
|
sourceFormatToken: String(stem[range]),
|
|
|
remainder: tail.trimmingCharacters(in: separators),
|
|
|
fileExtension: fileExtension,
|
|
|
head: head,
|
|
|
tail: tail
|
|
|
)
|
|
|
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if lastRange(of: targetFormatPattern, in: stem) != nil {
|
|
|
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
|
|
|
}
|
|
|
|
|
|
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
|
|
|
}
|
|
|
|
|
|
/// Ausweg für Dateien ohne Format im Namen: der ganze Dateistamm wird zum
|
|
|
/// Projekt, "4x5" wird hinten angehängt — mit dem Trenner, den der Name
|
|
|
/// schon benutzt. `nil` für Finder-Duplikate und Namen, die regulär
|
|
|
/// geparst werden können.
|
|
|
public static func appendingTargetFormat(to fileName: String) -> ParsedFileName? {
|
|
|
guard case .rejected(.formatSegmentMissing) = parse(fileName).outcome else { return nil }
|
|
|
let (stem, fileExtension) = splitExtension(fileName)
|
|
|
let projectName = stem.trimmingCharacters(in: separators)
|
|
|
guard !projectName.isEmpty else { return nil }
|
|
|
return ParsedFileName(
|
|
|
originalFileName: fileName,
|
|
|
projectName: projectName,
|
|
|
sourceFormatToken: "",
|
|
|
remainder: "",
|
|
|
fileExtension: fileExtension,
|
|
|
head: projectName + preferredSeparator(in: projectName),
|
|
|
tail: ""
|
|
|
)
|
|
|
}
|
|
|
|
|
|
/// Sucht von rechts, damit bei mehrfachem Vorkommen (z.B. weil der
|
|
|
/// Projektname selbst "9x16" enthält) das echte Format-Segment gewinnt —
|
|
|
/// das direkt vor Version/Sprache.
|
|
|
private static func lastRange(of pattern: String, in stem: String) -> Range<String.Index>? {
|
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
|
let fullRange = NSRange(stem.startIndex..<stem.endIndex, in: stem)
|
|
|
guard let match = regex.matches(in: stem, range: fullRange).last else { return nil }
|
|
|
return Range(match.range, in: stem)
|
|
|
}
|
|
|
|
|
|
/// Der Trenner, den ein Name schon benutzt: `_` oder Leerzeichen, wenn
|
|
|
/// nur dieser vorkommt, sonst `-` wie in der Namenskonvention.
|
|
|
private static func preferredSeparator(in name: String) -> String {
|
|
|
let hasHyphen = name.contains("-")
|
|
|
if !hasHyphen && name.contains("_") { return "_" }
|
|
|
if !hasHyphen && name.contains(" ") { return " " }
|
|
|
return "-"
|
|
|
}
|
|
|
|
|
|
private static func splitExtension(_ fileName: String) -> (stem: String, extension: String) {
|
|
|
guard let dotIndex = fileName.lastIndex(of: ".") else {
|
|
|
return (fileName, "")
|
|
|
}
|
|
|
let stem = String(fileName[fileName.startIndex..<dotIndex])
|
|
|
let fileExtension = String(fileName[fileName.index(after: dotIndex)...])
|
|
|
return (stem, fileExtension)
|
|
|
}
|
|
|
|
|
|
/// Erkennt das von Finder angehängte Duplikat-Suffix, z.B. "Name (1)".
|
|
|
private static func isFinderDuplicate(_ stem: String) -> Bool {
|
|
|
guard let regex = try? NSRegularExpression(pattern: #"\s\(\d+\)$"#) else { return false }
|
|
|
let range = NSRange(stem.startIndex..<stem.endIndex, in: stem)
|
|
|
return regex.firstMatch(in: stem, options: [], range: range) != nil
|
|
|
}
|
|
|
}
|