Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

322 lines
13 KiB
Swift

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..<symbols.count] + symbols[0..<startIndex])
}
private func lunarText(for date: Date) -> 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")
}
}