From 2b7839592bfb5b581e6c5942ca1511a127b9707a Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Mon, 14 Sep 2026 01:31:03 +0200 Subject: [PATCH] FileNameParser: Dateinamen nach Namenskonvention parsen und bauen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schritt 1 der Umsetzungsreihenfolge. Anker ist das Format-Segment (9x16/4x5), nicht die Position — dadurch bleiben Projektnamen und Sprachkürzel mit eigenem Bindestrich intakt (z.B. "Somos-Pendant" oder "EN-US"). Sucht das rechteste Vorkommen, verlangt mindestens ein Segment davor und danach, erkennt Finder-Duplikate und meldet Ablehnungen/Skips unterscheidbar statt still zu scheitern. 14 Tests inklusive der Bindestrich- und Groß-/Kleinschreibungs-Kantenfälle. --- Sources/DropTwoCore/FileNameParser.swift | 126 +++++++++++++++ .../FileNameParserTests.swift | 145 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 Sources/DropTwoCore/FileNameParser.swift create mode 100644 Tests/DropTwoCoreTests/FileNameParserTests.swift diff --git a/Sources/DropTwoCore/FileNameParser.swift b/Sources/DropTwoCore/FileNameParser.swift new file mode 100644 index 0000000..4be4d69 --- /dev/null +++ b/Sources/DropTwoCore/FileNameParser.swift @@ -0,0 +1,126 @@ +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..