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. 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"). public let sourceFormatToken: String /// Alles nach dem Format-Segment, zeichengleich, z.B. "v3-DE" oder "v3-EN-US". public let remainder: String /// Ohne führenden Punkt, unverändert übernommen (Groß-/Kleinschreibung inklusive). public let fileExtension: String /// Zieldateiname mit ausschließlich dem Format-Segment ersetzt (immer /// klein geschrieben als "4x5"). Alle anderen Segmente bleiben zeichengleich. public var targetFileName: String { let ext = fileExtension.isEmpty ? "" : ".\(fileExtension)" return "\(projectName)-\(FileNameParser.targetFormatToken)-\(remainder)\(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" noch "4x5" als /// eigenständiges Segment mit mindestens einem Segment davor und danach. 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 sourceFormatToken = "9x16" fileprivate static let targetFormatToken = "4x5" 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)) } let segments = stem.components(separatedBy: "-") if let index = lastIndex(ofSegmentMatching: sourceFormatToken, in: segments), hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) { let parsed = ParsedFileName( originalFileName: fileName, projectName: segments[.. Int? { for index in stride(from: segments.count - 1, through: 0, by: -1) { if segments[index].caseInsensitiveCompare(token) == .orderedSame { return index } } return nil } /// Mindestens ein Segment davor (Projekt) und mindestens eins danach /// (Rest). Bewusst NICHT auf genau ein Segment danach geprüft — sonst /// bricht ein mehrteiliges Sprachkürzel wie "EN-US". private static func hasProjectAndRemainder(atIndex index: Int, segmentCount: Int) -> Bool { index > 0 && index < segmentCount - 1 } 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.. Bool { guard let regex = try? NSRegularExpression(pattern: #"\s\(\d+\)$"#) else { return false } let range = NSRange(stem.startIndex..