import Foundation /// Produces filenames safe for an iPod's volume, and unique within one export. /// /// Click-wheel iPods are usually FAT32 and their Contacts/Calendars browsers /// handle non-ASCII filenames poorly, so names are folded to ASCII rather than /// passed through. Accents fold to their base letter (`Renée` → `Renee`); /// anything still outside the safe set is dropped. public struct FileNameAllocator { /// Lowercased names already handed out, so collisions can be numbered. private var used: Set = [] public static let maximumLength = 60 public init() {} /// Returns a unique filename with `fileExtension`, based on `preferred`. public mutating func allocate( preferred: String, fallback: String = "item", fileExtension: String ) -> String { let base = Self.sanitize(preferred, fallback: fallback) var candidate = base var counter = 1 while used.contains(candidate.lowercased()) { counter += 1 let suffix = "_\(counter)" let trimmed = String(base.prefix(Self.maximumLength - suffix.count)) candidate = trimmed + suffix } used.insert(candidate.lowercased()) return "\(candidate).\(fileExtension)" } /// Folds to ASCII, keeps `[A-Za-z0-9 ._-]`, collapses runs of whitespace. static func sanitize(_ name: String, fallback: String) -> String { let folded = name.folding( options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX") ) let allowed = folded.map { character -> Character in character.isASCII && (character.isLetter || character.isNumber || character == " " || character == "." || character == "_" || character == "-") ? character : " " } let collapsed = String(allowed) .split(separator: " ", omittingEmptySubsequences: true) .joined(separator: " ") // A name of only dots would collide with "." / ".." on the device. let trimmed = collapsed .trimmingCharacters(in: CharacterSet(charactersIn: " .")) .prefix(maximumLength) return trimmed.isEmpty ? fallback : String(trimmed) } }