FileNameParser: Dateinamen nach Namenskonvention parsen und bauen

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.
main
Samuel Zielke 1 month ago
parent 7dcbfc2ba0
commit 2b7839592b

@ -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[..<index].joined(separator: "-"),
sourceFormatToken: segments[index],
remainder: segments[(index + 1)...].joined(separator: "-"),
fileExtension: fileExtension
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
}
if let index = lastIndex(ofSegmentMatching: targetFormatToken, in: segments),
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
}
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
}
/// 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 lastIndex(ofSegmentMatching token: String, in segments: [String]) -> 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..<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
}
}

@ -0,0 +1,145 @@
import XCTest
@testable import DropTwoCore
final class FileNameParserTests: XCTestCase {
// MARK: - Erfolgreiches Parsen
func testParsesStandardFileName() {
let result = FileNameParser.parse("SOMOS-9x16-v3-DE.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "SOMOS")
XCTAssertEqual(parsed.sourceFormatToken, "9x16")
XCTAssertEqual(parsed.remainder, "v3-DE")
XCTAssertEqual(parsed.fileExtension, "mp4")
XCTAssertEqual(parsed.originalFileName, "SOMOS-9x16-v3-DE.mp4")
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.mp4")
}
func testProjectNameWithHyphenIsPreserved() {
let result = FileNameParser.parse("Somos-Pendant-9x16-v3-DE.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "Somos-Pendant")
XCTAssertEqual(parsed.targetFileName, "Somos-Pendant-4x5-v3-DE.mp4")
}
func testLanguageSegmentWithHyphenIsPreserved() {
let result = FileNameParser.parse("SOMOS-9x16-v3-EN-US.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "SOMOS")
XCTAssertEqual(parsed.remainder, "v3-EN-US")
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-EN-US.mp4")
}
func testCaseInsensitiveFormatTokenIsRecognized() {
let result = FileNameParser.parse("somos-9X16-v3-de.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
// Sonst unveränderte Schreibweise bleibt erhalten.
XCTAssertEqual(parsed.projectName, "somos")
XCTAssertEqual(parsed.sourceFormatToken, "9X16")
XCTAssertEqual(parsed.remainder, "v3-de")
XCTAssertEqual(parsed.targetFileName, "somos-4x5-v3-de.mp4")
}
func testFileExtensionIsPreservedVerbatim() {
for ext in ["mp4", "mov", "mxf", "MOV"] {
let fileName = "SOMOS-9x16-v3-DE.\(ext)"
let result = FileNameParser.parse(fileName)
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed für \(fileName), bekam \(result.outcome)")
}
XCTAssertEqual(parsed.fileExtension, ext)
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.\(ext)")
}
}
// MARK: - Skip
func testAlreadyTargetFormatIsSkipped() {
let result = FileNameParser.parse("SOMOS-4x5-v3-DE.mp4")
XCTAssertEqual(result.outcome, .skipped(.alreadyTargetFormat))
}
// MARK: - Rejection
func testMissingFormatSegmentIsRejected() {
let result = FileNameParser.parse("SOMOS-v3-DE.mp4")
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
}
func testFinderDuplicateIsRejected() {
let result = FileNameParser.parse("SOMOS-9x16-v3-DE (1).mp4")
XCTAssertEqual(result.outcome, .rejected(.finderDuplicate))
}
func testFinderDuplicateTakesPriorityOverMissingFormatSegment() {
// Beide Aussagen wären technisch wahr; die handlungsleitende
// Meldung (falsche Kopie erwischt) muss gewinnen.
let result = FileNameParser.parse("SOMOS-v3-DE (1).mp4")
XCTAssertEqual(result.outcome, .rejected(.finderDuplicate))
}
// MARK: - a) Rechtestes Vorkommen gewinnt
func testRightmostFormatOccurrenceWins() {
let result = FileNameParser.parse("Kampagne-9x16-Teaser-9x16-v3-DE.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "Kampagne-9x16-Teaser")
XCTAssertEqual(parsed.remainder, "v3-DE")
XCTAssertEqual(parsed.targetFileName, "Kampagne-9x16-Teaser-4x5-v3-DE.mp4")
}
func testRightmostAlreadyTargetFormatOccurrenceWins() {
let result = FileNameParser.parse("Kampagne-4x5-Teaser-4x5-v3-DE.mp4")
XCTAssertEqual(result.outcome, .skipped(.alreadyTargetFormat))
}
// MARK: - b) Projekt und Rest müssen existieren
func testRejectsWhenNoProjectSegment() {
let result = FileNameParser.parse("9x16-v3-DE.mp4")
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
}
func testRejectsWhenNoRemainderSegment() {
let result = FileNameParser.parse("SOMOS-9x16.mp4")
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
}
// MARK: - c) Format-Segment in der Ausgabe immer klein geschrieben
func testTargetFormatTokenIsAlwaysLowercase() {
let result = FileNameParser.parse("SOMOS-9X16-v3-DE.mp4")
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.sourceFormatToken, "9X16")
XCTAssertTrue(parsed.targetFileName.contains("-4x5-"))
XCTAssertFalse(parsed.targetFileName.contains("4X16"))
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5-v3-DE.mp4")
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.