|
|
@@ -309,6 +309,25 @@ class ViewController: NSViewController {
|
|
|
private weak var meetingsPageRootView: NSView?
|
|
|
private weak var widgetsPageRootView: NSView?
|
|
|
private weak var aiCompanionPageRootView: NSView?
|
|
|
+ private weak var aiCompanionListStack: NSStackView?
|
|
|
+ private weak var aiCompanionListScroll: NSScrollView?
|
|
|
+ private weak var aiCompanionDetailScroll: NSScrollView?
|
|
|
+ private weak var aiCompanionDetailStack: NSStackView?
|
|
|
+ private weak var aiCompanionStatusLabel: NSTextField?
|
|
|
+ private weak var aiCompanionCountLabel: NSTextField?
|
|
|
+ private weak var aiCompanionRefreshButton: NSButton?
|
|
|
+ private weak var aiCompanionRangePopup: NSPopUpButton?
|
|
|
+ private weak var aiCompanionEmptyLabel: NSTextField?
|
|
|
+ private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
|
|
|
+ private weak var aiCompanionDetailEmptyLabel: NSTextField?
|
|
|
+ private weak var aiCompanionOpenDocButton: NSButton?
|
|
|
+ private weak var aiCompanionCopyMarkdownButton: NSButton?
|
|
|
+ private var aiCompanionRowButtons: [NSButton] = []
|
|
|
+ private var aiCompanionSummaries: [AICompanionSummary] = []
|
|
|
+ private var aiCompanionSelectedIndex: Int?
|
|
|
+ private var aiCompanionIsLoading: Bool = false
|
|
|
+ private var aiCompanionLastLoadedAt: Date?
|
|
|
+ private var aiCompanionSelectedRange: String = "Last 30 days"
|
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
|
@@ -732,6 +751,21 @@ class ViewController: NSViewController {
|
|
|
meetingsPageRootView = nil
|
|
|
widgetsPageRootView = nil
|
|
|
aiCompanionPageRootView = nil
|
|
|
+ aiCompanionListStack = nil
|
|
|
+ aiCompanionListScroll = nil
|
|
|
+ aiCompanionDetailScroll = nil
|
|
|
+ aiCompanionDetailStack = nil
|
|
|
+ aiCompanionStatusLabel = nil
|
|
|
+ aiCompanionCountLabel = nil
|
|
|
+ aiCompanionRefreshButton = nil
|
|
|
+ aiCompanionRangePopup = nil
|
|
|
+ aiCompanionEmptyLabel = nil
|
|
|
+ aiCompanionLoadingIndicator = nil
|
|
|
+ aiCompanionDetailEmptyLabel = nil
|
|
|
+ aiCompanionOpenDocButton = nil
|
|
|
+ aiCompanionCopyMarkdownButton = nil
|
|
|
+ aiCompanionRowButtons.removeAll()
|
|
|
+ aiCompanionLastLoadedAt = nil
|
|
|
homeView?.removeFromSuperview()
|
|
|
if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
|
|
|
topBarProfileInitial = String(profileName.prefix(1)).uppercased()
|
|
|
@@ -1775,9 +1809,15 @@ class ViewController: NSViewController {
|
|
|
zoomOAuth.clearSavedTokens()
|
|
|
persistLoggedInState(false)
|
|
|
allScheduledMeetings = []
|
|
|
+ aiCompanionSummaries = []
|
|
|
+ aiCompanionSelectedIndex = nil
|
|
|
+ aiCompanionLastLoadedAt = nil
|
|
|
applyAuthenticationUIState(triggerInitialRefresh: false)
|
|
|
Task { @MainActor in
|
|
|
self.applyMeetings([])
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ self.renderAiCompanionDetail()
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -3317,6 +3357,222 @@ class ViewController: NSViewController {
|
|
|
return mapMeetings(allMeetings)
|
|
|
}
|
|
|
|
|
|
+ // MARK: - AI Companion Meeting Summaries (Zoom API)
|
|
|
+
|
|
|
+ /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
|
|
|
+ /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
|
|
|
+ /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
|
|
|
+ struct AICompanionSummary: Equatable {
|
|
|
+ struct SummarySection: Equatable {
|
|
|
+ let label: String
|
|
|
+ let summary: String
|
|
|
+ }
|
|
|
+
|
|
|
+ let meetingUUID: String?
|
|
|
+ let meetingID: String?
|
|
|
+ let meetingTopic: String?
|
|
|
+ let meetingHostEmail: String?
|
|
|
+ let meetingStartTime: Date?
|
|
|
+ let meetingEndTime: Date?
|
|
|
+
|
|
|
+ let summaryTitle: String?
|
|
|
+ let summaryOverview: String?
|
|
|
+ let summaryDetails: [SummarySection]
|
|
|
+ let nextSteps: [String]
|
|
|
+ let summaryContent: String?
|
|
|
+ let summaryDocURL: URL?
|
|
|
+
|
|
|
+ let summaryCreatedTime: Date?
|
|
|
+ let summaryLastModifiedTime: Date?
|
|
|
+ }
|
|
|
+
|
|
|
+ private struct ZoomMeetingSummaryAPIItem: Decodable {
|
|
|
+ struct SummaryDetail: Decodable {
|
|
|
+ let label: String?
|
|
|
+ let summary: String?
|
|
|
+ }
|
|
|
+
|
|
|
+ let meeting_host_id: String?
|
|
|
+ let meeting_host_email: String?
|
|
|
+ let meeting_uuid: String?
|
|
|
+ let meeting_id: Int64?
|
|
|
+ let meeting_topic: String?
|
|
|
+ let meeting_start_time: String?
|
|
|
+ let meeting_end_time: String?
|
|
|
+
|
|
|
+ let summary_start_time: String?
|
|
|
+ let summary_end_time: String?
|
|
|
+ let summary_created_time: String?
|
|
|
+ let summary_last_modified_time: String?
|
|
|
+
|
|
|
+ let summary_title: String?
|
|
|
+ let summary_overview: String?
|
|
|
+ let summary_details: [SummaryDetail]?
|
|
|
+ let next_steps: [String]?
|
|
|
+ let summary_content: String?
|
|
|
+ let summary_doc_url: String?
|
|
|
+
|
|
|
+ func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
|
|
|
+ func parseDate(_ s: String?) -> Date? {
|
|
|
+ guard let s, s.isEmpty == false else { return nil }
|
|
|
+ return iso.date(from: s) ?? fallback.date(from: s)
|
|
|
+ }
|
|
|
+ let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
|
|
|
+ let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if label.isEmpty && body.isEmpty { return nil }
|
|
|
+ return AICompanionSummary.SummarySection(label: label, summary: body)
|
|
|
+ }
|
|
|
+ let steps: [String] = (next_steps ?? [])
|
|
|
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
+ .filter { $0.isEmpty == false }
|
|
|
+ let docURL: URL? = {
|
|
|
+ guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ raw.isEmpty == false else { return nil }
|
|
|
+ return URL(string: raw)
|
|
|
+ }()
|
|
|
+ return AICompanionSummary(
|
|
|
+ meetingUUID: meeting_uuid,
|
|
|
+ meetingID: meeting_id.map(String.init),
|
|
|
+ meetingTopic: meeting_topic,
|
|
|
+ meetingHostEmail: meeting_host_email,
|
|
|
+ meetingStartTime: parseDate(meeting_start_time),
|
|
|
+ meetingEndTime: parseDate(meeting_end_time),
|
|
|
+ summaryTitle: summary_title,
|
|
|
+ summaryOverview: summary_overview,
|
|
|
+ summaryDetails: details,
|
|
|
+ nextSteps: steps,
|
|
|
+ summaryContent: summary_content,
|
|
|
+ summaryDocURL: docURL,
|
|
|
+ summaryCreatedTime: parseDate(summary_created_time),
|
|
|
+ summaryLastModifiedTime: parseDate(summary_last_modified_time)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Fetches the signed-in user's AI Companion meeting summaries via Zoom's
|
|
|
+ /// `GET /v2/meetings/meeting_summaries` endpoint. Requires the `meeting:read:summary` scope.
|
|
|
+ private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
|
|
|
+ struct Page: Decodable {
|
|
|
+ let summaries: [ZoomMeetingSummaryAPIItem]?
|
|
|
+ let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
|
|
|
+ let next_page_token: String?
|
|
|
+ }
|
|
|
+
|
|
|
+ let iso = ISO8601DateFormatter()
|
|
|
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
+ let fallbackISO = ISO8601DateFormatter()
|
|
|
+ fallbackISO.formatOptions = [.withInternetDateTime]
|
|
|
+
|
|
|
+ let dayFormatter = DateFormatter()
|
|
|
+ dayFormatter.dateFormat = "yyyy-MM-dd"
|
|
|
+ dayFormatter.timeZone = TimeZone(identifier: "UTC")
|
|
|
+
|
|
|
+ var collected: [AICompanionSummary] = []
|
|
|
+ var seenUUIDs = Set<String>()
|
|
|
+ var nextPageToken: String?
|
|
|
+ var safetyCounter = 0
|
|
|
+ repeat {
|
|
|
+ var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
|
|
|
+ var items: [URLQueryItem] = [
|
|
|
+ URLQueryItem(name: "page_size", value: "30")
|
|
|
+ ]
|
|
|
+ if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
|
|
|
+ if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
|
|
|
+ if let nextPageToken, nextPageToken.isEmpty == false {
|
|
|
+ items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
|
|
|
+ }
|
|
|
+ components.queryItems = items
|
|
|
+ var request = URLRequest(url: components.url!)
|
|
|
+ request.httpMethod = "GET"
|
|
|
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
+
|
|
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
|
|
|
+ }
|
|
|
+ if http.statusCode == 429 {
|
|
|
+ let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
+ let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
|
+ throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
|
|
|
+ }
|
|
|
+ guard (200..<300).contains(http.statusCode) else {
|
|
|
+ let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
|
|
|
+ if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
+ throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
+ }
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ }
|
|
|
+
|
|
|
+ let decoded = try JSONDecoder().decode(Page.self, from: data)
|
|
|
+ let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
|
|
|
+ for item in raw {
|
|
|
+ let model = item.toModel(using: iso, fallback: fallbackISO)
|
|
|
+ let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
|
|
|
+ if seenUUIDs.insert(dedupKey).inserted {
|
|
|
+ collected.append(model)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ nextPageToken = (token?.isEmpty == false) ? token : nil
|
|
|
+ safetyCounter += 1
|
|
|
+ if safetyCounter >= 10 { break }
|
|
|
+ } while nextPageToken != nil
|
|
|
+
|
|
|
+ return collected
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Fetches a single AI Companion summary by meeting ID or UUID via
|
|
|
+ /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
|
|
|
+ /// double-URL-encoded per Zoom's API conventions.
|
|
|
+ private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
|
|
|
+ let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard trimmed.isEmpty == false else { return nil }
|
|
|
+
|
|
|
+ let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
|
|
|
+ let pathSegment: String
|
|
|
+ if isLikelyUUID {
|
|
|
+ // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
|
|
|
+ let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
|
|
|
+ let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
|
|
|
+ pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
|
|
|
+ } else {
|
|
|
+ pathSegment = trimmed
|
|
|
+ }
|
|
|
+ let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
|
|
|
+ guard let url = URL(string: urlString) else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
|
|
|
+ }
|
|
|
+ var request = URLRequest(url: url)
|
|
|
+ request.httpMethod = "GET"
|
|
|
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
|
+
|
|
|
+ let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
+ guard let http = response as? HTTPURLResponse else {
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
|
|
|
+ }
|
|
|
+ if http.statusCode == 404 || http.statusCode == 204 { return nil }
|
|
|
+ if http.statusCode == 429 {
|
|
|
+ let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
|
|
|
+ let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
|
|
+ throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
|
|
|
+ }
|
|
|
+ guard (200..<300).contains(http.statusCode) else {
|
|
|
+ let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summary"
|
|
|
+ if raw.localizedCaseInsensitiveContains("does not contain scopes") {
|
|
|
+ throw ZoomOAuthError.missingRequiredScope(raw)
|
|
|
+ }
|
|
|
+ throw GoogleOAuthError.tokenExchangeFailed(raw)
|
|
|
+ }
|
|
|
+
|
|
|
+ let iso = ISO8601DateFormatter()
|
|
|
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
+ let fallbackISO = ISO8601DateFormatter()
|
|
|
+ fallbackISO.formatOptions = [.withInternetDateTime]
|
|
|
+ let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
|
|
|
+ return item.toModel(using: iso, fallback: fallbackISO)
|
|
|
+ }
|
|
|
+
|
|
|
private func makeSettingsView() -> NSView {
|
|
|
let panel = NSView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -6138,9 +6394,118 @@ class ViewController: NSViewController {
|
|
|
container.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
root.addSubview(container)
|
|
|
|
|
|
+ // Header row: title / subtitle / count + range selector + refresh.
|
|
|
let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
|
|
|
- title.translatesAutoresizingMaskIntoConstraints = false
|
|
|
- container.addSubview(title)
|
|
|
+ let subtitle = makeLabel("AI-generated notes from your past Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ let count = makeLabel("0 summaries", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
+ let titleStack = NSStackView(views: [title, subtitle, count])
|
|
|
+ titleStack.orientation = .vertical
|
|
|
+ titleStack.spacing = 4
|
|
|
+ titleStack.alignment = .leading
|
|
|
+ titleStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let rangePopup = NSPopUpButton()
|
|
|
+ rangePopup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ rangePopup.addItems(withTitles: ["Last 7 days", "Last 30 days", "Last 90 days", "All available"])
|
|
|
+ rangePopup.selectItem(withTitle: aiCompanionSelectedRange)
|
|
|
+ rangePopup.target = self
|
|
|
+ rangePopup.action = #selector(aiCompanionRangeChanged(_:))
|
|
|
+
|
|
|
+ let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
|
|
|
+ refresh.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ refresh.isBordered = false
|
|
|
+ refresh.wantsLayer = true
|
|
|
+ refresh.layer?.backgroundColor = accentBlue.cgColor
|
|
|
+ refresh.layer?.cornerRadius = 10
|
|
|
+ refresh.contentTintColor = .white
|
|
|
+ refresh.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
+
|
|
|
+ let loadingIndicator = NSProgressIndicator()
|
|
|
+ loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ loadingIndicator.style = .spinning
|
|
|
+ loadingIndicator.controlSize = .small
|
|
|
+ loadingIndicator.isDisplayedWhenStopped = false
|
|
|
+ loadingIndicator.isIndeterminate = true
|
|
|
+
|
|
|
+ let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
|
|
|
+ toolbarRight.orientation = .horizontal
|
|
|
+ toolbarRight.alignment = .centerY
|
|
|
+ toolbarRight.spacing = 10
|
|
|
+ toolbarRight.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ container.addSubview(titleStack)
|
|
|
+ container.addSubview(toolbarRight)
|
|
|
+
|
|
|
+ // Status label below header.
|
|
|
+ let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ status.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ container.addSubview(status)
|
|
|
+
|
|
|
+ // List scroll view (left column).
|
|
|
+ let listScroll = NSScrollView()
|
|
|
+ listScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ listScroll.drawsBackground = false
|
|
|
+ listScroll.hasHorizontalScroller = false
|
|
|
+ listScroll.hasVerticalScroller = true
|
|
|
+ listScroll.autohidesScrollers = true
|
|
|
+ listScroll.borderType = .noBorder
|
|
|
+ listScroll.scrollerStyle = .overlay
|
|
|
+ listScroll.automaticallyAdjustsContentInsets = false
|
|
|
+
|
|
|
+ let listClip = TopAlignedClipView()
|
|
|
+ listClip.drawsBackground = false
|
|
|
+ listScroll.contentView = listClip
|
|
|
+
|
|
|
+ let listStack = NSStackView()
|
|
|
+ listStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ listStack.orientation = .vertical
|
|
|
+ listStack.alignment = .leading
|
|
|
+ listStack.spacing = 10
|
|
|
+ listScroll.documentView = listStack
|
|
|
+ container.addSubview(listScroll)
|
|
|
+
|
|
|
+ let listEmpty = makeLabel("No AI notes yet for the selected range.", size: 12, color: secondaryText, weight: .regular, centered: true)
|
|
|
+ listEmpty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ listEmpty.maximumNumberOfLines = 3
|
|
|
+ listEmpty.lineBreakMode = .byWordWrapping
|
|
|
+ listEmpty.isHidden = true
|
|
|
+ container.addSubview(listEmpty)
|
|
|
+
|
|
|
+ // Detail scroll view (right column).
|
|
|
+ let detailScroll = NSScrollView()
|
|
|
+ detailScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ detailScroll.drawsBackground = false
|
|
|
+ detailScroll.hasHorizontalScroller = false
|
|
|
+ detailScroll.hasVerticalScroller = true
|
|
|
+ detailScroll.autohidesScrollers = true
|
|
|
+ detailScroll.borderType = .noBorder
|
|
|
+ detailScroll.scrollerStyle = .overlay
|
|
|
+ detailScroll.automaticallyAdjustsContentInsets = false
|
|
|
+
|
|
|
+ let detailClip = TopAlignedClipView()
|
|
|
+ detailClip.drawsBackground = false
|
|
|
+ detailScroll.contentView = detailClip
|
|
|
+
|
|
|
+ let detailStack = NSStackView()
|
|
|
+ detailStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ detailStack.orientation = .vertical
|
|
|
+ detailStack.alignment = .leading
|
|
|
+ detailStack.spacing = 14
|
|
|
+ detailStack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 20, right: 20)
|
|
|
+ detailScroll.documentView = detailStack
|
|
|
+ container.addSubview(detailScroll)
|
|
|
+
|
|
|
+ let detailEmpty = makeLabel("Select a meeting on the left to view its AI notes.", size: 13, color: secondaryText, weight: .regular, centered: true)
|
|
|
+ detailEmpty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ detailEmpty.maximumNumberOfLines = 3
|
|
|
+ detailEmpty.lineBreakMode = .byWordWrapping
|
|
|
+ container.addSubview(detailEmpty)
|
|
|
+
|
|
|
+ let divider = NSView()
|
|
|
+ divider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ divider.wantsLayer = true
|
|
|
+ divider.layer?.backgroundColor = palette.inputBorder.cgColor
|
|
|
+ container.addSubview(divider)
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
|
|
|
@@ -6148,13 +6513,490 @@ class ViewController: NSViewController {
|
|
|
container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
|
|
|
container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
|
|
|
|
|
|
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
|
|
|
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
|
|
|
- title.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -20)
|
|
|
+ titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
|
|
|
+ titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
|
+ titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
|
|
|
+
|
|
|
+ toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
|
|
|
+ toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
|
+
|
|
|
+ refresh.heightAnchor.constraint(equalToConstant: 30),
|
|
|
+ refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
|
|
|
+ rangePopup.heightAnchor.constraint(equalToConstant: 28),
|
|
|
+ rangePopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
|
|
|
+
|
|
|
+ status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
|
|
|
+ status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
|
+ status.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
|
+
|
|
|
+ listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
|
|
|
+ listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
+ listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
|
|
|
+ listScroll.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.34),
|
|
|
+
|
|
|
+ listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
|
|
|
+ listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor, constant: 8),
|
|
|
+ listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor, constant: -8),
|
|
|
+ listStack.widthAnchor.constraint(equalTo: listClip.widthAnchor, constant: -16),
|
|
|
+
|
|
|
+ listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
|
|
|
+ listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
|
|
|
+ listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
|
|
|
+
|
|
|
+ divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
|
|
|
+ divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
|
|
|
+ divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
|
|
|
+ divider.widthAnchor.constraint(equalToConstant: 1),
|
|
|
+
|
|
|
+ detailScroll.topAnchor.constraint(equalTo: listScroll.topAnchor),
|
|
|
+ detailScroll.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
|
|
|
+ detailScroll.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10),
|
|
|
+ detailScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
+
|
|
|
+ detailStack.topAnchor.constraint(equalTo: detailClip.topAnchor),
|
|
|
+ detailStack.leadingAnchor.constraint(equalTo: detailClip.leadingAnchor),
|
|
|
+ detailStack.trailingAnchor.constraint(equalTo: detailClip.trailingAnchor),
|
|
|
+ detailStack.widthAnchor.constraint(equalTo: detailClip.widthAnchor),
|
|
|
+
|
|
|
+ detailEmpty.centerXAnchor.constraint(equalTo: detailScroll.centerXAnchor),
|
|
|
+ detailEmpty.centerYAnchor.constraint(equalTo: detailScroll.centerYAnchor),
|
|
|
+ detailEmpty.widthAnchor.constraint(lessThanOrEqualTo: detailScroll.widthAnchor, constant: -32)
|
|
|
])
|
|
|
+
|
|
|
+ aiCompanionListScroll = listScroll
|
|
|
+ aiCompanionListStack = listStack
|
|
|
+ aiCompanionDetailScroll = detailScroll
|
|
|
+ aiCompanionDetailStack = detailStack
|
|
|
+ aiCompanionStatusLabel = status
|
|
|
+ aiCompanionCountLabel = count
|
|
|
+ aiCompanionRefreshButton = refresh
|
|
|
+ aiCompanionRangePopup = rangePopup
|
|
|
+ aiCompanionEmptyLabel = listEmpty
|
|
|
+ aiCompanionLoadingIndicator = loadingIndicator
|
|
|
+ aiCompanionDetailEmptyLabel = detailEmpty
|
|
|
+
|
|
|
+ renderAiCompanionList()
|
|
|
+ renderAiCompanionDetail()
|
|
|
return root
|
|
|
}
|
|
|
|
|
|
+ // MARK: - AI Companion list / detail rendering
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func renderAiCompanionList() {
|
|
|
+ guard let stack = aiCompanionListStack else { return }
|
|
|
+ aiCompanionRowButtons.removeAll()
|
|
|
+ stack.arrangedSubviews.forEach { view in
|
|
|
+ stack.removeArrangedSubview(view)
|
|
|
+ view.removeFromSuperview()
|
|
|
+ }
|
|
|
+
|
|
|
+ let count = aiCompanionSummaries.count
|
|
|
+ aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
|
|
|
+
|
|
|
+ let showEmpty: Bool
|
|
|
+ if isUserLoggedIn() == false {
|
|
|
+ showEmpty = true
|
|
|
+ aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
|
|
|
+ } else if count == 0 {
|
|
|
+ showEmpty = true
|
|
|
+ aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
|
|
|
+ } else {
|
|
|
+ showEmpty = false
|
|
|
+ }
|
|
|
+ aiCompanionEmptyLabel?.isHidden = !showEmpty
|
|
|
+
|
|
|
+ for (idx, summary) in aiCompanionSummaries.enumerated() {
|
|
|
+ let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
|
|
|
+ stack.addArrangedSubview(row)
|
|
|
+ row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func makeAiCompanionListRow(summary: AICompanionSummary, index: Int, isSelected: Bool) -> NSButton {
|
|
|
+ let button = NSButton(title: "", target: self, action: #selector(aiCompanionRowTapped(_:)))
|
|
|
+ button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ button.bezelStyle = .regularSquare
|
|
|
+ button.isBordered = false
|
|
|
+ button.setButtonType(.momentaryChange)
|
|
|
+ button.tag = index
|
|
|
+ button.wantsLayer = true
|
|
|
+ button.layer?.cornerRadius = 12
|
|
|
+ button.layer?.borderWidth = 1
|
|
|
+
|
|
|
+ let baseBg: NSColor = palette.isDarkMode
|
|
|
+ ? NSColor.white.withAlphaComponent(0.04)
|
|
|
+ : NSColor.black.withAlphaComponent(0.03)
|
|
|
+ let selectedBg: NSColor = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.22 : 0.14)
|
|
|
+ let baseBorder: NSColor = palette.inputBorder
|
|
|
+ let selectedBorder: NSColor = accentBlue
|
|
|
+
|
|
|
+ button.layer?.backgroundColor = (isSelected ? selectedBg : baseBg).cgColor
|
|
|
+ button.layer?.borderColor = (isSelected ? selectedBorder : baseBorder).cgColor
|
|
|
+
|
|
|
+ let topic = summary.summaryTitle?.isEmpty == false
|
|
|
+ ? summary.summaryTitle!
|
|
|
+ : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
|
|
|
+ let titleLabel = makeLabel(topic, size: 14, color: primaryText, weight: .semibold, centered: false)
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ titleLabel.maximumNumberOfLines = 2
|
|
|
+ titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
+
|
|
|
+ let dateText = aiCompanionDateText(for: summary)
|
|
|
+ let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ dateLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let badge = makeLabel("AI notes", size: 10, color: accentBlue, weight: .semibold, centered: false)
|
|
|
+ badge.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ button.addSubview(titleLabel)
|
|
|
+ button.addSubview(dateLabel)
|
|
|
+ button.addSubview(badge)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
|
|
|
+ titleLabel.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
|
|
|
+
|
|
|
+ dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
|
+ dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
|
+ dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
|
|
+
|
|
|
+ badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
|
|
|
+ badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
|
+ badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
|
|
|
+ ])
|
|
|
+
|
|
|
+ aiCompanionRowButtons.append(button)
|
|
|
+ return button
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func renderAiCompanionDetail() {
|
|
|
+ guard let stack = aiCompanionDetailStack else { return }
|
|
|
+ stack.arrangedSubviews.forEach { view in
|
|
|
+ stack.removeArrangedSubview(view)
|
|
|
+ view.removeFromSuperview()
|
|
|
+ }
|
|
|
+
|
|
|
+ guard isUserLoggedIn() else {
|
|
|
+ aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
|
|
|
+ aiCompanionDetailEmptyLabel?.isHidden = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ guard let index = aiCompanionSelectedIndex,
|
|
|
+ aiCompanionSummaries.indices.contains(index) else {
|
|
|
+ aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
|
|
|
+ ? "Run an AI Companion-enabled meeting to see notes here."
|
|
|
+ : "Select a meeting on the left to view its AI notes."
|
|
|
+ aiCompanionDetailEmptyLabel?.isHidden = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ aiCompanionDetailEmptyLabel?.isHidden = true
|
|
|
+
|
|
|
+ let summary = aiCompanionSummaries[index]
|
|
|
+
|
|
|
+ let topic = summary.summaryTitle?.isEmpty == false
|
|
|
+ ? summary.summaryTitle!
|
|
|
+ : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
|
|
|
+ let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
|
|
|
+ titleLabel.maximumNumberOfLines = 0
|
|
|
+ titleLabel.lineBreakMode = .byWordWrapping
|
|
|
+ stack.addArrangedSubview(titleLabel)
|
|
|
+
|
|
|
+ let metaText = aiCompanionDateText(for: summary)
|
|
|
+ let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ metaLabel.maximumNumberOfLines = 0
|
|
|
+ stack.addArrangedSubview(metaLabel)
|
|
|
+
|
|
|
+ let actionsRow = NSStackView()
|
|
|
+ actionsRow.orientation = .horizontal
|
|
|
+ actionsRow.spacing = 10
|
|
|
+ actionsRow.alignment = .centerY
|
|
|
+ actionsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+
|
|
|
+ let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
|
|
|
+ openDocButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ openDocButton.isBordered = false
|
|
|
+ openDocButton.wantsLayer = true
|
|
|
+ openDocButton.layer?.backgroundColor = accentBlue.cgColor
|
|
|
+ openDocButton.layer?.cornerRadius = 10
|
|
|
+ openDocButton.contentTintColor = .white
|
|
|
+ openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
+ openDocButton.isEnabled = summary.summaryDocURL != nil
|
|
|
+ openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
|
|
|
+
|
|
|
+ let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
|
|
|
+ copyButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ copyButton.isBordered = false
|
|
|
+ copyButton.wantsLayer = true
|
|
|
+ copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
|
|
|
+ copyButton.layer?.cornerRadius = 10
|
|
|
+ copyButton.layer?.borderWidth = 1
|
|
|
+ copyButton.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ copyButton.contentTintColor = primaryText
|
|
|
+ copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
+
|
|
|
+ [openDocButton, copyButton].forEach { btn in
|
|
|
+ btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
|
+ btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
|
|
|
+ }
|
|
|
+ actionsRow.addArrangedSubview(openDocButton)
|
|
|
+ actionsRow.addArrangedSubview(copyButton)
|
|
|
+ stack.addArrangedSubview(actionsRow)
|
|
|
+
|
|
|
+ aiCompanionOpenDocButton = openDocButton
|
|
|
+ aiCompanionCopyMarkdownButton = copyButton
|
|
|
+
|
|
|
+ if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
|
|
|
+ stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
|
|
|
+ stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
|
|
|
+ }
|
|
|
+
|
|
|
+ for section in summary.summaryDetails {
|
|
|
+ let labelText = section.label.isEmpty ? "Details" : section.label
|
|
|
+ stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
|
|
|
+ if section.summary.isEmpty == false {
|
|
|
+ stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if summary.nextSteps.isEmpty == false {
|
|
|
+ stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
|
|
|
+ let bullets = summary.nextSteps.map { "• \($0)" }.joined(separator: "\n")
|
|
|
+ stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
|
|
|
+ }
|
|
|
+
|
|
|
+ if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
|
|
|
+ let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ fullContent.isEmpty == false {
|
|
|
+ stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
|
|
|
+ stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
|
|
|
+ let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
|
|
|
+ label.maximumNumberOfLines = 1
|
|
|
+ return label
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
|
|
|
+ let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
|
|
|
+ label.maximumNumberOfLines = 0
|
|
|
+ label.lineBreakMode = .byWordWrapping
|
|
|
+ label.isSelectable = true
|
|
|
+ return label
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
|
|
|
+ let formatter = DateFormatter()
|
|
|
+ formatter.dateStyle = .medium
|
|
|
+ formatter.timeStyle = .short
|
|
|
+ formatter.timeZone = TimeZone.current
|
|
|
+ let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
|
|
|
+ let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
|
|
|
+ switch (startText, endText) {
|
|
|
+ case let (start?, end?): return "\(start) → \(end)"
|
|
|
+ case let (start?, nil): return start
|
|
|
+ case let (nil, end?): return "Ended \(end)"
|
|
|
+ case (nil, nil): return "Date unavailable"
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - AI Companion data loading & actions
|
|
|
+
|
|
|
+ private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
|
|
|
+ let now = Date()
|
|
|
+ let calendar = Calendar.current
|
|
|
+ let days: Int
|
|
|
+ switch aiCompanionSelectedRange {
|
|
|
+ case "Last 7 days": days = 7
|
|
|
+ case "Last 30 days": days = 30
|
|
|
+ case "Last 90 days": days = 90
|
|
|
+ case "All available": return (nil, nil)
|
|
|
+ default: days = 30
|
|
|
+ }
|
|
|
+ let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
|
|
|
+ return (from, now)
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func setAiCompanionLoadingUI(_ loading: Bool) {
|
|
|
+ if loading {
|
|
|
+ aiCompanionLoadingIndicator?.startAnimation(nil)
|
|
|
+ } else {
|
|
|
+ aiCompanionLoadingIndicator?.stopAnimation(nil)
|
|
|
+ }
|
|
|
+ aiCompanionRefreshButton?.isEnabled = !loading
|
|
|
+ aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
|
|
|
+ aiCompanionRangePopup?.isEnabled = !loading
|
|
|
+ }
|
|
|
+
|
|
|
+ private func loadAICompanionSummaries(force: Bool) async {
|
|
|
+ guard isUserLoggedIn() else {
|
|
|
+ await MainActor.run {
|
|
|
+ self.aiCompanionSummaries = []
|
|
|
+ self.aiCompanionSelectedIndex = nil
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ self.renderAiCompanionDetail()
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if aiCompanionIsLoading && force == false { return }
|
|
|
+ aiCompanionIsLoading = true
|
|
|
+ await MainActor.run {
|
|
|
+ self.setAiCompanionLoadingUI(true)
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
|
|
|
+ }
|
|
|
+ defer {
|
|
|
+ aiCompanionIsLoading = false
|
|
|
+ Task { @MainActor in self.setAiCompanionLoadingUI(false) }
|
|
|
+ }
|
|
|
+
|
|
|
+ do {
|
|
|
+ let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
|
|
|
+ let (from, to) = aiCompanionDateRange()
|
|
|
+ let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
|
|
|
+ await MainActor.run {
|
|
|
+ self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
|
|
|
+ (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
|
|
|
+ })
|
|
|
+ self.aiCompanionLastLoadedAt = Date()
|
|
|
+ if self.aiCompanionSummaries.isEmpty {
|
|
|
+ self.aiCompanionSelectedIndex = nil
|
|
|
+ } else {
|
|
|
+ if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
|
|
|
+ self.aiCompanionSelectedIndex = 0
|
|
|
+ } else if self.aiCompanionSelectedIndex == nil {
|
|
|
+ self.aiCompanionSelectedIndex = 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ self.renderAiCompanionDetail()
|
|
|
+ if self.aiCompanionSummaries.isEmpty {
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
|
|
|
+ } else {
|
|
|
+ let formatter = DateFormatter()
|
|
|
+ formatter.dateStyle = .none
|
|
|
+ formatter.timeStyle = .short
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ await MainActor.run {
|
|
|
+ self.aiCompanionSummaries = []
|
|
|
+ self.aiCompanionSelectedIndex = nil
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ self.renderAiCompanionDetail()
|
|
|
+
|
|
|
+ if case ZoomOAuthError.missingRequiredScope = error {
|
|
|
+ self.zoomOAuth.clearSavedTokens()
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Zoom permissions are missing. Add meeting:read:summary in your Zoom Marketplace app, then sign in again."
|
|
|
+ } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
|
|
|
+ let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
|
|
|
+ } else if case ZoomOAuthError.missingClientId = error {
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
+ } else if case ZoomOAuthError.missingClientSecret = error {
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
|
|
|
+ } else if case ZoomOAuthError.authenticationCancelled = error {
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
|
|
|
+ } else {
|
|
|
+ self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionRefreshIfStale() {
|
|
|
+ let stale: Bool
|
|
|
+ if let lastLoaded = aiCompanionLastLoadedAt {
|
|
|
+ stale = Date().timeIntervalSince(lastLoaded) > 60
|
|
|
+ } else {
|
|
|
+ stale = true
|
|
|
+ }
|
|
|
+ if stale {
|
|
|
+ Task { await self.loadAICompanionSummaries(force: false) }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionRefreshTapped() {
|
|
|
+ Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
|
|
|
+ aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
|
|
|
+ Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionRowTapped(_ sender: NSButton) {
|
|
|
+ let index = sender.tag
|
|
|
+ guard aiCompanionSummaries.indices.contains(index) else { return }
|
|
|
+ aiCompanionSelectedIndex = index
|
|
|
+ renderAiCompanionList()
|
|
|
+ renderAiCompanionDetail()
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionOpenDocTapped() {
|
|
|
+ guard let index = aiCompanionSelectedIndex,
|
|
|
+ aiCompanionSummaries.indices.contains(index),
|
|
|
+ let url = aiCompanionSummaries[index].summaryDocURL else { return }
|
|
|
+ openWebURLPreferringInApp(url)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionCopyMarkdownTapped() {
|
|
|
+ guard let index = aiCompanionSelectedIndex,
|
|
|
+ aiCompanionSummaries.indices.contains(index) else { return }
|
|
|
+ let summary = aiCompanionSummaries[index]
|
|
|
+ let markdown = aiCompanionMarkdown(for: summary)
|
|
|
+ let pasteboard = NSPasteboard.general
|
|
|
+ pasteboard.clearContents()
|
|
|
+ pasteboard.setString(markdown, forType: .string)
|
|
|
+ aiCompanionStatusLabel?.stringValue = "Copied AI notes as Markdown."
|
|
|
+ }
|
|
|
+
|
|
|
+ private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
|
|
|
+ if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
+ content.isEmpty == false {
|
|
|
+ return content
|
|
|
+ }
|
|
|
+ var lines: [String] = []
|
|
|
+ let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
|
|
|
+ lines.append("# \(title)")
|
|
|
+ lines.append("")
|
|
|
+ lines.append("_\(aiCompanionDateText(for: summary))_")
|
|
|
+ lines.append("")
|
|
|
+ if let overview = summary.summaryOverview, overview.isEmpty == false {
|
|
|
+ lines.append("## Overview")
|
|
|
+ lines.append(overview)
|
|
|
+ lines.append("")
|
|
|
+ }
|
|
|
+ for section in summary.summaryDetails {
|
|
|
+ let header = section.label.isEmpty ? "Details" : section.label
|
|
|
+ lines.append("## \(header)")
|
|
|
+ if section.summary.isEmpty == false {
|
|
|
+ lines.append(section.summary)
|
|
|
+ }
|
|
|
+ lines.append("")
|
|
|
+ }
|
|
|
+ if summary.nextSteps.isEmpty == false {
|
|
|
+ lines.append("## Next steps")
|
|
|
+ for step in summary.nextSteps {
|
|
|
+ lines.append("- \(step)")
|
|
|
+ }
|
|
|
+ lines.append("")
|
|
|
+ }
|
|
|
+ if let url = summary.summaryDocURL {
|
|
|
+ lines.append("Full document: \(url.absoluteString)")
|
|
|
+ }
|
|
|
+ return lines.joined(separator: "\n")
|
|
|
+ }
|
|
|
+
|
|
|
@MainActor
|
|
|
private func refreshWidgetsPagePremiumState() {
|
|
|
guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
|
|
|
@@ -6794,6 +7636,9 @@ class ViewController: NSViewController {
|
|
|
if isMeetings {
|
|
|
applyMeetingsPageFilters(resetVisible: false)
|
|
|
}
|
|
|
+ if isAiCompanion {
|
|
|
+ aiCompanionRefreshIfStale()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
|
|
|
@@ -7677,10 +8522,13 @@ final class ZoomOAuthService: NSObject {
|
|
|
|| part == "meeting:read:admin"
|
|
|
|| part == "meeting:write"
|
|
|
|| part == "meeting:write:admin"
|
|
|
+ || part == "meeting:read:summary"
|
|
|
+ || part == "meeting:read:summary:admin"
|
|
|
|| part.contains("meeting:read")
|
|
|
|| part.contains("meeting:write")
|
|
|
|| part.contains("list_meetings")
|
|
|
|| part.contains("list_user_meetings")
|
|
|
+ || part.contains("meeting:read:summary")
|
|
|
}
|
|
|
}
|
|
|
|