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 = #"(? 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[.. 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? { guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let fullRange = NSRange(stem.startIndex.. 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.. Bool { guard let regex = try? NSRegularExpression(pattern: #"\s\(\d+\)$"#) else { return false } let range = NSRange(stem.startIndex..