Explorar el Código

Harden Zoom meeting time parsing and timezone rendering consistency.

Made-with: Cursor
huzaifahayat12 hace 3 meses
padre
commit
f59eb194be
Se han modificado 1 ficheros con 146 adiciones y 26 borrados
  1. 146 26
      zoom_app/ViewController.swift

+ 146 - 26
zoom_app/ViewController.swift

@@ -26,6 +26,7 @@ class ViewController: NSViewController {
     private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
     private let ratingCompletedKey = "zoom_app.ratingCompleted"
     private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
+    private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
 
     private struct Palette {
         let isDarkMode: Bool
@@ -320,6 +321,8 @@ class ViewController: NSViewController {
     private let scrollRefreshCooldown: TimeInterval = 3
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
     private var meetingsRateLimitedUntil: Date?
+    private var zoomAccountTimeZone: TimeZone?
+    private var meetingTimeZoneOverrides: [String: String] = [:]
     private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
     private var meetingsPageSelectedRangePreset: String = "All"
     private var meetingsPageFromDateFilter: Date?
@@ -573,6 +576,7 @@ class ViewController: NSViewController {
         palette = Palette(isDarkMode: darkModeEnabled)
         NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
         view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
+        meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
         ensureUsageSessionStartDate()
         scheduleUsageBasedRatingPromptIfNeeded()
         setupUI()
@@ -749,9 +753,11 @@ class ViewController: NSViewController {
                 let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
                 let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
                 let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
+                let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
 
                 await MainActor.run {
                     self.resetLoginSigningInState()
+                    self.zoomAccountTimeZone = profileTimeZone
                     self.completeLoginAndRestart(profile: profile)
                 }
             } catch {
@@ -1074,6 +1080,9 @@ class ViewController: NSViewController {
                     durationMinutes: duration,
                     timeZone: tz
                 )
+                await MainActor.run {
+                    self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
+                }
                 await MainActor.run {
                     self.scheduleSubmitButton?.isEnabled = true
                     self.scheduleMeetingWindow?.performClose(nil)
@@ -1155,6 +1164,42 @@ class ViewController: NSViewController {
         }
     }
 
+    /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
+    private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
+        guard let raw else { return nil }
+        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmed.isEmpty == false else { return nil }
+        if let named = TimeZone(identifier: trimmed) { return named }
+        if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
+
+        let normalized = trimmed.uppercased()
+        let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
+        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+        let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
+        guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
+              match.numberOfRanges == 4,
+              let signRange = Range(match.range(at: 1), in: normalized),
+              let hourRange = Range(match.range(at: 2), in: normalized) else {
+            return nil
+        }
+        let sign = normalized[signRange] == "-" ? -1 : 1
+        let hours = Int(normalized[hourRange]) ?? -1
+        let minutes: Int = {
+            if let minuteRange = Range(match.range(at: 3), in: normalized) {
+                return Int(normalized[minuteRange]) ?? -1
+            }
+            return 0
+        }()
+        guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
+        return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
+    }
+
+    private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
+        guard let meetingID, meetingID.isEmpty == false else { return }
+        meetingTimeZoneOverrides[meetingID] = timeZone.identifier
+        UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
+    }
+
     private struct ScheduleMeetingDraft {
         let startDate: Date
         let startTimeUTC: String
@@ -1168,30 +1213,72 @@ class ViewController: NSViewController {
         guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
         let tz = resolvedScheduleTimeZoneForMeeting()
         let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
-        let combined = "\(datePart) \(normalizedTime)"
-        let df = DateFormatter()
-        df.locale = Locale(identifier: "en_US_POSIX")
-        df.timeZone = tz
-        df.calendar = Calendar(identifier: .gregorian)
-        df.isLenient = false
-        let formats = ["yyyy-MM-dd h:mm a", "yyyy-MM-dd hh:mm a", "yyyy-MM-dd h:mm:ss a"]
-        for format in formats {
-            df.dateFormat = format
-            if let d = df.date(from: combined) {
-                let utcFormatter = DateFormatter()
-                utcFormatter.locale = Locale(identifier: "en_US_POSIX")
-                utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
-                utcFormatter.calendar = Calendar(identifier: .gregorian)
-                // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
-                utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
-                return ScheduleMeetingDraft(
-                    startDate: d,
-                    startTimeUTC: utcFormatter.string(from: d),
-                    timeZone: tz
-                )
+
+        // Parse date strictly as yyyy-MM-dd.
+        let dateComponentsRaw = datePart.split(separator: "-")
+        guard dateComponentsRaw.count == 3,
+              let year = Int(dateComponentsRaw[0]),
+              let month = Int(dateComponentsRaw[1]),
+              let day = Int(dateComponentsRaw[2]) else {
+            return nil
+        }
+
+        // Parse time strictly as h:mm AM/PM (optional :ss).
+        // This avoids any locale/date formatter ambiguity that can flip AM/PM.
+        let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
+        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+        let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
+        guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
+              match.numberOfRanges == 5,
+              let hourRange = Range(match.range(at: 1), in: normalizedTime),
+              let minuteRange = Range(match.range(at: 2), in: normalizedTime),
+              let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
+            return nil
+        }
+
+        let rawHour = Int(normalizedTime[hourRange]) ?? -1
+        let minute = Int(normalizedTime[minuteRange]) ?? -1
+        let second: Int = {
+            if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
+                return Int(normalizedTime[secondRange]) ?? -1
             }
+            return 0
+        }()
+        let meridiem = normalizedTime[meridiemRange].uppercased()
+
+        guard (1...12).contains(rawHour),
+              (0...59).contains(minute),
+              (0...59).contains(second) else {
+            return nil
         }
-        return nil
+
+        var hour24 = rawHour % 12
+        if meridiem == "PM" { hour24 += 12 }
+
+        var calendar = Calendar(identifier: .gregorian)
+        calendar.timeZone = tz
+        var components = DateComponents()
+        components.year = year
+        components.month = month
+        components.day = day
+        components.hour = hour24
+        components.minute = minute
+        components.second = second
+        components.timeZone = tz
+
+        guard let startDate = calendar.date(from: components) else { return nil }
+
+        let utcFormatter = DateFormatter()
+        utcFormatter.locale = Locale(identifier: "en_US_POSIX")
+        utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+        utcFormatter.calendar = Calendar(identifier: .gregorian)
+        // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
+        utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
+        return ScheduleMeetingDraft(
+            startDate: startDate,
+            startTimeUTC: utcFormatter.string(from: startDate),
+            timeZone: tz
+        )
     }
 
     @objc private func joinMeetingTapped() {
@@ -2714,7 +2801,13 @@ class ViewController: NSViewController {
         }
         do {
             let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
-            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
+            if zoomAccountTimeZone == nil {
+                if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
+                    let profileTZ = parseZoomTimeZone(profile.timezone)
+                    await MainActor.run { self.zoomAccountTimeZone = profileTZ }
+                }
+            }
+            let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
             await MainActor.run {
                 self.meetingsRateLimitedUntil = nil
                 self.applyMeetings(zoomMeetings)
@@ -2809,6 +2902,7 @@ class ViewController: NSViewController {
         let display_name: String?
         let email: String?
         let pic_url: String?
+        let timezone: String?
 
         var displayName: String? {
             if let display_name, display_name.isEmpty == false { return display_name }
@@ -2844,6 +2938,7 @@ class ViewController: NSViewController {
     }
 
     private struct ZoomCreateMeetingAPIResult: Decodable {
+        let id: Int?
         let join_url: String?
     }
 
@@ -2883,7 +2978,7 @@ class ViewController: NSViewController {
         return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
     }
 
-    private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
+    private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
         struct ZoomMeeting: Decodable {
             let id: Int?
             let topic: String?
@@ -2906,12 +3001,37 @@ class ViewController: NSViewController {
         let fallbackISO = ISO8601DateFormatter()
         fallbackISO.formatOptions = [.withInternetDateTime]
 
+        func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
+            if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
+                return parsedExplicit
+            }
+
+            if let fallbackTimeZone { return fallbackTimeZone }
+
+            if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
+                return TimeZone(secondsFromGMT: 0)
+            }
+            let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
+            guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
+            let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
+            guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
+                  let offsetRange = Range(match.range(at: 1), in: startRaw) else {
+                return nil
+            }
+            return parseZoomTimeZone(String(startRaw[offsetRange]))
+        }
+
         func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
             raw.compactMap { meeting in
                 guard let startRaw = meeting.start_time else { return nil }
                 let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
                 guard let start else { return nil }
                 let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
+                let meetingIDString = meeting.id.map(String.init)
+                let overrideZone = meetingIDString
+                    .flatMap { self.meetingTimeZoneOverrides[$0] }
+                    .flatMap { TimeZone(identifier: $0) }
+                let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
                 let webURL: URL? = {
                     func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
                         var components = URLComponents()
@@ -2948,10 +3068,10 @@ class ViewController: NSViewController {
                     title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
                     start: start,
                     end: end,
-                    meetingTimeZone: meeting.timezone.flatMap { TimeZone(identifier: $0) },
+                    meetingTimeZone: displayZone,
                     host: meeting.host_id ?? "Zoom Host",
                     source: "Zoom",
-                    meetingID: meeting.id.map(String.init),
+                    meetingID: meetingIDString,
                     typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
                     statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
                     webURL: webURL