فهرست منبع

Add English localization for user-facing app strings.

Introduce en.lproj strings, localization helpers, and wire UI, alerts, widgets, and API error copy while keeping the Meetings brand name fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ماه پیش
والد
کامیت
9c7c20beea

+ 11 - 11
meetings_app/Auth/GoogleOAuthService.swift

@@ -409,10 +409,10 @@ final class GoogleOAuthService: NSObject {
 
         let (_, response) = try await URLSession.shared.data(for: request)
         guard let http = response as? HTTPURLResponse else {
-            throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.")
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid revoke response.".localized)
         }
         guard (200..<300).contains(http.statusCode) else {
-            throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status \(http.statusCode).")
+            throw GoogleOAuthError.tokenExchangeFailed("Token revoke failed with status %d.".localized(with: http.statusCode))
         }
     }
 }
@@ -581,21 +581,21 @@ extension GoogleOAuthError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingClientId:
-            return "Missing Google OAuth Client ID."
+            return "Missing Google OAuth Client ID.".localized
         case .missingClientSecret:
-            return "Missing Google OAuth Client Secret."
+            return "Missing Google OAuth Client Secret.".localized
         case .invalidCallbackURL:
-            return "Invalid OAuth callback URL."
+            return "Invalid OAuth callback URL.".localized
         case .missingAuthorizationCode:
-            return "Google did not return an authorization code."
+            return "Google did not return an authorization code.".localized
         case .tokenExchangeFailed(let details):
-            return "Token exchange failed: \(details)"
+            return "Token exchange failed: %@".localized(with: details)
         case .unableToOpenBrowser:
-            return "Could not open browser for Google sign-in."
+            return "Could not open browser for Google sign-in.".localized
         case .authenticationTimedOut:
-            return "Google sign-in timed out."
+            return "Google sign-in timed out.".localized
         case .noStoredTokens:
-            return "No stored Google tokens found."
+            return "No stored Google tokens found.".localized
         }
     }
 }
@@ -641,7 +641,7 @@ private final class InAppOAuthWindowController: NSWindowController, WKNavigation
             backing: .buffered,
             defer: false
         )
-        window.title = "Google Sign-In"
+        window.title = "Google Sign-In".localized
         window.contentView = container
         window.center()
         super.init(window: window)

+ 5 - 5
meetings_app/Auth/KeychainTokenStore.swift

@@ -48,13 +48,13 @@ final class KeychainTokenStore {
         let status = SecItemCopyMatching(query as CFDictionary, &result)
         if status == errSecItemNotFound { return nil }
         guard status == errSecSuccess else {
-            throw makeKeychainError(status: status, message: "Failed to read OAuth tokens from Keychain.")
+            throw makeKeychainError(status: status, message: "Failed to read OAuth tokens from Keychain.".localized)
         }
         guard let data = result as? Data else {
             throw NSError(
                 domain: NSOSStatusErrorDomain,
                 code: Int(errSecDecode),
-                userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload."]
+                userInfo: [NSLocalizedDescriptionKey: "Keychain returned an invalid token payload.".localized]
             )
         }
         return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
@@ -78,7 +78,7 @@ final class KeychainTokenStore {
         let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributesToUpdate as CFDictionary)
         if updateStatus == errSecSuccess { return }
         if updateStatus != errSecItemNotFound {
-            throw makeKeychainError(status: updateStatus, message: "Failed to update OAuth tokens in Keychain.")
+            throw makeKeychainError(status: updateStatus, message: "Failed to update OAuth tokens in Keychain.".localized)
         }
 
         var addQuery = baseQuery
@@ -86,7 +86,7 @@ final class KeychainTokenStore {
         addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
         let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
         guard addStatus == errSecSuccess else {
-            throw makeKeychainError(status: addStatus, message: "Failed to save OAuth tokens to Keychain.")
+            throw makeKeychainError(status: addStatus, message: "Failed to save OAuth tokens to Keychain.".localized)
         }
     }
 
@@ -102,7 +102,7 @@ final class KeychainTokenStore {
 
         let status = SecItemDelete(query as CFDictionary)
         guard status == errSecSuccess || status == errSecItemNotFound else {
-            throw makeKeychainError(status: status, message: "Failed to delete OAuth tokens from Keychain.")
+            throw makeKeychainError(status: status, message: "Failed to delete OAuth tokens from Keychain.".localized)
         }
     }
 }

+ 7 - 7
meetings_app/Google/GoogleCalendarClient.swift

@@ -98,7 +98,7 @@ final class GoogleCalendarClient {
 
             return ScheduledMeeting(
                 id: item.id ?? UUID().uuidString,
-                title: (title?.isEmpty == false) ? title! : "Untitled meeting",
+                title: (title?.isEmpty == false) ? title! : "Untitled meeting".localized,
                 subtitle: subtitle,
                 startDate: start,
                 endDate: end,
@@ -165,7 +165,7 @@ final class GoogleCalendarClient {
 
         guard let startDate = created.start?.resolvedDate,
               let endDate = created.end?.resolvedDate else {
-            throw GoogleCalendarClientError.decodeFailed("Created event missing start/end.")
+            throw GoogleCalendarClientError.decodeFailed("Created event missing start/end.".localized)
         }
 
         let isAllDay = created.start?.date != nil
@@ -177,14 +177,14 @@ final class GoogleCalendarClient {
             return nil
         }()
         guard let meetURL else {
-            throw GoogleCalendarClientError.decodeFailed("Created event missing Meet/HTML link.")
+            throw GoogleCalendarClientError.decodeFailed("Created event missing Meet/HTML link.".localized)
         }
 
         let cleanedTitle = created.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
         let subtitle = created.organizer?.displayName ?? created.organizer?.email
         return ScheduledMeeting(
             id: created.id ?? UUID().uuidString,
-            title: (cleanedTitle?.isEmpty == false) ? cleanedTitle! : "Untitled meeting",
+            title: (cleanedTitle?.isEmpty == false) ? cleanedTitle! : "Untitled meeting".localized,
             subtitle: subtitle,
             startDate: startDate,
             endDate: endDate,
@@ -198,11 +198,11 @@ extension GoogleCalendarClientError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .invalidResponse:
-            return "Google Calendar returned an invalid response."
+            return "Google Calendar returned an invalid response.".localized
         case let .httpStatus(status, body):
-            return "Google Calendar API error (\(status)): \(body)"
+            return "Google Calendar API error (%d): %@".localized(with: status, body)
         case let .decodeFailed(raw):
-            return "Failed to parse Google Calendar events: \(raw)"
+            return "Failed to parse Google Calendar events: %@".localized(with: raw)
         }
     }
 }

+ 3 - 3
meetings_app/Google/GoogleMeetClient.swift

@@ -205,11 +205,11 @@ extension GoogleMeetClientError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .invalidResponse:
-            return "Google Meet returned an invalid response."
+            return "Google Meet returned an invalid response.".localized
         case let .httpStatus(status, body):
-            return "Google Meet API error (\(status)): \(body)"
+            return "Google Meet API error (%d): %@".localized(with: status, body)
         case let .decodeFailed(raw):
-            return "Failed to parse Google Meet response: \(raw)"
+            return "Failed to parse Google Meet response: %@".localized(with: raw)
         }
     }
 }

