|
| 1 | +// |
| 2 | +// DateEditingService.swift |
| 3 | +// TablePro |
| 4 | +// |
| 5 | +// Parses a database date/time string for editing and writes the edited value |
| 6 | +// back in the same shape. Distinct from DateFormattingService, which formats |
| 7 | +// for display using the user's locale and format preference. |
| 8 | +// |
| 9 | + |
| 10 | +import Foundation |
| 11 | + |
| 12 | +struct TemporalLayout: Equatable { |
| 13 | + let hasDate: Bool |
| 14 | + let hasTime: Bool |
| 15 | + let dateTimeSeparator: String |
| 16 | + let fractionalSeconds: String? |
| 17 | + let timeZoneSuffix: String? |
| 18 | +} |
| 19 | + |
| 20 | +struct ParsedTemporalValue: Equatable { |
| 21 | + let date: Date |
| 22 | + let timeZone: TimeZone |
| 23 | + let layout: TemporalLayout |
| 24 | +} |
| 25 | + |
| 26 | +enum TemporalComponents: Equatable { |
| 27 | + case dateOnly |
| 28 | + case timeOnly |
| 29 | + case dateAndTime |
| 30 | +} |
| 31 | + |
| 32 | +enum DateEditingService { |
| 33 | + private static let pattern = |
| 34 | + #"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"# |
| 35 | + |
| 36 | + private static let matcher = try? NSRegularExpression(pattern: pattern) |
| 37 | + |
| 38 | + private static let referenceDateComponents = (year: 2_000, month: 1, day: 1) |
| 39 | + |
| 40 | + static func parse(_ rawValue: String?) -> ParsedTemporalValue? { |
| 41 | + guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { |
| 42 | + return nil |
| 43 | + } |
| 44 | + let range = NSRange(raw.startIndex..., in: raw) |
| 45 | + guard let match = matcher.firstMatch(in: raw, range: range) else { return nil } |
| 46 | + |
| 47 | + func group(_ index: Int) -> String? { |
| 48 | + let groupRange = match.range(at: index) |
| 49 | + guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else { |
| 50 | + return nil |
| 51 | + } |
| 52 | + return String(raw[swiftRange]) |
| 53 | + } |
| 54 | + |
| 55 | + let year = group(1).flatMap(Int.init) |
| 56 | + let month = group(2).flatMap(Int.init) |
| 57 | + let day = group(3).flatMap(Int.init) |
| 58 | + let hour = group(5).flatMap(Int.init) |
| 59 | + let minute = group(6).flatMap(Int.init) |
| 60 | + let second = group(7).flatMap(Int.init) |
| 61 | + |
| 62 | + let hasDate = year != nil && month != nil && day != nil |
| 63 | + let hasTime = hour != nil && minute != nil && second != nil |
| 64 | + guard hasDate || hasTime else { return nil } |
| 65 | + |
| 66 | + let timeZoneSuffix = group(9) |
| 67 | + let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? .gmt |
| 68 | + |
| 69 | + var components = DateComponents() |
| 70 | + components.year = hasDate ? year : referenceDateComponents.year |
| 71 | + components.month = hasDate ? month : referenceDateComponents.month |
| 72 | + components.day = hasDate ? day : referenceDateComponents.day |
| 73 | + components.hour = hasTime ? hour : 0 |
| 74 | + components.minute = hasTime ? minute : 0 |
| 75 | + components.second = hasTime ? second : 0 |
| 76 | + |
| 77 | + var calendar = Calendar(identifier: .gregorian) |
| 78 | + calendar.timeZone = timeZone |
| 79 | + guard let date = calendar.date(from: components) else { return nil } |
| 80 | + |
| 81 | + let separator = group(4) ?? (hasDate && hasTime ? " " : "") |
| 82 | + let layout = TemporalLayout( |
| 83 | + hasDate: hasDate, |
| 84 | + hasTime: hasTime, |
| 85 | + dateTimeSeparator: separator, |
| 86 | + fractionalSeconds: group(8), |
| 87 | + timeZoneSuffix: timeZoneSuffix |
| 88 | + ) |
| 89 | + return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout) |
| 90 | + } |
| 91 | + |
| 92 | + static func string(from date: Date, like parsed: ParsedTemporalValue) -> String { |
| 93 | + var calendar = Calendar(identifier: .gregorian) |
| 94 | + calendar.timeZone = parsed.timeZone |
| 95 | + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) |
| 96 | + let layout = parsed.layout |
| 97 | + |
| 98 | + let datePart = dateString(from: components) |
| 99 | + let timePart = timeString(from: components) + (layout.fractionalSeconds ?? "") |
| 100 | + |
| 101 | + var result: String |
| 102 | + if layout.hasDate && layout.hasTime { |
| 103 | + result = datePart + layout.dateTimeSeparator + timePart |
| 104 | + } else if layout.hasDate { |
| 105 | + result = datePart |
| 106 | + } else { |
| 107 | + result = timePart |
| 108 | + } |
| 109 | + if let suffix = layout.timeZoneSuffix { |
| 110 | + result += suffix |
| 111 | + } |
| 112 | + return result |
| 113 | + } |
| 114 | + |
| 115 | + static func defaultString(from date: Date, columnType: ColumnType) -> String { |
| 116 | + var calendar = Calendar(identifier: .gregorian) |
| 117 | + calendar.timeZone = .gmt |
| 118 | + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) |
| 119 | + |
| 120 | + if case .date = columnType { |
| 121 | + return dateString(from: components) |
| 122 | + } |
| 123 | + if columnType.isTimeOnly { |
| 124 | + return timeString(from: components) |
| 125 | + } |
| 126 | + return dateString(from: components) + " " + timeString(from: components) |
| 127 | + } |
| 128 | + |
| 129 | + static func components(for columnType: ColumnType) -> TemporalComponents { |
| 130 | + if case .date = columnType { return .dateOnly } |
| 131 | + if columnType.isTimeOnly { return .timeOnly } |
| 132 | + return .dateAndTime |
| 133 | + } |
| 134 | + |
| 135 | + private static func dateString(from components: DateComponents) -> String { |
| 136 | + String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) |
| 137 | + } |
| 138 | + |
| 139 | + private static func timeString(from components: DateComponents) -> String { |
| 140 | + String(format: "%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0) |
| 141 | + } |
| 142 | + |
| 143 | + private static func timeZone(fromSuffix suffix: String) -> TimeZone { |
| 144 | + if suffix == "Z" { return .gmt } |
| 145 | + let sign = suffix.hasPrefix("-") ? -1 : 1 |
| 146 | + let digits = suffix.dropFirst().filter(\.isNumber) |
| 147 | + let hours = Int(digits.prefix(2)) ?? 0 |
| 148 | + let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0 |
| 149 | + return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt |
| 150 | + } |
| 151 | +} |
0 commit comments