import Foundation enum SyncScheduleKind: String, CaseIterable, Identifiable, Codable { case manual case once case hourly case daily case weekly case monthly case custom var id: String { rawValue } var title: String { switch self { case .manual: return "Manual only" case .once: return "Once" case .hourly: return "Hourly" case .daily: return "Daily" case .weekly: return "Weekly" case .monthly: return "Monthly" case .custom: return "Custom interval" } } var systemImage: String { switch self { case .manual: return "hand.tap" case .once: return "calendar.badge.clock" case .hourly: return "clock.arrow.circlepath" case .daily: return "sun.max" case .weekly: return "calendar" case .monthly: return "calendar.circle" case .custom: return "timer" } } } enum SyncRunTrigger: String, Codable { case manual case scheduled case watched case syncAll var title: String { switch self { case .manual: return "Manual" case .scheduled: return "Scheduled" case .watched: return "File change" case .syncAll: return "Sync All" } } var systemImage: String { switch self { case .manual: return "play.fill" case .scheduled: return "calendar.badge.clock" case .watched: return "eye.fill" case .syncAll: return "arrow.triangle.2.circlepath" } } } extension SyncJob { var scheduleKind: SyncScheduleKind { get { SyncScheduleKind(rawValue: scheduleKindRaw) ?? .manual } set { scheduleKindRaw = newValue.rawValue } } var effectiveWatchPath: String { let trimmed = watchPath.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? sourcePath : trimmed } var scheduleDescription: String { guard scheduleEnabled, scheduleKind != .manual else { return "Manual" } let time = scheduleStartAt.formatted(date: .omitted, time: .shortened) switch scheduleKind { case .manual: return "Manual" case .once: return "Once · \(scheduleStartAt.formatted(date: .abbreviated, time: .shortened))" case .hourly: return "Every hour" case .daily: return "Daily · \(time)" case .weekly: return "Weekly · \(scheduleStartAt.formatted(.dateTime.weekday(.abbreviated))) · \(time)" case .monthly: return "Monthly · day \(Calendar.current.component(.day, from: scheduleStartAt)) · \(time)" case .custom: return "Every \(max(1, scheduleIntervalMinutes)) min" } } func calculateNextRun(after date: Date = Date()) -> Date? { guard scheduleEnabled, scheduleKind != .manual else { return nil } let calendar = Calendar.current switch scheduleKind { case .manual: return nil case .once: return scheduleStartAt > date ? scheduleStartAt : nil case .hourly: return nextInterval(after: date, seconds: 3600) case .custom: return nextInterval(after: date, seconds: TimeInterval(max(1, scheduleIntervalMinutes) * 60)) case .daily: let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) return calendar.nextDate(after: date, matching: time, matchingPolicy: .nextTime) case .weekly: var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) components.weekday = calendar.component(.weekday, from: scheduleStartAt) return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) case .monthly: var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt) components.day = calendar.component(.day, from: scheduleStartAt) return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime) } } func occurrences(from start: Date, through end: Date, limit: Int = 400) -> [Date] { guard scheduleEnabled, scheduleKind != .manual, start < end else { return [] } var output: [Date] = [] var cursor = start.addingTimeInterval(-1) if scheduleKind == .once { return (scheduleStartAt >= start && scheduleStartAt < end) ? [scheduleStartAt] : [] } while output.count < limit, let next = calculateNextRun(after: cursor), next < end { if next >= start { output.append(next) } cursor = next.addingTimeInterval(0.5) } return output } private func nextInterval(after date: Date, seconds: TimeInterval) -> Date? { guard seconds > 0 else { return nil } if scheduleStartAt > date { return scheduleStartAt } let elapsed = date.timeIntervalSince(scheduleStartAt) let steps = floor(elapsed / seconds) + 1 return scheduleStartAt.addingTimeInterval(steps * seconds) } }