import Foundation /// One file written during an export. public struct ExportedFile: Sendable, Equatable, Identifiable { public var id: String { path.path } public var path: URL /// Which of the device's folders this belongs in. public var destination: ExportSession.Folder public init(path: URL, destination: ExportSession.Folder) { self.path = path self.destination = destination } } /// Writes sanitized output into a destination folder. /// /// Output is laid out as `Contacts/` and `Calendars/` subfolders, mirroring the /// folders on the iPod itself, so the result can be dragged across in Finder /// without sorting anything by hand. public struct ExportSession { public enum Folder: String, Sendable, CaseIterable { case contacts = "Contacts" case calendars = "Calendars" } public let destination: URL /// One allocator per folder: names only need to be unique among their peers. private var contactNames = FileNameAllocator() private var calendarNames = FileNameAllocator() public private(set) var writtenFiles: [ExportedFile] = [] public private(set) var warnings: [String] = [] public init(destination: URL) { self.destination = destination } // MARK: - Writing from the system databases /// Writes one vCard per contact. public mutating func write(contacts: [IPodContact]) throws { var skipped = 0 for contact in contacts { guard !contact.isEmpty else { skipped += 1 continue } let name = contactNames.allocate( preferred: contact.displayName, fallback: "contact", fileExtension: "vcf" ) try write(VCardWriter.makeVCard(for: contact), named: name, in: .contacts) } if skipped > 0 { warnings.append("\(skipped) contact(s) had no name or details and were skipped.") } } /// Writes one `.ics` per calendar. public mutating func write( events: [IPodEvent], calendarName: String, timeZone: TimeZone = .current ) throws { guard !events.isEmpty else { return } let name = calendarNames.allocate( preferred: calendarName, fallback: "calendar", fileExtension: "ics" ) let contents = ICalendarWriter.makeCalendar(events: events, timeZone: timeZone) try write(contents, named: name, in: .calendars) } // MARK: - Writing from sanitized input files public mutating func write(sanitized: FileSanitizationResult, as folder: Folder) throws { let fileExtension = folder == .contacts ? "vcf" : "ics" for file in sanitized.files { let fallback = folder == .contacts ? "contact" : "calendar" let name = folder == .contacts ? contactNames.allocate( preferred: file.suggestedName, fallback: fallback, fileExtension: fileExtension ) : calendarNames.allocate( preferred: file.suggestedName, fallback: fallback, fileExtension: fileExtension ) try write(file.contents, named: name, in: folder) } warnings.append(contentsOf: sanitized.warnings) } /// Records something the caller noticed that the user should see. public mutating func noteWarning(_ message: String) { warnings.append(message) } // MARK: - private mutating func write(_ contents: String, named name: String, in folder: Folder) throws { let directory = destination.appendingPathComponent(folder.rawValue, isDirectory: true) try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true ) let path = directory.appendingPathComponent(name) try Data(contents.utf8).write(to: path, options: .atomic) writtenFiles.append(ExportedFile(path: path, destination: folder)) } public var summary: String { let contacts = writtenFiles.filter { $0.destination == .contacts }.count let calendars = writtenFiles.filter { $0.destination == .calendars }.count var parts: [String] = [] if contacts > 0 { parts.append("\(contacts) contact file\(contacts == 1 ? "" : "s")") } if calendars > 0 { parts.append("\(calendars) calendar file\(calendars == 1 ? "" : "s")") } return parts.isEmpty ? "Nothing was written." : "Wrote " + parts.joined(separator: " and ") + "." } }