import Foundation import IPodSyncKit import Observation @MainActor @Observable final class ExportModel { enum Phase: Equatable { case idle case working(String) case finished } // What to include var includeContacts = true var includeCalendars = true var includeFiles = false // Access var contactsGranted = ContactsReader.authorizationStatus == .authorized var calendarsGranted = CalendarReader.authorizationStatus == .fullAccess // Calendars var calendars: [CalendarSource] = [] var selectedCalendarIDs: Set = [] var rangeStart: Date var rangeEnd: Date // Existing files var inputFiles: [URL] = [] // Output var destination: URL? // Results var phase: Phase = .idle var summary = "" var warnings: [String] = [] var writtenFiles: [ExportedFile] = [] var errorMessage: String? private let contactsReader = ContactsReader() private let calendarReader = CalendarReader() init() { // A window wide enough to cover what you'd actually browse to on the // device, without dragging a decade of history across. let calendar = Calendar.current let now = Date() rangeStart = calendar.date(byAdding: .month, value: -1, to: now) ?? now rangeEnd = calendar.date(byAdding: .year, value: 1, to: now) ?? now } var isWorking: Bool { if case .working = phase { return true } return false } var canExport: Bool { guard destination != nil, !isWorking else { return false } if includeContacts && contactsGranted { return true } if includeCalendars && calendarsGranted && !selectedCalendarIDs.isEmpty { return true } if includeFiles && !inputFiles.isEmpty { return true } return false } // MARK: - Access func requestContactsAccess() async { contactsGranted = await contactsReader.requestAccess() if !contactsGranted { errorMessage = "Contacts access was denied. Grant it in System Settings › " + "Privacy & Security › Contacts, then reopen the app." } } func requestCalendarAccess() async { calendarsGranted = await calendarReader.requestAccess() if calendarsGranted { await loadCalendars() } else { errorMessage = "Calendar access was denied. Grant it in System Settings › " + "Privacy & Security › Calendars, then reopen the app." } } func loadCalendars() async { guard calendarsGranted else { return } calendars = await calendarReader.calendars() // Everything is selected by default; unchecking is the rarer action. if selectedCalendarIDs.isEmpty { selectedCalendarIDs = Set(calendars.map(\.id)) } } func refreshAccessOnAppear() async { contactsGranted = ContactsReader.authorizationStatus == .authorized calendarsGranted = CalendarReader.authorizationStatus == .fullAccess await loadCalendars() } func isSelected(_ calendar: CalendarSource) -> Bool { selectedCalendarIDs.contains(calendar.id) } func setSelected(_ calendar: CalendarSource, _ selected: Bool) { if selected { selectedCalendarIDs.insert(calendar.id) } else { selectedCalendarIDs.remove(calendar.id) } } func addInputFiles(_ urls: [URL]) { let supported = urls.filter { ["vcf", "ics"].contains($0.pathExtension.lowercased()) } for url in supported where !inputFiles.contains(url) { inputFiles.append(url) } if supported.count < urls.count { errorMessage = "Only .vcf and .ics files can be sanitized; the rest were ignored." } } // MARK: - Export func export() async { guard let destination else { return } phase = .working("Preparing…") summary = "" warnings = [] writtenFiles = [] errorMessage = nil var session = ExportSession(destination: destination) do { if includeContacts && contactsGranted { phase = .working("Reading contacts…") let contacts = try await contactsReader.fetchContacts() phase = .working("Writing \(contacts.count) contacts…") try session.write(contacts: contacts) } if includeCalendars && calendarsGranted { // One file per calendar, so a bad calendar can't take the // others down with it on the device. for calendar in calendars where selectedCalendarIDs.contains(calendar.id) { phase = .working("Reading “\(calendar.title)”…") let events = await calendarReader.events( calendarIdentifiers: [calendar.id], from: rangeStart, to: rangeEnd ) if events.isEmpty { session.noteWarning( "“\(calendar.title)” had no events in the selected dates." ) continue } try session.write(events: events, calendarName: calendar.title) } } if includeFiles { for url in inputFiles { phase = .working("Sanitizing \(url.lastPathComponent)…") switch url.pathExtension.lowercased() { case "vcf": let result = try VCardFileSanitizer.sanitize(contentsOf: url) try session.write(sanitized: result, as: .contacts) case "ics": let result = try ICalendarFileSanitizer.sanitize(contentsOf: url) try session.write(sanitized: result, as: .calendars) default: continue } } } summary = session.summary warnings = session.warnings writtenFiles = session.writtenFiles phase = .finished } catch { errorMessage = error.localizedDescription summary = session.summary warnings = session.warnings writtenFiles = session.writtenFiles phase = .finished } } }