|
|
@@ -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
|