You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
127 lines
5.5 KiB
127 lines
5.5 KiB
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
|
|
}
|
|
}
|