diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7866a76..5b390d4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + lfs: true - name: Select Xcode id: toolchain @@ -25,7 +28,9 @@ jobs: fi swift --version - XCODE_VERSION="$(xcodebuild -version | awk '/^Xcode / { print $2; exit }')" + XCODE_BUILD_VERSION="$(xcodebuild -version)" + printf '%s\n' "$XCODE_BUILD_VERSION" + XCODE_VERSION="$(printf '%s\n' "$XCODE_BUILD_VERSION" | awk '/^Xcode / { print $2 }')" echo "xcode_version=$XCODE_VERSION" >> "$GITHUB_OUTPUT" SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version || echo 0)" @@ -40,14 +45,48 @@ jobs: ${{ runner.os }}-xcode-${{ steps.toolchain.outputs.xcode_version }}-spm- ${{ runner.os }}-spm- + - name: Verify Ghostty archive + run: | + set -euo pipefail + ARCHIVE="Frameworks/GhosttyKit.xcframework/macos-arm64/libghostty-fat.a" + test -f "$ARCHIVE" + file "$ARCHIVE" + ar -t "$ARCHIVE" >/dev/null + - name: Build run: | swift build - name: Run SwiftLint run: | + set -euo pipefail brew install swiftlint - swiftlint lint Sources/ + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + BASE_SHA="${{ github.event.before }}" + HEAD_SHA="${{ github.sha }}" + fi + + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + SWIFT_FILES="$(find Sources -name '*.swift' -print | sort)" + else + SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^Sources/.*\.swift$' || true)" + fi + + if [ -z "$SWIFT_FILES" ]; then + echo "No changed Swift source files to lint." + exit 0 + fi + + FILE_COUNT="$(printf '%s\n' "$SWIFT_FILES" | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'Linting %s changed Swift files\n' "$FILE_COUNT" + printf '%s\n' "$SWIFT_FILES" | while IFS= read -r file; do + [ -n "$file" ] || continue + swiftlint lint --config .swiftlint-ci.yml "$file" + done - name: Run tests run: swift test diff --git a/.swiftlint-ci.yml b/.swiftlint-ci.yml new file mode 100644 index 00000000..e20648a6 --- /dev/null +++ b/.swiftlint-ci.yml @@ -0,0 +1,14 @@ +parent_config: .swiftlint.yml + +disabled_rules: + - trailing_comma + - todo + - opening_brace + - multiple_closures_with_trailing_closure + - force_cast + - force_try + - shorthand_operator + - cyclomatic_complexity + - function_body_length + - type_body_length + - file_length diff --git a/Sources/Bugbook/App/AppEnvironment.swift b/Sources/Bugbook/App/AppEnvironment.swift new file mode 100644 index 00000000..00e0db4b --- /dev/null +++ b/Sources/Bugbook/App/AppEnvironment.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Detects whether this is a dev or release build of Bugbook. +enum AppEnvironment { + /// True when running a development build (Xcode Debug / swift build debug). + static var isDev: Bool { + #if DEBUG + return true + #else + // Release builds from the release script use com.bugbook.Bugbook. + // Dev/Xcode builds use com.maxforsey.Bugbook.dev. + let bundleID = Bundle.main.bundleIdentifier ?? "" + return bundleID.hasSuffix(".dev") + #endif + } + + /// Human-readable version string, e.g. "0.408 (build 408)". + static var versionString: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" + return "\(version) (build \(build))" + } +} diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 3efeb13d..b63156df 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -45,6 +45,7 @@ struct MCPServerInfo: Identifiable { var mcpServers: [MCPServerInfo] = [] var isRecording: Bool = false + var recordingBlockId: UUID? var flashcardReviewOpen: Bool = false @ObservationIgnored lazy var aiThreadStore = AiThreadStore() @@ -241,6 +242,21 @@ struct MCPServerInfo: Identifiable { } private func resolveEntry(for path: String) -> FileEntry { + switch path { + case "bugbook://mail": + return FileEntry(id: path, name: "Mail", path: path, isDirectory: false, kind: .mail, icon: "envelope") + case "bugbook://calendar": + return FileEntry(id: path, name: "Calendar", path: path, isDirectory: false, kind: .calendar, icon: "calendar.badge.clock") + case "bugbook://meetings": + return FileEntry(id: path, name: "Meetings", path: path, isDirectory: false, kind: .meetings, icon: "person.2") + case "bugbook://graph": + return FileEntry(id: path, name: "Graph View", path: path, isDirectory: false, kind: .graphView, icon: "sf:point.3.connected.trianglepath.dotted") + case "bugbook://gateway": + return FileEntry(id: path, name: "Gateway", path: path, isDirectory: false, kind: .gateway, icon: "square.grid.2x2") + default: + break + } + if let row = DatabaseRowNavigationPath.parse(path) { return FileEntry( id: path, diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index b2bab353..25c5fc1d 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -11,6 +11,20 @@ struct BugbookApp: App { WindowGroup { ContentView() .tint(Color.fallbackAccent) + .overlay(alignment: .topTrailing) { + if AppEnvironment.isDev { + Text("DEV") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.85)) + .clipShape(.capsule) + .padding(.top, 4) + .padding(.trailing, 72) + .allowsHitTesting(false) + } + } } .windowStyle(.hiddenTitleBar) .defaultSize(width: 1100, height: 700) @@ -91,11 +105,21 @@ struct BugbookApp: App { } .keyboardShortcut("g", modifiers: [.command, .shift]) + Button("Mail") { + NotificationCenter.default.post(name: .openMail, object: nil) + } + .keyboardShortcut("m", modifiers: [.command, .shift]) + Button("Calendar") { NotificationCenter.default.post(name: .openCalendar, object: nil) } .keyboardShortcut("y", modifiers: [.command, .shift]) + Button("Gateway") { + NotificationCenter.default.post(name: .openGateway, object: nil) + } + .keyboardShortcut("0", modifiers: [.command, .shift]) + Button("Toggle Theme") { NotificationCenter.default.post(name: .toggleTheme, object: nil) } @@ -365,15 +389,18 @@ extension Notification.Name { static let navigateForward = Notification.Name("navigateForward") static let openDailyNote = Notification.Name("openDailyNote") static let openGraphView = Notification.Name("openGraphView") + static let openMail = Notification.Name("openMail") static let editorZoomIn = Notification.Name("editorZoomIn") static let editorZoomOut = Notification.Name("editorZoomOut") static let editorZoomReset = Notification.Name("editorZoomReset") static let openCalendar = Notification.Name("openCalendar") static let openMeetings = Notification.Name("openMeetings") + static let openGateway = Notification.Name("openGateway") static let fileDeleted = Notification.Name("fileDeleted") static let fileMoved = Notification.Name("fileMoved") static let movePage = Notification.Name("movePage") static let movePageToDir = Notification.Name("movePageToDir") + static let addToSidebar = Notification.Name("addToSidebar") // Pane/Workspace system static let splitPaneRight = Notification.Name("splitPaneRight") diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift index dd59b4ba..844a50c1 100644 --- a/Sources/Bugbook/Models/AppSettings.swift +++ b/Sources/Bugbook/Models/AppSettings.swift @@ -16,11 +16,13 @@ enum PreferredAIEngine: String, Codable, CaseIterable { enum AnthropicModel: String, Codable, CaseIterable { case haiku = "claude-haiku-4-5-20251001" case sonnet = "claude-sonnet-4-20250514" + case opus = "claude-opus-4-20250514" var displayName: String { switch self { case .haiku: return "Haiku (fast)" case .sonnet: return "Sonnet (quality)" + case .opus: return "Opus (best)" } } } @@ -31,7 +33,7 @@ enum ExecutionPolicy: String, Codable, CaseIterable { case denyAll = "Deny All" } -struct AppSettings: Codable { +struct AppSettings: Codable, Equatable { var theme: ThemeMode var focusModeOnType: Bool var preferredAIEngine: PreferredAIEngine @@ -41,15 +43,21 @@ struct AppSettings: Codable { var qmdSearchMode: QmdSearchMode var anthropicApiKey: String var anthropicModel: AnthropicModel + var mailBackgroundAnalysisEnabled: Bool + var mailBackgroundDraftGenerationEnabled: Bool + var mailSenderLookupEnabled: Bool + var mailMemoryLearningEnabled: Bool /// Path to the page opened for new/empty tabs. Empty string = default Bugbook landing page. var defaultNewTabPage: String - // Google Calendar - var googleCalendarRefreshToken: String - var googleCalendarAccessToken: String - var googleCalendarTokenExpiry: Double - var googleCalendarConnectedEmail: String - var googleCalendarBannerDismissed: Bool + // Shared Google account + var googleClientID: String + var googleClientSecret: String + var googleRefreshToken: String + var googleAccessToken: String + var googleTokenExpiry: Double + var googleConnectedEmail: String + var googleGrantedScopes: [String] static let `default` = AppSettings( theme: .system, @@ -61,15 +69,51 @@ struct AppSettings: Codable { qmdSearchMode: .bm25, anthropicApiKey: "", anthropicModel: .sonnet, + mailBackgroundAnalysisEnabled: true, + mailBackgroundDraftGenerationEnabled: true, + mailSenderLookupEnabled: true, + mailMemoryLearningEnabled: true, defaultNewTabPage: "", - googleCalendarRefreshToken: "", - googleCalendarAccessToken: "", - googleCalendarTokenExpiry: 0, - googleCalendarConnectedEmail: "", - googleCalendarBannerDismissed: false + googleClientID: "", + googleClientSecret: "", + googleRefreshToken: "", + googleAccessToken: "", + googleTokenExpiry: 0, + googleConnectedEmail: "", + googleGrantedScopes: [] ) - // Backward-compatible decoding — new fields default gracefully + private enum CodingKeys: String, CodingKey { + case theme + case focusModeOnType + case preferredAIEngine + case executionPolicy + case bugbookSkillEnabled + case agentsMdContent + case qmdSearchMode + case anthropicApiKey + case anthropicModel + case mailBackgroundAnalysisEnabled + case mailBackgroundDraftGenerationEnabled + case mailSenderLookupEnabled + case mailMemoryLearningEnabled + case defaultNewTabPage + case googleClientID + case googleClientSecret + case googleRefreshToken + case googleAccessToken + case googleTokenExpiry + case googleConnectedEmail + case googleGrantedScopes + + // Legacy calendar-only auth keys. + case googleCalendarRefreshToken + case googleCalendarAccessToken + case googleCalendarTokenExpiry + case googleCalendarConnectedEmail + } + + // Backward-compatible decoding — new fields default gracefully. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) theme = try container.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system @@ -81,12 +125,22 @@ struct AppSettings: Codable { qmdSearchMode = try container.decodeIfPresent(QmdSearchMode.self, forKey: .qmdSearchMode) ?? .bm25 anthropicApiKey = try container.decodeIfPresent(String.self, forKey: .anthropicApiKey) ?? "" anthropicModel = try container.decodeIfPresent(AnthropicModel.self, forKey: .anthropicModel) ?? .sonnet + mailBackgroundAnalysisEnabled = try container.decodeIfPresent(Bool.self, forKey: .mailBackgroundAnalysisEnabled) ?? true + mailBackgroundDraftGenerationEnabled = try container.decodeIfPresent(Bool.self, forKey: .mailBackgroundDraftGenerationEnabled) ?? true + mailSenderLookupEnabled = try container.decodeIfPresent(Bool.self, forKey: .mailSenderLookupEnabled) ?? true + mailMemoryLearningEnabled = try container.decodeIfPresent(Bool.self, forKey: .mailMemoryLearningEnabled) ?? true defaultNewTabPage = try container.decodeIfPresent(String.self, forKey: .defaultNewTabPage) ?? "" - googleCalendarRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarRefreshToken) ?? "" - googleCalendarAccessToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarAccessToken) ?? "" - googleCalendarTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleCalendarTokenExpiry) ?? 0 - googleCalendarConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleCalendarConnectedEmail) ?? "" - googleCalendarBannerDismissed = try container.decodeIfPresent(Bool.self, forKey: .googleCalendarBannerDismissed) ?? false + googleClientID = try container.decodeIfPresent(String.self, forKey: .googleClientID) ?? "" + googleClientSecret = try container.decodeIfPresent(String.self, forKey: .googleClientSecret) ?? "" + let legacyRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarRefreshToken) + let legacyAccessToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarAccessToken) + let legacyTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleCalendarTokenExpiry) + let legacyConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleCalendarConnectedEmail) + googleRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleRefreshToken) ?? legacyRefreshToken ?? "" + googleAccessToken = try container.decodeIfPresent(String.self, forKey: .googleAccessToken) ?? legacyAccessToken ?? "" + googleTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleTokenExpiry) ?? legacyTokenExpiry ?? 0 + googleConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleConnectedEmail) ?? legacyConnectedEmail ?? "" + googleGrantedScopes = try container.decodeIfPresent([String].self, forKey: .googleGrantedScopes) ?? [] } init( @@ -99,12 +153,18 @@ struct AppSettings: Codable { qmdSearchMode: QmdSearchMode, anthropicApiKey: String, anthropicModel: AnthropicModel = .sonnet, + mailBackgroundAnalysisEnabled: Bool = true, + mailBackgroundDraftGenerationEnabled: Bool = true, + mailSenderLookupEnabled: Bool = true, + mailMemoryLearningEnabled: Bool = true, defaultNewTabPage: String, - googleCalendarRefreshToken: String = "", - googleCalendarAccessToken: String = "", - googleCalendarTokenExpiry: Double = 0, - googleCalendarConnectedEmail: String = "", - googleCalendarBannerDismissed: Bool = false + googleClientID: String = "", + googleClientSecret: String = "", + googleRefreshToken: String = "", + googleAccessToken: String = "", + googleTokenExpiry: Double = 0, + googleConnectedEmail: String = "", + googleGrantedScopes: [String] = [] ) { self.theme = theme self.focusModeOnType = focusModeOnType @@ -115,11 +175,84 @@ struct AppSettings: Codable { self.qmdSearchMode = qmdSearchMode self.anthropicApiKey = anthropicApiKey self.anthropicModel = anthropicModel + self.mailBackgroundAnalysisEnabled = mailBackgroundAnalysisEnabled + self.mailBackgroundDraftGenerationEnabled = mailBackgroundDraftGenerationEnabled + self.mailSenderLookupEnabled = mailSenderLookupEnabled + self.mailMemoryLearningEnabled = mailMemoryLearningEnabled self.defaultNewTabPage = defaultNewTabPage - self.googleCalendarRefreshToken = googleCalendarRefreshToken - self.googleCalendarAccessToken = googleCalendarAccessToken - self.googleCalendarTokenExpiry = googleCalendarTokenExpiry - self.googleCalendarConnectedEmail = googleCalendarConnectedEmail - self.googleCalendarBannerDismissed = googleCalendarBannerDismissed + self.googleClientID = googleClientID + self.googleClientSecret = googleClientSecret + self.googleRefreshToken = googleRefreshToken + self.googleAccessToken = googleAccessToken + self.googleTokenExpiry = googleTokenExpiry + self.googleConnectedEmail = googleConnectedEmail + self.googleGrantedScopes = googleGrantedScopes + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(theme, forKey: .theme) + try container.encode(focusModeOnType, forKey: .focusModeOnType) + try container.encode(preferredAIEngine, forKey: .preferredAIEngine) + try container.encode(executionPolicy, forKey: .executionPolicy) + try container.encode(bugbookSkillEnabled, forKey: .bugbookSkillEnabled) + try container.encode(agentsMdContent, forKey: .agentsMdContent) + try container.encode(qmdSearchMode, forKey: .qmdSearchMode) + try container.encode(anthropicApiKey, forKey: .anthropicApiKey) + try container.encode(anthropicModel, forKey: .anthropicModel) + try container.encode(mailBackgroundAnalysisEnabled, forKey: .mailBackgroundAnalysisEnabled) + try container.encode(mailBackgroundDraftGenerationEnabled, forKey: .mailBackgroundDraftGenerationEnabled) + try container.encode(mailSenderLookupEnabled, forKey: .mailSenderLookupEnabled) + try container.encode(mailMemoryLearningEnabled, forKey: .mailMemoryLearningEnabled) + try container.encode(defaultNewTabPage, forKey: .defaultNewTabPage) + try container.encode(googleClientID, forKey: .googleClientID) + try container.encode(googleClientSecret, forKey: .googleClientSecret) + try container.encode(googleRefreshToken, forKey: .googleRefreshToken) + try container.encode(googleAccessToken, forKey: .googleAccessToken) + try container.encode(googleTokenExpiry, forKey: .googleTokenExpiry) + try container.encode(googleConnectedEmail, forKey: .googleConnectedEmail) + try container.encode(googleGrantedScopes, forKey: .googleGrantedScopes) + } + + var googleConfigured: Bool { + !googleClientID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !googleClientSecret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var googleConnected: Bool { + !googleRefreshToken.isEmpty + } + + var googleToken: GoogleOAuthToken? { + guard googleConnected else { return nil } + return GoogleOAuthToken( + accessToken: googleAccessToken, + refreshToken: googleRefreshToken, + expiresAt: Date(timeIntervalSince1970: googleTokenExpiry), + grantedScopes: googleGrantedScopes + ) + } + + mutating func applyGoogleAuthResult(_ result: GoogleOAuthResult) { + googleAccessToken = result.accessToken + googleRefreshToken = result.refreshToken + googleTokenExpiry = result.expiresAt.timeIntervalSince1970 + googleConnectedEmail = result.email + googleGrantedScopes = result.grantedScopes + } + + mutating func updateGoogleToken(_ token: GoogleOAuthToken) { + googleAccessToken = token.accessToken + googleRefreshToken = token.refreshToken + googleTokenExpiry = token.expiresAt.timeIntervalSince1970 + googleGrantedScopes = token.grantedScopes + } + + mutating func disconnectGoogle() { + googleRefreshToken = "" + googleAccessToken = "" + googleTokenExpiry = 0 + googleConnectedEmail = "" + googleGrantedScopes = [] } } diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index 8c70142a..7564e12e 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -3,15 +3,21 @@ import Foundation enum TabKind: Equatable, Hashable, Codable { case page case database + case mail case calendar case meetings case graphView + case skill + case gateway case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } + var isMail: Bool { self == .mail } var isCalendar: Bool { self == .calendar } var isMeetings: Bool { self == .meetings } var isGraphView: Bool { self == .graphView } + var isSkill: Bool { self == .skill } + var isGateway: Bool { self == .gateway } var isDatabaseRow: Bool { if case .databaseRow = self { return true }; return false } var databasePath: String? { if case .databaseRow(let p, _) = self { return p }; return nil } var databaseRowId: String? { if case .databaseRow(_, let r) = self { return r }; return nil } @@ -29,6 +35,8 @@ struct FileEntry: Identifiable, Hashable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } + var isMail: Bool { kind.isMail } + var isSkill: Bool { kind.isSkill } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/MailIntelligenceModels.swift b/Sources/Bugbook/Models/MailIntelligenceModels.swift new file mode 100644 index 00000000..6d53734d --- /dev/null +++ b/Sources/Bugbook/Models/MailIntelligenceModels.swift @@ -0,0 +1,338 @@ +import Foundation + +enum MailPriority: String, Codable, CaseIterable, Identifiable { + case high + case medium + case low + case skip + + var id: String { rawValue } + + var displayName: String { + switch self { + case .high: return "High" + case .medium: return "Medium" + case .low: return "Low" + case .skip: return "Skip" + } + } +} + +enum MailThreadFlag: String, Codable, CaseIterable, Identifiable { + case needsReply = "needs_reply" + case waiting + case archiveReady = "archive_ready" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .needsReply: return "Needs Reply" + case .waiting: return "Waiting" + case .archiveReady: return "Archive Ready" + } + } +} + +enum MailAnalysisStatus: String, Codable, Equatable { + case idle + case pending + case complete + case failed +} + +enum MailDraftSuggestionStatus: String, Codable, Equatable { + case none + case suggested + case accepted + case edited +} + +struct MailThreadAnnotation: Codable, Equatable { + var analysisStatus: MailAnalysisStatus = .idle + var analysisUpdatedAt: Date? + var suggestedPriority: MailPriority? + var statusFlags: [MailThreadFlag] = [] + var draftStatus: MailDraftSuggestionStatus = .none + var hasSenderContext = false +} + +struct MailThreadAnalysis: Codable, Equatable { + var priority: MailPriority + var reason: String + var suggestedAction: String + var flags: [MailThreadFlag] + var shouldGenerateDraft: Bool + var prefersReplyAll: Bool + var analyzedAt: Date +} + +struct MailDraftRefinement: Codable, Equatable, Identifiable { + var id: String + var instruction: String + var body: String + var createdAt: Date + + init(id: String = UUID().uuidString, instruction: String, body: String, createdAt: Date = Date()) { + self.id = id + self.instruction = instruction + self.body = body + self.createdAt = createdAt + } +} + +struct MailDraftSuggestion: Codable, Equatable, Identifiable { + var id: String + var threadID: String + var subject: String + var body: String + var rationale: String + var generatedAt: Date + var status: MailDraftSuggestionStatus + var refinementHistory: [MailDraftRefinement] + + init( + id: String = UUID().uuidString, + threadID: String, + subject: String, + body: String, + rationale: String, + generatedAt: Date = Date(), + status: MailDraftSuggestionStatus = .suggested, + refinementHistory: [MailDraftRefinement] = [] + ) { + self.id = id + self.threadID = threadID + self.subject = subject + self.body = body + self.rationale = rationale + self.generatedAt = generatedAt + self.status = status + self.refinementHistory = refinementHistory + } +} + +struct MailSenderContextReference: Codable, Equatable, Identifiable { + var id: String + var path: String + var excerpt: String + + init(id: String = UUID().uuidString, path: String, excerpt: String) { + self.id = id + self.path = path + self.excerpt = excerpt + } +} + +struct MailSenderContext: Codable, Equatable { + var threadID: String + var senderName: String + var senderEmail: String + var summary: String + var references: [MailSenderContextReference] + var generatedAt: Date +} + +enum MailMemoryKind: String, Codable, CaseIterable, Identifiable { + case writingStyle = "writing_style" + case priorityPreference = "priority_preference" + case manualNote = "manual_note" + case senderInsight = "sender_insight" + + var id: String { rawValue } +} + +struct MailMemory: Codable, Equatable, Identifiable { + var id: String + var kind: MailMemoryKind + var title: String + var detail: String + var senderEmail: String? + var senderDomain: String? + var createdAt: Date + var updatedAt: Date + + init( + id: String = UUID().uuidString, + kind: MailMemoryKind, + title: String, + detail: String, + senderEmail: String? = nil, + senderDomain: String? = nil, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.kind = kind + self.title = title + self.detail = detail + self.senderEmail = senderEmail + self.senderDomain = senderDomain + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +struct MailPriorityOverride: Codable, Equatable, Identifiable { + var id: String + var senderEmail: String? + var senderDomain: String? + var subjectContains: String? + var priority: MailPriority + var note: String + var createdAt: Date + var updatedAt: Date + + init( + id: String = UUID().uuidString, + senderEmail: String? = nil, + senderDomain: String? = nil, + subjectContains: String? = nil, + priority: MailPriority, + note: String, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.senderEmail = senderEmail + self.senderDomain = senderDomain + self.subjectContains = subjectContains + self.priority = priority + self.note = note + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +enum MailAgentActionKind: String, Codable, CaseIterable, Identifiable { + case draftReply = "draft_reply" + case createTask = "create_task" + case createNote = "create_note" + case createCalendarEvent = "create_calendar_event" + case summarizeToNote = "summarize_to_note" + case gatherContext = "gather_context" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .draftReply: return "Draft Reply" + case .createTask: return "Create Bugbook Task" + case .createNote: return "Create Bugbook Note" + case .createCalendarEvent: return "Create Calendar Event" + case .summarizeToNote: return "Summarize To Note" + case .gatherContext: return "Gather Context" + } + } +} + +struct MailAgentActionProposal: Codable, Equatable, Identifiable { + var id: String + var kind: MailAgentActionKind + var title: String + var detail: String + + init(id: String = UUID().uuidString, kind: MailAgentActionKind, title: String, detail: String) { + self.id = id + self.kind = kind + self.title = title + self.detail = detail + } +} + +enum MailAgentSessionRole: String, Codable { + case system + case assistant + case user + case action +} + +struct MailAgentSessionEntry: Codable, Equatable, Identifiable { + var id: String + var role: MailAgentSessionRole + var content: String + var createdAt: Date + + init(id: String = UUID().uuidString, role: MailAgentSessionRole, content: String, createdAt: Date = Date()) { + self.id = id + self.role = role + self.content = content + self.createdAt = createdAt + } +} + +struct MailAgentSession: Codable, Equatable, Identifiable { + var id: String + var threadID: String + var proposals: [MailAgentActionProposal] + var entries: [MailAgentSessionEntry] + var createdAt: Date + var updatedAt: Date + + init( + id: String = UUID().uuidString, + threadID: String, + proposals: [MailAgentActionProposal] = [], + entries: [MailAgentSessionEntry] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.threadID = threadID + self.proposals = proposals + self.entries = entries + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +struct MailThreadIntelligenceRecord: Codable, Equatable { + var threadID: String + var sourceSignature: String? + var annotation: MailThreadAnnotation + var analysis: MailThreadAnalysis? + var draftSuggestion: MailDraftSuggestion? + var senderContext: MailSenderContext? + var acceptedDraftBody: String? + var editedDraftBody: String? + var updatedAt: Date + + init( + threadID: String, + sourceSignature: String? = nil, + annotation: MailThreadAnnotation = MailThreadAnnotation(), + analysis: MailThreadAnalysis? = nil, + draftSuggestion: MailDraftSuggestion? = nil, + senderContext: MailSenderContext? = nil, + acceptedDraftBody: String? = nil, + editedDraftBody: String? = nil, + updatedAt: Date = Date() + ) { + self.threadID = threadID + self.sourceSignature = sourceSignature + self.annotation = annotation + self.analysis = analysis + self.draftSuggestion = draftSuggestion + self.senderContext = senderContext + self.acceptedDraftBody = acceptedDraftBody + self.editedDraftBody = editedDraftBody + self.updatedAt = updatedAt + } +} + +enum MailInboxSplit: String, CaseIterable, Identifiable { + case priority = "Priority" + case other = "Other" + case all = "All" + + var id: String { rawValue } +} + +enum MailDetailTab: String, CaseIterable, Identifiable { + case thread = "Thread" + case draft = "Draft" + case context = "Context" + case agent = "Agent" + + var id: String { rawValue } +} diff --git a/Sources/Bugbook/Models/MailModels.swift b/Sources/Bugbook/Models/MailModels.swift new file mode 100644 index 00000000..8295773a --- /dev/null +++ b/Sources/Bugbook/Models/MailModels.swift @@ -0,0 +1,165 @@ +import Foundation + +enum MailMailbox: String, CaseIterable, Codable, Identifiable { + case inbox + case sent + case drafts + case starred + case trash + + var id: String { rawValue } + + var displayName: String { + switch self { + case .inbox: return "Inbox" + case .sent: return "Sent" + case .drafts: return "Drafts" + case .starred: return "Starred" + case .trash: return "Trash" + } + } + + var systemImage: String { + switch self { + case .inbox: return "tray.full" + case .sent: return "paperplane" + case .drafts: return "square.and.pencil" + case .starred: return "star" + case .trash: return "trash" + } + } + + var gmailLabelIDs: [String] { + switch self { + case .inbox: return ["INBOX"] + case .sent: return ["SENT"] + case .drafts: return ["DRAFT"] + case .starred: return ["STARRED"] + case .trash: return ["TRASH"] + } + } +} + +struct MailMessageRecipient: Codable, Equatable, Hashable, Identifiable { + var name: String? + var email: String + + var id: String { "\(email.lowercased())|\(name ?? "")" } + + var displayName: String { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedName.isEmpty ? email : "\(trimmedName) <\(email)>" + } +} + +enum MailComposerMode: String, Codable, Equatable { + case newMessage + case reply + case replyAll +} + +struct MailDraft: Codable, Equatable { + var mode: MailComposerMode = .newMessage + var to: String = "" + var cc: String = "" + var bcc: String = "" + var subject: String = "" + var body: String = "" + var threadId: String? + var replyToMessageID: String? + var referencesHeader: String? + + var isEmpty: Bool { + to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct MailSearchState: Codable, Equatable { + var query: String = "" + + var isActive: Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct MailMessage: Identifiable, Codable, Equatable { + let id: String + var threadId: String + var subject: String + var snippet: String + var labelIds: [String] + var from: MailMessageRecipient? + var to: [MailMessageRecipient] + var cc: [MailMessageRecipient] + var bcc: [MailMessageRecipient] + var date: Date? + var plainBody: String + var htmlBody: String? + var messageIDHeader: String? + var referencesHeader: String? + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isDraft: Bool { labelIds.contains("DRAFT") } + + var bodyText: String { + if !plainBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return plainBody + } + return htmlBody ?? "" + } +} + +struct MailThreadSummary: Identifiable, Codable, Equatable { + let id: String + var mailbox: MailMailbox? + var subject: String + var snippet: String + var participants: [String] + var date: Date? + var messageCount: Int + var labelIds: [String] + var historyId: String? = nil + var annotation: MailThreadAnnotation? = nil + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isStarred: Bool { labelIds.contains("STARRED") } +} + +struct MailThreadDetail: Identifiable, Codable, Equatable { + let id: String + var mailbox: MailMailbox? + var subject: String + var snippet: String + var participants: [String] + var messages: [MailMessage] + var labelIds: [String] + var historyId: String? = nil + var annotation: MailThreadAnnotation? = nil + var draftSuggestion: MailDraftSuggestion? = nil + var senderContext: MailSenderContext? = nil + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isStarred: Bool { labelIds.contains("STARRED") } + + var lastDate: Date? { + messages.compactMap(\.date).max() + } +} + +struct MailCacheSnapshot: Codable, Equatable { + var mailboxThreads: [MailMailbox: [MailThreadSummary]] + var threadDetails: [String: MailThreadDetail] + var savedAt: Date +} + +enum MailThreadAction: Equatable { + case archive + case trash + case untrash + case setStarred(Bool) + case setUnread(Bool) +} diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index cccdcd88..fdbc59a9 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -15,9 +15,12 @@ struct OpenFile: Identifiable, Equatable, Codable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } + var isMail: Bool { kind.isMail } var isCalendar: Bool { kind.isCalendar } var isMeetings: Bool { kind.isMeetings } var isGraphView: Bool { kind.isGraphView } + var isSkill: Bool { kind.isSkill } + var isGateway: Bool { kind.isGateway } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/PaneContent.swift b/Sources/Bugbook/Models/PaneContent.swift index f71324c7..af920c69 100644 --- a/Sources/Bugbook/Models/PaneContent.swift +++ b/Sources/Bugbook/Models/PaneContent.swift @@ -16,6 +16,15 @@ enum PaneContent: Codable, Equatable { return .document(openFile: OpenFile(id: id, path: "", content: "", isDirty: false, isEmptyTab: true)) } + /// A mail pane. + static func mailDocument() -> PaneContent { + let id = UUID() + return .document(openFile: OpenFile( + id: id, path: "bugbook://mail", content: "", isDirty: false, isEmptyTab: false, + kind: .mail, displayName: "Mail", icon: "envelope" + )) + } + /// A calendar pane. static func calendarDocument() -> PaneContent { let id = UUID() @@ -34,6 +43,15 @@ enum PaneContent: Codable, Equatable { )) } + /// A gateway dashboard pane. + static func gatewayDocument() -> PaneContent { + let id = UUID() + return .document(openFile: OpenFile( + id: id, path: "bugbook://gateway", content: "", isDirty: false, isEmptyTab: false, + kind: .gateway, displayName: "Gateway", icon: "square.grid.2x2" + )) + } + /// A meetings pane. static func meetingsDocument() -> PaneContent { let id = UUID() diff --git a/Sources/Bugbook/Services/AiService.swift b/Sources/Bugbook/Services/AiService.swift index 001c2be2..5e193156 100644 --- a/Sources/Bugbook/Services/AiService.swift +++ b/Sources/Bugbook/Services/AiService.swift @@ -114,6 +114,13 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain hasDetectedEngines = true } + func ensureDetectedEngines() async -> AiEngineStatus { + if !hasDetectedEngines { + await detectEngines() + } + return engineStatus + } + // MARK: - Chat func chatWithNotes(engine: PreferredAIEngine, workspacePath: String, question: String, apiKey: String = "", model: AnthropicModel = .sonnet) async throws -> String { @@ -208,7 +215,7 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain let actionItems: String } - func summarizeTranscript(_ transcript: String, apiKey: String) async throws -> TranscriptSummary { + func summarizeTranscript(_ transcript: String, apiKey: String, model: AnthropicModel = .sonnet) async throws -> TranscriptSummary { guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } let systemPrompt = """ @@ -227,7 +234,8 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain apiKey: apiKey, systemPrompt: systemPrompt, userPrompt: "Summarize this meeting transcript:\n\n\(transcript)", - maxTokens: 2048 + maxTokens: 2048, + model: model ) // Split the AI response into summary and action items sections @@ -304,41 +312,83 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain } } - // MARK: - CLI Execution + func executePrompt( + engine: PreferredAIEngine, + workspacePath: String?, + systemPrompt: String? = nil, + prompt: String, + apiKey: String = "", + model: AnthropicModel = .sonnet, + maxTokens: Int = 2048 + ) async throws -> String { + if engine == .claudeAPI { + guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } + isRunning = true + error = nil + phase = .generating + defer { isRunning = false; phase = .idle } + do { + return try await callAPI( + apiKey: apiKey, + systemPrompt: systemPrompt, + userPrompt: prompt, + maxTokens: maxTokens, + model: model + ) + } catch { + self.error = error.localizedDescription + throw error + } + } - /// Execute a bugbook CLI command and return the output. - func executeBugbookCommand(_ command: String) async throws -> String { - try await runCommand("bugbook \(command)") - } + let status = await ensureDetectedEngines() + engineStatus = status - // MARK: - Transcript Summarization + let resolvedEngine = resolveEngine(engine) + guard let cli = resolvedEngine else { + throw AiError.noEngineAvailable + } - func summarizeTranscript(_ transcript: String, apiKey: String, model: AnthropicModel = .sonnet) async throws -> String { - guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } isRunning = true error = nil - defer { isRunning = false } - let systemPrompt = """ - You are a meeting assistant. Given a transcript, produce a concise meeting summary in markdown with these sections: - ## Summary - A brief overview of what was discussed. + phase = .generating + defer { isRunning = false; phase = .idle } - ## Key Points - - Bullet list of main topics and decisions + let fullPrompt: String + if let systemPrompt, !systemPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + fullPrompt = "\(systemPrompt)\n\n\(prompt)" + } else { + fullPrompt = prompt + } - ## Action Items - - [ ] Task items identified in the meeting + let command: String + switch cli { + case .claude: + command = "claude -p \(shellSingleQuoted(fullPrompt))" + case .codex: + command = "codex \(shellSingleQuoted(fullPrompt))" + case .auto, .claudeAPI: + throw AiError.noEngineAvailable + } - Return ONLY the markdown. No explanations or code fences. - """ do { - return try await callAPI(apiKey: apiKey, systemPrompt: systemPrompt, userPrompt: transcript, maxTokens: 2048, model: model) + return try await runCommand(command, cwd: workspacePath) + } catch let err as AiError { + error = err.errorDescription + throw err } catch { self.error = error.localizedDescription throw error } } + // MARK: - CLI Execution + + /// Execute a bugbook CLI command and return the output. + func executeBugbookCommand(_ command: String) async throws -> String { + try await runCommand("bugbook \(command)") + } + // MARK: - Pre-warming func prewarmSession() async { diff --git a/Sources/Bugbook/Services/AppSettingsStore.swift b/Sources/Bugbook/Services/AppSettingsStore.swift new file mode 100644 index 00000000..a00a5360 --- /dev/null +++ b/Sources/Bugbook/Services/AppSettingsStore.swift @@ -0,0 +1,104 @@ +import Foundation + +struct AppSettingsStore { + private let fileManager: FileManager + private let fileURL: URL + private let secretStore: SecretStoring + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init( + fileManager: FileManager = .default, + fileURL: URL? = nil, + secretStore: SecretStoring = KeychainSecretStore() + ) { + self.fileManager = fileManager + self.fileURL = fileURL ?? Self.defaultFileURL(fileManager: fileManager) + self.secretStore = secretStore + } + + func load() -> AppSettings { + guard let data = try? Data(contentsOf: fileURL), + let settings = try? decoder.decode(AppSettings.self, from: data) else { + var fallback = AppSettings.default + hydrateSecrets(into: &fallback) + return fallback + } + var loaded = settings + let shouldRewrite = migrateLegacySecrets(from: loaded) + hydrateSecrets(into: &loaded) + if shouldRewrite { + save(loaded) + } + return loaded + } + + func save(_ settings: AppSettings) { + do { + try ensureParentDirectoryExists() + persistSecrets(from: settings) + let data = try encoder.encode(sanitized(settings)) + try data.write(to: fileURL, options: .atomic) + } catch { + Log.app.error("Failed to save app settings: \(error.localizedDescription)") + } + } + + private func hydrateSecrets(into settings: inout AppSettings) { + if settings.anthropicApiKey.isEmpty { + settings.anthropicApiKey = secretStore.string(for: .anthropicApiKey) ?? "" + } + if settings.googleAccessToken.isEmpty { + settings.googleAccessToken = secretStore.string(for: .googleAccessToken) ?? "" + } + if settings.googleRefreshToken.isEmpty { + settings.googleRefreshToken = secretStore.string(for: .googleRefreshToken) ?? "" + } + } + + private func migrateLegacySecrets(from settings: AppSettings) -> Bool { + var migrated = false + migrated = migrateLegacySecret(settings.anthropicApiKey, key: .anthropicApiKey) || migrated + migrated = migrateLegacySecret(settings.googleAccessToken, key: .googleAccessToken) || migrated + migrated = migrateLegacySecret(settings.googleRefreshToken, key: .googleRefreshToken) || migrated + return migrated + } + + private func migrateLegacySecret(_ value: String, key: AppSecretKey) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return false } + if secretStore.string(for: key) != normalized { + secretStore.set(normalized, for: key) + } + return true + } + + private func persistSecrets(from settings: AppSettings) { + secretStore.set(settings.anthropicApiKey, for: .anthropicApiKey) + secretStore.set(settings.googleAccessToken, for: .googleAccessToken) + secretStore.set(settings.googleRefreshToken, for: .googleRefreshToken) + } + + private func sanitized(_ settings: AppSettings) -> AppSettings { + var sanitized = settings + sanitized.anthropicApiKey = "" + sanitized.googleAccessToken = "" + sanitized.googleRefreshToken = "" + return sanitized + } + + private func ensureParentDirectoryExists() throws { + let parent = fileURL.deletingLastPathComponent() + guard !fileManager.fileExists(atPath: parent.path) else { return } + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + } + + private static func defaultFileURL(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("Settings", isDirectory: true) + .appendingPathComponent("app-settings.json") + } +} diff --git a/Sources/Bugbook/Services/CalendarService.swift b/Sources/Bugbook/Services/CalendarService.swift index 4680de59..556d6b94 100644 --- a/Sources/Bugbook/Services/CalendarService.swift +++ b/Sources/Bugbook/Services/CalendarService.swift @@ -1,142 +1,112 @@ import Foundation -import AuthenticationServices import BugbookCore enum CalendarError: LocalizedError { case notAuthenticated case apiError(String) - case tokenRefreshFailed - case oauthFailed(String) var errorDescription: String? { switch self { case .notAuthenticated: return "Not signed in to Google Calendar." case .apiError(let msg): return msg - case .tokenRefreshFailed: return "Failed to refresh Google Calendar token. Try signing in again." - case .oauthFailed(let msg): return "Google sign-in failed: \(msg)" } } } -// MARK: - Google OAuth Token - -struct GoogleOAuthToken: Codable { - var accessToken: String - var refreshToken: String - var expiresAt: Date - - var isExpired: Bool { Date() >= expiresAt } -} +struct CalendarEventDraft: Equatable { + var title: String + var startDate: Date + var endDate: Date + var isAllDay: Bool + var location: String + var notes: String + var calendarId: String + + init( + title: String = "", + startDate: Date, + endDate: Date, + isAllDay: Bool = false, + location: String = "", + notes: String = "", + calendarId: String = "primary" + ) { + self.title = title + self.startDate = startDate + self.endDate = endDate + self.isAllDay = isAllDay + self.location = location + self.notes = notes + self.calendarId = calendarId + } -// MARK: - Google OAuth Browser Flow + func normalized(calendar: Calendar = .current) -> CalendarEventDraft { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) -struct GoogleOAuthResult { - var accessToken: String - var refreshToken: String - var expiresAt: Date - var email: String -} + if isAllDay { + let normalizedStart = calendar.startOfDay(for: startDate) + let normalizedEnd = max(calendar.startOfDay(for: endDate), normalizedStart) + return CalendarEventDraft( + title: trimmedTitle, + startDate: normalizedStart, + endDate: normalizedEnd, + isAllDay: true, + location: trimmedLocation, + notes: trimmedNotes, + calendarId: calendarId + ) + } -enum GoogleOAuthFlow { - // Register a "Desktop app" OAuth client in Google Cloud Console with the Calendar API enabled. - // For installed/desktop apps, Google documents that the client ID and secret are not truly secret. - // Replace these with your registered credentials. - static let clientID = "YOUR_CLIENT_ID_HERE" - static let clientSecret = "YOUR_CLIENT_SECRET_HERE" - private static let redirectURI = "http://127.0.0.1" - private static let scopes = "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/userinfo.email" - - @MainActor - static func signIn() async throws -> GoogleOAuthResult { - let authCode = try await requestAuthCode() - let tokenResult = try await exchangeCode(authCode) - let email = try await fetchUserEmail(accessToken: tokenResult.0) - return GoogleOAuthResult( - accessToken: tokenResult.0, - refreshToken: tokenResult.1, - expiresAt: tokenResult.2, - email: email + let normalizedEnd = endDate > startDate ? endDate : startDate.addingTimeInterval(3600) + return CalendarEventDraft( + title: trimmedTitle, + startDate: startDate, + endDate: normalizedEnd, + isAllDay: false, + location: trimmedLocation, + notes: trimmedNotes, + calendarId: calendarId ) } +} - @MainActor - private static func requestAuthCode() async throws -> String { - var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! - components.queryItems = [ - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: scopes), - URLQueryItem(name: "access_type", value: "offline"), - URLQueryItem(name: "prompt", value: "consent"), +enum GoogleCalendarEventRequestEncoder { + static func requestBody(for draft: CalendarEventDraft, timeZone: TimeZone = .current) throws -> Data { + let normalized = draft.normalized() + let eventTitle = normalized.title.isEmpty ? "Untitled event" : normalized.title + var payload: [String: Any] = [ + "summary": eventTitle, ] - return try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: components.url!, - callbackURLScheme: "http" - ) { callbackURL, error in - if let error { - continuation.resume(throwing: CalendarError.oauthFailed(error.localizedDescription)) - return - } - guard let callbackURL, - let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, - let code = items.first(where: { $0.name == "code" })?.value else { - continuation.resume(throwing: CalendarError.oauthFailed("No authorization code received.")) - return - } - continuation.resume(returning: code) - } - session.prefersEphemeralWebBrowserSession = true - session.presentationContextProvider = OAuthPresentationContext.shared - session.start() - } - } - private static func exchangeCode(_ code: String) async throws -> (String, String, Date) { - var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - var body = URLComponents() - body.queryItems = [ - URLQueryItem(name: "code", value: code), - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "client_secret", value: clientSecret), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "grant_type", value: "authorization_code"), - ] - request.httpBody = body.query?.data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - let msg = String(data: data, encoding: .utf8) ?? "" - throw CalendarError.oauthFailed("Token exchange failed: \(msg)") + if !normalized.location.isEmpty { + payload["location"] = normalized.location } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let accessToken = json["access_token"] as? String, - let refreshToken = json["refresh_token"] as? String, - let expiresIn = json["expires_in"] as? Int else { - throw CalendarError.oauthFailed("Unexpected token response format.") + if !normalized.notes.isEmpty { + payload["description"] = normalized.notes } - return (accessToken, refreshToken, Date().addingTimeInterval(TimeInterval(expiresIn))) - } - private static func fetchUserEmail(accessToken: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!) - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200, - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let email = json["email"] as? String else { - return "" + if normalized.isAllDay { + let exclusiveEnd = Calendar.current.date(byAdding: .day, value: 1, to: normalized.endDate) ?? normalized.endDate.addingTimeInterval(86400) + payload["start"] = [ + "date": CalendarFormatters.allDay.string(from: normalized.startDate), + ] + payload["end"] = [ + "date": CalendarFormatters.allDay.string(from: exclusiveEnd), + ] + } else { + payload["start"] = [ + "dateTime": CalendarFormatters.isoFallback.string(from: normalized.startDate), + "timeZone": timeZone.identifier, + ] + payload["end"] = [ + "dateTime": CalendarFormatters.isoFallback.string(from: normalized.endDate), + "timeZone": timeZone.identifier, + ] } - return email - } -} -private class OAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { - static let shared = OAuthPresentationContext() - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - NSApplication.shared.keyWindow ?? ASPresentationAnchor() + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) } } @@ -189,43 +159,79 @@ class CalendarService { defer { isSyncing = false } do { - var currentToken = token - if currentToken.isExpired { - currentToken = try await refreshToken(currentToken) + let calendars = try await fetchGoogleCalendarList(token: token) + let calendarIds = syncedCalendarIDs(from: calendars) + + var fetchedEvents: [CalendarEvent] = [] + var failedCalendarIDs: [String] = [] + for calendarId in calendarIds { + do { + let result = try await fetchGoogleEvents(token: token, syncToken: nil, calendarId: calendarId) + fetchedEvents.append(contentsOf: result.events) + } catch { + failedCalendarIDs.append(calendarId) + } } - let syncToken = store.loadSyncToken(in: workspace) - let result = try await fetchGoogleEvents(token: currentToken, syncToken: syncToken) + let existingEvents = store.loadEvents(in: workspace) + let fetchedIds = Set(fetchedEvents.map(\.id)) + let syncedSourceIds = Set(calendarIds) + let staleIds = Set( + existingEvents + .filter { syncedSourceIds.contains($0.calendarId) && !fetchedIds.contains($0.id) } + .map(\.id) + ) - try store.upsertEvents(result.events, in: workspace) - if let newSyncToken = result.nextSyncToken { - try store.saveSyncToken(newSyncToken, in: workspace) + try store.upsertEvents(fetchedEvents, in: workspace) + if staleIds.isEmpty == false { + try store.removeEvents(withIds: staleIds, in: workspace) } + try persistSources(calendars, in: workspace, ensuringVisible: "primary") + events = store.loadEvents(in: workspace) lastSyncDate = Date() - - let calendars = try await fetchGoogleCalendarList(token: currentToken) - let existingSources = store.loadSources(in: workspace) - let existingVisibility: [String: Bool] = Dictionary( - existingSources.map { ($0.id, $0.isVisible) }, - uniquingKeysWith: { first, _ in first } - ) - let mergedSources = calendars.map { cal in - CalendarSource( - id: cal.id, - name: cal.name, - color: cal.color, - isVisible: existingVisibility[cal.id] ?? true - ) + if failedCalendarIDs.isEmpty == false { + error = "Some Google calendars could not be synced. Loaded \(fetchedEvents.count) events from the rest." } - try store.saveSources(mergedSources, in: workspace) - sources = mergedSources } catch { self.error = error.localizedDescription } } + func createGoogleEvent(workspace: String, token: GoogleOAuthToken, draft: CalendarEventDraft) async throws -> CalendarEvent { + let normalizedDraft = draft.normalized() + var request = URLRequest(url: googleEventsURL(calendarId: normalizedDraft.calendarId)) + request.httpMethod = "POST" + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try GoogleCalendarEventRequestEncoder.requestBody(for: normalizedDraft) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CalendarError.apiError("No response from Google Calendar API") + } + guard (200...299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw CalendarError.apiError("Google Calendar create error \(http.statusCode): \(body)") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let event = parseGoogleEvent(json, calendarId: normalizedDraft.calendarId) else { + throw CalendarError.apiError("Google Calendar returned an unreadable event response.") + } + + try store.upsertEvents([event], in: workspace) + events = store.loadEvents(in: workspace) + lastSyncDate = Date() + + try ensureLocalSourceExists(for: event.calendarId, in: workspace) + if let calendars = try? await fetchGoogleCalendarList(token: token) { + try? persistSources(calendars, in: workspace, ensuringVisible: event.calendarId) + } + + return event + } + // MARK: - Database Overlay Items func loadDatabaseOverlayItems(workspace: String) async { @@ -317,7 +323,7 @@ class CalendarService { } private func fetchGoogleEvents(token: GoogleOAuthToken, syncToken: String? = nil, calendarId: String = "primary") async throws -> FetchResult { - var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(calendarId)/events")! + var components = URLComponents(url: googleEventsURL(calendarId: calendarId), resolvingAgainstBaseURL: false)! var queryItems: [URLQueryItem] = [] if let syncToken { queryItems.append(URLQueryItem(name: "syncToken", value: syncToken)) @@ -342,7 +348,7 @@ class CalendarService { } if http.statusCode == 410 { - return try await fetchGoogleEvents(token: token, syncToken: nil) + return try await fetchGoogleEvents(token: token, syncToken: nil, calendarId: calendarId) } guard http.statusCode == 200 else { @@ -413,8 +419,73 @@ class CalendarService { } } + private func persistSources(_ calendars: [CalendarSource], in workspace: String, ensuringVisible ensuredSourceID: String? = nil) throws { + let existingSources = store.loadSources(in: workspace) + let existingVisibility: [String: Bool] = Dictionary( + existingSources.map { ($0.id, $0.isVisible) }, + uniquingKeysWith: { first, _ in first } + ) + var mergedSources = calendars.map { cal in + CalendarSource( + id: cal.id, + name: cal.name, + color: cal.color, + isVisible: existingVisibility[cal.id] ?? true + ) + } + + if let ensuredSourceID, + !mergedSources.contains(where: { $0.id == ensuredSourceID }) { + let fallbackName = ensuredSourceID == "primary" ? "Primary" : ensuredSourceID + mergedSources.append( + CalendarSource( + id: ensuredSourceID, + name: fallbackName, + color: "#4285F4", + isVisible: existingVisibility[ensuredSourceID] ?? true + ) + ) + } + + try store.saveSources(mergedSources, in: workspace) + sources = mergedSources + } + + private func ensureLocalSourceExists(for calendarId: String, in workspace: String) throws { + guard !sources.contains(where: { $0.id == calendarId }) else { return } + var updatedSources = sources + updatedSources.append( + CalendarSource( + id: calendarId, + name: calendarId == "primary" ? "Primary" : calendarId, + color: "#4285F4", + isVisible: true + ) + ) + try store.saveSources(updatedSources, in: workspace) + sources = updatedSources + } + + private func googleEventsURL(calendarId: String) -> URL { + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + let encodedCalendarID = calendarId.addingPercentEncoding(withAllowedCharacters: allowed) ?? calendarId + return URL(string: "https://www.googleapis.com/calendar/v3/calendars/\(encodedCalendarID)/events")! + } + + private func syncedCalendarIDs(from calendars: [CalendarSource]) -> [String] { + var orderedIds = ["primary"] + for calendar in calendars where orderedIds.contains(calendar.id) == false { + orderedIds.append(calendar.id) + } + return orderedIds + } + + private func eventID(remoteID: String, calendarId: String) -> String { + "\(calendarId)::\(remoteID)" + } + private func parseGoogleEvent(_ json: [String: Any], calendarId: String = "primary") -> CalendarEvent? { - guard let id = json["id"] as? String, + guard let remoteID = json["id"] as? String, let summary = json["summary"] as? String else { return nil } let start = json["start"] as? [String: Any] ?? [:] @@ -458,7 +529,7 @@ class CalendarService { } return CalendarEvent( - id: id, + id: eventID(remoteID: remoteID, calendarId: calendarId), title: summary, startDate: startDate, endDate: endDate, @@ -471,39 +542,4 @@ class CalendarService { htmlLink: json["htmlLink"] as? String ) } - - // MARK: - Token Refresh - - private func refreshToken(_ token: GoogleOAuthToken) async throws -> GoogleOAuthToken { - var components = URLComponents(string: "https://oauth2.googleapis.com/token")! - components.queryItems = [ - URLQueryItem(name: "client_id", value: GoogleOAuthFlow.clientID), - URLQueryItem(name: "client_secret", value: GoogleOAuthFlow.clientSecret), - URLQueryItem(name: "refresh_token", value: token.refreshToken), - URLQueryItem(name: "grant_type", value: "refresh_token"), - ] - - var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = components.query?.data(using: .utf8) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw CalendarError.tokenRefreshFailed - } - - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let accessToken = json["access_token"] as? String, - let expiresIn = json["expires_in"] as? Int else { - throw CalendarError.tokenRefreshFailed - } - - return GoogleOAuthToken( - accessToken: accessToken, - refreshToken: token.refreshToken, - expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) - ) - } - } diff --git a/Sources/Bugbook/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index f2292d43..f5959efc 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -267,14 +267,6 @@ class FileSystemService { userInfo: [NSLocalizedDescriptionKey: "Cannot move a page into its own sub-pages."]) } - // Prevent moving a database folder into a new companion folder — databases - // should only be moved via explicit user actions, not nested inside pages. - let sourceIsDatabase = isDatabaseFolder(at: sourcePath) - if sourceIsDatabase && !fileManager.fileExists(atPath: destDir) { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteInvalidFileNameError, - userInfo: [NSLocalizedDescriptionKey: "Cannot move a database into a new sub-page folder."]) - } - // Create destination directory if needed (e.g. companion folder for a parent page) let createdDestDir = !fileManager.fileExists(atPath: destDir) if createdDestDir { diff --git a/Sources/Bugbook/Services/GoogleAuthService.swift b/Sources/Bugbook/Services/GoogleAuthService.swift new file mode 100644 index 00000000..074e22b1 --- /dev/null +++ b/Sources/Bugbook/Services/GoogleAuthService.swift @@ -0,0 +1,543 @@ +import Foundation +import AuthenticationServices +import AppKit +import Network + +enum GoogleAuthError: LocalizedError { + case missingClientConfiguration + case notAuthenticated + case missingScopes([String]) + case oauthFailed(String) + case tokenRefreshFailed + + var errorDescription: String? { + switch self { + case .missingClientConfiguration: + return "Add your Google OAuth client ID and client secret in Settings before connecting." + case .notAuthenticated: + return "Sign in to Google before using Mail or Calendar." + case .missingScopes(let scopes): + return "Google access is missing required scopes: \(scopes.joined(separator: ", ")). Sign in again to grant access." + case .oauthFailed(let message): + return "Google sign-in failed: \(message)" + case .tokenRefreshFailed: + return "Failed to refresh the Google access token. Sign in again." + } + } +} + +struct GoogleOAuthToken: Codable, Equatable { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var grantedScopes: [String] + + var isExpired: Bool { Date() >= expiresAt } +} + +struct GoogleOAuthResult: Equatable { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var email: String + var grantedScopes: [String] +} + +private struct GoogleOAuthAuthorizationGrant { + var code: String + var redirectURI: String +} + +enum GoogleScopeSet { + static let userEmail = "https://www.googleapis.com/auth/userinfo.email" + static let calendarReadonly = "https://www.googleapis.com/auth/calendar.readonly" + static let calendarEvents = "https://www.googleapis.com/auth/calendar.events" + static let calendarListReadonly = "https://www.googleapis.com/auth/calendar.calendarlist.readonly" + static let gmailModify = "https://www.googleapis.com/auth/gmail.modify" + static let gmailSend = "https://www.googleapis.com/auth/gmail.send" + + static let calendar = [ + calendarEvents, + calendarListReadonly, + userEmail, + ] + + static let mail = [ + gmailModify, + gmailSend, + userEmail, + ] + + static let calendarAndMail = Array(Set(calendar + mail)).sorted() +} + +enum GoogleAuthService { + private static let redirectHost = "127.0.0.1" + private static let redirectPath = "/oauth/callback" + + @MainActor + static func signIn(using settings: AppSettings, scopes: [String]) async throws -> GoogleOAuthResult { + let clientID = settings.googleClientID.trimmingCharacters(in: .whitespacesAndNewlines) + let clientSecret = settings.googleClientSecret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clientID.isEmpty, !clientSecret.isEmpty else { + throw GoogleAuthError.missingClientConfiguration + } + + let normalizedScopes = normalized(scopeList: scopes) + let grant = try await requestAuthCode(clientID: clientID, scopes: normalizedScopes) + let tokenResult = try await exchangeCode( + grant.code, + redirectURI: grant.redirectURI, + clientID: clientID, + clientSecret: clientSecret, + scopes: normalizedScopes + ) + let email = try await fetchUserEmail(accessToken: tokenResult.accessToken) + + return GoogleOAuthResult( + accessToken: tokenResult.accessToken, + refreshToken: tokenResult.refreshToken, + expiresAt: tokenResult.expiresAt, + email: email, + grantedScopes: normalizedScopes + ) + } + + static func validToken(using settings: inout AppSettings, requiredScopes: [String]) async throws -> GoogleOAuthToken { + guard settings.googleConfigured else { + throw GoogleAuthError.missingClientConfiguration + } + guard var token = settings.googleToken else { + throw GoogleAuthError.notAuthenticated + } + + let normalizedScopes = normalized(scopeList: requiredScopes) + let granted = Set(token.grantedScopes) + let missingScopes = normalizedScopes.filter { !granted.contains($0) } + guard missingScopes.isEmpty else { + throw GoogleAuthError.missingScopes(missingScopes) + } + + if token.isExpired { + token = try await refreshToken( + token, + clientID: settings.googleClientID, + clientSecret: settings.googleClientSecret + ) + settings.updateGoogleToken(token) + } + + return token + } + + private static func normalized(scopeList: [String]) -> [String] { + Array(Set(scopeList.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })).sorted() + } + + @MainActor + private static func requestAuthCode(clientID: String, scopes: [String]) async throws -> GoogleOAuthAuthorizationGrant { + let callbackServer = try GoogleOAuthLoopbackServer.start(host: redirectHost, path: redirectPath) + defer { callbackServer.stop() } + + let state = UUID().uuidString + var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: callbackServer.redirectURI.absoluteString), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: scopes.joined(separator: " ")), + URLQueryItem(name: "access_type", value: "offline"), + URLQueryItem(name: "prompt", value: "consent"), + URLQueryItem(name: "state", value: state), + ] + + return try await withCheckedThrowingContinuation { continuation in + let resolver = GoogleAuthRequestResolver() + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: nil + ) { _, error in + if let error { + Task { + resolver.resumeIfNeeded( + continuation, + result: .failure(GoogleAuthError.oauthFailed(error.localizedDescription)) + ) + } + return + } + Task { + resolver.resumeIfNeeded( + continuation, + result: .failure(GoogleAuthError.oauthFailed("No authorization code received.")) + ) + } + } + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = GoogleAuthPresentationContext.shared + + Task { + do { + let response = try await callbackServer.waitForCallback() + guard response.state == state else { + throw GoogleAuthError.oauthFailed("OAuth state validation failed.") + } + + resolver.resumeIfNeeded( + continuation, + result: .success( + GoogleOAuthAuthorizationGrant( + code: response.code, + redirectURI: callbackServer.redirectURI.absoluteString + ) + ) + ) + await MainActor.run { + session.cancel() + } + } catch { + resolver.resumeIfNeeded(continuation, result: .failure(error)) + await MainActor.run { + session.cancel() + } + } + } + + if session.start() == false { + Task { + resolver.resumeIfNeeded( + continuation, + result: .failure(GoogleAuthError.oauthFailed("Unable to start Google sign-in.")) + ) + } + } + } + } + + private static func exchangeCode( + _ code: String, + redirectURI: String, + clientID: String, + clientSecret: String, + scopes: [String] + ) async throws -> GoogleOAuthToken { + var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "grant_type", value: "authorization_code"), + ] + request.httpBody = body.query?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "" + throw GoogleAuthError.oauthFailed("Token exchange failed: \(message)") + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + let expiresIn = json["expires_in"] as? Int else { + throw GoogleAuthError.oauthFailed("Unexpected token response format.") + } + + return GoogleOAuthToken( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + grantedScopes: normalizedGrantedScopes(from: json["scope"] as? String, fallback: scopes) + ) + } + + private static func fetchUserEmail(accessToken: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let email = json["email"] as? String else { + throw GoogleAuthError.oauthFailed("Unable to fetch the connected Google account email.") + } + + return email + } + + private static func refreshToken( + _ token: GoogleOAuthToken, + clientID: String, + clientSecret: String + ) async throws -> GoogleOAuthToken { + var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "refresh_token", value: token.refreshToken), + URLQueryItem(name: "grant_type", value: "refresh_token"), + ] + request.httpBody = body.query?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw GoogleAuthError.tokenRefreshFailed + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let expiresIn = json["expires_in"] as? Int else { + throw GoogleAuthError.tokenRefreshFailed + } + + return GoogleOAuthToken( + accessToken: accessToken, + refreshToken: token.refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + grantedScopes: normalizedGrantedScopes(from: json["scope"] as? String, fallback: token.grantedScopes) + ) + } + + private static func normalizedGrantedScopes(from scopeString: String?, fallback: [String]) -> [String] { + guard let scopeString else { + return normalized(scopeList: fallback) + } + let scopes = scopeString + .split(separator: " ") + .map(String.init) + return normalized(scopeList: scopes.isEmpty ? fallback : scopes) + } +} + +private final class GoogleAuthRequestResolver: @unchecked Sendable { + private let lock = NSLock() + private var hasResumed = false + + func resumeIfNeeded( + _ continuation: CheckedContinuation, + result: Result + ) { + lock.lock() + defer { lock.unlock() } + + guard hasResumed == false else { return } + hasResumed = true + + switch result { + case .success(let grant): + continuation.resume(returning: grant) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +struct GoogleOAuthLoopbackCallback: Equatable { + var code: String + var state: String +} + +struct GoogleOAuthLoopbackRequestParser { + static func parse(requestLine: String, host: String) throws -> GoogleOAuthLoopbackCallback { + let segments = requestLine.split(separator: " ") + guard segments.count >= 2 else { + throw GoogleAuthError.oauthFailed("Malformed OAuth callback request.") + } + + guard let components = URLComponents(string: "http://\(host)\(segments[1])") else { + throw GoogleAuthError.oauthFailed("Malformed OAuth callback URL.") + } + + let queryItems = components.queryItems ?? [] + if let errorCode = queryItems.first(where: { $0.name == "error" })?.value { + throw GoogleAuthError.oauthFailed(errorCode) + } + + guard let code = queryItems.first(where: { $0.name == "code" })?.value, + let state = queryItems.first(where: { $0.name == "state" })?.value, + code.isEmpty == false, + state.isEmpty == false else { + throw GoogleAuthError.oauthFailed("No authorization code received.") + } + + return GoogleOAuthLoopbackCallback(code: code, state: state) + } +} + +private final class GoogleOAuthLoopbackServer: @unchecked Sendable { + private(set) var redirectURI: URL! + + private let listener: NWListener + private let host: String + private let path: String + private let queue = DispatchQueue(label: "Bugbook.GoogleOAuthLoopback") + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var pendingResult: Result? + + static func start(host: String, path: String) throws -> GoogleOAuthLoopbackServer { + let listener = try NWListener(using: .tcp, on: .any) + let server = GoogleOAuthLoopbackServer(listener: listener, host: host, path: path) + try server.start() + return server + } + + private init(listener: NWListener, host: String, path: String) { + self.listener = listener + self.host = host + self.path = path + } + + private func start() throws { + let readySemaphore = DispatchSemaphore(value: 0) + var startupError: Error? + + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + readySemaphore.signal() + case .failed(let error): + startupError = error + readySemaphore.signal() + self?.finish(with: .failure(GoogleAuthError.oauthFailed(error.localizedDescription))) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: queue) + readySemaphore.wait() + + if let startupError { + throw startupError + } + + guard let port = listener.port?.rawValue else { + throw GoogleAuthError.oauthFailed("Unable to allocate a localhost redirect port.") + } + + redirectURI = URL(string: "http://\(host):\(port)\(path)")! + } + + func waitForCallback() async throws -> GoogleOAuthLoopbackCallback { + if let pendingResult = consumePendingResult() { + return try pendingResult.get() + } + + return try await withCheckedThrowingContinuation { continuation in + lock.lock() + if let pendingResult = pendingResult { + self.pendingResult = nil + lock.unlock() + continuation.resume(with: pendingResult) + return + } + self.continuation = continuation + lock.unlock() + } + } + + func stop() { + listener.cancel() + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + receiveRequest(on: connection, data: Data()) + } + + private func receiveRequest(on connection: NWConnection, data: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] chunk, _, isComplete, error in + guard let self else { return } + + if let error { + self.finish(with: .failure(GoogleAuthError.oauthFailed(error.localizedDescription))) + connection.cancel() + return + } + + var accumulated = data + if let chunk { + accumulated.append(chunk) + } + + if accumulated.range(of: Data("\r\n\r\n".utf8)) == nil, isComplete == false { + self.receiveRequest(on: connection, data: accumulated) + return + } + + let request = String(decoding: accumulated, as: UTF8.self) + let requestLine = request.components(separatedBy: "\r\n").first ?? "" + + let result: Result + do { + let callback = try GoogleOAuthLoopbackRequestParser.parse(requestLine: requestLine, host: self.host) + result = .success(callback) + self.respond(on: connection, body: """ +

You can close this window and return to Bugbook.

+ """) + } catch { + result = .failure(error) + self.respond(on: connection, body: """ +

Bugbook sign-in failed. You can close this window and return to the app.

+ """) + } + + self.finish(with: result) + } + } + + private func respond(on connection: NWConnection, body: String) { + let data = Data(""" + HTTP/1.1 200 OK\r + Content-Type: text/html; charset=utf-8\r + Content-Length: \(body.utf8.count)\r + Connection: close\r + \r + \(body) + """.utf8) + + connection.send(content: data, completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private func finish(with result: Result) { + lock.lock() + defer { lock.unlock() } + + if let continuation { + self.continuation = nil + continuation.resume(with: result) + } else { + pendingResult = result + } + } + + private func consumePendingResult() -> Result? { + lock.lock() + defer { lock.unlock() } + let result = pendingResult + pendingResult = nil + return result + } +} + +private final class GoogleAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = GoogleAuthPresentationContext() + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + NSApplication.shared.keyWindow ?? ASPresentationAnchor() + } +} diff --git a/Sources/Bugbook/Services/KeychainSecretStore.swift b/Sources/Bugbook/Services/KeychainSecretStore.swift new file mode 100644 index 00000000..15ee10a3 --- /dev/null +++ b/Sources/Bugbook/Services/KeychainSecretStore.swift @@ -0,0 +1,85 @@ +import Foundation +import Security + +enum AppSecretKey: String, CaseIterable { + case anthropicApiKey = "anthropic-api-key" + case googleAccessToken = "google-access-token" + case googleRefreshToken = "google-refresh-token" +} + +protocol SecretStoring { + func string(for key: AppSecretKey) -> String? + func set(_ value: String?, for key: AppSecretKey) +} + +struct KeychainSecretStore: SecretStoring { + private let service = "com.maxforsey.Bugbook.app-settings" + + func string(for key: AppSecretKey) -> String? { + var query = baseQuery(for: key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } + + func set(_ value: String?, for key: AppSecretKey) { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if normalized.isEmpty { + SecItemDelete(baseQuery(for: key) as CFDictionary) + return + } + + let data = Data(normalized.utf8) + let query = baseQuery(for: key) + let attributes = [kSecValueData as String: data] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = data + SecItemAdd(addQuery as CFDictionary, nil) + return + } + + if status != errSecSuccess { + Log.app.error("Failed to update keychain secret: \(key.rawValue)") + } + } + + private func baseQuery(for key: AppSecretKey) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.rawValue, + ] + } +} + +final class InMemorySecretStore: SecretStoring { + private var values: [AppSecretKey: String] + + init(values: [AppSecretKey: String] = [:]) { + self.values = values + } + + func string(for key: AppSecretKey) -> String? { + values[key] + } + + func set(_ value: String?, for key: AppSecretKey) { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if normalized.isEmpty { + values.removeValue(forKey: key) + } else { + values[key] = normalized + } + } +} diff --git a/Sources/Bugbook/Services/Logger.swift b/Sources/Bugbook/Services/Logger.swift index e3bf1d93..963d7e4e 100644 --- a/Sources/Bugbook/Services/Logger.swift +++ b/Sources/Bugbook/Services/Logger.swift @@ -20,6 +20,8 @@ enum Log { static let agent = Logger(subsystem: subsystem, category: "Agent") /// Audio capture and transcription static let transcription = Logger(subsystem: subsystem, category: "Transcription") + /// Gmail sync, thread actions, and compose/send flows + static let mail = Logger(subsystem: subsystem, category: "Mail") /// General app lifecycle static let app = Logger(subsystem: subsystem, category: "App") diff --git a/Sources/Bugbook/Services/MailIntelligenceService.swift b/Sources/Bugbook/Services/MailIntelligenceService.swift new file mode 100644 index 00000000..2ead7205 --- /dev/null +++ b/Sources/Bugbook/Services/MailIntelligenceService.swift @@ -0,0 +1,1014 @@ +import Foundation +import BugbookCore + +enum MailIntelligenceError: LocalizedError { + case noModelProvider + case missingWorkspace + case missingCalendarToken + case noSelectedThread + + var errorDescription: String? { + switch self { + case .noModelProvider: + return "Configure an AI engine before using Mail intelligence." + case .missingWorkspace: + return "Open a workspace to use Bugbook-linked mail actions." + case .missingCalendarToken: + return "Connect Google Calendar before creating events from Mail." + case .noSelectedThread: + return "Select a thread first." + } + } +} + +enum MailModelExecutionPath: String, Equatable { + case anthropicAPI + case claudeCLI + case codexCLI + case unavailable +} + +struct MailModelProviderResolver { + static func resolve(settings: AppSettings, engineStatus: AiEngineStatus) -> MailModelExecutionPath { + let hasAPIKey = !settings.anthropicApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + switch settings.preferredAIEngine { + case .claudeAPI: + return hasAPIKey ? .anthropicAPI : .unavailable + case .claude: + return engineStatus.claudeAvailable ? .claudeCLI : .unavailable + case .codex: + return engineStatus.codexAvailable ? .codexCLI : .unavailable + case .auto: + if hasAPIKey { return .anthropicAPI } + if engineStatus.claudeAvailable { return .claudeCLI } + if engineStatus.codexAvailable { return .codexCLI } + return .unavailable + } + } +} + +@MainActor +protocol MailModelProvider { + func executionPath(using settings: AppSettings) async -> MailModelExecutionPath + func generate(systemPrompt: String, userPrompt: String, workspacePath: String?, settings: AppSettings, maxTokens: Int) async throws -> String +} + +@MainActor +final class AiServiceMailModelProvider: MailModelProvider { + private let aiService: AiService + + init(aiService: AiService) { + self.aiService = aiService + } + + func executionPath(using settings: AppSettings) async -> MailModelExecutionPath { + let status = await aiService.ensureDetectedEngines() + return MailModelProviderResolver.resolve(settings: settings, engineStatus: status) + } + + func generate( + systemPrompt: String, + userPrompt: String, + workspacePath: String?, + settings: AppSettings, + maxTokens: Int = 2048 + ) async throws -> String { + let path = await executionPath(using: settings) + let engine: PreferredAIEngine + + switch path { + case .anthropicAPI: + engine = .claudeAPI + case .claudeCLI: + engine = .claude + case .codexCLI: + engine = .codex + case .unavailable: + throw MailIntelligenceError.noModelProvider + } + + return try await aiService.executePrompt( + engine: engine, + workspacePath: workspacePath, + systemPrompt: systemPrompt, + prompt: userPrompt, + apiKey: settings.anthropicApiKey, + model: settings.anthropicModel, + maxTokens: maxTokens + ) + } +} + +@MainActor +@Observable +final class MailIntelligenceService { + var records: [String: MailThreadIntelligenceRecord] = [:] + var priorityOverrides: [MailPriorityOverride] = [] + var memories: [MailMemory] = [] + var agentSessions: [String: MailAgentSession] = [:] + var isAnalyzing = false + var isGeneratingDraft = false + var isLoadingContext = false + var isRunningAgentAction = false + var lastSavedAt: Date? + var error: String? + + @ObservationIgnored private let accountStore: MailIntelligenceStore + @ObservationIgnored private let workspaceStore: MailAgentSessionStore + @ObservationIgnored private let fileSystem: FileSystemService + @ObservationIgnored private let agentWorkspaceStore: AgentWorkspaceStore + + private var activeAccountEmail: String? + private var activeWorkspacePath: String? + + init( + accountStore: MailIntelligenceStore = MailIntelligenceStore(), + workspaceStore: MailAgentSessionStore = MailAgentSessionStore(), + fileSystem: FileSystemService? = nil, + agentWorkspaceStore: AgentWorkspaceStore = AgentWorkspaceStore() + ) { + self.accountStore = accountStore + self.workspaceStore = workspaceStore + self.fileSystem = fileSystem ?? FileSystemService() + self.agentWorkspaceStore = agentWorkspaceStore + } + + func load(accountEmail: String, workspacePath: String?, mailService: MailService) { + let normalizedEmail = accountEmail.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if activeAccountEmail != normalizedEmail { + records = [:] + lastSavedAt = nil + } + + activeAccountEmail = normalizedEmail + if let snapshot = accountStore.load(accountEmail: normalizedEmail) { + records = snapshot.threadRecords + lastSavedAt = snapshot.savedAt + } + + if let workspacePath { + activeWorkspacePath = workspacePath + let snapshot = workspaceStore.load(workspacePath: workspacePath) + priorityOverrides = snapshot.priorityOverrides + memories = snapshot.memories + agentSessions = Dictionary(uniqueKeysWithValues: snapshot.agentSessions.map { ($0.threadID, $0) }) + } else { + activeWorkspacePath = nil + priorityOverrides = [] + memories = [] + agentSessions = [:] + } + + mailService.applyIntelligenceRecords(records) + } + + func record(for threadID: String) -> MailThreadIntelligenceRecord? { + records[threadID] + } + + func session(for thread: MailThreadDetail) -> MailAgentSession { + if let existing = agentSessions[thread.id] { + return existing + } + + let newSession = MailAgentSession( + threadID: thread.id, + proposals: defaultProposals(for: thread), + entries: [ + MailAgentSessionEntry( + role: .system, + content: "Mail agent session started for \(thread.subject). Actions stay local to this workspace." + ) + ] + ) + agentSessions[thread.id] = newSession + persistWorkspaceStateIfPossible() + return newSession + } + + func runBackgroundAnalysis( + mailService: MailService, + token: GoogleOAuthToken, + settings: AppSettings, + workspacePath: String?, + aiService: AiService + ) async { + guard settings.mailBackgroundAnalysisEnabled else { return } + let provider = AiServiceMailModelProvider(aiService: aiService) + guard await provider.executionPath(using: settings) != .unavailable else { return } + + let inboxThreads = mailService.mailboxThreads[.inbox] ?? [] + let candidates = inboxThreads.filter { shouldRefresh(thread: $0) } + guard !candidates.isEmpty else { return } + + isAnalyzing = true + error = nil + defer { + isAnalyzing = false + persistAccountStateIfPossible() + } + + for threadSummary in candidates { + do { + let detail = try await mailService.fetchThreadDetailSnapshot( + id: threadSummary.id, + mailbox: threadSummary.mailbox ?? .inbox, + token: token + ) + let record = try await analyze(thread: detail, summary: threadSummary, workspacePath: workspacePath, settings: settings, provider: provider) + upsert(record, into: mailService) + + if settings.mailBackgroundDraftGenerationEnabled, + let analysis = record.analysis, + analysis.shouldGenerateDraft { + try await generateDraftIfNeeded(for: detail, mailService: mailService, settings: settings, workspacePath: workspacePath, provider: provider) + } + } catch { + self.error = error.localizedDescription + } + } + } + + func ensureThreadArtifacts( + for thread: MailThreadDetail, + mailService: MailService, + settings: AppSettings, + workspacePath: String?, + aiService: AiService + ) async { + let provider = AiServiceMailModelProvider(aiService: aiService) + if record(for: thread.id)?.analysis == nil, + settings.mailBackgroundAnalysisEnabled, + await provider.executionPath(using: settings) != .unavailable { + do { + let syntheticSummary = MailThreadSummary( + id: thread.id, + mailbox: thread.mailbox, + subject: thread.subject, + snippet: thread.snippet, + participants: thread.participants, + date: thread.lastDate, + messageCount: thread.messages.count, + labelIds: thread.labelIds, + historyId: thread.historyId + ) + let record = try await analyze(thread: thread, summary: syntheticSummary, workspacePath: workspacePath, settings: settings, provider: provider) + upsert(record, into: mailService) + } catch { + self.error = error.localizedDescription + } + } + + if settings.mailBackgroundDraftGenerationEnabled, + let analysis = records[thread.id]?.analysis, + analysis.shouldGenerateDraft, + records[thread.id]?.draftSuggestion == nil, + await provider.executionPath(using: settings) != .unavailable { + do { + try await generateDraftIfNeeded(for: thread, mailService: mailService, settings: settings, workspacePath: workspacePath, provider: provider) + } catch { + self.error = error.localizedDescription + } + } + + if settings.mailSenderLookupEnabled, records[thread.id]?.senderContext == nil { + do { + let context = try await buildSenderContext(for: thread, workspacePath: workspacePath, settings: settings, provider: provider) + var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) + record.senderContext = context + record.annotation.hasSenderContext = true + record.updatedAt = Date() + upsert(record, into: mailService) + } catch { + self.error = error.localizedDescription + } + } + } + + func refineDraft( + for thread: MailThreadDetail, + instruction: String, + mailService: MailService, + settings: AppSettings, + workspacePath: String?, + aiService: AiService + ) async { + let trimmedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstruction.isEmpty else { return } + + let provider = AiServiceMailModelProvider(aiService: aiService) + guard await provider.executionPath(using: settings) != .unavailable else { + error = MailIntelligenceError.noModelProvider.localizedDescription + return + } + + guard var record = records[thread.id], let draft = record.draftSuggestion else { return } + + isGeneratingDraft = true + error = nil + defer { + isGeneratingDraft = false + persistAccountStateIfPossible() + } + + do { + let systemPrompt = """ + You refine an email reply draft for a human user. Return only JSON with keys: + body, rationale. + Keep the same intent unless the instruction explicitly changes it. + """ + let prompt = """ + Thread subject: \(thread.subject) + + Existing draft: + \(draft.body) + + Refinement instruction: + \(trimmedInstruction) + """ + let response = try await provider.generate( + systemPrompt: systemPrompt, + userPrompt: prompt, + workspacePath: workspacePath, + settings: settings, + maxTokens: 1400 + ) + let payload = try decodeJSON(response, as: DraftRefinementPayload.self) + let refinement = MailDraftRefinement(instruction: trimmedInstruction, body: payload.body) + record.draftSuggestion?.body = payload.body + record.draftSuggestion?.rationale = payload.rationale + record.draftSuggestion?.status = .suggested + record.draftSuggestion?.refinementHistory.append(refinement) + record.annotation.draftStatus = .suggested + record.updatedAt = Date() + upsert(record, into: mailService) + } catch { + self.error = error.localizedDescription + } + } + + func acceptDraft(for thread: MailThreadDetail, connectedEmail: String, mailService: MailService) { + guard var record = records[thread.id], var draftSuggestion = record.draftSuggestion else { return } + mailService.prepareReplyDraft( + thread: thread, + connectedEmail: connectedEmail, + replyAll: record.analysis?.prefersReplyAll ?? false + ) + mailService.composer.subject = draftSuggestion.subject + mailService.composer.body = draftSuggestion.body + draftSuggestion.status = .accepted + record.draftSuggestion = draftSuggestion + record.acceptedDraftBody = draftSuggestion.body + record.annotation.draftStatus = .accepted + record.updatedAt = Date() + upsert(record, into: mailService) + } + + func recordDraftEditIfNeeded(for threadID: String, finalBody: String) { + guard var record = records[threadID] else { return } + let normalizedFinal = finalBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedFinal.isEmpty else { return } + record.editedDraftBody = normalizedFinal + if normalizedFinal != record.acceptedDraftBody?.trimmingCharacters(in: .whitespacesAndNewlines) { + record.annotation.draftStatus = .edited + record.draftSuggestion?.status = .edited + } + record.updatedAt = Date() + records[threadID] = record + persistAccountStateIfPossible() + } + + func learnFromSentDraft(threadID: String, subject: String, finalBody: String) { + let trimmedBody = finalBody.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedBody.isEmpty else { return } + + recordDraftEditIfNeeded(for: threadID, finalBody: trimmedBody) + + let senderEmail = activeAccountEmail + let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) + let excerpt = trimmedBody + .components(separatedBy: .newlines) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .prefix(3) + .joined(separator: " ") + + memories.insert( + MailMemory( + kind: .writingStyle, + title: "Sent style: \(subject)", + detail: excerpt.isEmpty ? trimmedBody : excerpt, + senderEmail: senderEmail, + senderDomain: senderDomain + ), + at: 0 + ) + persistWorkspaceStateIfPossible() + } + + func recordPriorityOverride( + _ priority: MailPriority, + note: String, + for thread: MailThreadDetail, + mailService: MailService + ) { + let subjectToken = subjectHint(for: thread.subject) + let senderEmail = thread.messages.last?.from?.email.lowercased() + let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) + + let override = MailPriorityOverride( + senderEmail: senderEmail, + senderDomain: senderDomain, + subjectContains: subjectToken, + priority: priority, + note: note.trimmingCharacters(in: .whitespacesAndNewlines) + ) + priorityOverrides.insert(override, at: 0) + + if !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + memories.insert( + MailMemory( + kind: .priorityPreference, + title: "Priority override for \(thread.subject)", + detail: note, + senderEmail: senderEmail, + senderDomain: senderDomain + ), + at: 0 + ) + } + + var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) + record.annotation.suggestedPriority = priority + record.analysis?.priority = priority + record.updatedAt = Date() + upsert(record, into: mailService) + persistWorkspaceStateIfPossible() + } + + func createManualMemory(title: String, detail: String, thread: MailThreadDetail) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTitle.isEmpty, !trimmedDetail.isEmpty else { return } + + let senderEmail = thread.messages.last?.from?.email.lowercased() + let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) + memories.insert( + MailMemory( + kind: .manualNote, + title: trimmedTitle, + detail: trimmedDetail, + senderEmail: senderEmail, + senderDomain: senderDomain + ), + at: 0 + ) + persistWorkspaceStateIfPossible() + } + + func performAgentAction( + _ action: MailAgentActionKind, + thread: MailThreadDetail, + mailService: MailService, + settings: AppSettings, + workspacePath: String?, + aiService: AiService, + calendarService: CalendarService, + calendarToken: GoogleOAuthToken? + ) async -> String? { + guard let workspacePath else { + error = MailIntelligenceError.missingWorkspace.localizedDescription + return nil + } + + isRunningAgentAction = true + defer { isRunningAgentAction = false } + + var session = session(for: thread) + session.entries.append(MailAgentSessionEntry(role: .user, content: action.displayName)) + agentSessions[thread.id] = session + persistWorkspaceStateIfPossible() + + let run = try? agentWorkspaceStore.startRun( + in: workspacePath, + agent: "mail-agent", + cwd: workspacePath + ) + defer { + if let run { + _ = try? agentWorkspaceStore.finishRun( + in: workspacePath, + runId: run.id, + status: .succeeded, + summary: "Completed \(action.displayName.lowercased()) for \(thread.subject)" + ) + } + } + + do { + let result: String + switch action { + case .draftReply: + await ensureThreadArtifacts( + for: thread, + mailService: mailService, + settings: settings, + workspacePath: workspacePath, + aiService: aiService + ) + acceptDraft(for: thread, connectedEmail: settings.googleConnectedEmail, mailService: mailService) + result = "Inserted a suggested reply into the composer." + case .createTask: + let task = try agentWorkspaceStore.createTask( + in: workspacePath, + title: thread.subject, + detail: taskDetail(for: thread), + labels: ["mail"] + ) + result = "Created task \(task.title)." + case .createNote: + let path = try createNote(from: thread, workspacePath: workspacePath, titlePrefix: "Mail Note") + result = "Created note at \(path)." + case .createCalendarEvent: + guard let calendarToken else { throw MailIntelligenceError.missingCalendarToken } + let draft = calendarDraft(for: thread) + let event = try await calendarService.createGoogleEvent( + workspace: workspacePath, + token: calendarToken, + draft: draft + ) + result = "Created calendar event \(event.title)." + case .summarizeToNote: + let path = try await createSummaryNote(from: thread, settings: settings, workspacePath: workspacePath, aiService: aiService) + result = "Created thread summary note at \(path)." + case .gatherContext: + await ensureThreadArtifacts( + for: thread, + mailService: mailService, + settings: settings, + workspacePath: workspacePath, + aiService: aiService + ) + result = "Updated sender context from local workspace notes." + } + + var updatedSession = self.session(for: thread) + updatedSession.entries.append(MailAgentSessionEntry(role: .action, content: result)) + updatedSession.updatedAt = Date() + agentSessions[thread.id] = updatedSession + persistWorkspaceStateIfPossible() + if let run { + _ = try? agentWorkspaceStore.logEvent(in: workspacePath, runId: run.id, level: .info, message: result) + } + return result + } catch { + self.error = error.localizedDescription + var updatedSession = self.session(for: thread) + updatedSession.entries.append(MailAgentSessionEntry(role: .assistant, content: error.localizedDescription)) + updatedSession.updatedAt = Date() + agentSessions[thread.id] = updatedSession + persistWorkspaceStateIfPossible() + if let run { + _ = try? agentWorkspaceStore.logEvent(in: workspacePath, runId: run.id, level: .error, message: error.localizedDescription) + _ = try? agentWorkspaceStore.finishRun( + in: workspacePath, + runId: run.id, + status: .failed, + summary: error.localizedDescription + ) + } + return nil + } + } + + private func shouldRefresh(thread: MailThreadSummary) -> Bool { + let record = records[thread.id] + guard let sourceSignature = threadSignature(thread: thread) else { + return record == nil + } + return record?.sourceSignature != sourceSignature + } + + private func analyze( + thread: MailThreadDetail, + summary: MailThreadSummary, + workspacePath: String?, + settings: AppSettings, + provider: MailModelProvider + ) async throws -> MailThreadIntelligenceRecord { + let systemPrompt = """ + You triage an email thread for a local-first desktop mail client inspired by Exo. + Return only JSON with keys: + priority, reason, suggested_action, flags, should_generate_draft, prefers_reply_all. + priority must be one of: high, medium, low, skip. + flags must only contain: needs_reply, waiting, archive_ready. + Be concise, pragmatic, and prioritize actionable triage. + """ + let prompt = """ + Connected account: \(settings.googleConnectedEmail) + + Thread: + \(threadTranscript(for: thread)) + + Local preferences: + \(memoryPrompt(for: thread)) + """ + let response = try await provider.generate( + systemPrompt: systemPrompt, + userPrompt: prompt, + workspacePath: workspacePath, + settings: settings, + maxTokens: 1200 + ) + let payload = try decodeJSON(response, as: AnalysisPayload.self) + let priority = appliedPriorityOverride(for: thread) ?? payload.priority + let analysis = MailThreadAnalysis( + priority: priority, + reason: payload.reason, + suggestedAction: payload.suggestedAction, + flags: payload.flags, + shouldGenerateDraft: payload.shouldGenerateDraft, + prefersReplyAll: payload.prefersReplyAll, + analyzedAt: Date() + ) + let annotation = MailThreadAnnotation( + analysisStatus: .complete, + analysisUpdatedAt: analysis.analyzedAt, + suggestedPriority: priority, + statusFlags: analysis.flags, + draftStatus: records[thread.id]?.annotation.draftStatus ?? .none, + hasSenderContext: records[thread.id]?.annotation.hasSenderContext ?? false + ) + return MailThreadIntelligenceRecord( + threadID: thread.id, + sourceSignature: threadSignature(thread: summary), + annotation: annotation, + analysis: analysis, + draftSuggestion: records[thread.id]?.draftSuggestion, + senderContext: records[thread.id]?.senderContext, + acceptedDraftBody: records[thread.id]?.acceptedDraftBody, + editedDraftBody: records[thread.id]?.editedDraftBody, + updatedAt: Date() + ) + } + + private func generateDraftIfNeeded( + for thread: MailThreadDetail, + mailService: MailService, + settings: AppSettings, + workspacePath: String?, + provider: MailModelProvider + ) async throws { + guard let analysis = records[thread.id]?.analysis, analysis.shouldGenerateDraft else { return } + guard records[thread.id]?.draftSuggestion == nil else { return } + + isGeneratingDraft = true + defer { isGeneratingDraft = false } + + let systemPrompt = """ + You draft a thoughtful email reply for a human user. + Return only JSON with keys: + subject, body, rationale. + The draft must be ready for review, not for auto-send. + """ + let prompt = """ + Connected account: \(settings.googleConnectedEmail) + Thread subject: \(thread.subject) + Suggested action: \(analysis.suggestedAction) + + Thread: + \(threadTranscript(for: thread)) + + Writing preferences and local memories: + \(memoryPrompt(for: thread)) + """ + let response = try await provider.generate( + systemPrompt: systemPrompt, + userPrompt: prompt, + workspacePath: workspacePath, + settings: settings, + maxTokens: 1800 + ) + let payload = try decodeJSON(response, as: DraftPayload.self) + var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) + record.draftSuggestion = MailDraftSuggestion( + threadID: thread.id, + subject: payload.subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? MailService.replySubject(for: thread.subject) : payload.subject, + body: payload.body, + rationale: payload.rationale + ) + record.annotation.draftStatus = .suggested + record.updatedAt = Date() + upsert(record, into: mailService) + } + + private func buildSenderContext( + for thread: MailThreadDetail, + workspacePath: String?, + settings: AppSettings, + provider: MailModelProvider + ) async throws -> MailSenderContext { + isLoadingContext = true + defer { isLoadingContext = false } + + let sender = thread.messages.last?.from ?? thread.messages.first?.from ?? MailMessageRecipient(name: nil, email: "unknown@example.com") + let references = workspaceMatches(for: thread, workspacePath: workspacePath) + + var summary = references.isEmpty + ? "No related workspace notes matched this sender yet." + : references.prefix(3).map { "\(($0.path as NSString).lastPathComponent): \($0.excerpt)" }.joined(separator: "\n") + + if !references.isEmpty, await provider.executionPath(using: settings) != .unavailable { + let systemPrompt = """ + You summarize sender context for a local desktop mail client. + Return only JSON with key summary. + Ground everything in the provided workspace references. + """ + let prompt = """ + Sender: \(sender.displayName) + Email: \(sender.email) + Thread subject: \(thread.subject) + + Workspace references: + \(references.map { "\($0.path): \($0.excerpt)" }.joined(separator: "\n\n")) + """ + if let response = try? await provider.generate( + systemPrompt: systemPrompt, + userPrompt: prompt, + workspacePath: workspacePath, + settings: settings, + maxTokens: 900 + ) { + let payload = try decodeJSON(response, as: ContextPayload.self) + summary = payload.summary + } + } + + return MailSenderContext( + threadID: thread.id, + senderName: sender.name ?? sender.email, + senderEmail: sender.email, + summary: summary, + references: references, + generatedAt: Date() + ) + } + + private func upsert(_ record: MailThreadIntelligenceRecord, into mailService: MailService) { + records[record.threadID] = record + mailService.applyIntelligenceRecord(record) + persistAccountStateIfPossible() + } + + private func persistAccountStateIfPossible() { + guard let activeAccountEmail, !activeAccountEmail.isEmpty else { return } + let snapshot = MailIntelligenceAccountSnapshot(threadRecords: records, savedAt: Date()) + accountStore.save(snapshot, accountEmail: activeAccountEmail) + lastSavedAt = snapshot.savedAt + } + + private func persistWorkspaceStateIfPossible() { + guard let activeWorkspacePath else { return } + let snapshot = MailWorkspaceIntelligenceSnapshot( + priorityOverrides: priorityOverrides, + memories: memories, + agentSessions: Array(agentSessions.values).sorted { $0.updatedAt > $1.updatedAt } + ) + workspaceStore.save(snapshot, workspacePath: activeWorkspacePath) + } + + private func appliedPriorityOverride(for thread: MailThreadDetail) -> MailPriority? { + let senderEmail = thread.messages.last?.from?.email.lowercased() + let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) + let subject = thread.subject.lowercased() + + for override in priorityOverrides { + let emailMatches = override.senderEmail?.lowercased() == senderEmail + let domainMatches = override.senderDomain?.lowercased() == senderDomain + let subjectMatches: Bool + if let subjectContains = override.subjectContains?.lowercased(), !subjectContains.isEmpty { + subjectMatches = subject.contains(subjectContains) + } else { + subjectMatches = true + } + + if (emailMatches || domainMatches) && subjectMatches { + return override.priority + } + } + + return nil + } + + private func memoryPrompt(for thread: MailThreadDetail) -> String { + let senderEmail = thread.messages.last?.from?.email.lowercased() + let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) + let relevantMemories = memories.filter { memory in + memory.senderEmail?.lowercased() == senderEmail || + memory.senderDomain?.lowercased() == senderDomain || + memory.kind == .writingStyle + } + if relevantMemories.isEmpty { + return "No stored mail memories." + } + return relevantMemories.prefix(6).map { "[\($0.kind.rawValue)] \($0.title): \($0.detail)" }.joined(separator: "\n") + } + + private func defaultProposals(for thread: MailThreadDetail) -> [MailAgentActionProposal] { + [ + MailAgentActionProposal(kind: .draftReply, title: "Draft a reply", detail: "Prepare a suggested reply for review."), + MailAgentActionProposal(kind: .createTask, title: "Create a task", detail: "Turn this thread into a Bugbook task."), + MailAgentActionProposal(kind: .createNote, title: "Create a note", detail: "Capture this thread as a workspace note."), + MailAgentActionProposal(kind: .createCalendarEvent, title: "Create a calendar event", detail: "Schedule follow-up work in Google Calendar."), + MailAgentActionProposal(kind: .summarizeToNote, title: "Summarize to note", detail: "Save a concise thread summary locally."), + MailAgentActionProposal(kind: .gatherContext, title: "Gather context", detail: "Refresh sender and workspace context."), + ] + } + + private func createNote(from thread: MailThreadDetail, workspacePath: String, titlePrefix: String) throws -> String { + let name = "\(titlePrefix) \(thread.subject)" + let path = try fileSystem.createNewFile(in: workspacePath, name: sanitizedFileTitle(name)) + let content = """ + # \(thread.subject) + + ## Participants + \(thread.participants.joined(separator: ", ")) + + ## Summary + \(thread.snippet) + + ## Thread + \(threadTranscript(for: thread)) + """ + try fileSystem.saveFile(at: path, content: content) + return path + } + + private func createSummaryNote(from thread: MailThreadDetail, settings: AppSettings, workspacePath: String, aiService: AiService) async throws -> String { + let provider = AiServiceMailModelProvider(aiService: aiService) + var summary = thread.snippet + if await provider.executionPath(using: settings) != .unavailable { + let systemPrompt = """ + Summarize an email thread into markdown. + Return only markdown with sections: + ## Summary + ## Action Items + """ + let prompt = threadTranscript(for: thread) + if let response = try? await provider.generate( + systemPrompt: systemPrompt, + userPrompt: prompt, + workspacePath: workspacePath, + settings: settings, + maxTokens: 900 + ) { + summary = response + } + } + + let path = try fileSystem.createNewFile(in: workspacePath, name: sanitizedFileTitle("Mail Summary \(thread.subject)")) + let content = """ + # \(thread.subject) + + \(summary) + """ + try fileSystem.saveFile(at: path, content: content) + return path + } + + private func calendarDraft(for thread: MailThreadDetail) -> CalendarEventDraft { + let startDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600) + let endDate = startDate.addingTimeInterval(3600) + let summary = records[thread.id]?.analysis?.suggestedAction ?? thread.snippet + return CalendarEventDraft( + title: thread.subject, + startDate: startDate, + endDate: endDate, + isAllDay: false, + notes: summary, + calendarId: "primary" + ) + } + + private func taskDetail(for thread: MailThreadDetail) -> String { + var lines = [ + "Thread: \(thread.subject)", + "Participants: \(thread.participants.joined(separator: ", "))", + "Snippet: \(thread.snippet)", + ] + if let action = records[thread.id]?.analysis?.suggestedAction { + lines.append("Suggested action: \(action)") + } + return lines.joined(separator: "\n") + } + + private func threadTranscript(for thread: MailThreadDetail) -> String { + thread.messages.suffix(6).map { message in + let sender = message.from?.displayName ?? "Unknown" + let body = message.bodyText.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + From: \(sender) + Date: \(message.date?.formatted(date: .abbreviated, time: .shortened) ?? "Unknown") + Subject: \(message.subject) + Body: + \(body.isEmpty ? message.snippet : body) + """ + }.joined(separator: "\n\n---\n\n") + } + + private func threadSignature(thread: MailThreadSummary) -> String? { + if let historyId = thread.historyId, !historyId.isEmpty { + return historyId + } + guard let date = thread.date else { return nil } + return "\(thread.messageCount)|\(date.timeIntervalSince1970)" + } + + private func workspaceMatches(for thread: MailThreadDetail, workspacePath: String?) -> [MailSenderContextReference] { + guard let workspacePath else { return [] } + let senderTokens = candidateTokens(for: thread) + guard !senderTokens.isEmpty else { return [] } + + var matches: [MailSenderContextReference] = [] + let enumerator = FileManager.default.enumerator(atPath: workspacePath) + while let item = enumerator?.nextObject() as? String { + guard item.hasSuffix(".md") else { continue } + let fullPath = (workspacePath as NSString).appendingPathComponent(item) + guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue } + let lowercased = content.lowercased() + guard senderTokens.contains(where: { lowercased.contains($0) }) else { continue } + let excerpt = content + .components(separatedBy: .newlines) + .first(where: { line in senderTokens.contains { line.lowercased().contains($0) } }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + ?? String(content.prefix(140)) + matches.append(MailSenderContextReference(path: fullPath, excerpt: excerpt)) + if matches.count >= 5 { break } + } + return matches + } + + private func candidateTokens(for thread: MailThreadDetail) -> [String] { + guard let sender = thread.messages.last?.from ?? thread.messages.first?.from else { return [] } + var tokens = [sender.email.lowercased()] + if let name = sender.name?.lowercased() { + tokens.append(name) + tokens.append(contentsOf: name.split(separator: " ").map(String.init).filter { $0.count > 2 }) + } + return Array(Set(tokens)) + } + + private func subjectHint(for subject: String) -> String? { + let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.split(separator: " ").prefix(3).joined(separator: " ") + } + + private func sanitizedFileTitle(_ value: String) -> String { + let sanitized = value + .replacingOccurrences(of: "[/:]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + return sanitized.isEmpty ? "Mail Note" : sanitized + } + + private func decodeJSON(_ raw: String, as type: T.Type) throws -> T { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^```(?:json)?\\s*", with: "", options: .regularExpression) + .replacingOccurrences(of: "\\s*```$", with: "", options: .regularExpression) + guard let start = cleaned.firstIndex(of: "{"), + let end = cleaned.lastIndex(of: "}") else { + throw MailIntelligenceError.noModelProvider + } + let jsonString = String(cleaned[start...end]) + let data = Data(jsonString.utf8) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: data) + } +} + +private struct AnalysisPayload: Decodable { + var priority: MailPriority + var reason: String + var suggestedAction: String + var flags: [MailThreadFlag] + var shouldGenerateDraft: Bool + var prefersReplyAll: Bool +} + +private struct DraftPayload: Decodable { + var subject: String + var body: String + var rationale: String +} + +private struct DraftRefinementPayload: Decodable { + var body: String + var rationale: String +} + +private struct ContextPayload: Decodable { + var summary: String +} diff --git a/Sources/Bugbook/Services/MailIntelligenceStore.swift b/Sources/Bugbook/Services/MailIntelligenceStore.swift new file mode 100644 index 00000000..bd487503 --- /dev/null +++ b/Sources/Bugbook/Services/MailIntelligenceStore.swift @@ -0,0 +1,108 @@ +import Foundation + +struct MailIntelligenceAccountSnapshot: Codable, Equatable { + var threadRecords: [String: MailThreadIntelligenceRecord] + var savedAt: Date +} + +struct MailWorkspaceIntelligenceSnapshot: Codable, Equatable { + var priorityOverrides: [MailPriorityOverride] + var memories: [MailMemory] + var agentSessions: [MailAgentSession] +} + +struct MailIntelligenceStore { + private let fileManager: FileManager + private let baseDirectory: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(fileManager: FileManager = .default, directoryURL: URL? = nil) { + self.fileManager = fileManager + self.baseDirectory = directoryURL ?? Self.defaultDirectory(fileManager: fileManager) + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func load(accountEmail: String) -> MailIntelligenceAccountSnapshot? { + guard let data = try? Data(contentsOf: fileURL(for: accountEmail)) else { return nil } + return try? decoder.decode(MailIntelligenceAccountSnapshot.self, from: data) + } + + func save(_ snapshot: MailIntelligenceAccountSnapshot, accountEmail: String) { + do { + try ensureBaseDirectoryExists() + let data = try encoder.encode(snapshot) + try data.write(to: fileURL(for: accountEmail), options: .atomic) + } catch { + Log.mail.error("Failed to save mail intelligence cache: \(error.localizedDescription)") + } + } + + private func ensureBaseDirectoryExists() throws { + guard !fileManager.fileExists(atPath: baseDirectory.path) else { return } + try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + } + + private func fileURL(for accountEmail: String) -> URL { + let sanitized = accountEmail + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + let filename = sanitized.isEmpty ? "mail-intelligence" : sanitized + return baseDirectory.appendingPathComponent("\(filename).json") + } + + private static func defaultDirectory(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("MailIntelligence", isDirectory: true) + } +} + +struct MailAgentSessionStore { + private let fileManager: FileManager + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func load(workspacePath: String) -> MailWorkspaceIntelligenceSnapshot { + let fileURL = workspaceFileURL(workspacePath: workspacePath) + guard let data = try? Data(contentsOf: fileURL), + let snapshot = try? decoder.decode(MailWorkspaceIntelligenceSnapshot.self, from: data) else { + return MailWorkspaceIntelligenceSnapshot(priorityOverrides: [], memories: [], agentSessions: []) + } + return snapshot + } + + func save(_ snapshot: MailWorkspaceIntelligenceSnapshot, workspacePath: String) { + do { + let directory = workspaceDirectory(workspacePath: workspacePath) + if !fileManager.fileExists(atPath: directory.path) { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + let data = try encoder.encode(snapshot) + try data.write(to: workspaceFileURL(workspacePath: workspacePath), options: .atomic) + } catch { + Log.mail.error("Failed to save workspace mail intelligence: \(error.localizedDescription)") + } + } + + func workspaceDirectory(workspacePath: String) -> URL { + URL(fileURLWithPath: workspacePath, isDirectory: true) + .appendingPathComponent(".bugbook", isDirectory: true) + .appendingPathComponent("mail", isDirectory: true) + } + + private func workspaceFileURL(workspacePath: String) -> URL { + workspaceDirectory(workspacePath: workspacePath) + .appendingPathComponent("mail-intelligence.json") + } +} diff --git a/Sources/Bugbook/Services/MailService.swift b/Sources/Bugbook/Services/MailService.swift new file mode 100644 index 00000000..e3716c67 --- /dev/null +++ b/Sources/Bugbook/Services/MailService.swift @@ -0,0 +1,839 @@ +import Foundation + +@MainActor +@Observable +final class MailService { + var mailboxThreads: [MailMailbox: [MailThreadSummary]] = [:] + var threadDetails: [String: MailThreadDetail] = [:] + var selectedMailbox: MailMailbox = .inbox + var selectedThreadID: String? + var searchState = MailSearchState() + var searchResults: [MailThreadSummary] = [] + var composer = MailDraft() + var isComposing = false + var isLoadingMailbox = false + var isLoadingThread = false + var isSearching = false + var isSending = false + var error: String? + var lastSyncDate: Date? + + @ObservationIgnored private let cacheStore: MailCacheStore + @ObservationIgnored private var activeAccountEmail: String? + + init(cacheStore: MailCacheStore = MailCacheStore()) { + self.cacheStore = cacheStore + } + + var visibleThreads: [MailThreadSummary] { + searchState.isActive ? searchResults : (mailboxThreads[selectedMailbox] ?? []) + } + + var selectedThread: MailThreadDetail? { + guard let selectedThreadID else { return nil } + return threadDetails[selectedThreadID] + } + + func loadCachedData(accountEmail: String) { + activeAccountEmail = accountEmail + mailboxThreads = [:] + threadDetails = [:] + searchState = MailSearchState() + searchResults = [] + selectedThreadID = nil + lastSyncDate = nil + + guard let snapshot = cacheStore.load(accountEmail: accountEmail) else { return } + mailboxThreads = snapshot.mailboxThreads + threadDetails = snapshot.threadDetails + lastSyncDate = snapshot.savedAt + if selectedThreadID == nil { + selectedThreadID = mailboxThreads[selectedMailbox]?.first?.id + } + } + + func selectMailbox(_ mailbox: MailMailbox) { + selectedMailbox = mailbox + searchState = MailSearchState() + searchResults = [] + selectedThreadID = mailboxThreads[mailbox]?.first?.id + error = nil + } + + func clearSearch() { + searchState = MailSearchState() + searchResults = [] + } + + func presentNewComposer() { + composer = MailDraft() + isComposing = true + } + + func dismissComposer() { + composer = MailDraft() + isComposing = false + } + + func prepareReplyDraft(thread: MailThreadDetail, connectedEmail: String, replyAll: Bool) { + guard let source = thread.messages.last(where: { !$0.isDraft }) ?? thread.messages.last else { return } + let sourceEmail = connectedEmail.lowercased() + let toRecipients = source.from.map { [$0.email] } ?? [] + let ccRecipients: [String] + + if replyAll { + let replyAllAddresses = (source.to + source.cc) + .map(\.email) + .filter { $0.caseInsensitiveCompare(sourceEmail) != .orderedSame } + .filter { !toRecipients.contains($0) } + ccRecipients = uniqueStrings(replyAllAddresses) + } else { + ccRecipients = [] + } + + composer = MailDraft( + mode: replyAll ? .replyAll : .reply, + to: toRecipients.joined(separator: ", "), + cc: ccRecipients.joined(separator: ", "), + bcc: "", + subject: MailService.replySubject(for: thread.subject), + body: "", + threadId: thread.id, + replyToMessageID: source.messageIDHeader, + referencesHeader: source.referencesHeader ?? source.messageIDHeader + ) + isComposing = true + } + + func loadMailbox(_ mailbox: MailMailbox, token: GoogleOAuthToken, forceRefresh: Bool = false) async { + if !forceRefresh, let cached = mailboxThreads[mailbox], !cached.isEmpty { + selectedThreadID = selectedThreadID ?? cached.first?.id + return + } + + isLoadingMailbox = true + error = nil + defer { isLoadingMailbox = false } + + do { + let threadIDs = try await GmailMailAPI.listThreadIDs( + labelIDs: mailbox.gmailLabelIDs, + query: nil, + token: token + ) + let summaries = try await GmailMailAPI.fetchThreadSummaries( + ids: threadIDs, + mailbox: mailbox, + token: token + ) + mailboxThreads[mailbox] = summaries.sorted(by: MailService.threadSort) + if selectedMailbox == mailbox { + selectedThreadID = mailboxThreads[mailbox]?.first?.id + } + persistSnapshot() + } catch { + self.error = error.localizedDescription + } + } + + func refreshSelectedMailbox(token: GoogleOAuthToken) async { + await loadMailbox(selectedMailbox, token: token, forceRefresh: true) + } + + func performSearch(query: String, token: GoogleOAuthToken) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchState.query = query + guard !trimmed.isEmpty else { + searchResults = [] + return + } + + isSearching = true + error = nil + defer { isSearching = false } + + do { + let threadIDs = try await GmailMailAPI.listThreadIDs(labelIDs: [], query: trimmed, token: token) + let summaries = try await GmailMailAPI.fetchThreadSummaries(ids: threadIDs, mailbox: nil, token: token) + searchResults = summaries.sorted(by: MailService.threadSort) + selectedThreadID = searchResults.first?.id + } catch { + self.error = error.localizedDescription + } + } + + func loadThread(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken, forceRefresh: Bool = false) async { + if !forceRefresh, threadDetails[id] != nil { + selectedThreadID = id + return + } + + isLoadingThread = true + error = nil + defer { isLoadingThread = false } + + do { + let detail = try await GmailMailAPI.fetchThreadDetail(id: id, mailbox: mailbox, token: token) + threadDetails[id] = detail + selectedThreadID = id + updateSummary(for: detail) + persistSnapshot() + } catch { + self.error = error.localizedDescription + } + } + + func fetchThreadDetailSnapshot(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> MailThreadDetail { + if let cached = threadDetails[id] { + return cached + } + + let detail = try await GmailMailAPI.fetchThreadDetail(id: id, mailbox: mailbox, token: token) + threadDetails[id] = detail + updateSummary(for: detail) + persistSnapshot() + return detail + } + + func apply(action: MailThreadAction, to threadID: String, token: GoogleOAuthToken) async { + isLoadingThread = true + error = nil + defer { isLoadingThread = false } + + let previousMailboxThreads = mailboxThreads + let previousSearchResults = searchResults + let previousDetail = threadDetails[threadID] + let mailboxHint = previousDetail?.mailbox ?? selectedMailbox + applyLocal(action: action, threadID: threadID) + + do { + try await GmailMailAPI.apply(action: action, threadID: threadID, token: token) + let refreshed = try await GmailMailAPI.fetchThreadDetail(id: threadID, mailbox: mailboxHint, token: token) + threadDetails[threadID] = refreshed + updateSummary(for: refreshed) + persistSnapshot() + } catch { + mailboxThreads = previousMailboxThreads + searchResults = previousSearchResults + if let previousDetail { + threadDetails[threadID] = previousDetail + } + self.error = error.localizedDescription + } + } + + func sendComposer(connectedEmail: String, token: GoogleOAuthToken) async -> Bool { + let draft = composer + guard !draft.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "At least one recipient is required." + return false + } + guard !draft.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "Email body cannot be empty." + return false + } + + isSending = true + error = nil + defer { isSending = false } + + do { + _ = try await GmailMailAPI.send(draft: draft, connectedEmail: connectedEmail, token: token) + dismissComposer() + + // Refresh mailbox data opportunistically so the thread list stays close to Gmail. + await loadMailbox(.sent, token: token, forceRefresh: true) + if selectedMailbox == .inbox || selectedMailbox == .drafts { + await loadMailbox(selectedMailbox, token: token, forceRefresh: true) + } + if let threadID = draft.threadId { + await loadThread(id: threadID, mailbox: selectedMailbox, token: token, forceRefresh: true) + } + return true + } catch { + self.error = error.localizedDescription + return false + } + } + + private func updateSummary(for detail: MailThreadDetail) { + let updated = MailThreadSummary( + id: detail.id, + mailbox: detail.mailbox, + subject: detail.subject, + snippet: detail.snippet, + participants: detail.participants, + date: detail.lastDate, + messageCount: detail.messages.count, + labelIds: detail.labelIds, + historyId: detail.historyId, + annotation: detail.annotation + ) + + for mailbox in MailMailbox.allCases { + var list = mailboxThreads[mailbox] ?? [] + let matches = MailThreadLabelReducer.mailbox(mailbox, contains: detail.labelIds) + + if let index = list.firstIndex(where: { $0.id == detail.id }) { + if matches { + var mailboxSummary = updated + mailboxSummary.mailbox = mailbox + list[index] = mailboxSummary + } else { + list.remove(at: index) + } + } else if matches { + var mailboxSummary = updated + mailboxSummary.mailbox = mailbox + list.insert(mailboxSummary, at: 0) + } + + mailboxThreads[mailbox] = list.sorted(by: MailService.threadSort) + } + + if let index = searchResults.firstIndex(where: { $0.id == detail.id }) { + searchResults[index] = updated + searchResults.sort(by: MailService.threadSort) + } + } + + private func applyLocal(action: MailThreadAction, threadID: String) { + for mailbox in MailMailbox.allCases { + guard var list = mailboxThreads[mailbox], + let index = list.firstIndex(where: { $0.id == threadID }) else { continue } + let nextLabels = MailThreadLabelReducer.mutatedLabels(list[index].labelIds, action: action) + if MailThreadLabelReducer.mailbox(mailbox, contains: nextLabels) { + list[index].labelIds = nextLabels + mailboxThreads[mailbox] = list + } else { + list.remove(at: index) + mailboxThreads[mailbox] = list + } + } + + if let index = searchResults.firstIndex(where: { $0.id == threadID }) { + searchResults[index].labelIds = MailThreadLabelReducer.mutatedLabels(searchResults[index].labelIds, action: action) + } + + if var detail = threadDetails[threadID] { + detail.labelIds = MailThreadLabelReducer.mutatedLabels(detail.labelIds, action: action) + for index in detail.messages.indices { + detail.messages[index].labelIds = MailThreadLabelReducer.mutatedLabels(detail.messages[index].labelIds, action: action) + } + threadDetails[threadID] = detail + } + } + + private func persistSnapshot() { + guard let activeAccountEmail else { return } + let snapshot = MailCacheSnapshot( + mailboxThreads: mailboxThreads, + threadDetails: threadDetails, + savedAt: Date() + ) + cacheStore.save(snapshot, accountEmail: activeAccountEmail) + lastSyncDate = snapshot.savedAt + } + + private static func threadSort(lhs: MailThreadSummary, rhs: MailThreadSummary) -> Bool { + let lhsDate = lhs.date ?? .distantPast + let rhsDate = rhs.date ?? .distantPast + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending + } + + static func replySubject(for subject: String) -> String { + let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.lowercased().hasPrefix("re:") else { return trimmed } + return trimmed.isEmpty ? "Re:" : "Re: \(trimmed)" + } + + private func uniqueStrings(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0.lowercased()).inserted } + } + + func applyIntelligenceRecords(_ records: [String: MailThreadIntelligenceRecord]) { + for record in records.values { + applyIntelligenceRecord(record) + } + } + + func applyIntelligenceRecord(_ record: MailThreadIntelligenceRecord) { + for mailbox in MailMailbox.allCases { + guard var list = mailboxThreads[mailbox] else { continue } + guard let index = list.firstIndex(where: { $0.id == record.threadID }) else { continue } + list[index].annotation = record.annotation + mailboxThreads[mailbox] = list + } + + if let index = searchResults.firstIndex(where: { $0.id == record.threadID }) { + searchResults[index].annotation = record.annotation + } + + if var detail = threadDetails[record.threadID] { + detail.annotation = record.annotation + detail.draftSuggestion = record.draftSuggestion + detail.senderContext = record.senderContext + threadDetails[record.threadID] = detail + updateSummary(for: detail) + } + } +} + +enum MailThreadLabelReducer { + static func mutatedLabels(_ labels: [String], action: MailThreadAction) -> [String] { + var next = Set(labels) + switch action { + case .archive: + next.remove("INBOX") + case .trash: + next.insert("TRASH") + case .untrash: + next.remove("TRASH") + next.insert("INBOX") + case .setStarred(let starred): + if starred { + next.insert("STARRED") + } else { + next.remove("STARRED") + } + case .setUnread(let unread): + if unread { + next.insert("UNREAD") + } else { + next.remove("UNREAD") + } + } + return Array(next).sorted() + } + + static func mailbox(_ mailbox: MailMailbox, contains labels: [String]) -> Bool { + let labelSet = Set(labels) + return mailbox.gmailLabelIDs.allSatisfy(labelSet.contains) + } +} + +enum MailComposerEncoder { + static func buildRawMessage(draft: MailDraft, connectedEmail: String) -> String { + var lines: [String] = [ + "From: \(connectedEmail)", + "To: \(draft.to)", + ] + + if !draft.cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Cc: \(draft.cc)") + } + if !draft.bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Bcc: \(draft.bcc)") + } + lines.append("Subject: \(draft.subject)") + if let replyToMessageID = draft.replyToMessageID, !replyToMessageID.isEmpty { + lines.append("In-Reply-To: \(replyToMessageID)") + } + if let referencesHeader = draft.referencesHeader, !referencesHeader.isEmpty { + lines.append("References: \(referencesHeader)") + } + lines.append("MIME-Version: 1.0") + lines.append("Content-Type: text/plain; charset=utf-8") + lines.append("Content-Transfer-Encoding: 8bit") + lines.append("") + lines.append(draft.body) + + let message = lines.joined(separator: "\r\n") + let data = Data(message.utf8) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private enum GmailMailAPI { + private static let baseURL = URL(string: "https://gmail.googleapis.com/gmail/v1/users/me")! + private static let listMaxResults = 25 + + static func listThreadIDs(labelIDs: [String], query: String?, token: GoogleOAuthToken) async throws -> [String] { + var components = URLComponents(url: baseURL.appendingPathComponent("threads"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "maxResults", value: "\(listMaxResults)")] + queryItems.append(contentsOf: labelIDs.map { URLQueryItem(name: "labelIds", value: $0) }) + if let query, !query.isEmpty { + queryItems.append(URLQueryItem(name: "q", value: query)) + } + components.queryItems = queryItems + + let json = try await requestJSON(url: components.url!, token: token) + let threads = json["threads"] as? [[String: Any]] ?? [] + return threads.compactMap { $0["id"] as? String } + } + + static func fetchThreadSummaries(ids: [String], mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> [MailThreadSummary] { + if ids.isEmpty { return [] } + return try await withThrowingTaskGroup(of: MailThreadSummary.self) { group in + for id in ids { + group.addTask { + try await fetchThreadSummary(id: id, mailbox: mailbox, token: token) + } + } + + var summaries: [MailThreadSummary] = [] + for try await summary in group { + summaries.append(summary) + } + return summaries + } + } + + static func fetchThreadSummary(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> MailThreadSummary { + let thread = try await fetchThreadResource( + id: id, + queryItems: [ + URLQueryItem(name: "format", value: "metadata"), + URLQueryItem(name: "metadataHeaders", value: "Subject"), + URLQueryItem(name: "metadataHeaders", value: "From"), + URLQueryItem(name: "metadataHeaders", value: "Date"), + ], + token: token + ) + + let messages = thread["messages"] as? [[String: Any]] ?? [] + let newestMessage = messages.last ?? messages.first ?? [:] + let newestHeaders = headerMap(from: newestMessage) + let participants = uniqueValues(messages.compactMap { + recipientDisplayName(from: headerMap(from: $0)["From"]) + }) + + return MailThreadSummary( + id: id, + mailbox: mailbox, + subject: newestHeaders["Subject"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(No Subject)", + snippet: (thread["snippet"] as? String) ?? "", + participants: participants, + date: parseRFC2822Date(newestHeaders["Date"]), + messageCount: messages.count, + labelIds: thread["labelIds"] as? [String] ?? [], + historyId: thread["historyId"] as? String, + annotation: nil + ) + } + + static func fetchThreadDetail(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> MailThreadDetail { + let thread = try await fetchThreadResource( + id: id, + queryItems: [URLQueryItem(name: "format", value: "full")], + token: token + ) + + let rawMessages = thread["messages"] as? [[String: Any]] ?? [] + let messages = rawMessages.compactMap { parseMessage($0, threadID: id) } + let participants = uniqueValues(messages.compactMap { $0.from?.displayName }) + let subject = messages.last?.subject + ?? messages.first?.subject + ?? "(No Subject)" + + return MailThreadDetail( + id: id, + mailbox: mailbox, + subject: subject, + snippet: (thread["snippet"] as? String) ?? "", + participants: participants, + messages: messages, + labelIds: thread["labelIds"] as? [String] ?? [], + historyId: thread["historyId"] as? String, + annotation: nil, + draftSuggestion: nil, + senderContext: nil + ) + } + + static func apply(action: MailThreadAction, threadID: String, token: GoogleOAuthToken) async throws { + switch action { + case .trash: + _ = try await postJSON(path: "threads/\(threadID)/trash", body: [:], token: token) + case .untrash: + _ = try await postJSON(path: "threads/\(threadID)/untrash", body: [:], token: token) + case .archive: + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: ["removeLabelIds": ["INBOX"]], + token: token + ) + case .setStarred(let starred): + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: starred ? ["addLabelIds": ["STARRED"]] : ["removeLabelIds": ["STARRED"]], + token: token + ) + case .setUnread(let unread): + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: unread ? ["addLabelIds": ["UNREAD"]] : ["removeLabelIds": ["UNREAD"]], + token: token + ) + } + } + + static func send(draft: MailDraft, connectedEmail: String, token: GoogleOAuthToken) async throws -> String { + let rawMessage = MailComposerEncoder.buildRawMessage(draft: draft, connectedEmail: connectedEmail) + var body: [String: Any] = ["raw": rawMessage] + if let threadID = draft.threadId, !threadID.isEmpty { + body["threadId"] = threadID + } + let response = try await postJSON(path: "messages/send", body: body, token: token) + return response["id"] as? String ?? "" + } + + private static func fetchThreadResource( + id: String, + queryItems: [URLQueryItem], + token: GoogleOAuthToken + ) async throws -> [String: Any] { + var components = URLComponents(url: baseURL.appendingPathComponent("threads/\(id)"), resolvingAgainstBaseURL: false)! + components.queryItems = queryItems + return try await requestJSON(url: components.url!, token: token) + } + + private static func requestJSON(url: URL, token: GoogleOAuthToken) async throws -> [String: Any] { + var request = URLRequest(url: url) + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MailServiceError.apiError("No response from Gmail.") + } + guard http.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "" + throw MailServiceError.apiError("Gmail error \(http.statusCode): \(message)") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MailServiceError.apiError("Invalid Gmail response.") + } + return json + } + + private static func postJSON(path: String, body: [String: Any], token: GoogleOAuthToken) async throws -> [String: Any] { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MailServiceError.apiError("No response from Gmail.") + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "" + throw MailServiceError.apiError("Gmail error \(http.statusCode): \(message)") + } + guard !data.isEmpty else { return [:] } + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + private static func headerMap(from message: [String: Any]) -> [String: String] { + let payload = message["payload"] as? [String: Any] ?? [:] + let headers = payload["headers"] as? [[String: Any]] ?? [] + return headers.reduce(into: [String: String]()) { result, header in + guard let name = header["name"] as? String, + let value = header["value"] as? String, + result[name] == nil else { return } + result[name] = value + } + } + + private static func parseMessage(_ json: [String: Any], threadID: String) -> MailMessage? { + guard let id = json["id"] as? String else { return nil } + let headers = headerMap(from: json) + let payload = json["payload"] as? [String: Any] ?? [:] + let body = extractBodies(from: payload) + + return MailMessage( + id: id, + threadId: threadID, + subject: headers["Subject"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(No Subject)", + snippet: json["snippet"] as? String ?? "", + labelIds: json["labelIds"] as? [String] ?? [], + from: parseSingleRecipient(from: headers["From"]), + to: parseRecipients(from: headers["To"]), + cc: parseRecipients(from: headers["Cc"]), + bcc: parseRecipients(from: headers["Bcc"]), + date: parseRFC2822Date(headers["Date"]), + plainBody: body.plain, + htmlBody: body.html, + messageIDHeader: headers["Message-ID"], + referencesHeader: headers["References"] + ) + } + + private static func extractBodies(from payload: [String: Any]) -> (plain: String, html: String?) { + var plainBody: String? + var htmlBody: String? + + func visit(_ part: [String: Any]) { + let mimeType = (part["mimeType"] as? String ?? "").lowercased() + let filename = part["filename"] as? String ?? "" + let body = part["body"] as? [String: Any] ?? [:] + let encodedData = body["data"] as? String + + if filename.isEmpty, let encodedData, let decoded = decodeBase64URL(encodedData) { + if mimeType == "text/plain" { + plainBody = plainBody ?? decoded + } else if mimeType == "text/html" { + htmlBody = htmlBody ?? decoded + } else if mimeType.isEmpty { + plainBody = plainBody ?? decoded + } + } + + let parts = part["parts"] as? [[String: Any]] ?? [] + for child in parts { + visit(child) + } + } + + visit(payload) + return (plainBody ?? "", htmlBody) + } + + private static func parseSingleRecipient(from header: String?) -> MailMessageRecipient? { + parseRecipients(from: header).first + } + + private static func parseRecipients(from header: String?) -> [MailMessageRecipient] { + guard let header, !header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + return header + .split(separator: ",") + .compactMap { chunk in + let value = chunk.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + + if let start = value.lastIndex(of: "<"), + let end = value.lastIndex(of: ">"), + start < end { + let name = value[.. String? { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + if padding > 0 { + normalized += String(repeating: "=", count: 4 - padding) + } + guard let data = Data(base64Encoded: normalized) else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func parseRFC2822Date(_ value: String?) -> Date? { + guard let value else { return nil } + for formatter in MailDateParsers.all { + if let date = formatter.date(from: value) { + return date + } + } + return nil + } + + private static func recipientDisplayName(from header: String?) -> String? { + parseSingleRecipient(from: header)?.displayName + } + + private static func uniqueValues(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0.lowercased()).inserted } + } +} + +enum MailServiceError: LocalizedError { + case apiError(String) + + var errorDescription: String? { + switch self { + case .apiError(let message): + return message + } + } +} + +private enum MailDateParsers { + static let all: [DateFormatter] = { + let formats = [ + "EEE, d MMM yyyy HH:mm:ss Z", + "d MMM yyyy HH:mm:ss Z", + "EEE, d MMM yyyy HH:mm:ss z", + "d MMM yyyy HH:mm:ss z", + ] + + return formats.map { format in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = format + return formatter + } + }() +} + +struct MailCacheStore { + private let fileManager: FileManager + private let baseDirectory: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(fileManager: FileManager = .default, directoryURL: URL? = nil) { + self.fileManager = fileManager + self.baseDirectory = directoryURL ?? Self.defaultDirectory(fileManager: fileManager) + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func load(accountEmail: String) -> MailCacheSnapshot? { + let fileURL = cacheFileURL(for: accountEmail) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? decoder.decode(MailCacheSnapshot.self, from: data) + } + + func save(_ snapshot: MailCacheSnapshot, accountEmail: String) { + do { + try ensureBaseDirectoryExists() + let data = try encoder.encode(snapshot) + try data.write(to: cacheFileURL(for: accountEmail), options: .atomic) + } catch { + Log.mail.error("Failed to save mail cache: \(error.localizedDescription)") + } + } + + private func ensureBaseDirectoryExists() throws { + guard !fileManager.fileExists(atPath: baseDirectory.path) else { return } + try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + } + + private func cacheFileURL(for accountEmail: String) -> URL { + let sanitized = accountEmail + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + let filename = sanitized.isEmpty ? "mail-cache" : sanitized + return baseDirectory.appendingPathComponent("\(filename).json") + } + + private static func defaultDirectory(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("MailCache", isDirectory: true) + } +} diff --git a/Sources/Bugbook/Services/MeetingNoteService.swift b/Sources/Bugbook/Services/MeetingNoteService.swift index 1bb79ff1..1428413c 100644 --- a/Sources/Bugbook/Services/MeetingNoteService.swift +++ b/Sources/Bugbook/Services/MeetingNoteService.swift @@ -186,7 +186,14 @@ class MeetingNoteService { if !apiKey.isEmpty { let plainTranscript = segments.map { $0.text }.joined(separator: " ") if let summary = try? await aiService.summarizeTranscript(plainTranscript, apiKey: apiKey, model: model) { - content = content.replacingOccurrences(of: "## Summary\n\n_AI summary will appear here when an API key is configured._", with: "## Summary\n\n\(summary)") + content = content.replacingOccurrences( + of: "## Summary\n\n_AI summary will appear here when an API key is configured._", + with: "## Summary\n\n\(summary.summary)" + ) + content = content.replacingOccurrences( + of: "## Action Items\n\n- [ ] ", + with: "## Action Items\n\n\(summary.actionItems)" + ) } } diff --git a/Sources/Bugbook/ViewModels/GatewayViewModel.swift b/Sources/Bugbook/ViewModels/GatewayViewModel.swift new file mode 100644 index 00000000..f37982a5 --- /dev/null +++ b/Sources/Bugbook/ViewModels/GatewayViewModel.swift @@ -0,0 +1,75 @@ +import Foundation +import BugbookCore + +/// Scans workspace databases and aggregates summary stats for the Gateway dashboard. +@MainActor +@Observable +class GatewayViewModel { + struct DatabaseSummary: Identifiable { + let id: String + let name: String + let path: String + let rowCount: Int + let statusCounts: [String: Int] // option name -> count + } + + struct TicketSummary { + var total: Int = 0 + var statusCounts: [String: Int] = [:] // option name -> count + } + + private(set) var databases: [DatabaseSummary] = [] + private(set) var ticketSummary = TicketSummary() + private(set) var isLoading = false + private(set) var recentFiles: [String] = [] // file names + + private let dbStore = DatabaseStore() + private let dbService = DatabaseService() + + func scan(workspacePath: String) { + isLoading = true + let infos = dbStore.listDatabases(in: workspacePath) + + var summaries: [DatabaseSummary] = [] + var aggregateTickets = TicketSummary() + + for info in infos { + guard let (schema, rows) = try? dbService.loadDatabase(at: info.path) else { continue } + + // Find the first select property (typically "Status") + var statusCounts: [String: Int] = [:] + if let statusProp = schema.properties.first(where: { $0.type == .select }), + let options = statusProp.options { + let optionMap = Dictionary(uniqueKeysWithValues: options.map { ($0.id, $0.name) }) + for row in rows { + if case .select(let optId) = row.properties[statusProp.id] { + let name = optionMap[optId] ?? optId + statusCounts[name, default: 0] += 1 + } else { + statusCounts["No Status", default: 0] += 1 + } + } + } + + summaries.append(DatabaseSummary( + id: info.id, + name: info.name, + path: info.path, + rowCount: rows.count, + statusCounts: statusCounts + )) + + // Aggregate ticket-like databases (ones with status properties) + if !statusCounts.isEmpty { + aggregateTickets.total += rows.count + for (status, count) in statusCounts { + aggregateTickets.statusCounts[status, default: 0] += count + } + } + } + + databases = summaries + ticketSummary = aggregateTickets + isLoading = false + } +} diff --git a/Sources/Bugbook/Views/Agent/SkillDetailView.swift b/Sources/Bugbook/Views/Agent/SkillDetailView.swift new file mode 100644 index 00000000..62586ec6 --- /dev/null +++ b/Sources/Bugbook/Views/Agent/SkillDetailView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Read-only view for agent skill files (SKILL.md). +/// Parses YAML frontmatter for name/description and renders the body as styled markdown. +struct SkillDetailView: View { + let filePath: String + let displayName: String + + @State private var markdownBody: String = "" + @State private var skillDescription: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 10) { + Image(systemName: "bolt.fill") + .font(.system(size: 20)) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.system(size: 24, weight: .bold)) + if let desc = skillDescription { + Text(desc) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + .padding(.bottom, 20) + + // Read-only badge + HStack(spacing: 6) { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + Text("Read-only skill file") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.primary.opacity(0.05)) + .clipShape(.rect(cornerRadius: 6)) + .padding(.bottom, 16) + + // Markdown content + Text(LocalizedStringKey(markdownBody)) + .font(.system(size: 14)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(40) + .frame(maxWidth: 720, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.fallbackEditorBg) + .onAppear { loadContent() } + .onChange(of: filePath) { _, _ in loadContent() } + } + + private func loadContent() { + guard let data = FileManager.default.contents(atPath: filePath), + let content = String(data: data, encoding: .utf8) else { + markdownBody = "Unable to read skill file." + return + } + let (desc, body) = Self.stripFrontmatter(content) + skillDescription = desc + markdownBody = body + } + + /// Strips YAML frontmatter and extracts the description field. + static func stripFrontmatter(_ content: String) -> (description: String?, body: String) { + guard content.hasPrefix("---") else { return (nil, content) } + let lines = content.components(separatedBy: "\n") + var description: String? + var endIndex = 0 + + for (i, line) in lines.dropFirst().enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { + endIndex = i + 2 // +1 for dropFirst offset, +1 to skip the closing --- + break + } + if trimmed.hasPrefix("description:") { + let value = trimmed.dropFirst(12).trimmingCharacters(in: .whitespaces) + if !value.isEmpty { description = value } + } + } + + if endIndex > 0, endIndex < lines.count { + let body = lines[endIndex...].joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return (description, body) + } + return (description, content) + } +} diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift index bf84aa67..f2684b93 100644 --- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift +++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift @@ -12,10 +12,20 @@ struct WorkspaceCalendarView: View { @State private var transcriptionService = TranscriptionService() @State private var showImportRecording = false + @State private var showCreateEventSheet = false + @State private var createEventDraft = CalendarEventDraft( + startDate: Date(), + endDate: Date().addingTimeInterval(3600) + ) + @State private var isCreatingEvent = false + @State private var createEventError: String? var body: some View { VStack(spacing: 0) { calendarHeader + if let error = calendarService.error, !error.isEmpty { + calendarErrorBanner(error) + } calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -23,12 +33,30 @@ struct WorkspaceCalendarView: View { .ignoresSafeArea(.container, edges: .top) .background(Color.fallbackEditorBg) .animation(.easeInOut(duration: 0.15), value: calendarVM.viewMode) + .sheet(isPresented: $showCreateEventSheet) { + CalendarEventComposerSheet( + draft: $createEventDraft, + connectedEmail: appState.settings.googleConnectedEmail, + isSaving: isCreatingEvent, + errorMessage: createEventError, + onCancel: { + showCreateEventSheet = false + createEventError = nil + }, + onSave: createCalendarEvent + ) + } .onAppear { if let workspace = appState.workspacePath { calendarService.loadCachedData(workspace: workspace) Task { await calendarService.loadDatabaseOverlayItems(workspace: workspace) } + if appState.settings.googleConnected, + calendarService.events.isEmpty, + calendarService.isSyncing == false { + syncCalendar() + } } } } @@ -145,6 +173,15 @@ struct WorkspaceCalendarView: View { ) } + // Create event button + Button(action: handleCreateEventButton) { + Image(systemName: "plus.circle") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(appState.settings.googleConnected ? "Create Google Calendar event" : "Connect Google Calendar to create events") + // Import recording button Button(action: { showImportRecording = true }) { Image(systemName: "waveform.badge.plus") @@ -180,6 +217,21 @@ struct WorkspaceCalendarView: View { .padding(.vertical, 6) } + private func calendarErrorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + Text(message) + .font(.system(size: 12)) + .lineLimit(2) + Spacer() + } + .foregroundStyle(.red) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.red.opacity(0.08)) + } + // MARK: - Data private var visibleSourceIds: Set { @@ -233,26 +285,93 @@ struct WorkspaceCalendarView: View { private func syncCalendar() { guard let workspace = appState.workspacePath else { return } - let token = loadGoogleToken() - guard let token else { - calendarService.error = "No Google Calendar credentials configured. Go to Settings > Calendar." + Task { + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.calendar) + appState.settings = settings + await calendarService.syncGoogleCalendar(workspace: workspace, token: token) + await calendarService.loadDatabaseOverlayItems(workspace: workspace) + } catch { + calendarService.error = error.localizedDescription + } + } + } + + private func handleCreateEventButton() { + guard appState.settings.googleConfigured, appState.settings.googleConnected else { + appState.showSettings = true + appState.selectedSettingsTab = "google" return } + + createEventDraft = makeCreateEventDraft() + createEventError = nil + showCreateEventSheet = true + } + + private func createCalendarEvent() { + guard let workspace = appState.workspacePath, !isCreatingEvent else { return } + createEventError = nil + isCreatingEvent = true + Task { - await calendarService.syncGoogleCalendar(workspace: workspace, token: token) - await calendarService.loadDatabaseOverlayItems(workspace: workspace) + defer { isCreatingEvent = false } + + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.calendar) + let createdEvent = try await calendarService.createGoogleEvent( + workspace: workspace, + token: token, + draft: createEventDraft + ) + appState.settings = settings + calendarVM.selectedDate = createdEvent.startDate + createEventDraft = makeCreateEventDraft() + showCreateEventSheet = false + } catch { + createEventError = error.localizedDescription + } } } - private func loadGoogleToken() -> GoogleOAuthToken? { - let settings = appState.settings - guard !settings.googleCalendarRefreshToken.isEmpty else { return nil } - return GoogleOAuthToken( - accessToken: settings.googleCalendarAccessToken, - refreshToken: settings.googleCalendarRefreshToken, - expiresAt: Date(timeIntervalSince1970: settings.googleCalendarTokenExpiry) + private func makeCreateEventDraft() -> CalendarEventDraft { + let calendar = Calendar.current + let selectedDayStart = calendar.startOfDay(for: calendarVM.selectedDate) + let now = Date() + + var startDate: Date + switch calendarVM.viewMode { + case .month: + startDate = calendar.date(byAdding: .hour, value: 9, to: selectedDayStart) ?? selectedDayStart + case .day, .week: + let candidate = max(now, calendarVM.selectedDate) + startDate = alignedToNextHalfHour(candidate) + if calendar.isDate(startDate, inSameDayAs: selectedDayStart) == false { + startDate = calendar.date(byAdding: .hour, value: 9, to: selectedDayStart) ?? selectedDayStart + } + } + + return CalendarEventDraft( + startDate: startDate, + endDate: startDate.addingTimeInterval(3600), + calendarId: "primary" ) } + + private func alignedToNextHalfHour(_ date: Date) -> Date { + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let minute = components.minute ?? 0 + let remainder = minute % 30 + if remainder == 0, let exact = calendar.date(from: components) { + return exact + } + components.minute = minute + (30 - remainder) + components.second = 0 + return calendar.date(from: components) ?? date + } } // MARK: - Source Picker @@ -332,3 +451,112 @@ struct CalendarSourcePicker: View { return TagColor.color(for: hex) } } + +struct CalendarEventComposerSheet: View { + @Binding var draft: CalendarEventDraft + let connectedEmail: String + let isSaving: Bool + let errorMessage: String? + var onCancel: () -> Void + var onSave: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + Text("New Event") + .font(.system(size: 18, weight: .semibold)) + + if !connectedEmail.isEmpty { + Label(connectedEmail, systemImage: "calendar.badge.plus") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Title") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextField("Planning review", text: $draft.title) + .textFieldStyle(.roundedBorder) + } + + Toggle("All-day", isOn: $draft.isAllDay) + .toggleStyle(.switch) + + if draft.isAllDay { + DatePicker("Starts", selection: $draft.startDate, displayedComponents: [.date]) + DatePicker("Ends", selection: $draft.endDate, displayedComponents: [.date]) + + Text("All-day events end on the selected day in Bugbook and are sent to Google Calendar with the correct exclusive end date.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + DatePicker("Starts", selection: $draft.startDate) + DatePicker("Ends", selection: $draft.endDate) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Location") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextField("Conference room or link", text: $draft.location) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextEditor(text: $draft.notes) + .font(.system(size: 13)) + .frame(minHeight: 110) + .padding(8) + .background(Color.primary.opacity(0.04)) + .clipShape(.rect(cornerRadius: 8)) + } + } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Spacer() + + Button("Cancel", action: onCancel) + .buttonStyle(.borderless) + + Button(action: onSave) { + HStack(spacing: 8) { + if isSaving { + ProgressView() + .controlSize(.small) + } + Text("Create Event") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(isSaving) + } + } + .padding(20) + .frame(width: 460) + .onChange(of: draft.isAllDay) { _, isAllDay in + guard isAllDay else { + if draft.endDate <= draft.startDate { + draft.endDate = draft.startDate.addingTimeInterval(3600) + } + return + } + + let calendar = Calendar.current + draft.startDate = calendar.startOfDay(for: draft.startDate) + draft.endDate = calendar.startOfDay(for: max(draft.startDate, draft.endDate)) + } + } +} diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..6443dc32 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -10,9 +10,11 @@ struct ContentView: View { private let editorDraftStore = EditorDraftStore() @State private var appState = AppState() + @State private var appSettingsStore = AppSettingsStore() @State private var fileSystem = FileSystemService() @State private var aiService = AiService() @State private var calendarService = CalendarService() + @State private var mailService = MailService() @State private var calendarVM = CalendarViewModel() @State private var meetingNoteService = MeetingNoteService() @State private var transcriptionService = TranscriptionService() @@ -51,6 +53,16 @@ struct ContentView: View { @State private var showPageOptionsMenu = false @State private var databaseRowFullWidth: [UUID: Bool] = [:] + // Cmd+K deferred navigation: set by palette closure, consumed by .onChange in ContentView's own cycle + @State private var pendingCmdKNavigation: CmdKNavRequest? + + private struct CmdKNavRequest: Equatable { + let entry: FileEntry + let inNewTab: Bool + let searchQuery: String? + let id: UUID // unique per request so repeated selections of the same entry still fire + } + var body: some View { configuredLayout } @@ -79,7 +91,9 @@ struct ContentView: View { private var configuredLayout: some View { applyDatabaseNotifications( to: applyCommandNotifications( - to: applyLifecycle(to: baseLayout) + to: applyWorkspaceNotifications( + to: applyLifecycle(to: baseLayout) + ) ) ) } @@ -89,11 +103,15 @@ struct ContentView: View { .ignoresSafeArea() .frame(minWidth: 800, minHeight: 500) .task { + loadAppSettings() initializeWorkspace() applyTheme(appState.settings.theme) editorZoomScale = clampedEditorZoomScale(editorZoomScale) editorUI.focusModeEnabled = appState.settings.focusModeOnType } + .onChange(of: appState.settings) { _, newSettings in + appSettingsStore.save(newSettings) + } .onChange(of: appState.settings.theme) { _, newTheme in applyTheme(newTheme) } @@ -146,15 +164,10 @@ struct ContentView: View { closeDatabaseRowModal() } .onChange(of: appState.currentView) { _, newView in - SentrySDK.addBreadcrumb(Breadcrumb(level: .info, category: "view.change.\(newView)")) - hideFormattingPanel() - closeDatabaseRowModal() - if newView == .chat { - ensureAiInitializedIfNeeded() - } + handleCurrentViewChange(newView) } .onChange(of: appState.isRecording) { _, recording in - recordingPillController.isRecording = recording + handleRecordingChange(recording) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in flushDirtyTabs() @@ -164,6 +177,7 @@ struct ContentView: View { } .onDisappear { flushDirtyTabs() + appSettingsStore.save(appState.settings) terminalManager.shutdown() aiInitTask?.cancel() aiInitTask = nil @@ -172,6 +186,10 @@ struct ContentView: View { workspaceWatcher?.stop() recordingPillController.cleanup() } + } + + private func applyWorkspaceNotifications(to view: V) -> some View { + view .onReceive(NotificationCenter.default.publisher(for: .fileDeleted)) { notification in if let path = notification.object as? String { saveTask?.cancel() @@ -215,6 +233,25 @@ struct ContentView: View { performMovePage(from: sourcePath, toDirectory: destDir, insertIndex: insertIndex, siblingNames: siblingNames) } } + .onReceive(NotificationCenter.default.publisher(for: .addToSidebar)) { notification in + if let payload = notification.object as? SidebarReferenceDragPayload { + addSidebarReference(payload) + } + } + } + + private func handleCurrentViewChange(_ newView: ViewMode) { + let breadcrumb = Breadcrumb(level: .info, category: "view.change.\(String(describing: newView))") + SentrySDK.addBreadcrumb(breadcrumb) + hideFormattingPanel() + closeDatabaseRowModal() + if case .chat = newView { + ensureAiInitializedIfNeeded() + } + } + + private func handleRecordingChange(_ recording: Bool) { + recordingPillController.isRecording = recording } private func applyCommandNotifications(to view: V) -> some View { @@ -229,6 +266,9 @@ struct ContentView: View { private func applyPaneNotifications(to view: V) -> some View { view + .onChange(of: pendingCmdKNavigation) { _, request in + handlePendingCmdKNavigation(request) + } .onReceive(NotificationCenter.default.publisher(for: .splitPaneRight)) { _ in _ = workspaceManager.splitFocusedPane(axis: .horizontal, newContent: .terminal) } @@ -323,6 +363,11 @@ struct ContentView: View { appState.showSettings = false openContentInFocusedPane(.graphDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .openMail)) { _ in + appState.currentView = .editor + appState.showSettings = false + openContentInFocusedPane(.mailDocument()) + } .onReceive(NotificationCenter.default.publisher(for: .openCalendar)) { _ in appState.currentView = .editor appState.showSettings = false @@ -333,6 +378,11 @@ struct ContentView: View { appState.showSettings = false openContentInFocusedPane(.meetingsDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .openGateway)) { _ in + appState.currentView = .editor + appState.showSettings = false + openContentInFocusedPane(.gatewayDocument()) + } .onReceive(NotificationCenter.default.publisher(for: .newDatabase)) { _ in createNewDatabase() } @@ -361,13 +411,14 @@ struct ContentView: View { view .onReceive(NotificationCenter.default.publisher(for: .openAIPanel)) { _ in ensureAiInitializedIfNeeded() - appState.toggleAiPanel() + appState.openNotesChat() } .onReceive(NotificationCenter.default.publisher(for: .askAI)) { notification in let prompt = notification.userInfo?["prompt"] as? String ?? notification.userInfo?["query"] as? String ensureAiInitializedIfNeeded() - appState.openAiPanel(prompt: prompt) + appState.aiInitialPrompt = prompt + appState.openNotesChat() } .onReceive(NotificationCenter.default.publisher(for: .blockTypeShortcut)) { notification in handleBlockTypeShortcut(notification.object as? String) @@ -515,32 +566,17 @@ struct ContentView: View { appState: appState, isPresented: $appState.commandPaletteOpen, onSelectFile: { entry in - navigateToEntry(entry) + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: false, searchQuery: nil, id: UUID()) }, onSelectFileNewTab: { entry in - navigateToEntry(entry, inNewTab: true) + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: true, searchQuery: nil, id: UUID()) }, onCreateFile: { name in createNewFileWithName(name) }, onSelectContentMatch: { entry, query in - if appState.commandPaletteMode == .newTab { - navigateToEntry(entry, inNewTab: true) - } else { - navigateToEntry(entry) - } - // Jump to the block containing the match - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let tab = appState.activeTab, - let doc = blockDocuments[tab.id] else { return } - let lowerQuery = query.lowercased() - if let block = doc.blocks.first(where: { - $0.text.lowercased().contains(lowerQuery) - }) { - doc.focusedBlockId = block.id - doc.cursorPosition = 0 - } - } + let newTab = appState.commandPaletteMode == .newTab + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: newTab, searchQuery: query, id: UUID()) } ) Spacer() @@ -812,9 +848,11 @@ struct ContentView: View { } private var activeTabLeadingPadding: CGFloat { + let isMail = appState.activeTab?.isMail ?? false let isCalendar = appState.activeTab?.isCalendar ?? false let isMeetings = appState.activeTab?.isMeetings ?? false - if isCalendar || isMeetings { return 0 } + let isGateway = appState.activeTab?.isGateway ?? false + if isMail || isCalendar || isMeetings || isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -926,7 +964,7 @@ struct ContentView: View { @ViewBuilder private func paneDocumentContent(leaf: PaneNode.Leaf, file: OpenFile) -> some View { VStack(spacing: 0) { - if !file.isEmptyTab && !file.isCalendar && !file.isMeetings { + if !file.isEmptyTab && !file.isMail && !file.isCalendar && !file.isMeetings && !file.isGateway { HStack { BreadcrumbView( items: breadcrumbs(for: file), @@ -936,7 +974,7 @@ struct ContentView: View { Spacer() - if !file.isEmptyTab && !file.isDatabase { + if !file.isEmptyTab && !file.isDatabase && !file.isSkill { Button { showPageOptionsMenu.toggle() } label: { @@ -970,7 +1008,7 @@ struct ContentView: View { } private func paneLeadingPadding(for file: OpenFile) -> CGFloat { - if file.isCalendar || file.isMeetings { return 0 } + if file.isMail || file.isCalendar || file.isMeetings || file.isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -1016,6 +1054,11 @@ struct ContentView: View { fullWidth: databaseRowFullWidth[leaf.id, default: false] ) .id(leaf.id) + } else if file.isMail { + MailPaneView( + appState: appState, + mailService: mailService + ) } else if file.isCalendar { WorkspaceCalendarView( appState: appState, @@ -1046,6 +1089,31 @@ struct ContentView: View { } ) } + } else if file.isSkill { + SkillDetailView( + filePath: file.path, + displayName: file.displayName ?? (file.path as NSString).lastPathComponent + ) + } else if file.isGateway { + GatewayView( + appState: appState, + workspacePath: appState.workspacePath, + onNavigateToFile: { path in + navigateToFilePath(path) + }, + onOpenGatewayLink: { link in + switch link { + case .calendar: + openContentInFocusedPane(.calendarDocument()) + case .graph: + openContentInFocusedPane(.graphDocument()) + case .meetings: + openContentInFocusedPane(.meetingsDocument()) + case .database(let path): + navigateToFilePath(path) + } + } + ) } else if file.isDatabase { DatabaseFullPageView(dbPath: file.path, initialRowId: dbInitialRowId) .id(leaf.id) @@ -1068,12 +1136,22 @@ struct ContentView: View { icon: Binding( get: { document.icon }, set: { - document.icon = $0 + let newIcon = $0 + document.icon = newIcon markActiveEditorTabDirty() + // Update via pane system + if let ws = workspaceManager.activeWorkspace, + let leaf = ws.focusedLeaf, + case .document(let openFile) = leaf.content { + workspaceManager.updatePaneOpenFile(paneId: leaf.id) { file in + file.icon = newIcon + } + appState.updateFileTreeIcon(for: openFile.path, icon: newIcon) + } + // Legacy tab path (fallback) if appState.activeTabIndex < appState.openTabs.count { - let tab = appState.openTabs[appState.activeTabIndex] - appState.openTabs[appState.activeTabIndex].icon = $0 - appState.updateFileTreeIcon(for: tab.path, icon: $0) + appState.openTabs[appState.activeTabIndex].icon = newIcon + appState.updateFileTreeIcon(for: appState.openTabs[appState.activeTabIndex].path, icon: newIcon) } scheduleSave() } @@ -1276,9 +1354,11 @@ struct ContentView: View { } let ts = transcriptionService doc.transcriptionService = ts - doc.onStartMeeting = { [weak doc] blockId in + doc.onStartMeeting = { [weak appState, weak doc] blockId in Task { await ts.startRecording() + appState?.isRecording = true + appState?.recordingBlockId = blockId // Poll confirmed segments and audio level after recording starts var lastSegmentCount = 0 var lastVolatile = "" @@ -1299,9 +1379,11 @@ struct ContentView: View { lastVolatile = volatile var entries = segments if !volatile.isEmpty { entries.append(volatile) } + let fullText = entries.joined(separator: " ") doc?.updateBlockProperty(id: blockId) { block in block.transcriptEntries = entries - block.meetingTranscript = entries.joined(separator: " ") + block.meetingTranscript = fullText + block.text = fullText } doc?.meetingVolatileText = volatile } @@ -1312,13 +1394,16 @@ struct ContentView: View { doc?.meetingVolatileText = "" } } - doc.onStopMeeting = { [weak doc] blockId in + doc.onStopMeeting = { [weak appState, weak doc] blockId in _ = ts.stopRecording() + appState?.isRecording = false + appState?.recordingBlockId = nil guard let doc else { return } let transcript = ts.currentTranscript doc.updateBlockProperty(id: blockId) { block in block.meetingState = .complete block.meetingTranscript = transcript + block.text = transcript } } doc.onDropPageFromSidebar = { [weak appState, weak doc] sourcePath, insertionIndex in @@ -1616,9 +1701,46 @@ struct ContentView: View { loadFileContentForPane(entry: entry, paneId: targetPaneId) } + /// Open a file entry in a new workspace tab (used by Cmd+K new-tab mode). + private func openEntryInNewWorkspaceTab(_ entry: FileEntry) { + appState.currentView = .editor + appState.showSettings = false + + let paneId = UUID() + let file = makeOpenFile(for: entry, id: paneId) + workspaceManager.addWorkspaceWith(content: .document(openFile: file)) + if let actualPaneId = workspaceManager.activeWorkspace?.focusedPaneId { + loadFileContentForPane(entry: entry, paneId: actualPaneId) + } + } + + /// Handle deferred Cmd+K navigation in ContentView's own render cycle. + private func handlePendingCmdKNavigation(_ request: CmdKNavRequest?) { + guard let request else { return } + pendingCmdKNavigation = nil + if request.inNewTab { + openEntryInNewWorkspaceTab(request.entry) + } else { + navigateToEntryInPane(request.entry) + } + if let query = request.searchQuery { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + guard let ws = workspaceManager.activeWorkspace, + let doc = blockDocuments[ws.focusedPaneId] else { return } + let lowerQuery = query.lowercased() + if let block = doc.blocks.first(where: { + $0.text.lowercased().contains(lowerQuery) + }) { + doc.focusedBlockId = block.id + doc.cursorPosition = 0 + } + } + } + } + /// Load file content from disk into a pane's BlockDocument. private func loadFileContentForPane(entry: FileEntry, paneId: UUID) { - guard !entry.isDatabase, !entry.isDatabaseRow else { return } + guard !entry.isDatabase, !entry.isDatabaseRow, !entry.isSkill else { return } let signpostState = Log.signpost.beginInterval("loadFileContent") defer { Log.signpost.endInterval("loadFileContent", signpostState) } formattingPanel?.hidePanel() @@ -1780,6 +1902,9 @@ struct ContentView: View { return !isDir.boolValue } + private func loadAppSettings() { + appState.settings = appSettingsStore.load() + } private func initializeWorkspace() { // Restore the most recently used workspace, falling back to the default @@ -1993,7 +2118,7 @@ struct ContentView: View { } private func loadFileContent(for entry: FileEntry) { - guard !entry.isDatabase, !entry.isDatabaseRow else { return } + guard !entry.isDatabase, !entry.isDatabaseRow, !entry.isSkill else { return } let signpostState = Log.signpost.beginInterval("loadFileContent") defer { Log.signpost.endInterval("loadFileContent", signpostState) } formattingPanel?.hidePanel() diff --git a/Sources/Bugbook/Views/Gateway/GatewayView.swift b/Sources/Bugbook/Views/Gateway/GatewayView.swift new file mode 100644 index 00000000..392c66d0 --- /dev/null +++ b/Sources/Bugbook/Views/Gateway/GatewayView.swift @@ -0,0 +1,289 @@ +import SwiftUI +import BugbookCore + +/// Navigation targets for Gateway quick links. +enum GatewayLink { + case calendar + case graph + case meetings + case database(path: String) +} + +/// Native mission-control dashboard — the home screen of Bugbook. +/// Shows live workspace state: databases with status breakdowns, ticket counts, and quick-nav links. +struct GatewayView: View { + var appState: AppState + var workspacePath: String? + var onNavigateToFile: (String) -> Void + var onOpenGatewayLink: (GatewayLink) -> Void + + @State private var viewModel = GatewayViewModel() + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + ticketOverview + quickLinks + databasesGrid + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.fallbackEditorBg) + .onAppear { + if let workspace = workspacePath { + viewModel.scan(workspacePath: workspace) + } + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text("Gateway") + .font(.system(size: Typography.title2, weight: .semibold)) + + Spacer() + + Text(formattedDate) + .font(.system(size: Typography.body)) + .foregroundStyle(.secondary) + + if viewModel.isLoading { + ProgressView() + .controlSize(.small) + } + + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.bottom, 4) + } + + // MARK: - Ticket Overview + + @ViewBuilder + private var ticketOverview: some View { + if !viewModel.ticketSummary.statusCounts.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Tickets") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + statBadge( + label: "Total", + value: "\(viewModel.ticketSummary.total)", + color: .primary + ) + + ForEach(sortedStatuses, id: \.0) { status, count in + statBadge( + label: status, + value: "\(count)", + color: colorForStatus(status) + ) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.md)) + } + } + + private var sortedStatuses: [(String, Int)] { + viewModel.ticketSummary.statusCounts.sorted { a, b in + statusOrder(a.key) < statusOrder(b.key) + } + } + + // MARK: - Quick Links + + private var quickLinks: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Links") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + quickLinkButton(icon: "calendar", label: "Today") { + NotificationCenter.default.post(name: .openDailyNote, object: nil) + } + quickLinkButton(icon: "calendar.badge.clock", label: "Calendar") { + onOpenGatewayLink(.calendar) + } + quickLinkButton(icon: "point.3.connected.trianglepath.dotted", label: "Graph") { + onOpenGatewayLink(.graph) + } + quickLinkButton(icon: "waveform", label: "Meetings") { + onOpenGatewayLink(.meetings) + } + } + } + } + + private func quickLinkButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12)) + Text(label) + .font(.system(size: Typography.bodySmall)) + } + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + } + .buttonStyle(.plain) + } + + // MARK: - Databases Grid + + @ViewBuilder + private var databasesGrid: some View { + if !viewModel.databases.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Databases") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + LazyVGrid(columns: columns, spacing: 12) { + ForEach(viewModel.databases) { db in + databaseCard(db) + } + } + } + } + } + + private func databaseCard(_ db: GatewayViewModel.DatabaseSummary) -> some View { + Button { + onOpenGatewayLink(.database(path: db.path)) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(db.name) + .font(.system(size: Typography.body, weight: .medium)) + .lineLimit(1) + Spacer() + Text("\(db.rowCount)") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(Capsule()) + } + + if !db.statusCounts.isEmpty { + statusBar(db.statusCounts) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.md)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: - Status Bar + + private func statusBar(_ counts: [String: Int]) -> some View { + let total = counts.values.reduce(0, +) + guard total > 0 else { return AnyView(EmptyView()) } + + let sorted = counts.sorted { statusOrder($0.key) < statusOrder($1.key) } + + return AnyView( + GeometryReader { geo in + HStack(spacing: 1) { + ForEach(sorted, id: \.key) { status, count in + let fraction = CGFloat(count) / CGFloat(total) + RoundedRectangle(cornerRadius: 2) + .fill(colorForStatus(status)) + .frame(width: max(4, geo.size.width * fraction)) + } + } + } + .frame(height: 4) + .clipShape(Capsule()) + ) + } + + // MARK: - Stat Badge + + private func statBadge(label: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(color) + Text(label) + .font(.system(size: Typography.caption)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + // MARK: - Helpers + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMM d" + return formatter.string(from: Date()) + } + + private func refresh() { + if let workspace = workspacePath { + viewModel.scan(workspacePath: workspace) + } + } + + private func colorForStatus(_ status: String) -> Color { + let lower = status.lowercased() + if lower.contains("done") || lower.contains("complete") || lower.contains("closed") { + return StatusColor.success + } + if lower.contains("progress") || lower.contains("doing") || lower.contains("active") || lower.contains("review") { + return StatusColor.active + } + if lower.contains("block") || lower.contains("stuck") { + return StatusColor.blocked + } + if lower.contains("cancel") || lower.contains("wont") { + return StatusColor.cancelled + } + if lower.contains("todo") || lower.contains("backlog") || lower.contains("queued") || lower.contains("not started") { + return StatusColor.info + } + return StatusColor.neutral + } + + private func statusOrder(_ status: String) -> Int { + let lower = status.lowercased() + if lower.contains("progress") || lower.contains("doing") || lower.contains("active") { return 0 } + if lower.contains("review") { return 1 } + if lower.contains("block") || lower.contains("stuck") { return 2 } + if lower.contains("todo") || lower.contains("backlog") || lower.contains("queued") || lower.contains("not started") { return 3 } + if lower.contains("done") || lower.contains("complete") || lower.contains("closed") { return 4 } + if lower.contains("cancel") { return 5 } + return 3 + } +} diff --git a/Sources/Bugbook/Views/Mail/MailPaneView.swift b/Sources/Bugbook/Views/Mail/MailPaneView.swift new file mode 100644 index 00000000..c0955845 --- /dev/null +++ b/Sources/Bugbook/Views/Mail/MailPaneView.swift @@ -0,0 +1,557 @@ +import SwiftUI +import WebKit + +struct MailPaneView: View { + var appState: AppState + @Bindable var mailService: MailService + + @State private var searchText = "" + + var body: some View { + VStack(spacing: 0) { + header + Divider() + + if !appState.settings.googleConfigured { + setupState( + title: "Configure Google access", + message: "Add your Google OAuth client ID and secret in Settings before connecting Mail." + ) + } else if !appState.settings.googleConnected { + setupState( + title: "Connect Gmail", + message: "Sign in once and Bugbook will use that Google account for both Mail and Calendar." + ) + } else { + HStack(spacing: 0) { + mailboxRail + .frame(width: 180) + Divider() + threadList + .frame(width: 320) + Divider() + detailPane + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.fallbackEditorBg) + .onAppear { + searchText = mailService.searchState.query + if let accountEmail = configuredAccountEmail { + mailService.loadCachedData(accountEmail: accountEmail) + if (mailService.mailboxThreads[mailService.selectedMailbox] ?? []).isEmpty { + refreshSelectedMailbox(force: false) + } + } + } + .onChange(of: appState.settings.googleConnectedEmail) { _, newEmail in + guard !newEmail.isEmpty else { return } + mailService.loadCachedData(accountEmail: newEmail) + refreshSelectedMailbox(force: false) + } + } + + private var header: some View { + HStack(spacing: 10) { + Text("Mail") + .font(.system(size: 16, weight: .semibold)) + + Spacer() + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search Gmail", text: $searchText) + .textFieldStyle(.plain) + .onSubmit { submitSearch() } + + if !searchText.isEmpty { + Button { + searchText = "" + mailService.clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.05)) + .clipShape(.rect(cornerRadius: 8)) + .frame(width: 320) + + if mailService.isSearching || mailService.isLoadingMailbox || mailService.isLoadingThread || mailService.isSending { + ProgressView() + .controlSize(.small) + } + + if let accountEmail = configuredAccountEmail { + Label(accountEmail, systemImage: "person.crop.circle") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Button(action: { refreshSelectedMailbox(force: true) }) { + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!appState.settings.googleConnected || mailService.isLoadingMailbox) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var mailboxRail: some View { + VStack(alignment: .leading, spacing: 6) { + Button(action: { mailService.presentNewComposer() }) { + Label("Compose", systemImage: "square.and.pencil") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.accentColor.opacity(0.12)) + .clipShape(.rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + + ForEach(MailMailbox.allCases) { mailbox in + Button { + mailService.selectMailbox(mailbox) + refreshSelectedMailbox(force: false) + } label: { + HStack(spacing: 10) { + Image(systemName: mailbox.systemImage) + .frame(width: 18) + Text(mailbox.displayName) + .font(.system(size: 13, weight: .medium)) + Spacer() + let count = mailService.mailboxThreads[mailbox]?.count ?? 0 + if count > 0 { + Text("\(count)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(mailService.selectedMailbox == mailbox ? Color.primary.opacity(0.08) : Color.clear) + .clipShape(.rect(cornerRadius: 8)) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + Spacer() + + Button { + appState.showSettings = true + appState.selectedSettingsTab = "google" + } label: { + Label("Google Settings", systemImage: "person.badge.key") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(12) + } + + private var threadList: some View { + VStack(spacing: 0) { + if let error = mailService.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 10) + } + + if mailService.visibleThreads.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "No threads" : "No search results", + systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", + description: Text(searchText.isEmpty ? "Refresh Gmail to load messages for \(mailService.selectedMailbox.displayName)." : "Try a different Gmail query.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(mailService.visibleThreads) { thread in + Button { + openThread(thread) + } label: { + threadRow(thread) + } + .buttonStyle(.plain) + } + } + } + } + } + .background(Color.fallbackEditorBg) + } + + @ViewBuilder + private var detailPane: some View { + if mailService.isComposing && mailService.composer.threadId == nil { + composeView(title: "New Message") + } else if let thread = mailService.selectedThread { + VStack(spacing: 0) { + threadToolbar(thread) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ForEach(thread.messages) { message in + messageCard(message) + } + + if mailService.isComposing, mailService.composer.threadId == thread.id { + composeView(title: mailService.composer.mode == .replyAll ? "Reply All" : "Reply") + } + } + .padding(20) + } + } + } else if mailService.isLoadingThread { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Select a thread", + systemImage: "envelope.open", + description: Text("Choose a message from \(mailService.selectedMailbox.displayName) to read or reply.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func setupState(title: String, message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "envelope.badge") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(title) + .font(.system(size: 20, weight: .semibold)) + Text(message) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 360) + Button("Open Google Settings") { + appState.showSettings = true + appState.selectedSettingsTab = "google" + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func threadRow(_ thread: MailThreadSummary) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(thread.participants.first ?? "(Unknown Sender)") + .font(.system(size: 12, weight: thread.isUnread ? .semibold : .regular)) + .lineLimit(1) + + Spacer() + + if thread.isStarred { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.yellow) + } + + if let date = thread.date { + Text(date, style: .date) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + Text(thread.subject) + .font(.system(size: 13, weight: thread.isUnread ? .semibold : .medium)) + .lineLimit(1) + + Text(thread.snippet) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(mailService.selectedThreadID == thread.id ? Color.primary.opacity(0.08) : Color.clear) + .overlay(alignment: .leading) { + if thread.isUnread { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .padding(.leading, 4) + } + } + } + + private func threadToolbar(_ thread: MailThreadDetail) -> some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(thread.subject) + .font(.system(size: 16, weight: .semibold)) + Text(thread.participants.joined(separator: ", ")) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Button("Reply") { + mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: false) + } + .buttonStyle(.bordered) + + Button("Reply All") { + mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: true) + } + .buttonStyle(.bordered) + + Button(action: { applyThreadAction(thread.isStarred ? .setStarred(false) : .setStarred(true), threadID: thread.id) }) { + Image(systemName: thread.isStarred ? "star.fill" : "star") + .foregroundStyle(thread.isStarred ? .yellow : .secondary) + } + .buttonStyle(.plain) + + Button(action: { applyThreadAction(thread.isUnread ? .setUnread(false) : .setUnread(true), threadID: thread.id) }) { + Image(systemName: thread.isUnread ? "envelope.open" : "envelope.badge") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Button(action: { applyThreadAction(thread.mailbox == .trash ? .untrash : .trash, threadID: thread.id) }) { + Image(systemName: thread.mailbox == .trash ? "arrow.uturn.left.circle" : "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if thread.mailbox != .trash { + Button(action: { applyThreadAction(.archive, threadID: thread.id) }) { + Image(systemName: "archivebox") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private func messageCard(_ message: MailMessage) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(Color.primary.opacity(0.08)) + .frame(width: 32, height: 32) + .overlay( + Text(String((message.from?.name ?? message.from?.email ?? "?").prefix(1)).uppercased()) + .font(.system(size: 13, weight: .semibold)) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(message.from?.displayName ?? "(Unknown Sender)") + .font(.system(size: 13, weight: .semibold)) + Text(recipientLine(for: message)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + if let date = message.date { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + Spacer() + } + + if let htmlBody = message.htmlBody, + !htmlBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MailHTMLView(html: htmlBody) + .frame(minHeight: 220) + .clipShape(.rect(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + ) + } else { + Text(message.bodyText.isEmpty ? message.snippet : message.bodyText) + .font(.system(size: 13)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .background(Color.primary.opacity(0.03)) + .clipShape(.rect(cornerRadius: 14)) + } + + private func composeView(title: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.system(size: 15, weight: .semibold)) + Spacer() + Button("Discard") { + mailService.dismissComposer() + } + .buttonStyle(.borderless) + } + + composeField("To", text: $mailService.composer.to) + composeField("Cc", text: $mailService.composer.cc) + composeField("Bcc", text: $mailService.composer.bcc) + composeField("Subject", text: $mailService.composer.subject) + + TextEditor(text: $mailService.composer.body) + .font(.system(size: 13)) + .frame(minHeight: 180) + .padding(8) + .background(Color.primary.opacity(0.04)) + .clipShape(.rect(cornerRadius: 10)) + + HStack { + Spacer() + Button("Send") { + sendComposer() + } + .buttonStyle(.borderedProminent) + .disabled(mailService.isSending) + } + } + .padding(20) + } + + private func composeField(_ label: String, text: Binding) -> some View { + HStack(spacing: 10) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 52, alignment: .leading) + TextField(label, text: text) + .textFieldStyle(.roundedBorder) + } + } + + private func recipientLine(for message: MailMessage) -> String { + let toLine = message.to.map(\.displayName).joined(separator: ", ") + if toLine.isEmpty { + return "No recipients" + } + return "To: \(toLine)" + } + + private var configuredAccountEmail: String? { + let value = appState.settings.googleConnectedEmail.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func submitSearch() { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + mailService.clearSearch() + return + } + withMailToken { token in + await mailService.performSearch(query: trimmed, token: token) + } + } + + private func refreshSelectedMailbox(force: Bool) { + withMailToken { token in + await mailService.loadMailbox(mailService.selectedMailbox, token: token, forceRefresh: force) + } + } + + private func openThread(_ thread: MailThreadSummary) { + withMailToken { token in + await mailService.loadThread(id: thread.id, mailbox: thread.mailbox, token: token) + } + } + + private func applyThreadAction(_ action: MailThreadAction, threadID: String) { + withMailToken { token in + await mailService.apply(action: action, to: threadID, token: token) + } + } + + private func sendComposer() { + withMailToken { token in + _ = await mailService.sendComposer( + connectedEmail: appState.settings.googleConnectedEmail, + token: token + ) + } + } + + private func withMailToken(_ operation: @escaping (GoogleOAuthToken) async -> Void) { + Task { + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.mail) + appState.settings = settings + await operation(token) + } catch { + mailService.error = error.localizedDescription + } + } + } +} + +private struct MailHTMLView: NSViewRepresentable { + let html: String + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.suppressesIncrementalRendering = false + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.setValue(false, forKey: "drawsBackground") + webView.isInspectable = true + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.loadHTMLString(wrappedHTML(html), baseURL: nil) + } + + private func wrappedHTML(_ body: String) -> String { + """ + + + + + + \(body) + + """ + } +} diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 8d68f6e4..376f4274 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -24,10 +24,12 @@ struct MeetingsView: View { } // MARK: - Header + // NOTE: Audio import (NSOpenPanel → TranscriptionService) was removed 2026-03-31. + // The plumbing exists in MeetingNoteService.importRecording if we revisit later. private var header: some View { HStack(spacing: 8) { - Text("Meetings") + Text("AI Meeting Notes") .font(.system(size: 16, weight: .semibold)) .lineLimit(1) @@ -38,6 +40,17 @@ struct MeetingsView: View { .controlSize(.small) } + Button(action: { appState.openNotesChat() }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 11)) + Text("Chat with all my notes") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Button(action: rescan) { Image(systemName: "arrow.clockwise") .font(.system(size: 12)) diff --git a/Sources/Bugbook/Views/Panes/PaneContentView.swift b/Sources/Bugbook/Views/Panes/PaneContentView.swift index 4a2a9d75..285c2b90 100644 --- a/Sources/Bugbook/Views/Panes/PaneContentView.swift +++ b/Sources/Bugbook/Views/Panes/PaneContentView.swift @@ -68,9 +68,11 @@ struct PaneContentView: View { private func paneTypeMenu(action: @escaping (PaneContent) -> Void) -> some View { Button("Terminal") { action(.terminal) } Button("Empty Page") { action(.emptyDocument()) } + Button("Mail") { action(.mailDocument()) } Button("Calendar") { action(.calendarDocument()) } Button("Meetings") { action(.meetingsDocument()) } Button("Graph View") { action(.graphDocument()) } + Button("Gateway") { action(.gatewayDocument()) } } @ViewBuilder @@ -123,7 +125,7 @@ private struct PaneActionBar: View { } // Merge another tab into this pane (show when other tabs exist) - if otherTabs.count > 0 { + if !otherTabs.isEmpty { divider mergeMenu @@ -162,6 +164,7 @@ private struct PaneActionBar: View { Menu { Button("Terminal") { action(.terminal) } Button("Empty Page") { action(.emptyDocument()) } + Button("Mail") { action(.mailDocument()) } Button("Calendar") { action(.calendarDocument()) } Button("Meetings") { action(.meetingsDocument()) } Button("Graph View") { action(.graphDocument()) } diff --git a/Sources/Bugbook/Views/Settings/AISettingsView.swift b/Sources/Bugbook/Views/Settings/AISettingsView.swift index 06672be0..3e1a6896 100644 --- a/Sources/Bugbook/Views/Settings/AISettingsView.swift +++ b/Sources/Bugbook/Views/Settings/AISettingsView.swift @@ -63,6 +63,16 @@ struct AISettingsView: View { } } + SettingsSection("Mail Intelligence") { + Toggle("Background inbox analysis", isOn: $appState.settings.mailBackgroundAnalysisEnabled) + Toggle("Background draft generation", isOn: $appState.settings.mailBackgroundDraftGenerationEnabled) + Toggle("Sender context lookup", isOn: $appState.settings.mailSenderLookupEnabled) + Toggle("Learn from edits locally", isOn: $appState.settings.mailMemoryLearningEnabled) + Text("Mail AI stays local-first. Gmail remains a direct app-to-Google connection, and Bugbook only stores derived mail intelligence on-device.") + .font(.caption) + .foregroundStyle(.secondary) + } + SettingsSection("Execution Policy") { Picker("Policy", selection: $appState.settings.executionPolicy) { ForEach(ExecutionPolicy.allCases, id: \.self) { policy in diff --git a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift b/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift deleted file mode 100644 index f2fe8d30..00000000 --- a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift +++ /dev/null @@ -1,126 +0,0 @@ -import SwiftUI -import BugbookCore - -struct CalendarSettingsView: View { - @Bindable var appState: AppState - @State private var overlays: [CalendarOverlay] = [] - @State private var isSigningIn = false - @State private var signInError: String? - - private let store = CalendarEventStore() - - var body: some View { - VStack(alignment: .leading, spacing: 24) { - SettingsSection("Google Calendar") { - if isConnected { - HStack(spacing: 10) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 16)) - - VStack(alignment: .leading, spacing: 2) { - Text("Connected to Google Calendar") - .font(.system(size: 13, weight: .medium)) - if !appState.settings.googleCalendarConnectedEmail.isEmpty { - Text(appState.settings.googleCalendarConnectedEmail) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Button("Disconnect") { - disconnect() - } - .buttonStyle(.borderless) - .foregroundStyle(.red) - .font(.system(size: 13)) - } - } else { - VStack(alignment: .leading, spacing: 8) { - Button { - Task { await signIn() } - } label: { - HStack(spacing: 8) { - if isSigningIn { - ProgressView().controlSize(.small) - } else { - Image(systemName: "person.badge.key") - } - Text("Sign in with Google") - } - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - .disabled(isSigningIn) - - if let signInError { - Text(signInError) - .font(.caption) - .foregroundStyle(.red) - } - } - } - } - - SettingsSection("Database Overlays") { - VStack(alignment: .leading, spacing: 8) { - Text("Show database rows with date properties on your calendar. Add overlays from the calendar view's filter menu.") - .font(.caption) - .foregroundStyle(.secondary) - - if overlays.isEmpty { - Text("No overlays configured yet.") - .font(.system(size: 13)) - .foregroundStyle(.tertiary) - } else { - ForEach(overlays) { overlay in - HStack { - Circle() - .fill(TagColor.color(for: overlay.color)) - .frame(width: 8, height: 8) - Text("\(overlay.databaseName) — \(overlay.datePropertyName)") - .font(.system(size: 13)) - Spacer() - } - } - } - } - } - } - .onAppear { - if let workspace = appState.workspacePath { - overlays = store.loadOverlays(in: workspace) - } - } - } - - private func signIn() async { - isSigningIn = true - signInError = nil - defer { isSigningIn = false } - - do { - let result = try await GoogleOAuthFlow.signIn() - appState.settings.googleCalendarAccessToken = result.accessToken - appState.settings.googleCalendarRefreshToken = result.refreshToken - appState.settings.googleCalendarTokenExpiry = result.expiresAt.timeIntervalSince1970 - appState.settings.googleCalendarConnectedEmail = result.email - appState.settings.googleCalendarBannerDismissed = false - } catch { - signInError = error.localizedDescription - } - } - - private func disconnect() { - appState.settings.googleCalendarAccessToken = "" - appState.settings.googleCalendarRefreshToken = "" - appState.settings.googleCalendarTokenExpiry = 0 - appState.settings.googleCalendarConnectedEmail = "" - } - - private var isConnected: Bool { - !appState.settings.googleCalendarRefreshToken.isEmpty - } -} diff --git a/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift b/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift index e012e782..522bd2b7 100644 --- a/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift +++ b/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift @@ -64,6 +64,20 @@ struct GeneralSettingsView: View { } SettingsSection("App") { + if AppEnvironment.isDev { + HStack(spacing: 6) { + Text("DEV") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange) + .clipShape(.capsule) + Text("Development build") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } infoRow(label: "Bundle ID", value: bundleIdentifier) infoRow(label: "Version", value: appVersion) infoRow(label: "Build", value: appBuild) diff --git a/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift b/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift new file mode 100644 index 00000000..01c3eb55 --- /dev/null +++ b/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import BugbookCore + +struct GoogleSettingsView: View { + @Bindable var appState: AppState + @State private var overlays: [CalendarOverlay] = [] + @State private var isSigningIn = false + @State private var signInError: String? + @State private var showClientSecret = false + + private let store = CalendarEventStore() + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + SettingsSection("OAuth App") { + VStack(alignment: .leading, spacing: 10) { + TextField("Google OAuth Client ID", text: $appState.settings.googleClientID) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + + HStack(spacing: 8) { + Group { + if showClientSecret { + TextField("Google OAuth Client Secret", text: $appState.settings.googleClientSecret) + } else { + SecureField("Google OAuth Client Secret", text: $appState.settings.googleClientSecret) + } + } + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + + Button { + showClientSecret.toggle() + } label: { + Image(systemName: showClientSecret ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + + Text("Use a Google desktop OAuth client. Bugbook uses this one account for both Mail and Calendar.") + .font(.caption) + .foregroundStyle(.secondary) + + Text("Enable the Gmail API and Google Calendar API in the same project, then use that desktop client here.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + SettingsSection("Google Account") { + if isConnected { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16)) + + VStack(alignment: .leading, spacing: 2) { + Text("Connected to Google") + .font(.system(size: 13, weight: .medium)) + if !appState.settings.googleConnectedEmail.isEmpty { + Text(appState.settings.googleConnectedEmail) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 12) { + Text(scopeSummary) + .font(.caption) + .foregroundStyle(.secondary) + + Button("Disconnect") { + disconnect() + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + .font(.system(size: 13)) + } + } + } else { + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await signIn() } + } label: { + HStack(spacing: 8) { + if isSigningIn { + ProgressView().controlSize(.small) + } else { + Image(systemName: "person.badge.key") + } + Text("Sign in with Google") + } + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(isSigningIn || !appState.settings.googleConfigured) + + if let signInError { + Text(signInError) + .font(.caption) + .foregroundStyle(.red) + } + + if !appState.settings.googleConfigured { + Text("Add your Google OAuth client ID and secret above before signing in.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let reconnectMessage { + Text(reconnectMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + SettingsSection("Used By") { + VStack(alignment: .leading, spacing: 8) { + Label("Mail uses Gmail threads, search, compose, and thread actions.", systemImage: "envelope") + .font(.system(size: 13)) + Label("Calendar uses Google Calendar sync, event creation, and database overlays.", systemImage: "calendar.badge.plus") + .font(.system(size: 13)) + } + } + + SettingsSection("Database Overlays") { + VStack(alignment: .leading, spacing: 8) { + Text("Show database rows with date properties on your calendar. Add overlays from the calendar view's filter menu.") + .font(.caption) + .foregroundStyle(.secondary) + + if overlays.isEmpty { + Text("No overlays configured yet.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + } else { + ForEach(overlays) { overlay in + HStack { + Circle() + .fill(TagColor.color(for: overlay.color)) + .frame(width: 8, height: 8) + Text("\(overlay.databaseName) — \(overlay.datePropertyName)") + .font(.system(size: 13)) + Spacer() + } + } + } + } + } + } + .onAppear { + if let workspace = appState.workspacePath { + overlays = store.loadOverlays(in: workspace) + } + } + } + + private func signIn() async { + isSigningIn = true + signInError = nil + defer { isSigningIn = false } + + do { + let result = try await GoogleAuthService.signIn(using: appState.settings, scopes: GoogleScopeSet.calendarAndMail) + appState.settings.applyGoogleAuthResult(result) + } catch { + signInError = error.localizedDescription + } + } + + private func disconnect() { + appState.settings.disconnectGoogle() + } + + private var isConnected: Bool { + appState.settings.googleConnected + } + + private var scopeSummary: String { + guard !appState.settings.googleGrantedScopes.isEmpty else { return "Scopes not recorded" } + + let grantedScopes = Set(appState.settings.googleGrantedScopes) + var descriptions: [String] = [] + if grantedScopes.contains(GoogleScopeSet.gmailModify) { + descriptions.append("Gmail read/modify") + } + if grantedScopes.contains(GoogleScopeSet.gmailSend) { + descriptions.append("Gmail send") + } + if grantedScopes.contains(GoogleScopeSet.calendarEvents) { + descriptions.append("Calendar create/edit") + } else if grantedScopes.contains(GoogleScopeSet.calendarReadonly) { + descriptions.append("Calendar read") + } + if grantedScopes.contains(GoogleScopeSet.calendarListReadonly) && !grantedScopes.contains(GoogleScopeSet.calendarEvents) { + descriptions.append("Calendar list") + } + return descriptions.joined(separator: " • ") + } + + private var reconnectMessage: String? { + guard isConnected else { return nil } + + let missingScopes = GoogleScopeSet.calendarAndMail.filter { !appState.settings.googleGrantedScopes.contains($0) } + guard !missingScopes.isEmpty else { return nil } + + if missingScopes.contains(GoogleScopeSet.calendarEvents) { + return "Reconnect Google access to grant calendar event creation." + } + return "Reconnect Google access if Mail or Calendar starts reporting missing scopes." + } +} diff --git a/Sources/Bugbook/Views/Settings/SettingsView.swift b/Sources/Bugbook/Views/Settings/SettingsView.swift index 593a5759..689c74e5 100644 --- a/Sources/Bugbook/Views/Settings/SettingsView.swift +++ b/Sources/Bugbook/Views/Settings/SettingsView.swift @@ -8,7 +8,7 @@ struct SettingsView: View { case "general": return "General" case "appearance": return "Appearance" case "ai": return "AI" - case "calendar": return "Calendar" + case "google", "calendar": return "Google" case "agents": return "Agents" case "search": return "Search" case "shortcuts": return "Shortcuts" @@ -32,8 +32,8 @@ struct SettingsView: View { AppearanceSettingsView(appState: appState) case "ai": AISettingsView(appState: appState) - case "calendar": - CalendarSettingsView(appState: appState) + case "google", "calendar": + GoogleSettingsView(appState: appState) case "agents": AgentsSettingsView(appState: appState) case "search": diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index a499df47..85c58126 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -30,7 +30,7 @@ struct SidebarView: View { ("general", "General", "gearshape"), ("appearance", "Appearance", "paintbrush"), ("ai", "AI", "cpu"), - ("calendar", "Calendar", "calendar"), + ("google", "Google", "person.badge.key"), ("agents", "Agents", "person.2"), ("search", "Search", "magnifyingglass"), ("shortcuts", "Shortcuts", "keyboard"), @@ -187,9 +187,28 @@ struct SidebarView: View { .padding(.horizontal, sectionHorizontalPadding) .padding(.vertical, sectionVerticalPadding) - // Daily note & Graph (hidden in compact/peek mode) + // Gateway, Daily note & Graph (hidden in compact/peek mode) if !isCompact { VStack(spacing: sectionSpacing) { + Button(action: { invokeAction { NotificationCenter.default.post(name: .openGateway, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "square.grid.2x2") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Gateway") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "gateway" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "gateway" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openDailyNote, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { Image(systemName: "calendar") @@ -228,6 +247,25 @@ struct SidebarView: View { .buttonStyle(.plain) .onHover { hovering in hoveredButton = hovering ? "graph" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openMail, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "envelope") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Mail") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "mail" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "mail" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openCalendar, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { Image(systemName: "calendar.badge.clock") @@ -252,7 +290,7 @@ struct SidebarView: View { Image(systemName: "waveform") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) - Text("Meetings") + Text("AI Meeting Notes") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) Spacer() diff --git a/Tests/BugbookTests/CalendarFeatureTests.swift b/Tests/BugbookTests/CalendarFeatureTests.swift new file mode 100644 index 00000000..da631ac7 --- /dev/null +++ b/Tests/BugbookTests/CalendarFeatureTests.swift @@ -0,0 +1,72 @@ +import Foundation +import XCTest +@testable import Bugbook + +final class CalendarFeatureTests: XCTestCase { + func testGoogleScopeSetCalendarIncludesEventWriteAndCalendarListRead() { + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarEvents)) + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarListReadonly)) + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.userEmail)) + XCTAssertFalse(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarReadonly)) + } + + func testGoogleCalendarEventRequestEncoderBuildsTimedBody() throws { + let startDate = Date(timeIntervalSince1970: 1_700_000_000) + let endDate = startDate.addingTimeInterval(5400) + let draft = CalendarEventDraft( + title: "Roadmap Review", + startDate: startDate, + endDate: endDate, + isAllDay: false, + location: "https://meet.example.com/room", + notes: "Agenda and open questions." + ) + + let data = try GoogleCalendarEventRequestEncoder.requestBody( + for: draft, + timeZone: TimeZone(identifier: "America/Los_Angeles") ?? .current + ) + let json = try decodedJSON(data) + + XCTAssertEqual(json["summary"] as? String, "Roadmap Review") + XCTAssertEqual(json["location"] as? String, "https://meet.example.com/room") + XCTAssertEqual(json["description"] as? String, "Agenda and open questions.") + + let start = try XCTUnwrap(json["start"] as? [String: Any]) + let end = try XCTUnwrap(json["end"] as? [String: Any]) + XCTAssertEqual(start["timeZone"] as? String, "America/Los_Angeles") + XCTAssertEqual(end["timeZone"] as? String, "America/Los_Angeles") + XCTAssertNotNil(start["dateTime"] as? String) + XCTAssertNotNil(end["dateTime"] as? String) + XCTAssertNil(start["date"] as? String) + XCTAssertNil(end["date"] as? String) + } + + func testGoogleCalendarEventRequestEncoderBuildsAllDayBodyWithExclusiveEndDate() throws { + let calendar = Calendar(identifier: .gregorian) + let startDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 2))! + let endDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))! + let draft = CalendarEventDraft( + title: "Offsite", + startDate: startDate, + endDate: endDate, + isAllDay: true, + notes: "Bring notes." + ) + + let data = try GoogleCalendarEventRequestEncoder.requestBody(for: draft) + let json = try decodedJSON(data) + let start = try XCTUnwrap(json["start"] as? [String: Any]) + let end = try XCTUnwrap(json["end"] as? [String: Any]) + + XCTAssertEqual(start["date"] as? String, "2026-04-02") + XCTAssertEqual(end["date"] as? String, "2026-04-04") + XCTAssertNil(start["dateTime"] as? String) + XCTAssertNil(end["dateTime"] as? String) + } + + private func decodedJSON(_ data: Data) throws -> [String: Any] { + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) + } +} diff --git a/Tests/BugbookTests/MailFeatureTests.swift b/Tests/BugbookTests/MailFeatureTests.swift new file mode 100644 index 00000000..4f982758 --- /dev/null +++ b/Tests/BugbookTests/MailFeatureTests.swift @@ -0,0 +1,300 @@ +import Foundation +import XCTest +@testable import Bugbook + +@MainActor +final class MailFeatureTests: XCTestCase { + func testMailDocumentFactoryProducesMailOpenFile() throws { + let content = PaneContent.mailDocument() + + guard case .document(let openFile) = content else { + XCTFail("Expected a document pane.") + return + } + + XCTAssertEqual(openFile.kind, .mail) + XCTAssertTrue(openFile.isMail) + XCTAssertEqual(openFile.path, "bugbook://mail") + XCTAssertEqual(openFile.displayName, "Mail") + XCTAssertEqual(openFile.icon, "envelope") + XCTAssertFalse(openFile.isEmptyTab) + } + + func testTabKindMailFlags() { + XCTAssertTrue(TabKind.mail.isMail) + XCTAssertFalse(TabKind.mail.isCalendar) + XCTAssertFalse(TabKind.mail.isMeetings) + XCTAssertFalse(TabKind.mail.isDatabase) + } + + func testAppSettingsDecodesLegacyCalendarGoogleFields() throws { + let json = """ + { + "googleClientID": "client-id", + "googleClientSecret": "client-secret", + "googleCalendarRefreshToken": "legacy-refresh", + "googleCalendarAccessToken": "legacy-access", + "googleCalendarTokenExpiry": 1234, + "googleCalendarConnectedEmail": "legacy@example.com" + } + """ + + let settings = try JSONDecoder().decode(AppSettings.self, from: Data(json.utf8)) + + XCTAssertEqual(settings.googleClientID, "client-id") + XCTAssertEqual(settings.googleClientSecret, "client-secret") + XCTAssertEqual(settings.googleRefreshToken, "legacy-refresh") + XCTAssertEqual(settings.googleAccessToken, "legacy-access") + XCTAssertEqual(settings.googleTokenExpiry, 1234) + XCTAssertEqual(settings.googleConnectedEmail, "legacy@example.com") + } + + func testAppSettingsGoogleTokenHelpers() throws { + var settings = AppSettings.default + let result = GoogleOAuthResult( + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSince1970: 1_000), + email: "user@example.com", + grantedScopes: GoogleScopeSet.calendarAndMail + ) + + settings.applyGoogleAuthResult(result) + + XCTAssertTrue(settings.googleConfigured == false) + XCTAssertTrue(settings.googleConnected) + XCTAssertEqual(settings.googleConnectedEmail, "user@example.com") + XCTAssertEqual(settings.googleGrantedScopes, GoogleScopeSet.calendarAndMail) + + let initialToken = try XCTUnwrap(settings.googleToken) + XCTAssertEqual(initialToken.accessToken, "access-token") + XCTAssertEqual(initialToken.refreshToken, "refresh-token") + XCTAssertEqual(initialToken.expiresAt, Date(timeIntervalSince1970: 1_000)) + + let refreshedToken = GoogleOAuthToken( + accessToken: "new-access", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSince1970: 2_000), + grantedScopes: GoogleScopeSet.mail + ) + settings.updateGoogleToken(refreshedToken) + + let updatedToken = try XCTUnwrap(settings.googleToken) + XCTAssertEqual(updatedToken.accessToken, "new-access") + XCTAssertEqual(updatedToken.expiresAt, Date(timeIntervalSince1970: 2_000)) + XCTAssertEqual(updatedToken.grantedScopes, GoogleScopeSet.mail) + + settings.disconnectGoogle() + XCTAssertFalse(settings.googleConnected) + XCTAssertNil(settings.googleToken) + XCTAssertEqual(settings.googleConnectedEmail, "") + XCTAssertTrue(settings.googleGrantedScopes.isEmpty) + } + + func testAppSettingsStorePersistsSharedGoogleSettings() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let secretStore = InMemorySecretStore() + let fileURL = directoryURL.appendingPathComponent("app-settings.json") + let store = AppSettingsStore(fileURL: fileURL, secretStore: secretStore) + var settings = AppSettings.default + settings.googleClientID = "client-id" + settings.googleClientSecret = "client-secret" + settings.anthropicApiKey = "sk-ant-test" + settings.googleAccessToken = "access-token" + settings.googleRefreshToken = "refresh-token" + settings.googleTokenExpiry = 9_999 + settings.googleConnectedEmail = "user@example.com" + settings.googleGrantedScopes = GoogleScopeSet.calendarAndMail + + store.save(settings) + let loaded = store.load() + let onDisk = try? String(contentsOf: fileURL, encoding: .utf8) + + XCTAssertEqual(loaded, settings) + XCTAssertEqual(secretStore.string(for: .anthropicApiKey), "sk-ant-test") + XCTAssertEqual(secretStore.string(for: .googleAccessToken), "access-token") + XCTAssertEqual(secretStore.string(for: .googleRefreshToken), "refresh-token") + XCTAssertFalse(onDisk?.contains("sk-ant-test") ?? true) + XCTAssertFalse(onDisk?.contains("access-token") ?? true) + XCTAssertFalse(onDisk?.contains("refresh-token") ?? true) + } + + func testMailCacheStoreRoundTripsSnapshot() throws { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = MailCacheStore(directoryURL: directoryURL) + let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 4_242)) + + store.save(snapshot, accountEmail: "Test.User+alias@gmail.com") + let loaded = try XCTUnwrap(store.load(accountEmail: "Test.User+alias@gmail.com")) + + XCTAssertEqual(loaded, snapshot) + } + + func testMailServiceLoadCachedDataClearsPreviousStateWithoutSnapshot() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let service = MailService(cacheStore: MailCacheStore(directoryURL: directoryURL)) + let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 200)) + service.mailboxThreads = snapshot.mailboxThreads + service.threadDetails = snapshot.threadDetails + service.selectedThreadID = "thread-1" + service.searchState = MailSearchState(query: "old") + service.searchResults = snapshot.mailboxThreads[.inbox] ?? [] + service.lastSyncDate = snapshot.savedAt + + service.loadCachedData(accountEmail: "new-account@example.com") + + XCTAssertTrue(service.mailboxThreads.isEmpty) + XCTAssertTrue(service.threadDetails.isEmpty) + XCTAssertEqual(service.searchState, MailSearchState()) + XCTAssertTrue(service.searchResults.isEmpty) + XCTAssertNil(service.selectedThreadID) + XCTAssertNil(service.lastSyncDate) + } + + func testMailComposerEncoderBuildsBase64URLReplyPayload() throws { + let draft = MailDraft( + mode: .replyAll, + to: "alice@example.com", + cc: "bob@example.com", + bcc: "carol@example.com", + subject: "Re: Project", + body: "Thanks for the update.", + threadId: "thread-1", + replyToMessageID: "", + referencesHeader: " " + ) + + let encoded = MailComposerEncoder.buildRawMessage(draft: draft, connectedEmail: "me@example.com") + let decoded = try XCTUnwrap(decodeBase64URL(encoded)) + + XCTAssertFalse(encoded.contains("+")) + XCTAssertFalse(encoded.contains("/")) + XCTAssertFalse(encoded.contains("=")) + XCTAssertTrue(decoded.contains("From: me@example.com\r\n")) + XCTAssertTrue(decoded.contains("To: alice@example.com\r\n")) + XCTAssertTrue(decoded.contains("Cc: bob@example.com\r\n")) + XCTAssertTrue(decoded.contains("Bcc: carol@example.com\r\n")) + XCTAssertTrue(decoded.contains("In-Reply-To: \r\n")) + XCTAssertTrue(decoded.contains("References: \r\n")) + XCTAssertTrue(decoded.hasSuffix("\r\n\r\nThanks for the update.")) + } + + func testMailThreadLabelReducerArchiveAndUnreadMutations() { + let initialLabels = ["INBOX", "STARRED", "UNREAD"] + + let archived = MailThreadLabelReducer.mutatedLabels(initialLabels, action: .archive) + XCTAssertFalse(archived.contains("INBOX")) + XCTAssertTrue(archived.contains("STARRED")) + XCTAssertTrue(archived.contains("UNREAD")) + XCTAssertFalse(MailThreadLabelReducer.mailbox(.inbox, contains: archived)) + XCTAssertTrue(MailThreadLabelReducer.mailbox(.starred, contains: archived)) + + let markedRead = MailThreadLabelReducer.mutatedLabels(archived, action: .setUnread(false)) + XCTAssertFalse(markedRead.contains("UNREAD")) + + let trashed = MailThreadLabelReducer.mutatedLabels(markedRead, action: .trash) + XCTAssertTrue(trashed.contains("TRASH")) + XCTAssertTrue(MailThreadLabelReducer.mailbox(.trash, contains: trashed)) + + let restored = MailThreadLabelReducer.mutatedLabels(trashed, action: .untrash) + XCTAssertTrue(restored.contains("INBOX")) + XCTAssertFalse(restored.contains("TRASH")) + } + + func testGoogleOAuthLoopbackRequestParserExtractsCodeAndState() throws { + let callback = try GoogleOAuthLoopbackRequestParser.parse( + requestLine: "GET /oauth/callback?code=test-code&state=test-state HTTP/1.1", + host: "127.0.0.1" + ) + + XCTAssertEqual(callback, GoogleOAuthLoopbackCallback(code: "test-code", state: "test-state")) + } + + func testGoogleOAuthLoopbackRequestParserSurfacesReturnedOAuthError() { + XCTAssertThrowsError( + try GoogleOAuthLoopbackRequestParser.parse( + requestLine: "GET /oauth/callback?error=access_denied HTTP/1.1", + host: "127.0.0.1" + ) + ) { error in + XCTAssertEqual(error.localizedDescription, "Google sign-in failed: access_denied") + } + } + + private func sampleSnapshot(savedAt: Date) -> MailCacheSnapshot { + let sender = MailMessageRecipient(name: "Alice", email: "alice@example.com") + let message = MailMessage( + id: "message-1", + threadId: "thread-1", + subject: "Hello", + snippet: "Test snippet", + labelIds: ["INBOX", "UNREAD"], + from: sender, + to: [MailMessageRecipient(name: "Me", email: "me@example.com")], + cc: [], + bcc: [], + date: Date(timeIntervalSince1970: 100), + plainBody: "Hello world", + htmlBody: nil, + messageIDHeader: "", + referencesHeader: nil + ) + + let detail = MailThreadDetail( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + messages: [message], + labelIds: ["INBOX", "UNREAD"], + historyId: "history-1", + annotation: nil, + draftSuggestion: nil, + senderContext: nil + ) + + let summary = MailThreadSummary( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + date: Date(timeIntervalSince1970: 100), + messageCount: 1, + labelIds: ["INBOX", "UNREAD"], + historyId: "history-1", + annotation: nil + ) + + return MailCacheSnapshot( + mailboxThreads: [.inbox: [summary]], + threadDetails: ["thread-1": detail], + savedAt: savedAt + ) + } + + private func temporaryDirectory() -> URL { + let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } + + private func decodeBase64URL(_ value: String) -> String? { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + if padding > 0 { + normalized += String(repeating: "=", count: 4 - padding) + } + guard let data = Data(base64Encoded: normalized) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Tests/BugbookTests/MailIntelligenceFeatureTests.swift b/Tests/BugbookTests/MailIntelligenceFeatureTests.swift new file mode 100644 index 00000000..079ddadc --- /dev/null +++ b/Tests/BugbookTests/MailIntelligenceFeatureTests.swift @@ -0,0 +1,242 @@ +import Foundation +import XCTest +@testable import Bugbook +import BugbookCore + +@MainActor +final class MailIntelligenceFeatureTests: XCTestCase { + func testMailModelProviderResolverPrefersAPIKeyInAutoMode() { + var settings = AppSettings.default + settings.preferredAIEngine = .auto + settings.anthropicApiKey = "sk-ant-test" + + let resolved = MailModelProviderResolver.resolve( + settings: settings, + engineStatus: AiEngineStatus(claudeAvailable: true, claudeVersion: "1.0", codexAvailable: true, codexVersion: "1.0") + ) + + XCTAssertEqual(resolved, .anthropicAPI) + } + + func testMailModelProviderResolverFallsBackToCodex() { + var settings = AppSettings.default + settings.preferredAIEngine = .auto + + let resolved = MailModelProviderResolver.resolve( + settings: settings, + engineStatus: AiEngineStatus(claudeAvailable: false, claudeVersion: nil, codexAvailable: true, codexVersion: "1.0") + ) + + XCTAssertEqual(resolved, .codexCLI) + } + + func testMailIntelligenceStoreRoundTripsRecords() throws { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = MailIntelligenceStore(directoryURL: directoryURL) + let snapshot = MailIntelligenceAccountSnapshot( + threadRecords: ["thread-1": sampleRecord()], + savedAt: Date(timeIntervalSince1970: 5_000) + ) + + store.save(snapshot, accountEmail: "Test.User+alias@gmail.com") + let loaded = try XCTUnwrap(store.load(accountEmail: "Test.User+alias@gmail.com")) + + XCTAssertEqual(loaded, snapshot) + } + + func testMailAgentSessionStoreRoundTripsWorkspaceSnapshot() throws { + let workspace = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: workspace) } + + let fixedDate = Date(timeIntervalSince1970: 100) + let store = MailAgentSessionStore() + let snapshot = MailWorkspaceIntelligenceSnapshot( + priorityOverrides: [ + MailPriorityOverride( + senderEmail: "alice@example.com", + priority: .high, + note: "Founder emails are urgent", + createdAt: fixedDate, + updatedAt: fixedDate + ) + ], + memories: [ + MailMemory( + kind: .writingStyle, + title: "Tone", + detail: "Keep replies concise.", + createdAt: fixedDate, + updatedAt: fixedDate + ) + ], + agentSessions: [ + MailAgentSession( + id: "session-1", + threadID: "thread-1", + proposals: [MailAgentActionProposal(id: "proposal-1", kind: .createTask, title: "Create Task", detail: "Turn this into work.")], + entries: [MailAgentSessionEntry(id: "entry-1", role: .system, content: "Started", createdAt: fixedDate)], + createdAt: fixedDate, + updatedAt: fixedDate + ) + ] + ) + + store.save(snapshot, workspacePath: workspace.path) + let loaded = store.load(workspacePath: workspace.path) + + XCTAssertEqual(loaded, snapshot) + } + + func testMailServiceApplyIntelligenceRecordAnnotatesThreadState() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let service = MailService(cacheStore: MailCacheStore(directoryURL: directoryURL)) + let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 200)) + service.mailboxThreads = snapshot.mailboxThreads + service.threadDetails = snapshot.threadDetails + + let record = sampleRecord() + service.applyIntelligenceRecord(record) + + XCTAssertEqual(service.mailboxThreads[.inbox]?.first?.annotation?.suggestedPriority, .high) + XCTAssertEqual(service.threadDetails["thread-1"]?.annotation?.statusFlags, [.needsReply]) + XCTAssertEqual(service.threadDetails["thread-1"]?.draftSuggestion?.body, "Thanks for the update. I can take this on.") + XCTAssertEqual(service.threadDetails["thread-1"]?.senderContext?.senderEmail, "alice@example.com") + } + + func testMailIntelligenceServiceLearnsFromSentDraftAndPersistsWorkspaceMemory() { + let cacheDirectory = temporaryDirectory() + let intelligenceDirectory = temporaryDirectory() + let workspaceDirectory = temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: cacheDirectory) + try? FileManager.default.removeItem(at: intelligenceDirectory) + try? FileManager.default.removeItem(at: workspaceDirectory) + } + + let service = MailIntelligenceService( + accountStore: MailIntelligenceStore(directoryURL: intelligenceDirectory), + workspaceStore: MailAgentSessionStore(), + fileSystem: FileSystemService(), + agentWorkspaceStore: AgentWorkspaceStore() + ) + let mailService = MailService(cacheStore: MailCacheStore(directoryURL: cacheDirectory)) + service.load(accountEmail: "me@example.com", workspacePath: workspaceDirectory.path, mailService: mailService) + service.records["thread-1"] = sampleRecord() + + service.learnFromSentDraft(threadID: "thread-1", subject: "Hello", finalBody: "Thanks. I will handle this today.") + + XCTAssertEqual(service.records["thread-1"]?.annotation.draftStatus, .edited) + XCTAssertEqual(service.memories.first?.kind, .writingStyle) + XCTAssertTrue(service.memories.first?.detail.contains("Thanks. I will handle this today.") ?? false) + + let reloaded = MailAgentSessionStore().load(workspacePath: workspaceDirectory.path) + XCTAssertEqual(reloaded.memories.first?.kind, .writingStyle) + } + + private func sampleRecord() -> MailThreadIntelligenceRecord { + let fixedDate = Date(timeIntervalSince1970: 100) + return MailThreadIntelligenceRecord( + threadID: "thread-1", + sourceSignature: "history-1", + annotation: MailThreadAnnotation( + analysisStatus: .complete, + analysisUpdatedAt: fixedDate, + suggestedPriority: .high, + statusFlags: [.needsReply], + draftStatus: .suggested, + hasSenderContext: true + ), + analysis: MailThreadAnalysis( + priority: .high, + reason: "This thread is asking for a direct response.", + suggestedAction: "Reply with next steps.", + flags: [.needsReply], + shouldGenerateDraft: true, + prefersReplyAll: false, + analyzedAt: fixedDate + ), + draftSuggestion: MailDraftSuggestion( + id: "draft-1", + threadID: "thread-1", + subject: "Re: Hello", + body: "Thanks for the update. I can take this on.", + rationale: "Direct and concise.", + generatedAt: fixedDate + ), + senderContext: MailSenderContext( + threadID: "thread-1", + senderName: "Alice", + senderEmail: "alice@example.com", + summary: "Alice is tied to roadmap work.", + references: [MailSenderContextReference(id: "ref-1", path: "/tmp/roadmap.md", excerpt: "Alice owns roadmap planning.")], + generatedAt: fixedDate + ), + acceptedDraftBody: "Thanks for the update.", + editedDraftBody: nil, + updatedAt: fixedDate + ) + } + + private func sampleSnapshot(savedAt: Date) -> MailCacheSnapshot { + let sender = MailMessageRecipient(name: "Alice", email: "alice@example.com") + let message = MailMessage( + id: "message-1", + threadId: "thread-1", + subject: "Hello", + snippet: "Test snippet", + labelIds: ["INBOX", "UNREAD"], + from: sender, + to: [MailMessageRecipient(name: "Me", email: "me@example.com")], + cc: [], + bcc: [], + date: Date(timeIntervalSince1970: 100), + plainBody: "Hello world", + htmlBody: nil, + messageIDHeader: "", + referencesHeader: nil + ) + + let detail = MailThreadDetail( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + messages: [message], + labelIds: ["INBOX", "UNREAD"], + historyId: "history-1", + annotation: nil, + draftSuggestion: nil, + senderContext: nil + ) + + let summary = MailThreadSummary( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + date: Date(timeIntervalSince1970: 100), + messageCount: 1, + labelIds: ["INBOX", "UNREAD"], + historyId: "history-1", + annotation: nil + ) + + return MailCacheSnapshot( + mailboxThreads: [.inbox: [summary]], + threadDetails: ["thread-1": detail], + savedAt: savedAt + ) + } + + private func temporaryDirectory() -> URL { + let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } +} diff --git a/macos/App/Info.plist b/macos/App/Info.plist index bcf8d9ba..9e59860e 100644 --- a/macos/App/Info.plist +++ b/macos/App/Info.plist @@ -17,13 +17,17 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion 14.0 NSHighResolutionCapable + NSMicrophoneUsageDescription + Bugbook needs microphone access to record meeting audio for live transcription. + NSSpeechRecognitionUsageDescription + Bugbook uses speech recognition to transcribe meeting recordings in real-time. SUFeedURL SUPublicEDKey @@ -31,21 +35,17 @@ UTExportedTypeDeclarations - UTTypeIdentifier - com.bugbook.sidebar-reference - UTTypeDescription - Bugbook Sidebar Reference UTTypeConformsTo public.data + UTTypeDescription + Bugbook Sidebar Reference + UTTypeIdentifier + com.bugbook.sidebar-reference UTTypeTagSpecification - NSMicrophoneUsageDescription - Bugbook needs microphone access to record meeting audio for live transcription. - NSSpeechRecognitionUsageDescription - Bugbook uses speech recognition to transcribe meeting recordings in real-time. diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 0fece574..5c1a74d3 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -15,14 +15,15 @@ 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */; }; 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */; }; 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */; }; + 0FED47257AB3BEA633EEFAE4 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D8DF23527AE667D4F5A45 /* AppEnvironment.swift */; }; 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */; }; - 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */; }; + 12F9C2F7025065DBF7872E5F /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B26920E522337E8BD55A4 /* MailModels.swift */; }; 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4358B9082C37BE1854810696 /* PaneContentView.swift */; }; 1364B73C31B34B4A59420743 /* Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47EF03A6E9A932800D5A95E /* Row.swift */; }; 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */; }; 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2262869710B320C9D232D7B /* CalendarEventStore.swift */; }; 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED677E977CBBB33D0ECA74CE /* GraphView.swift */; }; - 1E82C25E8134A1038E2D96A6 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */; }; + 1D7E279DFDE3F1EF429518E0 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C225B558AE138387C4A5A434 /* Metal.framework */; }; 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */; }; 203CE38A21215FE7D2ED90C2 /* FloatingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */; }; 208879A6F607FAA57195F686 /* ShellZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */; }; @@ -32,6 +33,7 @@ 26885DC83160B601DCBB61BB /* DatabaseInlineEmbedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF7FE7EFE7F26CAA875F2754 /* DatabaseInlineEmbedView.swift */; }; 26EDC26A3C6E5D61E5852773 /* BlockDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */; }; 279A6420F93CE53298E2AA56 /* SidebarDragPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */; }; + 28B792819ED4E4EEEE5F64E2 /* GoogleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */; }; 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D74F02ED2A5522822B783A /* GripDotsView.swift */; }; 290D6457E202795F776249B1 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E66496064A29930C3F3B17 /* AppSettings.swift */; }; 2AD1B1200C4938F37EFF82CA /* ViewModePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */; }; @@ -52,6 +54,7 @@ 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */; }; 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */; }; 4F1134DFB5BF17CC4D9E671A /* ColumnBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780A13E34CC19B07D99EFF46 /* ColumnBlockView.swift */; }; + 50052B736C1AF0B92EDD87DD /* GoogleAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */; }; 5071CA6077BA5B0723C3AFE1 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9365350F95628047D144D9F7 /* FormattingToolbar.swift */; }; 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */; }; 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2421DB7DD227294C11A1041 /* KanbanView.swift */; }; @@ -59,6 +62,7 @@ 55300AEF3CA2B3AABA0FF66D /* DatabaseZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */; }; 55AED705FAC0BD8F729640FD /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 189FFD8CFB64645025DB5876 /* FileEntry.swift */; }; 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */; }; + 57F11FACF4E5C3025AE96A31 /* IOSurface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 977F61568113A500928E2313 /* IOSurface.framework */; }; 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */; }; 5AC81CC4AEA7FE4D4424DEE4 /* RowSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */; }; 60473B1C3084B649F5458D8D /* Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398B466A4F5F82ACEA4874F6 /* Block.swift */; }; @@ -91,10 +95,14 @@ 7EF33F80D6BB6B2F0A6CBD74 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF7A98A5ECBDDCDEA159DDBE /* Assets.xcassets */; }; 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71023CB4011C219BC7514109 /* InlineStyle.swift */; }; 80083188D4B8A228121759CD /* DatabaseEmbedPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */; }; + 8121BFA67D4FBF6DE190E808 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6ADD216C50E87964622BD9C8 /* Carbon.framework */; }; 84F54BA37F46B7DD22EC20D9 /* TextBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E33F7472EFFA78D901306D /* TextBlockView.swift */; }; 85B4001C6EF6E27F2017560E /* BugbookUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */; }; 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */; }; + 8666FC986F5D189ADE7D49AE /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */; }; 868E88F7B10FE2B5E3928632 /* BlockColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */; }; + 87C881CF884C042ACBB9275D /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */; }; + 89894179425F2C21F8DBAA2A /* MailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D326464530C4745129644F6 /* MailService.swift */; }; 8C96674A0DB8992FC7B22444 /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */; }; 8EC461617F98B3FDD8069B3E /* DatabaseRowNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */; }; 90DDA2F7FC61E7CCE3304A2A /* FileTreeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */; }; @@ -103,6 +111,7 @@ 99B81B2510FEA9BB0B092324 /* QueryEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A49740409C913AF7DE543D6 /* QueryEngine.swift */; }; 9E7E6AC9222DBAAC08FAD6BF /* IndexManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70DE6960E273C11B104C1DD9 /* IndexManager.swift */; }; A017A5DD5FB8198FEEBAC1D1 /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */; }; + A07943BEB656C525B2DEDF14 /* MailIntelligenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */; }; A11F532663FB6DCE02C0C4ED /* WorkspacePathRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579A2C2A019CFEAC7344FE9C /* WorkspacePathRules.swift */; }; A395087E03AB1A889F4620AE /* SplitDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D0E04210ECEB0AD796BE41 /* SplitDividerView.swift */; }; A5C00BCC3313FC7DADD70BFA /* TranscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */; }; @@ -116,13 +125,16 @@ ACFC9D5DD251C31F2B07021B /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337D684DD11298A704DC254F /* TerminalSession.swift */; }; AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99E3355E0062163300B5893F /* BugbookCore.framework */; }; B62BD30B7E98CA7476B89377 /* WorkspaceCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */; }; + B739759EBB24637E9AB8D608 /* MailIntelligenceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7834ED8C2F8F828CCE06EC3 /* MailIntelligenceModels.swift */; }; B77203F53F96C299D954EC1F /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */; }; B79FDB9B9023CB05BAB3C66E /* CalloutBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B61CE715DA55EE073506CF6 /* CalloutBlockView.swift */; }; B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */; }; + BA264475137FDA61D8B523DA /* MailIntelligenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */; }; BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */; }; BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7150DDE83E7ED425899B1 /* TableView.swift */; }; BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6639981D84F86643DBB91CD /* AISettingsView.swift */; }; BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */; }; + C00B25FE68F8B8172E1A00E7 /* MailPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B03D29C957B51154565736 /* MailPaneView.swift */; }; C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */; }; C3D3D68199CF617D60D3A773 /* BlockTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE400CA9718CD5697976941B /* BlockTextView.swift */; }; C5D349DF0EBC35FC62C43471 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8535862722D5936666055431 /* Workspace.swift */; }; @@ -144,7 +156,6 @@ D76DEEE7E4AF655BCC15E804 /* RenderLoopDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */; }; D809F8300B119795D5F00A8B /* AiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68748E44202E7C8D638C24B /* AiService.swift */; }; DA7A9136A777D4A1E7E649A8 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37262953A2E00C21609C6836 /* SearchSettingsView.swift */; }; - DB652669C66A472349133778 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */; }; DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */; }; DC399F897D16862221AE55FF /* RowPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89D0872A52B79E2B456963C /* RowPageView.swift */; }; DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */; }; @@ -154,16 +165,20 @@ E45345FAE65412E3DFFB0272 /* DatabaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */; }; E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */; }; E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */; }; + E7CF6114C033056C7B567615 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */; }; E89317C6440E0357A8F3D179 /* BlockCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */; }; EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */; }; EB44EC9D598AA910AE80CE79 /* PaneFocusIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */; }; + EC1FEB44F619D0C010F9548B /* KeychainSecretStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60C3B84C6B10437033B8D43 /* KeychainSecretStore.swift */; }; EF9C6DDF179DD89F6FADB062 /* PropertyEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFF32AEDA72E376BBC436395 /* PropertyEditorView.swift */; }; F1D183B0EC41EE0853B0913F /* DatabaseTemplateEditorModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C636CE45E2D5E20C53C703 /* DatabaseTemplateEditorModal.swift */; }; + F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AC347581598E6C53507F712 /* CoreGraphics.framework */; }; F6196917751CF468444068C8 /* RowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC779B217122291C509E482 /* RowStore.swift */; }; F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B553FF577D3D25B20DA5DAC5 /* FormattingToolbarPanel.swift */; }; F65C1F27B0D5F2963A1BAAB7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6265630F0923203C1F9764 /* ContentView.swift */; }; F7250EE969D2D3094CD9172E /* RelationResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */; }; F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B384938321E8648F5447AE4D /* PageIcon.swift */; }; + FFCF132F09098A60325158E6 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 476358F19D676EC12E2AC59A /* CoreText.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -209,6 +224,7 @@ 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiThreadStore.swift; sourceTree = ""; }; 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsView.swift; sourceTree = ""; }; 189FFD8CFB64645025DB5876 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; + 18B03D29C957B51154565736 /* MailPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailPaneView.swift; sourceTree = ""; }; 1A49740409C913AF7DE543D6 /* QueryEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngine.swift; sourceTree = ""; }; 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeaderView.swift; sourceTree = ""; }; 21D7150DDE83E7ED425899B1 /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; @@ -222,6 +238,7 @@ 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringConverter.swift; sourceTree = ""; }; 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceWatcher.swift; sourceTree = ""; }; 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockColor.swift; sourceTree = ""; }; + 2D326464530C4745129644F6 /* MailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailService.swift; sourceTree = ""; }; 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDocument.swift; sourceTree = ""; }; 2F67AB5BB131FE8C947973D2 /* DatabaseViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseViewState.swift; sourceTree = ""; }; @@ -236,13 +253,18 @@ 3B66941CAB290DD322DB303C /* WorkspaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManager.swift; sourceTree = ""; }; 3CF5BCA96AF59718B60774D2 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationResolver.swift; sourceTree = ""; }; + 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthService.swift; sourceTree = ""; }; 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationEngine.swift; sourceTree = ""; }; 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModePickerButton.swift; sourceTree = ""; }; 4358B9082C37BE1854810696 /* PaneContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneContentView.swift; sourceTree = ""; }; + 476358F19D676EC12E2AC59A /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkspaceStore.swift; sourceTree = ""; }; 4A3E7869C11972E917693DA0 /* WorkspaceKnowledgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceKnowledgeService.swift; sourceTree = ""; }; + 4AC347581598E6C53507F712 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesChatView.swift; sourceTree = ""; }; + 4C7D8DF23527AE667D4F5A45 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; 4CF3076E2CD78CCA495DCDCB /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailIntelligenceStore.swift; sourceTree = ""; }; 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowNavigation.swift; sourceTree = ""; }; 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseZoomMetrics.swift; sourceTree = ""; }; 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; @@ -252,11 +274,13 @@ 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingKnowledgeView.swift; sourceTree = ""; }; 5C6265630F0923203C1F9764 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D16799A267ED55B558F74BD /* Agent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; + 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = ""; }; 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregationEngine.swift; sourceTree = ""; }; 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineRowPeekPanel.swift; sourceTree = ""; }; 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 661BA33DC8D61D2884B32072 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookUITests.swift; sourceTree = ""; }; + 6ADD216C50E87964622BD9C8 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesEditor.swift; sourceTree = ""; }; 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFullPageView.swift; sourceTree = ""; }; @@ -273,7 +297,6 @@ 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItem.swift; sourceTree = ""; }; 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsViewModel.swift; sourceTree = ""; }; 7B61CE715DA55EE073506CF6 /* CalloutBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalloutBlockView.swift; sourceTree = ""; }; - 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderLoopDetector.swift; sourceTree = ""; }; 7F257D6BA8D9A6DCAA4EB896 /* DatabaseRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowViewModel.swift; sourceTree = ""; }; 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCalendarView.swift; sourceTree = ""; }; @@ -282,21 +305,24 @@ 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragPreview.swift; sourceTree = ""; }; 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneFocusIndicator.swift; sourceTree = ""; }; 8535862722D5936666055431 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../Frameworks/GhosttyKit.xcframework; sourceTree = ""; }; 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPeekState.swift; sourceTree = ""; }; 8754B7A6F668B3311F152557 /* TerminalPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneView.swift; sourceTree = ""; }; - 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarWeekView.swift; sourceTree = ""; }; 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = ""; }; 9365350F95628047D144D9F7 /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; 968A3076C851ABC04C18EC51 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + 977F61568113A500928E2313 /* IOSurface.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOSurface.framework; path = System/Library/Frameworks/IOSurface.framework; sourceTree = SDKROOT; }; 99E3355E0062163300B5893F /* BugbookCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BugbookCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9E2E691CB7B90DC79363BC7B /* WorkspaceTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabBar.swift; sourceTree = ""; }; + 9E5B26920E522337E8BD55A4 /* MailModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailModels.swift; sourceTree = ""; }; 9FC779B217122291C509E482 /* RowStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowStore.swift; sourceTree = ""; }; A2C165B4FFDB916884D4293C /* FileTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeView.swift; sourceTree = ""; }; A2E6CAEA3C923DDA24C7C36A /* ToggleBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleBlockView.swift; sourceTree = ""; }; A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; A52CFB20317770DA9DB4D732 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseService.swift; sourceTree = ""; }; + A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A7712E15B025309AF9EAF67D /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePointerCursor.swift; sourceTree = ""; }; AE400CA9718CD5697976941B /* BlockTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockTextView.swift; sourceTree = ""; }; @@ -311,8 +337,11 @@ B6639981D84F86643DBB91CD /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = ""; }; BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseStore.swift; sourceTree = ""; }; BF65B82523BECFB622E38CCA /* BreadcrumbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbView.swift; sourceTree = ""; }; + C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSettingsView.swift; sourceTree = ""; }; + C225B558AE138387C4A5A434 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; C2262869710B320C9D232D7B /* CalendarEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventStore.swift; sourceTree = ""; }; C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNoteService.swift; sourceTree = ""; }; + C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailIntelligenceService.swift; sourceTree = ""; }; C68748E44202E7C8D638C24B /* AiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiService.swift; sourceTree = ""; }; C72383D5865CC78156A21463 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; @@ -346,8 +375,10 @@ F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiContextItem.swift; sourceTree = ""; }; F47A85AD47A48232C52EB55B /* BugbookApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookApp.swift; sourceTree = ""; }; F47EF03A6E9A932800D5A95E /* Row.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Row.swift; sourceTree = ""; }; + F60C3B84C6B10437033B8D43 /* KeychainSecretStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSecretStore.swift; sourceTree = ""; }; F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEvent.swift; sourceTree = ""; }; F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = ""; }; + F7834ED8C2F8F828CCE06EC3 /* MailIntelligenceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailIntelligenceModels.swift; sourceTree = ""; }; F7C636CE45E2D5E20C53C703 /* DatabaseTemplateEditorModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplateEditorModal.swift; sourceTree = ""; }; FA7E110D3A8E8C645366B20E /* DatabaseRowModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowModalView.swift; sourceTree = ""; }; FB1D2F73F8536CB22D1959D7 /* OutlineBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineBlockView.swift; sourceTree = ""; }; @@ -363,7 +394,13 @@ AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */, DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */, 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */, - DB652669C66A472349133778 /* SwiftTerm in Frameworks */, + 8666FC986F5D189ADE7D49AE /* GhosttyKit.xcframework in Frameworks */, + 8121BFA67D4FBF6DE190E808 /* Carbon.framework in Frameworks */, + 1D7E279DFDE3F1EF429518E0 /* Metal.framework in Frameworks */, + F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */, + FFCF132F09098A60325158E6 /* CoreText.framework in Frameworks */, + E7CF6114C033056C7B567615 /* QuartzCore.framework in Frameworks */, + 57F11FACF4E5C3025AE96A31 /* IOSurface.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -399,6 +436,8 @@ 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */, 189FFD8CFB64645025DB5876 /* FileEntry.swift */, 71023CB4011C219BC7514109 /* InlineStyle.swift */, + F7834ED8C2F8F828CCE06EC3 /* MailIntelligenceModels.swift */, + 9E5B26920E522337E8BD55A4 /* MailModels.swift */, 0F534031257A5C2CBDA600E6 /* OpenFile.swift */, B384938321E8648F5447AE4D /* PageIcon.swift */, 31C778C966E4AA3C20B1A3EC /* PaneContent.swift */, @@ -504,7 +543,6 @@ E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */, 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */, 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */, - 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */, 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */, A7712E15B025309AF9EAF67D /* WelcomeView.swift */, 9E2E691CB7B90DC79363BC7B /* WorkspaceTabBar.swift */, @@ -518,8 +556,8 @@ 57BA5285DC6913D9E172BA99 /* AgentsSettingsView.swift */, B6639981D84F86643DBB91CD /* AISettingsView.swift */, C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */, - 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */, C72383D5865CC78156A21463 /* GeneralSettingsView.swift */, + C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */, 37262953A2E00C21609C6836 /* SearchSettingsView.swift */, 661BA33DC8D61D2884B32072 /* SettingsView.swift */, 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */, @@ -555,6 +593,7 @@ B16E9F553D2EDB723B3CDFF8 /* Database */, 2579331778CC5EC6DB286690 /* Editor */, C575E14DD4F6A30BEA81C1B4 /* Graph */, + 9CE5FB4F5FE35247A228ACC7 /* Mail */, F3EE2A2FCDD080B2CD99613C /* Meetings */, D6B6A42068B2184566F971A5 /* Panes */, 3B7E87FA2AD5CC2E60AC7EF4 /* Settings */, @@ -603,12 +642,18 @@ children = ( C68748E44202E7C8D638C24B /* AiService.swift */, 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */, + 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */, E071A97E9E6B0892FA767898 /* BacklinkService.swift */, 59394152B4415173A37EE498 /* CalendarService.swift */, A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */, 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */, E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */, + 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */, + F60C3B84C6B10437033B8D43 /* KeychainSecretStore.swift */, 4CF3076E2CD78CCA495DCDCB /* Logger.swift */, + C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */, + 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */, + 2D326464530C4745129644F6 /* MailService.swift */, C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */, 22439903C988ACDC2263371A /* OnboardingService.swift */, EFF0F24D7EAF03362E0B0441 /* QmdService.swift */, @@ -625,12 +670,21 @@ 99A769FA75817DC84A883F15 /* App */ = { isa = PBXGroup; children = ( + 4C7D8DF23527AE667D4F5A45 /* AppEnvironment.swift */, E99B66C38F452E8D9E75E81F /* AppState.swift */, F47A85AD47A48232C52EB55B /* BugbookApp.swift */, ); path = App; sourceTree = ""; }; + 9CE5FB4F5FE35247A228ACC7 /* Mail */ = { + isa = PBXGroup; + children = ( + 18B03D29C957B51154565736 /* MailPaneView.swift */, + ); + path = Mail; + sourceTree = ""; + }; 9FAA7A86768FCC279708E6D2 /* Sidebar */ = { isa = PBXGroup; children = ( @@ -716,10 +770,25 @@ 0E0596AE897FCCE0A018DAA8 /* Bugbook */, B9ACAF37F4AD4DE19AB6BDF4 /* BugbookCore */, E790AFC40FB904B5B41C0C85 /* BugbookUITests */, + CACAA46E8989ABA8D0566C04 /* Frameworks */, D0BD45773DE8E09C582B07E9 /* Products */, ); sourceTree = ""; }; + CACAA46E8989ABA8D0566C04 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6ADD216C50E87964622BD9C8 /* Carbon.framework */, + 4AC347581598E6C53507F712 /* CoreGraphics.framework */, + 476358F19D676EC12E2AC59A /* CoreText.framework */, + 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */, + 977F61568113A500928E2313 /* IOSurface.framework */, + C225B558AE138387C4A5A434 /* Metal.framework */, + A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; D0BD45773DE8E09C582B07E9 /* Products */ = { isa = PBXGroup; children = ( @@ -805,7 +874,6 @@ packageProductDependencies = ( 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */, 093BE9A2C3A2769A90DE0579 /* Sentry */, - 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */, ); productName = BugbookApp; productReference = C7935F81E8213A92201CFAA6 /* BugbookApp.app */; @@ -858,7 +926,6 @@ packageReferences = ( CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */, - D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; projectDirPath = ""; @@ -930,7 +997,9 @@ D809F8300B119795D5F00A8B /* AiService.swift in Sources */, 7ED64068786FC4125769B869 /* AiSidePanelView.swift in Sources */, D152B548059890320AA5F1F5 /* AiThreadStore.swift in Sources */, + 0FED47257AB3BEA633EEFAE4 /* AppEnvironment.swift in Sources */, 290D6457E202795F776249B1 /* AppSettings.swift in Sources */, + 87C881CF884C042ACBB9275D /* AppSettingsStore.swift in Sources */, 7C8D7191B8B1FF31105C4608 /* AppState.swift in Sources */, EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */, 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */, @@ -949,7 +1018,6 @@ C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */, 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */, CBB52CB1DEEB63DEF5B95C42 /* CalendarService.swift in Sources */, - 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */, 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */, 79B94AC24F342F11305894B8 /* CalendarViewModel.swift in Sources */, 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */, @@ -988,14 +1056,23 @@ F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */, E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */, 045B1A2FD850725900D6FC22 /* GeneralSettingsView.swift in Sources */, + 50052B736C1AF0B92EDD87DD /* GoogleAuthService.swift in Sources */, + 28B792819ED4E4EEEE5F64E2 /* GoogleSettingsView.swift in Sources */, 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */, 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */, 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */, B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */, 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */, 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */, + EC1FEB44F619D0C010F9548B /* KeychainSecretStore.swift in Sources */, 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */, 31319D254BD21B3105E098D8 /* Logger.swift in Sources */, + B739759EBB24637E9AB8D608 /* MailIntelligenceModels.swift in Sources */, + A07943BEB656C525B2DEDF14 /* MailIntelligenceService.swift in Sources */, + BA264475137FDA61D8B523DA /* MailIntelligenceStore.swift in Sources */, + 12F9C2F7025065DBF7872E5F /* MailModels.swift in Sources */, + C00B25FE68F8B8172E1A00E7 /* MailPaneView.swift in Sources */, + 89894179425F2C21F8DBAA2A /* MailService.swift in Sources */, 3D9B4F3DBA52CA2B67CFBBB8 /* MarkdownBlockParser.swift in Sources */, BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */, 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */, @@ -1031,7 +1108,6 @@ D5404002070C02A7C49FAD38 /* SidebarView.swift in Sources */, 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */, A395087E03AB1A889F4620AE /* SplitDividerView.swift in Sources */, - 1E82C25E8134A1038E2D96A6 /* TabBarView.swift in Sources */, 6AED4644678A23789357E98F /* TableBlockView.swift in Sources */, BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */, CE6A8D6D7B08D23CE62776BE /* TemplatePickerView.swift in Sources */, @@ -1185,16 +1261,28 @@ CODE_SIGN_ENTITLEMENTS = App/Bugbook.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lz", + "-lc++", + ); PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; PRODUCT_NAME = Bugbook; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( + ); }; name = Release; }; @@ -1206,16 +1294,30 @@ CODE_SIGN_ENTITLEMENTS = App/Bugbook.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "-lz", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev; PRODUCT_NAME = Bugbook; SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( + DEBUG, + BUGBOOK_DEV, + ); }; name = Debug; }; @@ -1376,14 +1478,6 @@ minimumVersion = 8.40.0; }; }; - D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.2.5; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1397,11 +1491,6 @@ package = 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */ = { - isa = XCSwiftPackageProductDependency; - package = D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */; - productName = SwiftTerm; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 748DCF6AC60831FC7058F9CD /* Project object */; diff --git a/macos/project.yml b/macos/project.yml index ccfbd42b..f955a0c4 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -58,10 +58,20 @@ targets: path: App/Info.plist properties: CFBundleDisplayName: Bugbook + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion: "14.0" NSHighResolutionCapable: true SUFeedURL: "" SUPublicEDKey: "" + UTExportedTypeDeclarations: + - UTTypeIdentifier: com.bugbook.sidebar-reference + UTTypeDescription: Bugbook Sidebar Reference + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: {} + NSMicrophoneUsageDescription: Bugbook needs microphone access to record meeting audio for live transcription. + NSSpeechRecognitionUsageDescription: Bugbook uses speech recognition to transcribe meeting recordings in real-time. entitlements: path: App/Bugbook.entitlements properties: @@ -75,12 +85,21 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook PRODUCT_NAME: Bugbook + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES COMBINE_HIDPI_IMAGES: YES OTHER_LDFLAGS: ["-lz", "-lc++"] + configs: + Debug: + PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev + SWIFT_ACTIVE_COMPILATION_CONDITIONS: [DEBUG, BUGBOOK_DEV] + Release: + PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook + SWIFT_ACTIVE_COMPILATION_CONDITIONS: [] BugbookUITests: type: bundle.ui-testing platform: macOS diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 00000000..0ded8d7e --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# release.sh — Build Bugbook.app and install to ~/Applications +# +# Usage: ./scripts/release.sh +# +# Builds using swift build (Release config), creates a proper .app bundle, +# and copies it to ~/Applications/Bugbook.app. The release build uses a +# different bundle identifier (com.bugbook.Bugbook) so it can run alongside +# the Xcode dev build (com.maxforsey.Bugbook.dev). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +INSTALL_DIR="$HOME/Applications" +APP_NAME="Bugbook.app" +APP_PATH="$INSTALL_DIR/$APP_NAME" +BUNDLE_ID="com.bugbook.Bugbook" + +contains_item() { + local needle="$1" + shift + + local item + for item in "$@"; do + if [ "$item" = "$needle" ]; then + return 0 + fi + done + + return 1 +} + +copy_swift_runtime_libraries() { + local queue=() + local copied=() + local search_dirs=() + local library="" + local dependency="" + local search_dir="" + local source_path="" + local i=0 + + while IFS= read -r library; do + [ -n "$library" ] && queue+=("$library") + done < <(otool -L "$MACOS_DIR/Bugbook" | awk '$1 ~ /^@rpath\/libswift.*\.dylib$/ { sub("^@rpath/", "", $1); print $1 }') + + while IFS= read -r search_dir; do + [ -d "$search_dir" ] && search_dirs+=("$search_dir") + done < <(otool -l "$MACOS_DIR/Bugbook" | awk '$1 == "path" && $2 ~ /^\// { print $2 }') + + for ((i = 0; i < ${#queue[@]}; i++)); do + library="${queue[$i]}" + + if [ ${#copied[@]} -gt 0 ] && contains_item "$library" "${copied[@]}"; then + continue + fi + + source_path="" + for search_dir in "${search_dirs[@]}"; do + if [ -f "$search_dir/$library" ]; then + source_path="$search_dir/$library" + break + fi + done + + if [ -z "$source_path" ]; then + echo "ERROR: Swift runtime library not found: $library" + exit 1 + fi + + cp -R "$source_path" "$FRAMEWORKS_DIR/" + copied+=("$library") + + while IFS= read -r dependency; do + [ -n "$dependency" ] && queue+=("$dependency") + done < <(otool -L "$source_path" | awk '$1 ~ /^@rpath\/libswift.*\.dylib$/ { sub("^@rpath/", "", $1); print $1 }') + done +} + +# --- Version from git --- +VERSION="0.$(git rev-list --count HEAD 2>/dev/null || echo 1)" +BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || echo 1)" +GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +echo "-- Building Bugbook $VERSION (build $BUILD_NUMBER, $GIT_SHA)" + +# --- Build release binary --- +echo "-- swift build --configuration release --product Bugbook" +swift build --configuration release --product Bugbook 2>&1 | tail -5 + +BIN_DIR="$(swift build -c release --show-bin-path)" +BINARY="$BIN_DIR/Bugbook" +if [ ! -f "$BINARY" ]; then + echo "ERROR: Binary not found at $BINARY" + exit 1 +fi + +# --- Construct .app bundle --- +echo "-- Assembling $APP_NAME bundle" +STAGE_DIR="$REPO_ROOT/.build/release-app" +rm -rf "$STAGE_DIR" + +CONTENTS="$STAGE_DIR/$APP_NAME/Contents" +MACOS_DIR="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" +FRAMEWORKS_DIR="$CONTENTS/Frameworks" + +mkdir -p "$MACOS_DIR" "$RESOURCES" "$FRAMEWORKS_DIR" + +# Copy binary +cp "$BINARY" "$MACOS_DIR/Bugbook" + +# Teach the SwiftPM-built executable to resolve bundled frameworks. +if ! otool -l "$MACOS_DIR/Bugbook" | grep -Fq "@executable_path/../Frameworks"; then + install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/Bugbook" +fi + +# Copy runtime frameworks, bundles, and dylibs emitted by SwiftPM. +shopt -s nullglob +for framework in "$BIN_DIR"/*.framework; do + cp -R "$framework" "$FRAMEWORKS_DIR/" +done +for library in "$BIN_DIR"/*.dylib; do + cp -R "$library" "$FRAMEWORKS_DIR/" +done +for bundle in "$BIN_DIR"/*.bundle; do + cp -R "$bundle" "$RESOURCES/" +done +shopt -u nullglob + +# Bundle non-system Swift runtime libraries referenced through @rpath. +copy_swift_runtime_libraries + +# Compile asset catalog if actool is available, otherwise skip +XCASSETS="$REPO_ROOT/macos/App/Assets.xcassets" +if command -v actool &>/dev/null && [ -d "$XCASSETS" ]; then + echo "-- Compiling asset catalog" + actool "$XCASSETS" \ + --compile "$RESOURCES" \ + --platform macosx \ + --minimum-deployment-target 14.0 \ + --app-icon AppIcon \ + --accent-color AccentColor \ + --output-partial-info-plist /dev/null 2>/dev/null || true +else + # Copy icon PNG as a fallback + ICON_SRC="$REPO_ROOT/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png" + if [ -f "$ICON_SRC" ]; then + cp "$ICON_SRC" "$RESOURCES/AppIcon.png" + fi +fi + +# Generate Info.plist +cat > "$CONTENTS/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Bugbook + CFBundleExecutable + Bugbook + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Bugbook + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${BUILD_NUMBER} + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Bugbook needs microphone access to record meeting audio for live transcription. + NSSpeechRecognitionUsageDescription + Bugbook uses speech recognition to transcribe meeting recordings in real-time. + NSSupportsAutomaticGraphicsSwitching + + BugbookGitSHA + ${GIT_SHA} + + +PLIST + +# Write entitlements +cat > "$STAGE_DIR/Bugbook.entitlements" < + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.device.audio-input + + + +ENTITLEMENTS + +# Ad-hoc codesign for local use +echo "-- Codesigning (ad-hoc)" +codesign --force --deep \ + --sign - \ + --entitlements "$STAGE_DIR/Bugbook.entitlements" \ + "$STAGE_DIR/$APP_NAME" + +# --- Install --- +echo "-- Installing to $APP_PATH" +mkdir -p "$INSTALL_DIR" + +# Kill running release Bugbook if present (ignore errors) +pkill -f "$APP_PATH/Contents/MacOS/Bugbook" 2>/dev/null || true +sleep 0.5 + +rm -rf "$APP_PATH" +cp -R "$STAGE_DIR/$APP_NAME" "$APP_PATH" + +# Clean up staging +rm -rf "$STAGE_DIR" + +echo "" +echo "Done! Bugbook $VERSION installed to $APP_PATH" +echo " Bundle ID: $BUNDLE_ID" +echo " Version: $VERSION (build $BUILD_NUMBER)" +echo " Git: $GIT_SHA" +echo "" +echo "Launch with: open $APP_PATH"