import Foundation import MacToolsPluginKit struct CalendarEventColor: Equatable, Sendable { let red: Double let green: Double let blue: Double let alpha: Double static let accent = CalendarEventColor(red: 0.0, green: 0.48, blue: 1.0, alpha: 1.0) } struct CalendarEventInput: Equatable, Sendable { let id: String let title: String let startDate: Date let endDate: Date let isAllDay: Bool let color: CalendarEventColor } struct CalendarEventSummary: Identifiable, Equatable, Sendable { let id: String let title: String let timeText: String let startDate: Date let endDate: Date let isAllDay: Bool let color: CalendarEventColor } enum CalendarHolidayKind: Int, Equatable, Sendable { case workday = 1 case holiday = 2 func badgeText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { switch self { case .workday: return localization.string("holiday.badge.workday", defaultValue: "班") case .holiday: return localization.string("holiday.badge.holiday", defaultValue: "休") } } } struct CalendarDayModel: Identifiable, Equatable, Sendable { let id: String let date: Date let dayNumber: String let lunarText: String let isInDisplayedMonth: Bool let isToday: Bool let isWeekend: Bool let holidayKind: CalendarHolidayKind? let events: [CalendarEventSummary] var visibleEvents: [CalendarEventSummary] { Array(events.prefix(Self.maximumVisibleEvents)) } static let maximumVisibleEvents = 3 } struct CalendarMonthModel: Equatable, Sendable { let displayedMonthStart: Date let title: String let weekdaySymbols: [String] let days: [CalendarDayModel] } enum CalendarComponentCalendars { static func gregorianFollowingSystem() -> Calendar { let current = Calendar.autoupdatingCurrent var calendar = current.identifier == .gregorian ? current : Calendar(identifier: .gregorian) calendar.locale = current.locale calendar.timeZone = current.timeZone calendar.firstWeekday = current.firstWeekday return calendar } static func monthStart(containing date: Date, calendar: Calendar) -> Date { let components = calendar.dateComponents([.year, .month], from: date) return calendar.date(from: components) ?? calendar.startOfDay(for: date) } static func dayID(for date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) return String( format: "%04d%02d%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0 ) } } struct CalendarMonthModelBuilder { private let calendar: Calendar private let lunarCalendar: Calendar private let holidayProvider: CalendarHolidayProvider private let localization: PluginLocalization private let showsHolidayBadges: Bool init( calendar: Calendar = CalendarComponentCalendars.gregorianFollowingSystem(), holidayProvider: CalendarHolidayProvider = .empty, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.calendar = calendar self.holidayProvider = holidayProvider self.localization = localization var lunarCalendar = Calendar(identifier: .chinese) lunarCalendar.locale = Locale(identifier: "zh_Hans_CN") lunarCalendar.timeZone = calendar.timeZone self.lunarCalendar = lunarCalendar self.showsHolidayBadges = Self.shouldShowHolidayBadges(calendar: calendar) } func makeMonth( containing monthDate: Date, today: Date = Date(), eventsByDay: [Date: [CalendarEventSummary]] = [:] ) -> CalendarMonthModel { let monthStart = CalendarComponentCalendars.monthStart(containing: monthDate, calendar: calendar) let title = monthTitle(for: monthStart, calendar: calendar) let days = makeDays(displayedMonthStart: monthStart, today: today, eventsByDay: eventsByDay) return CalendarMonthModel( displayedMonthStart: monthStart, title: title, weekdaySymbols: weekdaySymbols(), days: days ) } func makeDays( displayedMonthStart: Date, today: Date = Date(), eventsByDay: [Date: [CalendarEventSummary]] = [:] ) -> [CalendarDayModel] { guard let gridStart = gridStartDate(for: displayedMonthStart) else { return [] } return (0..<42).compactMap { offset in guard let date = calendar.date(byAdding: .day, value: offset, to: gridStart) else { return nil } let dayStart = calendar.startOfDay(for: date) let components = calendar.dateComponents([.year, .month, .day], from: dayStart) let isInDisplayedMonth = calendar.isDate(dayStart, equalTo: displayedMonthStart, toGranularity: .month) let holidayKind = showsHolidayBadges ? holidayProvider.kind(for: dayStart, calendar: calendar) : nil return CalendarDayModel( id: CalendarComponentCalendars.dayID(for: dayStart, calendar: calendar), date: dayStart, dayNumber: String(components.day ?? 0), lunarText: lunarText(for: dayStart), isInDisplayedMonth: isInDisplayedMonth, isToday: calendar.isDate(dayStart, inSameDayAs: today), isWeekend: calendar.isDateInWeekend(dayStart), holidayKind: holidayKind, events: eventsByDay[dayStart] ?? [] ) } } private func gridStartDate(for displayedMonthStart: Date) -> Date? { let weekday = calendar.component(.weekday, from: displayedMonthStart) let leadingDays = (weekday - calendar.firstWeekday + 7) % 7 return calendar.date(byAdding: .day, value: -leadingDays, to: displayedMonthStart) } private func weekdaySymbols() -> [String] { let symbols = [ localization.string("weekday.sunday.short", defaultValue: "日"), localization.string("weekday.monday.short", defaultValue: "一"), localization.string("weekday.tuesday.short", defaultValue: "二"), localization.string("weekday.wednesday.short", defaultValue: "三"), localization.string("weekday.thursday.short", defaultValue: "四"), localization.string("weekday.friday.short", defaultValue: "五"), localization.string("weekday.saturday.short", defaultValue: "六") ] let startIndex = max(calendar.firstWeekday - 1, 0) % symbols.count return Array(symbols[startIndex.. String { let components = lunarCalendar.dateComponents([.year, .month, .day, .isLeapMonth], from: date) let month = components.month ?? 1 let day = components.day ?? 1 if isLastDayOfLunarYear(date) { return localization.string("lunar.festival.newYearsEve", defaultValue: "除夕") } if let festival = lunarFestival(month: month, day: day) { return festival } if day == 1 { let prefix = components.isLeapMonth == true ? localization.string("lunar.leapPrefix", defaultValue: "闰") : "" return prefix + lunarMonthName(month) } return lunarDayName(day) } private func isLastDayOfLunarYear(_ date: Date) -> Bool { guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { return false } let year = lunarCalendar.component(.year, from: date) let nextYear = lunarCalendar.component(.year, from: nextDay) return year != nextYear } private func lunarFestival(month: Int, day: Int) -> String? { switch (month, day) { case (1, 1): return localization.string("lunar.festival.springFestival", defaultValue: "春节") case (1, 15): return localization.string("lunar.festival.lanternFestival", defaultValue: "元宵") case (5, 5): return localization.string("lunar.festival.dragonBoat", defaultValue: "端午") case (7, 7): return localization.string("lunar.festival.qixi", defaultValue: "七夕") case (8, 15): return localization.string("lunar.festival.midAutumn", defaultValue: "中秋") case (9, 9): return localization.string("lunar.festival.doubleNinth", defaultValue: "重阳") case (12, 8): return localization.string("lunar.festival.laba", defaultValue: "腊八") case (12, 23): return localization.string("lunar.festival.littleNewYear", defaultValue: "小年") default: return nil } } private func monthTitle(for date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month], from: date) return String( format: localization.string("month.title", defaultValue: "%04d年%02d月"), locale: Locale(identifier: "en_US_POSIX"), components.year ?? 0, components.month ?? 0 ) } private func lunarMonthName(_ month: Int) -> String { let names = [ localization.string("lunar.month.1", defaultValue: "正月"), localization.string("lunar.month.2", defaultValue: "二月"), localization.string("lunar.month.3", defaultValue: "三月"), localization.string("lunar.month.4", defaultValue: "四月"), localization.string("lunar.month.5", defaultValue: "五月"), localization.string("lunar.month.6", defaultValue: "六月"), localization.string("lunar.month.7", defaultValue: "七月"), localization.string("lunar.month.8", defaultValue: "八月"), localization.string("lunar.month.9", defaultValue: "九月"), localization.string("lunar.month.10", defaultValue: "十月"), localization.string("lunar.month.11", defaultValue: "冬月"), localization.string("lunar.month.12", defaultValue: "腊月") ] guard (1...names.count).contains(month) else { return localization.string("lunar.month.fallback", defaultValue: "月") } return names[month - 1] } private func lunarDayName(_ day: Int) -> String { let names = [ localization.string("lunar.day.1", defaultValue: "初一"), localization.string("lunar.day.2", defaultValue: "初二"), localization.string("lunar.day.3", defaultValue: "初三"), localization.string("lunar.day.4", defaultValue: "初四"), localization.string("lunar.day.5", defaultValue: "初五"), localization.string("lunar.day.6", defaultValue: "初六"), localization.string("lunar.day.7", defaultValue: "初七"), localization.string("lunar.day.8", defaultValue: "初八"), localization.string("lunar.day.9", defaultValue: "初九"), localization.string("lunar.day.10", defaultValue: "初十"), localization.string("lunar.day.11", defaultValue: "十一"), localization.string("lunar.day.12", defaultValue: "十二"), localization.string("lunar.day.13", defaultValue: "十三"), localization.string("lunar.day.14", defaultValue: "十四"), localization.string("lunar.day.15", defaultValue: "十五"), localization.string("lunar.day.16", defaultValue: "十六"), localization.string("lunar.day.17", defaultValue: "十七"), localization.string("lunar.day.18", defaultValue: "十八"), localization.string("lunar.day.19", defaultValue: "十九"), localization.string("lunar.day.20", defaultValue: "二十"), localization.string("lunar.day.21", defaultValue: "廿一"), localization.string("lunar.day.22", defaultValue: "廿二"), localization.string("lunar.day.23", defaultValue: "廿三"), localization.string("lunar.day.24", defaultValue: "廿四"), localization.string("lunar.day.25", defaultValue: "廿五"), localization.string("lunar.day.26", defaultValue: "廿六"), localization.string("lunar.day.27", defaultValue: "廿七"), localization.string("lunar.day.28", defaultValue: "廿八"), localization.string("lunar.day.29", defaultValue: "廿九"), localization.string("lunar.day.30", defaultValue: "三十") ] guard (1...names.count).contains(day) else { return "" } return names[day - 1] } private static func shouldShowHolidayBadges(calendar: Calendar) -> Bool { let identifier = calendar.locale?.identifier ?? Locale.autoupdatingCurrent.identifier return identifier.lowercased().hasPrefix("zh") } }