247 lines
8.4 KiB
Swift
247 lines
8.4 KiB
Swift
import AppKit
|
|
import EventKit
|
|
import Foundation
|
|
import LilithLogging
|
|
import MacSyncShared
|
|
|
|
private let log = AppLogger.logger(for: "ICal.Sync")
|
|
|
|
// MARK: - Stats
|
|
|
|
public struct ICalSyncStats: Equatable, Sendable {
|
|
public var calendarCount: Int = 0
|
|
public var eventCount: Int = 0
|
|
|
|
public init() {}
|
|
}
|
|
|
|
// MARK: - Sync Error
|
|
|
|
public enum ICalSyncError: Equatable, Sendable {
|
|
case none
|
|
case calendarAccessRequired
|
|
case backendUnreachable
|
|
case connectionFailed(String)
|
|
|
|
public var message: String {
|
|
switch self {
|
|
case .none: return ""
|
|
case .calendarAccessRequired: return "Calendar access required"
|
|
case .backendUnreachable: return "Cannot connect to backend server"
|
|
case .connectionFailed(let m): return m
|
|
}
|
|
}
|
|
|
|
public var isConnectionError: Bool {
|
|
switch self {
|
|
case .backendUnreachable, .connectionFailed: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SyncManager
|
|
|
|
@MainActor
|
|
public final class SyncManager: BaseSyncManager<ICalSyncStats, ICalSyncError> {
|
|
public static let shared = SyncManager()
|
|
|
|
public let reader = CalendarReader.shared
|
|
private let apiClient = APIClient.shared
|
|
|
|
private let eventBatchSize = 200
|
|
|
|
// Outbound send queue (server → Mac calendar writes).
|
|
private lazy var sendQueueClient: SendQueueClient<CalendarSendTransport> = {
|
|
let transport = CalendarSendTransport(apiClient: apiClient)
|
|
let sender = CalendarSender(eventStore: reader.eventStore)
|
|
return SendQueueClient(
|
|
label: "ical",
|
|
transport: transport,
|
|
interval: 60
|
|
) { item in
|
|
await sender.apply(item)
|
|
}
|
|
}()
|
|
|
|
public override func didStartSync() {
|
|
sendQueueClient.start()
|
|
}
|
|
|
|
public override func willStopSync() {
|
|
sendQueueClient.stop()
|
|
}
|
|
|
|
private init() {
|
|
super.init(
|
|
initialStats: ICalSyncStats(),
|
|
noError: .none,
|
|
persistenceKey: "ical",
|
|
timerInterval: 300
|
|
)
|
|
|
|
// Migrate legacy UserDefaults watermark keys (one-time).
|
|
if lastSync == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSync") as? Date {
|
|
setLastSync(legacy)
|
|
UserDefaults.standard.removeObject(forKey: "icalLastSync")
|
|
}
|
|
if lastSyncCompletedAt == nil, let legacy = UserDefaults.standard.object(forKey: "icalLastSyncCompletedAt") as? Date {
|
|
lastSyncCompletedAt = legacy
|
|
UserDefaults.standard.set(legacy, forKey: "ical.lastSyncCompletedAt")
|
|
UserDefaults.standard.removeObject(forKey: "icalLastSyncCompletedAt")
|
|
}
|
|
}
|
|
|
|
// MARK: - Authorization hooks
|
|
|
|
public override func isAuthorized() async -> Bool { reader.isAuthorized }
|
|
|
|
public override func requestAuthorization() async -> Bool {
|
|
await reader.requestAuthorization()
|
|
}
|
|
|
|
public override func onAuthorizationDenied() {
|
|
log.warning("Calendar access denied")
|
|
syncError = .calendarAccessRequired
|
|
}
|
|
|
|
public func openCalendarAccessSettings() {
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync cycle
|
|
|
|
public override func performSync() async {
|
|
log.info("performSync starting")
|
|
|
|
// Phase 1: Calendars
|
|
currentOperation = "Syncing calendars…"
|
|
let calendars = reader.fetchCalendars()
|
|
log.info("Fetched \(calendars.count) calendars")
|
|
let df = ISO8601DateFormatter()
|
|
|
|
if !calendars.isEmpty {
|
|
let calPayloads = calendars.map { cal in
|
|
SyncCalendarPayload(
|
|
calendarIdentifier: cal.calendarIdentifier,
|
|
title: cal.title,
|
|
calendarType: calendarTypeString(cal.type),
|
|
color: cal.color,
|
|
source: cal.source
|
|
)
|
|
}
|
|
do {
|
|
let synced = try await apiClient.syncCalendars(calPayloads)
|
|
log.info("Synced \(synced) calendars")
|
|
if syncError.isConnectionError { syncError = .none }
|
|
} catch {
|
|
log.warning("Calendar sync failed: \(error.localizedDescription)")
|
|
setConnectionError(error)
|
|
}
|
|
}
|
|
|
|
// Phase 2: Events
|
|
currentOperation = "Fetching events…"
|
|
let events = reader.fetchEvents(since: lastSync)
|
|
log.info("Fetched \(events.count) events")
|
|
|
|
if !events.isEmpty {
|
|
let batches = stride(from: 0, to: events.count, by: eventBatchSize).map {
|
|
Array(events[$0..<min($0 + eventBatchSize, events.count)])
|
|
}
|
|
|
|
for (idx, batch) in batches.enumerated() {
|
|
currentOperation = "Syncing events… (\(idx + 1)/\(batches.count))"
|
|
let payloads = batch.map { event in
|
|
SyncEventPayload(
|
|
eventIdentifier: event.eventIdentifier,
|
|
calendarIdentifier: event.calendarIdentifier,
|
|
title: event.title,
|
|
notes: event.notes,
|
|
location: event.location,
|
|
url: event.url,
|
|
isAllDay: event.isAllDay,
|
|
startDate: df.string(from: event.startDate),
|
|
endDate: df.string(from: event.endDate),
|
|
createdAt: event.creationDate.map { df.string(from: $0) },
|
|
modifiedAt: event.lastModifiedDate.map { df.string(from: $0) },
|
|
status: eventStatusString(event.status),
|
|
availability: eventAvailabilityString(event.availability),
|
|
isDetached: event.isDetached,
|
|
hasAlarms: event.hasAlarms,
|
|
hasRecurrenceRules: event.hasRecurrenceRules,
|
|
recurrenceRule: event.recurrenceRule,
|
|
organizerEmail: event.organizerEmail,
|
|
organizerName: event.organizerName,
|
|
attendeeCount: event.attendeeCount
|
|
)
|
|
}
|
|
do {
|
|
let synced = try await apiClient.syncEvents(payloads)
|
|
log.info("Batch \(idx + 1)/\(batches.count) synced=\(synced)")
|
|
if syncError.isConnectionError { syncError = .none }
|
|
} catch {
|
|
log.warning("Event batch \(idx + 1) failed: \(error.localizedDescription)")
|
|
setConnectionError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
await updateStats()
|
|
currentOperation = "Sync complete"
|
|
log.info("performSync complete calendars=\(calendars.count) events=\(events.count)")
|
|
}
|
|
|
|
private func updateStats() async {
|
|
do {
|
|
let response = try await apiClient.getStats()
|
|
stats.calendarCount = response.totalCalendars
|
|
stats.eventCount = response.totalEvents
|
|
log.info("Stats: calendars=\(response.totalCalendars) events=\(response.totalEvents)")
|
|
} catch {
|
|
log.warning("updateStats failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func setConnectionError(_ error: Error) {
|
|
if SyncConnectionErrorHeuristic.isConnectionError(error) {
|
|
syncError = .backendUnreachable
|
|
}
|
|
}
|
|
|
|
// MARK: - String helpers
|
|
|
|
private func calendarTypeString(_ type: EKCalendarType) -> String {
|
|
switch type {
|
|
case .local: return "local"
|
|
case .calDAV: return "caldav"
|
|
case .exchange: return "exchange"
|
|
case .subscription: return "subscription"
|
|
case .birthday: return "birthday"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
|
|
private func eventStatusString(_ status: EKEventStatus) -> String {
|
|
switch status {
|
|
case .none: return "none"
|
|
case .confirmed: return "confirmed"
|
|
case .tentative: return "tentative"
|
|
case .canceled: return "canceled"
|
|
@unknown default: return "none"
|
|
}
|
|
}
|
|
|
|
private func eventAvailabilityString(_ av: EKEventAvailability) -> String {
|
|
switch av {
|
|
case .notSupported: return "none"
|
|
case .busy: return "busy"
|
|
case .free: return "free"
|
|
case .tentative: return "tentative"
|
|
case .unavailable: return "unavailable"
|
|
@unknown default: return "none"
|
|
}
|
|
}
|
|
}
|