+ 5 - 5
meetings_app/LaunchSplashView.swift

@@ -17,12 +17,12 @@ final class LaunchSplashView: NSView {
 
     private let iconView = NSImageView()
     private let titleLabel = NSTextField(labelWithString: "")
-    private let subtitleLabel = NSTextField(labelWithString: "Preparing your meetings workspace")
+    private let subtitleLabel = NSTextField(labelWithString: "Preparing your meetings workspace".localized)
     private let loadingTrackView = NSView()
     private let loadingFillView = NSView()
-    private let statusLabel = NSTextField(labelWithString: "CONNECTING SERVICES")
+    private let statusLabel = NSTextField(labelWithString: "CONNECTING SERVICES".localized)
     private let percentLabel = NSTextField(labelWithString: "0%")
-    private let taglineLabel = NSTextField(labelWithString: "Plan smart, achieve more")
+    private let taglineLabel = NSTextField(labelWithString: "Plan smart, achieve more".localized)
     private var loadingFillWidthConstraint: NSLayoutConstraint?
     private var textAnimationTimer: Timer?
     private var progressAnimationTimer: Timer?
@@ -50,7 +50,7 @@ final class LaunchSplashView: NSView {
         stopAnimations()
         progressValue = 100
         percentLabel.stringValue = "100%"
-        statusLabel.stringValue = "READY"
+        statusLabel.stringValue = "READY".localized
         updateLoadingFill(for: 100)
         layoutSubtreeIfNeeded()
 
@@ -193,7 +193,7 @@ final class LaunchSplashView: NSView {
 
     private func updateAnimatedText() {
         textAnimationFrame = (textAnimationFrame + 1) % 4
-        subtitleLabel.stringValue = "Preparing your meetings workspace" + String(repeating: ".", count: textAnimationFrame)
+        subtitleLabel.stringValue = "Preparing your meetings workspace".localized + String(repeating: ".", count: textAnimationFrame)
     }
 
     private func advanceLoadingBar() {

+ 6 - 0
meetings_app/Localization/AppBrand.swift

@@ -0,0 +1,6 @@
+import Foundation
+
+/// Fixed app brand name — never localized.
+enum AppBrand {
+    static let name = "Meetings"
+}

+ 17 - 0
meetings_app/Localization/String+Localization.swift

@@ -0,0 +1,17 @@
+import Foundation
+
+extension String {
+    /// Looks up this string in `Localizable.strings`.
+    var localized: String {
+        NSLocalizedString(self, bundle: .main, comment: "")
+    }
+
+    /// Localizes UI copy, except the fixed app brand name.
+    var localizedUI: String {
+        self == AppBrand.name ? self : localized
+    }
+
+    func localized(with arguments: CVarArg...) -> String {
+        String(format: localized, locale: Locale.current, arguments: arguments)
+    }
+}

+ 3 - 3
meetings_app/OpenAI/OpenAITranscriptionClient.swift

@@ -73,11 +73,11 @@ extension OpenAITranscriptionClientError: LocalizedError {
     var errorDescription: String? {
         switch self {
         case .invalidResponse:
-            return "OpenAI transcription returned an invalid response."
+            return "OpenAI transcription returned an invalid response.".localized
         case let .httpStatus(status, body):
-            return "OpenAI transcription failed (\(status)): \(body)"
+            return "OpenAI transcription failed (%d): %@".localized(with: status, body)
         case .missingTranscript:
-            return "OpenAI transcription returned no text."
+            return "OpenAI transcription returned no text.".localized
         }
     }
 }

+ 4 - 4
meetings_app/Reminders/MeetingReminderManager.swift

@@ -16,9 +16,9 @@ final class MeetingReminderManager: NSObject {
 
     private var intervals: [Interval] {
         [
-            Interval(offset: 86400, label: "Starts in 1 day", suffix: "1d", isEnabled: { ReminderPreferences.remind1Day }),
-            Interval(offset: 43200, label: "Starts in 12 hours", suffix: "12h", isEnabled: { ReminderPreferences.remind12Hours }),
-            Interval(offset: 3600, label: "Starts in 1 hour", suffix: "1h", isEnabled: { ReminderPreferences.remind1Hour })
+            Interval(offset: 86400, label: "Starts in 1 day".localized, suffix: "1d", isEnabled: { ReminderPreferences.remind1Day }),
+            Interval(offset: 43200, label: "Starts in 12 hours".localized, suffix: "12h", isEnabled: { ReminderPreferences.remind12Hours }),
+            Interval(offset: 3600, label: "Starts in 1 hour".localized, suffix: "1h", isEnabled: { ReminderPreferences.remind1Hour })
         ]
     }
 
@@ -59,7 +59,7 @@ final class MeetingReminderManager: NSObject {
 
                 let content = UNMutableNotificationContent()
                 content.title = "📅 \(title)"
-                content.subtitle = meeting.subtitle ?? "Google Meet"
+                content.subtitle = meeting.subtitle ?? "Google Meet".localized
                 content.body = interval.label
                 content.sound = .default
 

+ 15 - 15
meetings_app/StatusBar/StatusBarController.swift

@@ -15,13 +15,13 @@ final class StatusBarController: NSObject {
             // Template so the system tints like other menu bar icons (adapts light/dark menu bar).
             icon.isTemplate = true
             statusItem.button?.image = icon
-        } else if let icon = NSImage(systemSymbolName: "calendar.badge.clock", accessibilityDescription: "Meetings") {
+        } else if let icon = NSImage(systemSymbolName: "calendar.badge.clock", accessibilityDescription: AppBrand.name) {
             icon.isTemplate = true
             statusItem.button?.image = icon
         } else {
-            statusItem.button?.title = "Meetings"
+            statusItem.button?.title = AppBrand.name
         }
-        statusItem.button?.setAccessibilityLabel("Meetings status menu")
+        statusItem.button?.setAccessibilityLabel("Meetings status menu".localized)
         rebuildMenu()
 
         let center = NotificationCenter.default
@@ -50,28 +50,28 @@ final class StatusBarController: NSObject {
     private func rebuildMenu() {
         let menu = NSMenu()
 
-        let openAppItem = NSMenuItem(title: "Open App", action: #selector(openAppClicked), keyEquivalent: "")
+        let openAppItem = NSMenuItem(title: "Open App".localized, action: #selector(openAppClicked), keyEquivalent: "")
         openAppItem.target = self
         menu.addItem(openAppItem)
 
         menu.addItem(.separator())
-        addOpenSidebarItem(title: "Join Meetings", pageRaw: 0, to: menu)
-        addOpenSidebarItem(title: "Schedule", pageRaw: 1, to: menu)
-        addOpenSidebarItem(title: "Calendar", pageRaw: 2, to: menu)
-        addOpenSidebarItem(title: "Widgets", pageRaw: 3, to: menu)
-        addOpenSidebarItem(title: "AI Companion", pageRaw: 5, to: menu)
-        addOpenSidebarItem(title: "Settings", pageRaw: 4, to: menu)
+        addOpenSidebarItem(title: "Join Meetings".localized, pageRaw: 0, to: menu)
+        addOpenSidebarItem(title: "Schedule".localized, pageRaw: 1, to: menu)
+        addOpenSidebarItem(title: "Calendar".localized, pageRaw: 2, to: menu)
+        addOpenSidebarItem(title: "Widgets".localized, pageRaw: 3, to: menu)
+        addOpenSidebarItem(title: "AI Companion".localized, pageRaw: 5, to: menu)
+        addOpenSidebarItem(title: "Settings".localized, pageRaw: 4, to: menu)
 
         let signedIn = authService.loadTokens() != nil
         if signedIn && hasPremiumAccess {
             let topMeetings = WidgetMeetingStore.load().prefix(3).map { $0 }
             menu.addItem(.separator())
-            let header = NSMenuItem(title: "Top meetings", action: nil, keyEquivalent: "")
+            let header = NSMenuItem(title: "Top meetings".localized, action: nil, keyEquivalent: "")
             header.isEnabled = false
             menu.addItem(header)
 
             if topMeetings.isEmpty {
-                let empty = NSMenuItem(title: "No upcoming meetings", action: nil, keyEquivalent: "")
+                let empty = NSMenuItem(title: "No upcoming meetings".localized, action: nil, keyEquivalent: "")
                 empty.isEnabled = false
                 menu.addItem(empty)
             } else {
@@ -88,18 +88,18 @@ final class StatusBarController: NSObject {
 
         if signedIn {
             menu.addItem(.separator())
-            let signOutItem = NSMenuItem(title: "Sign Out", action: #selector(signOutClicked), keyEquivalent: "")
+            let signOutItem = NSMenuItem(title: "Sign Out".localized, action: #selector(signOutClicked), keyEquivalent: "")
             signOutItem.target = self
             menu.addItem(signOutItem)
         } else {
             menu.addItem(.separator())
-            let signInItem = NSMenuItem(title: "Sign In", action: #selector(signInClicked), keyEquivalent: "")
+            let signInItem = NSMenuItem(title: "Sign In".localized, action: #selector(signInClicked), keyEquivalent: "")
             signInItem.target = self
             menu.addItem(signInItem)
         }
 
         menu.addItem(.separator())
-        let quitItem = NSMenuItem(title: "Quit", action: #selector(quitClicked), keyEquivalent: "q")
+        let quitItem = NSMenuItem(title: "Quit".localized, action: #selector(quitClicked), keyEquivalent: "q")
         quitItem.target = self
         menu.addItem(quitItem)
 

+ 19 - 10
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -15,6 +15,15 @@ struct TranscriptSegment: Codable, Hashable {
 enum TranscriptSpeaker: String {
     case microphone = "You"
     case system = "Meeting"
+
+    var localizedLabel: String {
+        switch self {
+        case .microphone:
+            return "You".localized
+        case .system:
+            return "Meeting".localized
+        }
+    }
 }
 
 /// Progress snapshot for UI status updates.
@@ -32,13 +41,13 @@ enum MeetingTranscriptionError: Error, LocalizedError {
     var errorDescription: String? {
         switch self {
         case .authorizationDenied:
-            return "Speech recognition permission denied. Enable it in System Settings and try again."
+            return "Speech recognition permission denied. Enable it in System Settings and try again.".localized
         case .authorizationRestricted:
-            return "Speech recognition is restricted on this Mac."
+            return "Speech recognition is restricted on this Mac.".localized
         case .recognizerUnavailable(let locale):
-            return "Speech recognizer is unavailable for \(locale)."
+            return "Speech recognizer is unavailable for %@.".localized(with: locale)
         case .noAudioToTranscribe:
-            return "No audio was available to transcribe."
+            return "No audio was available to transcribe.".localized
         }
     }
 }
@@ -223,7 +232,7 @@ final class MeetingTranscriptionService {
             let normalized = textForSegment.trimmingCharacters(in: .whitespacesAndNewlines)
             if normalized.isEmpty { continue }
             segments.append(TranscriptSegment(
-                speaker: speaker.rawValue,
+                speaker: speaker.localizedLabel,
                 startOffset: plan.startOffset,
                 endOffset: plan.endOffset,
                 text: normalized
@@ -236,7 +245,7 @@ final class MeetingTranscriptionService {
         let audioFile = try AVAudioFile(forReading: url)
         audioFile.framePosition = startFrame
         guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: frameCount) else {
-            throw NSError(domain: "MeetingTranscriptionService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to allocate audio buffer."])
+            throw NSError(domain: "MeetingTranscriptionService", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unable to allocate audio buffer.".localized])
         }
         try audioFile.read(into: buffer, frameCount: frameCount)
         return buffer
@@ -472,13 +481,13 @@ enum MeetingNotesError: Error, LocalizedError {
     var errorDescription: String? {
         switch self {
         case .missingAPIKey:
-            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault."
+            return "OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault.".localized
         case .invalidResponse:
-            return "Notes generation returned an invalid response."
+            return "Notes generation returned an invalid response.".localized
         case let .httpStatus(code, body):
-            return "Notes generation failed (\(code)): \(body)"
+            return "Notes generation failed (%d): %@".localized(with: code, body)
         case .emptyNotes:
-            return "Notes generation returned empty text."
+            return "Notes generation returned empty text.".localized
         }
     }
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 163 - 167
meetings_app/ViewController.swift


+ 8 - 8
meetings_app/Widgets/DesktopWidgetView.swift

@@ -51,13 +51,13 @@ struct DesktopWidgetView: View {
         switch variant.size {
         case .small:
             VStack(alignment: .leading, spacing: 10) {
-                Text("Quick actions")
+                Text("Quick actions".localized)
                     .font(.system(size: 11.5, weight: .semibold))
                     .foregroundStyle(.white.opacity(0.78))
-                compactActionButton(title: "Open Meet", icon: "video.fill", destination: .joinMeetings)
+                compactActionButton(title: "Open Meet".localized, icon: "video.fill", destination: .joinMeetings)
                 HStack(spacing: 8) {
-                    compactActionButton(title: "Schedule", icon: "clock.badge.checkmark", destination: .schedule)
-                    compactActionButton(title: "Settings", icon: "gearshape.fill", destination: .settings)
+                    compactActionButton(title: "Schedule".localized, icon: "clock.badge.checkmark", destination: .schedule)
+                    compactActionButton(title: "Settings".localized, icon: "gearshape.fill", destination: .settings)
                 }
             }
         case .medium:
@@ -91,13 +91,13 @@ struct DesktopWidgetView: View {
 
     private func meetingBlock(maxRows: Int) -> some View {
         VStack(alignment: .leading, spacing: 9) {
-            Text("Upcoming meetings")
+            Text("Upcoming meetings".localized)
                 .font(.system(size: 11.5, weight: .semibold))
                 .foregroundStyle(.white.opacity(0.80))
             if !isSignedIn {
                 signInCTA
             } else if topMeetings.isEmpty {
-                Text("No upcoming meetings")
+                Text("No upcoming meetings".localized)
                     .font(.system(size: 12, weight: .medium))
                     .foregroundStyle(.white.opacity(0.82))
             } else {
@@ -112,7 +112,7 @@ struct DesktopWidgetView: View {
 
     private var signInCTA: some View {
         VStack(alignment: .leading, spacing: 10) {
-            Text("Sign in to see upcoming meetings")
+            Text("Sign in to see upcoming meetings".localized)
                 .font(.system(size: 12, weight: .medium))
                 .foregroundStyle(.white.opacity(0.86))
 
@@ -120,7 +120,7 @@ struct DesktopWidgetView: View {
                 HStack(spacing: 8) {
                     Image(systemName: "person.crop.circle.badge.checkmark")
                         .font(.system(size: 12, weight: .semibold))
-                    Text("Sign in with Google")
+                    Text("Sign in with Google".localized)
                         .font(.system(size: 12.5, weight: .bold))
                     Spacer(minLength: 0)
                 }

+ 1 - 1
meetings_app/Widgets/DesktopWidgetWindowManager.swift

@@ -275,7 +275,7 @@ final class DesktopWidgetWindowManager: ObservableObject {
             DesktopWidgetView(variant: variant, isPreview: false)
                 .id(manager.authStateRefreshToken)
                 .contextMenu {
-                    Button("Remove Widget", role: .destructive) {
+                    Button("Remove Widget".localized, role: .destructive) {
                         DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
                     }
                 }

+ 2 - 2
meetings_app/Widgets/WidgetSize.swift

@@ -6,8 +6,8 @@ enum WidgetSize: String, CaseIterable, Codable, Hashable {
 
     var title: String {
         switch self {
-        case .small: return "Small"
-        case .medium: return "Medium"
+        case .small: return "Small".localized
+        case .medium: return "Medium".localized
         }
     }
 }

+ 10 - 10
meetings_app/Widgets/WidgetTemplates.swift

@@ -30,24 +30,24 @@ enum WidgetTemplates {
     static let meetVariants: [WidgetVariant] = [
         WidgetVariant(
             id: "meet_small",
-            title: "Google Meet",
-            subtitle: "Open and join quickly",
+            title: "Google Meet".localized,
+            subtitle: "Open and join quickly".localized,
             size: .small,
             quickActions: [
-                WidgetQuickAction(id: "open", title: "Open Meet", systemImage: "video.fill", destination: .openMeetWeb),
-                WidgetQuickAction(id: "join", title: "Join Meetings", systemImage: "arrow.up.forward.app.fill", destination: .joinMeetings)
+                WidgetQuickAction(id: "open", title: "Open Meet".localized, systemImage: "video.fill", destination: .openMeetWeb),
+                WidgetQuickAction(id: "join", title: "Join Meetings".localized, systemImage: "arrow.up.forward.app.fill", destination: .joinMeetings)
             ]
         ),
         WidgetVariant(
             id: "meet_medium",
-            title: "Google Meet",
-            subtitle: "Upcoming meetings",
+            title: "Google Meet".localized,
+            subtitle: "Upcoming meetings".localized,
             size: .medium,
             quickActions: [
-                WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "clock.badge.checkmark", destination: .schedule),
-                WidgetQuickAction(id: "calendar", title: "Calendar", systemImage: "calendar", destination: .calendar),
-                WidgetQuickAction(id: "settings", title: "Settings", systemImage: "gearshape.fill", destination: .settings),
-                WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
+                WidgetQuickAction(id: "schedule", title: "Schedule".localized, systemImage: "clock.badge.checkmark", destination: .schedule),
+                WidgetQuickAction(id: "calendar", title: "Calendar".localized, systemImage: "calendar", destination: .calendar),
+                WidgetQuickAction(id: "settings", title: "Settings".localized, systemImage: "gearshape.fill", destination: .settings),
+                WidgetQuickAction(id: "refresh", title: "Refresh".localized, systemImage: "arrow.clockwise", destination: .refreshMeetings)
             ]
         )
     ]

+ 6 - 6
meetings_app/Widgets/WidgetsRootView.swift

@@ -19,9 +19,9 @@ struct WidgetsRootView: View {
         VStack(alignment: .leading, spacing: 16) {
             HStack {
                 VStack(alignment: .leading, spacing: 3) {
-                    Text("Add Widget to Desktop")
+                    Text("Add Widget to Desktop".localized)
                         .font(.system(size: 20, weight: .bold))
-                    Text("Choose a size and add your Google Meet widget.")
+                    Text("Choose a size and add your Google Meet widget.".localized)
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(secondaryTextColor)
                 }
@@ -47,7 +47,7 @@ struct WidgetsRootView: View {
             }
             .frame(height: variants.map(\.previewCardSize.height).max() ?? 320)
 
-            Text("Tip: Right-click a desktop widget to remove it.")
+            Text("Tip: Right-click a desktop widget to remove it.".localized)
                 .font(.system(size: 12, weight: .medium))
                 .foregroundStyle(secondaryTextColor)
         }
@@ -89,17 +89,17 @@ struct WidgetsRootView: View {
         if desktopWidgetManager.hasInstance(variantID: variant.id) {
             let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
             guard removed > 0 else { return }
-            showToast("Removed \(variant.size.title) widget from desktop.")
+            showToast("Removed %@ widget from desktop.".localized(with: variant.size.title))
         } else {
             guard canAddWidgets else {
-                showToast("Widgets are Premium only. Upgrade to add widgets.")
+                showToast("Widgets are Premium only. Upgrade to add widgets.".localized)
                 onAddBlocked?()
                 return
             }
             desktopWidgetManager.show(
                 instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
             )
-            showToast("Added \(variant.size.title) widget to desktop.")
+            showToast("Added %@ widget to desktop.".localized(with: variant.size.title))
         }
     }
 

+ 4 - 0
meetings_app/en.lproj/InfoPlist.strings

@@ -0,0 +1,4 @@
+"CFBundleDisplayName" = "AI Companion for Meet";
+"CFBundleName" = "AI Companion for Meet";
+"NSMicrophoneUsageDescription" = "This app records meeting audio locally with your consent so it can be shown in AI Companion after the meeting.";
+"NSSpeechRecognitionUsageDescription" = "This app converts your saved meeting audio into text transcripts for AI Companion.";

+ 448 - 0
meetings_app/en.lproj/Localizable.strings

@@ -0,0 +1,448 @@
+/* AppBrand.name ("Meetings") is intentionally omitted — the app name stays fixed in code. */
+
+/* MARK: - Common */
+"OK" = "OK";
+"Cancel" = "Cancel";
+"Continue" = "Continue";
+"Apply" = "Apply";
+"Reset" = "Reset";
+"Save" = "Save";
+"Copy" = "Copy";
+"Copied" = "Copied";
+"Close" = "Close";
+"Join" = "Join";
+"None" = "None";
+"Link" = "Link";
+"Stop" = "Stop";
+"Address" = "Address";
+"Title" = "Title";
+"Notes" = "Notes";
+"Start" = "Start";
+"Premium" = "Premium";
+"Popular" = "Popular";
+"Browser" = "Browser";
+"Meeting" = "Meeting";
+"Error" = "Error";
+"Success" = "Success";
+
+/* MARK: - Status bar */
+"Meetings status menu" = "Meetings status menu";
+"Open App" = "Open App";
+"Join Meetings" = "Join Meetings";
+"Schedule" = "Schedule";
+"Calendar" = "Calendar";
+"Widgets" = "Widgets";
+"AI Companion" = "AI Companion";
+"Settings" = "Settings";
+"Top meetings" = "Top meetings";
+"No upcoming meetings" = "No upcoming meetings";
+"Sign Out" = "Sign Out";
+"Sign In" = "Sign In";
+"Quit" = "Quit";
+
+/* MARK: - Launch splash */
+"Preparing your meetings workspace" = "Preparing your meetings workspace";
+"CONNECTING SERVICES" = "CONNECTING SERVICES";
+"READY" = "READY";
+"Plan smart, achieve more" = "Plan smart, achieve more";
+
+/* MARK: - Widgets */
+"Add Widget to Desktop" = "Add Widget to Desktop";
+"Choose a size and add your Google Meet widget." = "Choose a size and add your Google Meet widget.";
+"Tip: Right-click a desktop widget to remove it." = "Tip: Right-click a desktop widget to remove it.";
+"Removed %@ widget from desktop." = "Removed %@ widget from desktop.";
+"Added %@ widget from desktop." = "Added %@ widget from desktop.";
+"Widgets are Premium only. Upgrade to add widgets." = "Widgets are Premium only. Upgrade to add widgets.";
+"Remove Widget" = "Remove Widget";
+"Small" = "Small";
+"Medium" = "Medium";
+"Quick actions" = "Quick actions";
+"Open Meet" = "Open Meet";
+"Upcoming meetings" = "Upcoming meetings";
+"Sign in to see upcoming meetings" = "Sign in to see upcoming meetings";
+"Sign in with Google" = "Sign in with Google";
+"Google Meet" = "Google Meet";
+"Open and join quickly" = "Open and join quickly";
+"Refresh" = "Refresh";
+
+/* MARK: - Sidebar & navigation */
+"Planning" = "Planning";
+"Additional" = "Additional";
+"Get Premium" = "Get Premium";
+"Manage Subscription" = "Manage Subscription";
+"App for Google Meet" = "App for Google Meet";
+
+/* MARK: - Join meetings */
+"New Instant Meet" = "New Instant Meet";
+"Start instant Meet in more section with\nGoogle Meet meet." = "Start instant Meet in more section with\nGoogle Meet meet.";
+"Join with Link" = "Join with Link";
+"Code or meet.google.com/…" = "Code or meet.google.com/…";
+"Join with ID" = "Join with ID";
+"Join with URL" = "Join with URL";
+"Meeting URL" = "Meeting URL";
+"Enter meeting URL..." = "Enter meeting URL...";
+"Quick Join Meeting" = "Quick Join Meeting";
+"Invalid Meet link" = "Invalid Meet link";
+"Enter a valid Google Meet link or meeting code (for example nkd-grps-duv, meet.google.com/nkd-grps-duv, or https://meet.google.com/nkd-grps-duv)." = "Enter a valid Google Meet link or meeting code (for example nkd-grps-duv, meet.google.com/nkd-grps-duv, or https://meet.google.com/nkd-grps-duv).";
+"Instant Meeting" = "Instant Meeting";
+"Connect Google to start Instant Meeting" = "Connect Google to start Instant Meeting";
+"To auto-stop recording when the meeting ends, connect your Google account first." = "To auto-stop recording when the meeting ends, connect your Google account first.";
+"Could not create Instant Meeting" = "Could not create Instant Meeting";
+"Free trial used. Upgrade to continue." = "Free trial used. Upgrade to continue.";
+
+/* MARK: - Browse web */
+"Browse the web" = "Browse the web";
+"Enter a web address (for example meet.google.com)." = "Enter a web address (for example meet.google.com).";
+"Invalid address" = "Invalid address";
+"Enter a valid http or https URL." = "Enter a valid http or https URL.";
+"Browse" = "Browse";
+"Quick links" = "Quick links";
+"Zoom help" = "Zoom help";
+"https://example.com or example.com" = "https://example.com or example.com";
+"Enter a valid web address, for example https://example.com" = "Enter a valid web address, for example https://example.com";
+"Address not allowed" = "Address not allowed";
+"This URL is not permitted with the current in-app browser policy (whitelist)." = "This URL is not permitted with the current in-app browser policy (whitelist).";
+"Unable to load page" = "Unable to load page";
+"Could not load this page in the in-app browser.\n\n%@" = "Could not load this page in the in-app browser.\n\n%@";
+"Page stopped loading" = "Page stopped loading";
+"Unable to open browser" = "Unable to open browser";
+"Unable to Open Safari" = "Unable to Open Safari";
+
+/* MARK: - Schedule & calendar */
+"All" = "All";
+"Today" = "Today";
+"This week" = "This week";
+"This month" = "This month";
+"Previous week" = "Previous week";
+"Previous month" = "Previous month";
+"Custom range" = "Custom range";
+"No meetings match the selected filters." = "No meetings match the selected filters.";
+"Adjust the date range and tap Apply." = "Adjust the date range and tap Apply.";
+"Premium required. Get Premium now to unlock Calendar." = "Premium required. Get Premium now to unlock Calendar.";
+"Premium required. Click to open paywall." = "Premium required. Click to open paywall.";
+"Get Premium to see events" = "Get Premium to see events";
+"Connect Google" = "Connect Google";
+"Sign in with Google first to schedule a meeting from Calendar." = "Sign in with Google first to schedule a meeting from Calendar.";
+"Select date" = "Select date";
+"Choose a date to schedule your meeting." = "Choose a date to schedule your meeting.";
+"Google schedule" = "Google schedule";
+"To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme." = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme.";
+"Actions" = "Actions";
+"Schedule meeting" = "Schedule meeting";
+"Team sync" = "Team sync";
+"Duration (min)" = "Duration (min)";
+"Refresh saved meetings" = "Refresh saved meetings";
+
+/* MARK: - Settings */
+"Manage appearance, account, and app options." = "Manage appearance, account, and app options.";
+"Appearance" = "Appearance";
+"Account" = "Account";
+"App" = "App";
+"Notifications" = "Notifications";
+"Help & Legal" = "Help & Legal";
+"Dark Mode" = "Dark Mode";
+"Rate Us" = "Rate Us";
+"Share App" = "Share App";
+"Upgrade" = "Upgrade";
+"Privacy Policy" = "Privacy Policy";
+"Support" = "Support";
+"Terms of Services" = "Terms of Services";
+"AI Language 1" = "AI Language 1";
+"AI Language 2" = "AI Language 2";
+"Google account connected" = "Google account connected";
+"Google account not connected" = "Google account not connected";
+"Signed in" = "Signed in";
+"Sign in to sync your meetings and calendar." = "Sign in to sync your meetings and calendar.";
+"Log out" = "Log out";
+"Check out %@ for managing and joining meetings." = "Check out %@ for managing and joining meetings.";
+"Meetings App" = "Meetings App";
+
+/* MARK: - Recording consent */
+"Record this meeting locally?" = "Record this meeting locally?";
+"With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting." = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting.";
+"Allow and Continue" = "Allow and Continue";
+"Continue Without Recording" = "Continue Without Recording";
+"Transcription Languages" = "Transcription Languages";
+"Preferred Language 1" = "Preferred Language 1";
+"Preferred Language 2" = "Preferred Language 2";
+"Microphone permission denied" = "Microphone permission denied";
+"Microphone permission required" = "Microphone permission required";
+"Enable microphone access in System Settings to record meetings locally." = "Enable microphone access in System Settings to record meetings locally.";
+"Could not start recording" = "Could not start recording";
+"Recording will start when the meeting starts" = "Recording will start when the meeting starts";
+"Meeting recording started (meeting + microphone)" = "Meeting recording started (meeting + microphone)";
+"System audio unavailable. Recording microphone only." = "System audio unavailable. Recording microphone only.";
+"Meeting recording started (microphone only)" = "Meeting recording started (microphone only)";
+"Meeting recording saved" = "Meeting recording saved";
+
+/* MARK: - AI Companion */
+"Get notes from your previous meetings" = "Get notes from your previous meetings";
+"Connect your Google account to see your AI Companion meeting history." = "Connect your Google account to see your AI Companion meeting history.";
+"No previous meetings yet for %@. End a meeting to save it here and generate notes." = "No previous meetings yet for %@. End a meeting to save it here and generate notes.";
+"this account" = "this account";
+"Previous meetings" = "Previous meetings";
+"Recording in progress: %@" = "Recording in progress: %@";
+"Started: %@" = "Started: %@";
+"Stop Recording" = "Stop Recording";
+"Date: %@" = "Date: %@";
+"Saved time: %@" = "Saved time: %@";
+"Get Notes" = "Get Notes";
+"Premium required. Click to unlock AI Companion notes." = "Premium required. Click to unlock AI Companion notes.";
+"Play Audio" = "Play Audio";
+"Pause Audio" = "Pause Audio";
+"Stopped" = "Stopped";
+"Speaking..." = "Speaking...";
+"Paused" = "Paused";
+"Playing..." = "Playing...";
+"Not playing" = "Not playing";
+"Checking audio..." = "Checking audio...";
+"Audio file missing" = "Audio file missing";
+"Could not play recording" = "Could not play recording";
+"Failed: %@" = "Failed: %@";
+"Failed to play audio" = "Failed to play audio";
+"decode error" = "decode error";
+"Loading..." = "Loading...";
+"Buffering..." = "Buffering...";
+"Finished" = "Finished";
+"Transcript not requested" = "Transcript not requested";
+"Transcript processing..." = "Transcript processing...";
+"Transcript ready" = "Transcript ready";
+"Transcript unavailable (tap to retry)" = "Transcript unavailable (tap to retry)";
+"Notes not requested" = "Notes not requested";
+"Preparing notes, please wait..." = "Preparing notes, please wait...";
+"Notes ready" = "Notes ready";
+"Notes unavailable (tap to retry)" = "Notes unavailable (tap to retry)";
+"Transcript unavailable" = "Transcript unavailable";
+"Notes unavailable" = "Notes unavailable";
+"Could not find recording details for this meeting." = "Could not find recording details for this meeting.";
+"Transcript - %@" = "Transcript - %@";
+"Notes - %@" = "Notes - %@";
+"Meeting Notes" = "Meeting Notes";
+"Transcript processing..." = "Transcript processing...";
+
+/* MARK: - Paywall & StoreKit */
+"Meetings Pro" = "Meetings Pro";
+"PREMIUM EXPERIENCE" = "PREMIUM EXPERIENCE";
+"Elevate your meetings workflow" = "Elevate your meetings workflow";
+"Unlock automation, premium meeting tools, and priority support for every session." = "Unlock automation, premium meeting tools, and priority support for every session.";
+"Manage meetings" = "Manage meetings";
+"Smart scheduling" = "Smart scheduling";
+"Faster workflow" = "Faster workflow";
+"Reminder notifications" = "Reminder notifications";
+"Monthly" = "Monthly";
+"Bi Yearly" = "Bi Yearly";
+"Yearly" = "Yearly";
+"Most Popular" = "Most Popular";
+"%@ • Most Popular" = "%@ • Most Popular";
+"3 days free trial" = "3 days free trial";
+"Cancel anytime" = "Cancel anytime";
+"No lock-in" = "No lock-in";
+"Instant access" = "Instant access";
+"Unlock all tools" = "Unlock all tools";
+"Secure billing" = "Secure billing";
+"Handled by Apple" = "Handled by Apple";
+"Secured by Apple. Cancel anytime." = "Secured by Apple. Cancel anytime.";
+"Restore Purchase" = "Restore Purchase";
+"Continue with free plan" = "Continue with free plan";
+"Already Premium" = "Already Premium";
+"You are already premium. Do you want to continue with this purchase?" = "You are already premium. Do you want to continue with this purchase?";
+"Current plan: %@. Choose a different package to switch." = "Current plan: %@. Choose a different package to switch.";
+"Premium is active on this Apple ID." = "Premium is active on this Apple ID.";
+"Free for %@, then %@" = "Free for %@, then %@";
+"Billed via App Store" = "Billed via App Store";
+"%@  •  %@" = "%@  •  %@";
+"Save 0%" = "Save 0%";
+"Save %@%%" = "Save %@%%";
+"month" = "month";
+"year" = "year";
+"%@ free trial" = "%@ free trial";
+"Processing..." = "Processing...";
+"Premium Active" = "Premium Active";
+"Continue with 3 day trail" = "Continue with 3 day trail";
+"Please Wait" = "Please Wait";
+"A purchase is already being processed." = "A purchase is already being processed.";
+"This Apple ID already has premium access." = "This Apple ID already has premium access.";
+"Purchase Complete" = "Purchase Complete";
+"Premium has been unlocked successfully." = "Premium has been unlocked successfully.";
+"Purchase Pending" = "Purchase Pending";
+"Your purchase is pending approval. You can continue once it completes." = "Your purchase is pending approval. You can continue once it completes.";
+"Product Not Available" = "Product Not Available";
+"Unable to load this product. Check your StoreKit configuration and product IDs." = "Unable to load this product. Check your StoreKit configuration and product IDs.";
+"Already Purchased" = "Already Purchased";
+"This plan is already active on your Apple ID." = "This plan is already active on your Apple ID.";
+"Purchase Failed" = "Purchase Failed";
+"Restore Purchases" = "Restore Purchases";
+"Purchases restored successfully." = "Purchases restored successfully.";
+"No previous premium purchase was found for this Apple ID." = "No previous premium purchase was found for this Apple ID.";
+"Restore failed: %@" = "Restore failed: %@";
+"Unable to Open Subscriptions" = "Unable to Open Subscriptions";
+"The subscriptions URL is invalid." = "The subscriptions URL is invalid.";
+"%@ tapped." = "%@ tapped.";
+
+/* MARK: - Auth */
+"Google Sign-In" = "Google Sign-In";
+
+/* MARK: - Speech / transcript errors */
+"Speech recognition permission denied. Enable it in System Settings and try again." = "Speech recognition permission denied. Enable it in System Settings and try again.";
+"Speech recognition is restricted on this Mac." = "Speech recognition is restricted on this Mac.";
+"Speech recognition authorization is unavailable." = "Speech recognition authorization is unavailable.";
+"Speech recognizer is unavailable for the current locale." = "Speech recognizer is unavailable for the current locale.";
+"Speech recognizer is unavailable for %@." = "Speech recognizer is unavailable for %@.";
+"No audio was available to transcribe." = "No audio was available to transcribe.";
+
+/* MARK: - StoreKit transaction errors */
+"Unable to load products: %@" = "Unable to load products: %@";
+"Product ID not found in StoreKit response. Requested: %@. Loaded IDs: %@" = "Product ID not found in StoreKit response. Requested: %@. Loaded IDs: %@";
+"Purchase verification failed." = "Purchase verification failed.";
+"Unknown purchase state." = "Unknown purchase state.";
+"none" = "none";
+
+/* MARK: - Paywall pricing copy */
+"%@ one-time purchase" = "%@ one-time purchase";
+"%@/month" = "%@/month";
+"%@/6 months" = "%@/6 months";
+"%@/year (3 days free trial)" = "%@/year (3 days free trial)";
+"day" = "day";
+"week" = "week";
+"period" = "period";
+"1 %@" = "1 %@";
+"%d %@s" = "%d %@s";
+
+/* MARK: - Toasts & quick feedback */
+"Meeting added successfully." = "Meeting added successfully.";
+"An issue occurred. Please try again." = "An issue occurred. Please try again.";
+"Failed to load transcript." = "Failed to load transcript.";
+"Failed to generate notes." = "Failed to generate notes.";
+
+/* MARK: - Browser alerts */
+"Try Again" = "Try Again";
+"The in-app browser closed this page unexpectedly. You can try loading it again in this same window." = "The in-app browser closed this page unexpectedly. You can try loading it again in this same window.";
+
+/* MARK: - Schedule & calendar errors */
+"Couldn't load schedule." = "Couldn't load schedule.";
+"Couldn't refresh schedule." = "Couldn't refresh schedule.";
+"Couldn't connect Google account." = "Couldn't connect Google account.";
+"Couldn't logout Google account." = "Couldn't logout Google account.";
+"Couldn't load schedule" = "Couldn't load schedule";
+"Couldn't determine Meet meeting code from URL." = "Couldn't determine Meet meeting code from URL.";
+"Refreshing…" = "Refreshing…";
+"Connect Google to see meetings" = "Connect Google to see meetings";
+"Connect to load schedule" = "Connect to load schedule";
+"Loading…" = "Loading…";
+"Loading schedule…" = "Loading schedule…";
+"Start date must be on or before end date." = "Start date must be on or before end date.";
+"Invalid custom date range" = "Invalid custom date range";
+"No meetings" = "No meetings";
+"No meetings for selected filters" = "No meetings for selected filters";
+"No meetings on %@" = "No meetings on %@";
+"1 meeting on %@" = "1 meeting on %@";
+"%d meetings on %@" = "%d meetings on %@";
+"All day" = "All day";
+"Duration: all day" = "Duration: all day";
+"Duration: %dh %dm" = "Duration: %dh %dm";
+"Duration: %dh" = "Duration: %dh";
+"Duration: %dm" = "Duration: %dm";
+"%@ - %@" = "%@ - %@";
+"Scheduled Meeting" = "Scheduled Meeting";
+
+/* MARK: - Create meeting popover */
+"Please enter a title." = "Please enter a title.";
+"Duration must be a positive number of minutes." = "Duration must be a positive number of minutes.";
+"Invalid start time." = "Invalid start time.";
+
+/* MARK: - Reminders & notifications */
+"Reminders" = "Reminders";
+"1 Day Before" = "1 Day Before";
+"12 Hours Before" = "12 Hours Before";
+"1 Hour Before" = "1 Hour Before";
+"Starts in 1 day" = "Starts in 1 day";
+"Starts in 12 hours" = "Starts in 12 hours";
+"Starts in 1 hour" = "Starts in 1 hour";
+
+/* MARK: - Google OAuth errors */
+"Missing Google OAuth Client ID." = "Missing Google OAuth Client ID.";
+"Missing Google OAuth Client Secret." = "Missing Google OAuth Client Secret.";
+"Invalid OAuth callback URL." = "Invalid OAuth callback URL.";
+"Google did not return an authorization code." = "Google did not return an authorization code.";
+"Token exchange failed: %@" = "Token exchange failed: %@";
+"Could not open browser for Google sign-in." = "Could not open browser for Google sign-in.";
+"Google sign-in timed out." = "Google sign-in timed out.";
+"No stored Google tokens found." = "No stored Google tokens found.";
+
+/* MARK: - AI Companion / transcript errors */
+"Recording not found." = "Recording not found.";
+"Local meeting audio is missing." = "Local meeting audio is missing.";
+"Generated transcript was empty." = "Generated transcript was empty.";
+"(No transcript entries found.)" = "(No transcript entries found.)";
+"No conference record found for this meeting." = "No conference record found for this meeting.";
+"No transcript found for this meeting." = "No transcript found for this meeting.";
+"Unable to create audio composition tracks." = "Unable to create audio composition tracks.";
+"Unable to create audio export session." = "Unable to create audio export session.";
+"Audio mix export failed." = "Audio mix export failed.";
+"No display available for system audio capture." = "No display available for system audio capture.";
+"Cannot add audio writer input." = "Cannot add audio writer input.";
+
+/* MARK: - Google API errors */
+"Google Meet returned an invalid response." = "Google Meet returned an invalid response.";
+"Google Meet API error (%d): %@" = "Google Meet API error (%d): %@";
+"Failed to parse Google Meet response: %@" = "Failed to parse Google Meet response: %@";
+"Google Calendar returned an invalid response." = "Google Calendar returned an invalid response.";
+"Google Calendar API error (%d): %@" = "Google Calendar API error (%d): %@";
+"Failed to parse Google Calendar events: %@" = "Failed to parse Google Calendar events: %@";
+
+/* MARK: - OpenAI errors */
+"OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault." = "OpenAI API key is missing. Set OPENAI_API_KEY in the environment or app key vault.";
+"Notes generation returned an invalid response." = "Notes generation returned an invalid response.";
+"Notes generation failed (%d): %@" = "Notes generation failed (%d): %@";
+"Notes generation returned empty text." = "Notes generation returned empty text.";
+"OpenAI transcription returned an invalid response." = "OpenAI transcription returned an invalid response.";
+"OpenAI transcription failed (%d): %@" = "OpenAI transcription failed (%d): %@";
+"OpenAI transcription returned no text." = "OpenAI transcription returned no text.";
+
+/* MARK: - Browse & in-app browser */
+"Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app." = "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.";
+"Open in app browser" = "Open in app browser";
+"Meet help" = "Meet help";
+"Go" = "Go";
+"Back" = "Back";
+"Forward" = "Forward";
+"Reload" = "Reload";
+
+/* MARK: - Schedule & calendar labels */
+"Google Calendar" = "Google Calendar";
+"Untitled meeting" = "Untitled meeting";
+"Created event missing start/end." = "Created event missing start/end.";
+"Created event missing Meet/HTML link." = "Created event missing Meet/HTML link.";
+
+/* MARK: - Google account */
+"Google account" = "Google account";
+"%@, Google account" = "%@, Google account";
+
+/* MARK: - Zoom join (unused section) */
+"Meeting ID" = "Meeting ID";
+"Enter meeting ID..." = "Enter meeting ID...";
+"Meeting Passcode" = "Meeting Passcode";
+"Enter meeting passcode..." = "Enter meeting passcode...";
+
+/* MARK: - Paywall (alternate benefits) */
+"Virtual backgrounds" = "Virtual backgrounds";
+"Tools for productivity" = "Tools for productivity";
+"24/7 support" = "24/7 support";
+
+/* MARK: - Transcription speakers */
+"You" = "You";
+
+/* MARK: - Keychain errors */
+"Failed to read OAuth tokens from Keychain." = "Failed to read OAuth tokens from Keychain.";
+"Keychain returned an invalid token payload." = "Keychain returned an invalid token payload.";
+"Failed to update OAuth tokens in Keychain." = "Failed to update OAuth tokens in Keychain.";
+"Failed to save OAuth tokens to Keychain." = "Failed to save OAuth tokens to Keychain.";
+"Failed to delete OAuth tokens from Keychain." = "Failed to delete OAuth tokens from Keychain.";
+"Unable to allocate audio buffer." = "Unable to allocate audio buffer.";
+"Invalid revoke response." = "Invalid revoke response.";
+"Token revoke failed with status %d." = "Token revoke failed with status %d.";
+
+/* MARK: - Error alert diagnostics */
+"Keychain: %d (%@)" = "Keychain: %d (%@)";
+"OSStatus: %d" = "OSStatus: %d";
+"Item: service=%@, account=%@" = "Item: service=%@, account=%@";

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است