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.

163 lines
7.4 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 = #"(?<![\p{L}\p{N}])9 ?[xX×] ?16(?![\p{L}\p{N}])"#
private static let targetFormatPattern = #"(?<![\p{L}\p{N}])4 ?[xX×] ?5(?![\p{L}\p{N}])"#
private static let separators = CharacterSet(charactersIn: "-_ ()[]")
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))
}
if let range = lastRange(of: sourceFormatPattern, in: stem) {
let head = String(stem[..<range.lowerBound])
let tail = String(stem[range.upperBound...])
let projectName = head.trimmingCharacters(in: separators)
if !projectName.isEmpty {
let parsed = ParsedFileName(
originalFileName: fileName,
projectName: projectName,
sourceFormatToken: String(stem[range]),
remainder: tail.trimmingCharacters(in: separators),
fileExtension: fileExtension,
head: head,
tail: tail
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
}
}
if lastRange(of: targetFormatPattern, in: stem) != nil {
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
}
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
}
/// Ausweg für Dateien ohne Format im Namen: der ganze Dateistamm wird zum
/// Projekt, "4x5" wird hinten angehängt — mit dem Trenner, den der Name
/// schon benutzt. `nil` für Finder-Duplikate und Namen, die regulär
/// geparst werden können.
public static func appendingTargetFormat(to fileName: String) -> 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<String.Index>? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let fullRange = NSRange(stem.startIndex..<stem.endIndex, in: stem)
guard let match = regex.matches(in: stem, range: fullRange).last else { return nil }
return Range(match.range, in: stem)
}
/// Der Trenner, den ein Name schon benutzt: `_` oder Leerzeichen, wenn
/// nur dieser vorkommt, sonst `-` wie in der Namenskonvention.
private static func preferredSeparator(in name: String) -> 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..<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
}
}

Powered by TurnKey Linux.