Explore the YouTubeTranscripts-macOS project to understand:
1. How transcripts are fetched (ViewModels, Services)
2. The flow from URL input to transcript display
3. How batch or queue processing might work

Focus on finding:
- TranscriptInputViewModel or similar
- MCPService or transcript fetching logic
- Any existing batch/queue processing patterns

Path: /Users/neog/Apps/macOS/YouTubeTranscripts-macOS/


I'll explore the YouTubeTranscripts-macOS project to understand its architecture and transcript fetching mechanisms.


Bash

find /Users/neog/Apps/macOS/YouTubeTranscripts-macOS -type f -name "*.swift" | head -20

List Swift files in the project


List project directory structure


total 80
drwxr-xr-x@ 14 neog  staff   448 Dec 21 16:06 .
drwxr-xr-x@ 27 neog  staff   864 Dec 26 14:55 ..
-rw-r--r--@  1 neog  staff  6148 Dec 14 11:10 .DS_Store
drwxr-xr-x@ 12 neog  staff   384 Dec 26 17:07 .build
drwxr-xr-x@ 12 neog  staff   384 Dec 26 15:34 .git
-rw-------@  1 neog  staff   712 Dec 21 14:44 .gitignore
-rw-------@  1 neog  staff  1068 Dec 21 15:56 LICENSE
-rw-------@  1 neog  staff   469 Dec  3 13:49 Package.swift
-rw-------@  1 neog  staff  4729 Dec 21 15:54 README.md
drwx------@  3 neog  staff    96 Dec  8 02:05 Scripts
drwxr-xr-x@ 11 neog  staff   352 Dec  3 16:22 Sources
-rw-------@  1 neog  staff  4928 Dec 21 14:02 WHISPER-SETUP.md
drwxr-xr-x@  6 neog  staff   192 Dec  8 02:45 YouTubeTranscripts.xcodeproj
-rw-------@  1 neog  staff   699 Dec  3 01:43 project.yml


Now let me examine the key ViewModels and Services:


find /Users/neog/Apps/macOS/YouTubeTranscripts-macOS/Sources -type f -name "*Service*" -o -name "*MCP*" -o -name "*Fetch*" -o -name "*Queue*"

Search for service-related files


     1→import Foundation
     2→import SwiftUI
     3→import SwiftData
     4→import UniformTypeIdentifiers
     5→
     6→// MARK: - Transcript Input ViewModel
     7→
     8→@MainActor
     9→final class TranscriptInputViewModel: ObservableObject {
    10→
    11→    // MARK: - Published State
    12→
    13→    @Published var youtubeURL = ""
    14→    @Published var selectedLanguage = "en"
    15→    @Published var includeTimestamps = false
    16→    @Published var isLoading = false
    17→    @Published var logs: [InputLogEntry] = []
    18→    @Published var currentStep: TranscriptStep = .idle
    19→    @Published var clipboardURL: String? = nil
    20→    @Published var isDropTargeted = false
    21→    @Published var showingLogsCopied = false
    22→    @Published var elapsedTime: Int = 0
    23→
    24→    // MARK: - Private Properties
    25→
    26→    private var progressTimer: Timer?
    27→
    28→    // MARK: - Lifecycle
    29→
    30→    deinit {
    31→        progressTimer?.invalidate()
    32→        progressTimer = nil
    33→    }
    34→
    35→    // MARK: - Computed Properties
    36→
    37→    var videoID: String? {
    38→        MCPService.extractVideoID(from: youtubeURL)
    39→    }
    40→
    41→    var isValidURL: Bool {
    42→        videoID != nil
    43→    }
    44→
    45→    var canFetch: Bool {
    46→        !youtubeURL.isEmpty && isValidURL && !isLoading
    47→    }
    48→
    49→    var logsAsText: String {
    50→        let header = "=== YouTube Transcripts Log ===\nDate: \(Date().formatted())\nURL: \(youtubeURL)\n\n"
    51→        let logLines = logs.map { $0.plainText }.joined(separator: "\n")
    52→        return header + logLines
    53→    }
    54→
    55→    var languages: [(code: String, name: String)] {
    56→        AppConfig.YouTube.supportedLanguages
    57→    }
    58→
    59→    // MARK: - Initialization
    60→
    61→    func initialize(defaultLanguage: String, defaultTimestamps: Bool, autoCheckClipboard: Bool) {
    62→        selectedLanguage = defaultLanguage
    63→        includeTimestamps = defaultTimestamps
    64→
    65→        if autoCheckClipboard {
    66→            checkClipboard()
    67→        }
    68→    }
    69→
    70→    // MARK: - Clipboard Handling
    71→
    72→    func checkClipboard() {
    73→        if let string = NSPasteboard.general.string(forType: .string),
    74→           MCPService.extractVideoID(from: string) != nil {
    75→            clipboardURL = string
    76→        }
    77→    }
    78→
    79→    func useClipboardURL() {
    80→        guard let url = clipboardURL else { return }
    81→        youtubeURL = url
    82→        clipboardURL = nil
    83→    }
    84→
    85→    func dismissClipboardBanner() {
    86→        clipboardURL = nil
    87→    }
    88→
    89→    // MARK: - Drop Handling
    90→
    91→    func handleDrop(providers: [NSItemProvider]) -> Bool {
    92→        for provider in providers {
    93→            // Try URL first
    94→            if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
    95→                provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in
    96→                    if let url = item as? URL {
    97→                        DispatchQueue.main.async {
    98→                            self?.youtubeURL = url.absoluteString
    99→                        }
   100→                    }
   101→                }
   102→                return true
   103→            }
   104→
   105→            // Try plain text
   106→            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
   107→                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in
   108→                    var extractedString: String?
   109→
   110→                    if let data = item as? Data, let string = String(data: data, encoding: .utf8) {
   111→                        extractedString = string
   112→                    } else if let string = item as? String {
   113→                        extractedString = string
   114→                    }
   115→
   116→                    if let string = extractedString, MCPService.extractVideoID(from: string) != nil {
   117→                        DispatchQueue.main.async {
   118→                            self?.youtubeURL = string
   119→                        }
   120→                    }
   121→                }
   122→                return true
   123→            }
   124→        }
   125→        return false
   126→    }
   127→
   128→    // MARK: - Logging
   129→
   130→    func log(_ message: String, type: InputLogEntry.InputLogType = .info) {
   131→        let entry = InputLogEntry(timestamp: Date(), message: message, type: type)
   132→        logs.append(entry)
   133→
   134→        // Also log to AppLogger for persistence
   135→        switch type {
   136→        case .error:
   137→            logError(message, category: .transcript)
   138→        case .warning:
   139→            logWarning(message, category: .transcript)
   140→        case .success:
   141→            logInfo(message, category: .transcript)
   142→        default:
   143→            logDebug(message, category: .transcript)
   144→        }
   145→    }
   146→
   147→    func clearLogs() {
   148→        logs.removeAll()
   149→    }
   150→
   151→    func copyLogs() {
   152→        NSPasteboard.general.clearContents()
   153→        NSPasteboard.general.setString(logsAsText, forType: .string)
   154→
   155→        withAnimation {
   156→            showingLogsCopied = true
   157→        }
   158→
   159→        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
   160→            withAnimation {
   161→                self?.showingLogsCopied = false
   162→            }
   163→        }
   164→    }
   165→
   166→    // MARK: - Step Management
   167→
   168→    func updateStep(_ step: TranscriptStep) {
   169→        currentStep = step
   170→    }
   171→
   172→    // MARK: - Timer Management
   173→
   174→    func startProgressTimer() {
   175→        elapsedTime = 0
   176→        progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
   177→            guard let self = self else { return }
   178→
   179→            Task { @MainActor in
   180→                self.elapsedTime += 1
   181→
   182→                // Log progress every 30 seconds
   183→                if self.elapsedTime > 0 && self.elapsedTime % 30 == 0 {
   184→                    self.log("⏳ Ainda processando... (\(AppFormatters.formatElapsed(self.elapsedTime)))", type: .progress)
   185→                }
   186→
   187→                // Reassure user for long waits
   188→                if self.elapsedTime == 60 {
   189→                    self.log("ℹ Vídeos longos podem demorar alguns minutos...", type: .info)
   190→                }
   191→                if self.elapsedTime == 120 {
   192→                    self.log("ℹ Aguarde, ainda recebendo dados do YouTube...", type: .info)
   193→                }
   194→                if self.elapsedTime == 180 {
   195→                    self.log("ℹ Quase lá! Processando transcrição grande...", type: .info)
   196→                }
   197→            }
   198→        }
   199→    }
   200→
   201→    func stopProgressTimer() {
   202→        progressTimer?.invalidate()
   203→        progressTimer = nil
   204→    }
   205→
   206→    // MARK: - Fetch Transcript
   207→
   208→    func fetchTranscript(modelContext: ModelContext, onSave: @escaping (SavedVideo) -> Void) async {
   209→        guard let videoID = videoID else {
   210→            log("❌ URL do YouTube inválida", type: .error)
   211→            return
   212→        }
   213→
   214→        isLoading = true
   215→        logs.removeAll()
   216→        startProgressTimer()
   217→
   218→        // Track event
   219→        startTimedEvent("transcript_fetch")
   220→        trackEvent(.transcriptFetchStarted, properties: [
   221→            "video_id": videoID,
   222→            "language": selectedLanguage
   223→        ])
   224→
   225→        // Step 1: Validate
   226→        updateStep(.validating)
   227→        log("🚀 Iniciando extração de transcrição...", type: .info)
   228→        log("🔑 Video ID: \(videoID)", type: .success)
   229→        log("🌍 Idioma: \(selectedLanguage.uppercased())", type: .info)
   230→        log("⏱ Timestamps: \(includeTimestamps ? "Sim" : "Não")", type: .info)
   231→
   232→        do {
   233→            // Step 2: Get video info
   234→            updateStep(.fetchingInfo)
   235→            log("🔄 Conectando ao YouTube...", type: .progress)
   236→
   237→            let videoInfo = try await MCPService.shared.getVideoInfo(url: youtubeURL)
   238→
   239→            log("📺 Vídeo encontrado: \"\(videoInfo.title ?? "Desconhecido")\"", type: .success)
   240→            log("👤 Canal: \(videoInfo.channelName)", type: .info)
   241→
   242→            // Step 3: Get transcript with progress callback
   243→            updateStep(.fetchingTranscript)
   244→
   245→            if includeTimestamps {
   246→                log("📝 Incluindo timestamps na transcrição...", type: .info)
   247→            }
   248→
   249→            log("⏳ Baixando transcrição do YouTube...", type: .progress)
   250→            log("ℹ Isso pode levar alguns minutos para vídeos longos", type: .info)
   251→
   252→            let (transcript, source) = try await MCPService.shared.getTranscriptWithWhisperFallback(
   253→                url: youtubeURL,
   254→                language: selectedLanguage,
   255→                timed: includeTimestamps,
   256→                timeout: AppConfig.Network.transcriptTimeout,
   257→                progress: { [weak self] message in
   258→                    Task { @MainActor in
   259→                        self?.log(message, type: .progress)
   260→                    }
   261→                }
   262→            )
   263→
   264→            // Step 4: Process
   265→            updateStep(.processing)
   266→            stopProgressTimer()
   267→
   268→            let wordCount = transcript.split(separator: " ").count
   269→            let charCount = transcript.count
   270→            let estimatedReadingTime = AppFormatters.estimateReadingTime(wordCount: wordCount)
   271→
   272→            log("✅ Transcrição recebida!", type: .success)
   273→            log("📊 Palavras: \(wordCount.formattedNumber) | Caracteres: \(charCount.formattedNumber)", type: .info)
   274→            log("📖 Tempo de leitura estimado: ~\(estimatedReadingTime) min", type: .info)
   275→            log("⏱ Tempo total de download: \(AppFormatters.formatElapsed(elapsedTime))", type: .success)
   276→
   277→            // Step 5: Save
   278→            updateStep(.saving)
   279→            log("💾 Salvando no banco de dados local...", type: .progress)
   280→
   281→            let savedVideo = SavedVideo(
   282→                youtubeURL: youtubeURL,
   283→                videoID: videoID,
   284→                title: videoInfo.title ?? "Vídeo sem título",
   285→                channelName: videoInfo.channelName,
   286→                transcript: transcript,
   287→                language: selectedLanguage,
   288→                duration: nil,
   289→                thumbnailURL: videoInfo.thumbnail_url
   290→            )
   291→
   292→            // Set transcript source metadata
   293→            savedVideo.transcriptSource = source == .whisperTranscription ? "whisper" : "youtube"
   294→
   295→            // Log source information
   296→            if source == .whisperTranscription {
   297→                log("🤖 Transcrição gerada via Whisper (áudio)", type: .info)
   298→            } else {
   299→                log("📝 Legendas obtidas do YouTube", type: .info)
   300→            }
   301→
   302→            modelContext.insert(savedVideo)
   303→            try modelContext.save()
   304→
   305→            log("✅ Salvo com sucesso!", type: .success)
   306→
   307→            // Track success
   308→            endTimedEvent("transcript_fetch", event: .transcriptFetchCompleted, properties: [
   309→                "video_id": videoID,
   310→                "language": selectedLanguage,
   311→                "word_count": String(wordCount),
   312→                "char_count": String(charCount)
   313→            ])
   314→
   315→            // Complete
   316→            updateStep(.complete)
   317→            log("🎉 Tudo pronto! Abrindo transcrição...", type: .success)
   318→
   319→            // Small delay to show completion
   320→            try? await Task.sleep(nanoseconds: 500_000_000)
   321→
   322→            isLoading = false
   323→            stopProgressTimer()
   324→            onSave(savedVideo)
   325→
   326→        } catch {
   327→            stopProgressTimer()
   328→            updateStep(.failed)
   329→
   330→            // Track failure
   331→            trackEvent(.transcriptFetchFailed, properties: [
   332→                "video_id": videoID,
   333→                "error": error.localizedDescription
   334→            ])
   335→
   336→            if let mcpError = error as? MCPError, case .timeout = mcpError {
   337→                log("⚠ Timeout: A operação excedeu o tempo limite", type: .error)
   338→                log("💡 Dica: Verifique sua conexão e tente novamente", type: .info)
   339→            } else {
   340→                log("❌ Erro: \(error.localizedDescription)", type: .error)
   341→            }
   342→
   343→            isLoading = false
   344→        }
   345→    }
   346→}
   347→
   348→// MARK: - Transcript Step
   349→
   350→enum TranscriptStep: String {
   351→    case idle = "Ready"
   352→    case validating = "Validating URL..."
   353→    case fetchingInfo = "Fetching video info..."
   354→    case fetchingTranscript = "Fetching transcript..."
   355→    case processing = "Processing text..."
   356→    case saving = "Saving to database..."
   357→    case complete = "Complete!"
   358→    case failed = "Failed"
   359→}
   360→
   361→// MARK: - Input Log Entry
   362→
   363→struct InputLogEntry: Identifiable {
   364→    let id = UUID()
   365→    let timestamp: Date
   366→    let message: String
   367→    let type: InputLogType
   368→
   369→    enum InputLogType: String {
   370→        case info = "INFO"
   371→        case success = "OK"
   372→        case warning = "WARN"
   373→        case error = "ERROR"
   374→        case progress = "..."
   375→
   376→        var icon: String {
   377→            switch self {
   378→            case .info: return "info.circle.fill"
   379→            case .success: return "checkmark.circle.fill"
   380→            case .warning: return "exclamationmark.triangle.fill"
   381→            case .error: return "xmark.circle.fill"
   382→            case .progress: return "arrow.trianglehead.2.clockwise.rotate.90"
   383→            }
   384→        }
   385→
   386→        var color: Color {
   387→            switch self {
   388→            case .info: return .blue
   389→            case .success: return .green
   390→            case .warning: return .orange
   391→            case .error: return .red
   392→            case .progress: return .purple
   393→            }
   394→        }
   395→    }
   396→
   397→    var plainText: String {
   398→        "[\(AppFormatters.formatTime(timestamp))] [\(type.rawValue)] \(message)"
   399→    }
   400→}
   401→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import SwiftUI
import SwiftData
import UniformTypeIdentifiers

// MARK: - Transcript Input ViewModel

@MainActor
final class TranscriptInputViewModel: ObservableObject {

    // MARK: - Published State

    @Published var youtubeURL = ""
    @Published var selectedLanguage = "en"
    @Published var includeTimestamps = false
    @Published var isLoading = false
    @Published var logs: [InputLogEntry] = []
    @Published var currentStep: TranscriptStep = .idle
    @Published var clipboardURL: String? = nil
    @Published var isDropTargeted = false
    @Published var showingLogsCopied = false
    @Published var elapsedTime: Int = 0

    // MARK: - Private Properties

    private var progressTimer: Timer?

    // MARK: - Lifecycle

    deinit {
        progressTimer?.invalidate()
        progressTimer = nil
    }

    // MARK: - Computed Properties

    var videoID: String? {
        MCPService.extractVideoID(from: youtubeURL)
    }

    var isValidURL: Bool {
        videoID != nil
    }

    var canFetch: Bool {
        !youtubeURL.isEmpty && isValidURL && !isLoading
    }

    var logsAsText: String {
        let header = "=== YouTube Transcripts Log ===\nDate: \(Date().formatted())\nURL: \(youtubeURL)\n\n"
        let logLines = logs.map { $0.plainText }.joined(separator: "\n")
        return header + logLines
    }

    var languages: [(code: String, name: String)] {
        AppConfig.YouTube.supportedLanguages
    }

    // MARK: - Initialization

    func initialize(defaultLanguage: String, defaultTimestamps: Bool, autoCheckClipboard: Bool) {
        selectedLanguage = defaultLanguage
        includeTimestamps = defaultTimestamps

        if autoCheckClipboard {
            checkClipboard()
        }
    }

    // MARK: - Clipboard Handling

    func checkClipboard() {
        if let string = NSPasteboard.general.string(forType: .string),
           MCPService.extractVideoID(from: string) != nil {
            clipboardURL = string
        }
    }

    func useClipboardURL() {
        guard let url = clipboardURL else { return }
        youtubeURL = url
        clipboardURL = nil
    }

    func dismissClipboardBanner() {
        clipboardURL = nil
    }

    // MARK: - Drop Handling

    func handleDrop(providers: [NSItemProvider]) -> Bool {
        for provider in providers {
            // Try URL first
            if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
                provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in
                    if let url = item as? URL {
                        DispatchQueue.main.async {
                            self?.youtubeURL = url.absoluteString
                        }
                    }
                }
                return true
            }

            // Try plain text
            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in
                    var extractedString: String?

                    if let data = item as? Data, let string = String(data: data, encoding: .utf8) {
                        extractedString = string
                    } else if let string = item as? String {
                        extractedString = string
                    }

                    if let string = extractedString, MCPService.extractVideoID(from: string) != nil {
                        DispatchQueue.main.async {
                            self?.youtubeURL = string
                        }
                    }
                }
                return true
            }
        }
        return false
    }

    // MARK: - Logging

    func log(_ message: String, type: InputLogEntry.InputLogType = .info) {
        let entry = InputLogEntry(timestamp: Date(), message: message, type: type)
        logs.append(entry)

        // Also log to AppLogger for persistence
        switch type {
        case .error:
            logError(message, category: .transcript)
        case .warning:
            logWarning(message, category: .transcript)
        case .success:
            logInfo(message, category: .transcript)
        default:
            logDebug(message, category: .transcript)
        }
    }

    func clearLogs() {
        logs.removeAll()
    }

    func copyLogs() {
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(logsAsText, forType: .string)

        withAnimation {
            showingLogsCopied = true
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
            withAnimation {
                self?.showingLogsCopied = false
            }
        }
    }

    // MARK: - Step Management

    func updateStep(_ step: TranscriptStep) {
        currentStep = step
    }

    // MARK: - Timer Management

    func startProgressTimer() {
        elapsedTime = 0
        progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self = self else { return }

            Task { @MainActor in
                self.elapsedTime += 1

                // Log progress every 30 seconds
                if self.elapsedTime > 0 && self.elapsedTime % 30 == 0 {
                    self.log("⏳ Ainda processando... (\(AppFormatters.formatElapsed(self.elapsedTime)))", type: .progress)
                }

                // Reassure user for long waits
                if self.elapsedTime == 60 {
                    self.log("ℹ Vídeos longos podem demorar alguns minutos...", type: .info)
                }
                if self.elapsedTime == 120 {
                    self.log("ℹ Aguarde, ainda recebendo dados do YouTube...", type: .info)
                }
                if self.elapsedTime == 180 {
                    self.log("ℹ Quase lá! Processando transcrição grande...", type: .info)
                }
            }
        }
    }

    func stopProgressTimer() {
        progressTimer?.invalidate()
        progressTimer = nil
    }

    // MARK: - Fetch Transcript

    func fetchTranscript(modelContext: ModelContext, onSave: @escaping (SavedVideo) -> Void) async {
        guard let videoID = videoID else {
            log("❌ URL do YouTube inválida", type: .error)
            return
        }

        isLoading = true
        logs.removeAll()
        startProgressTimer()

        // Track event
        startTimedEvent("transcript_fetch")
        trackEvent(.transcriptFetchStarted, properties: [
            "video_id": videoID,
            "language": selectedLanguage
        ])

        // Step 1: Validate
        updateStep(.validating)
        log("🚀 Iniciando extração de transcrição...", type: .info)
        log("🔑 Video ID: \(videoID)", type: .success)
        log("🌍 Idioma: \(selectedLanguage.uppercased())", type: .info)
        log("⏱ Timestamps: \(includeTimestamps ? "Sim" : "Não")", type: .info)

        do {
            // Step 2: Get video info
            updateStep(.fetchingInfo)
            log("🔄 Conectando ao YouTube...", type: .progress)

            let videoInfo = try await MCPService.shared.getVideoInfo(url: youtubeURL)

            log("📺 Vídeo encontrado: \"\(videoInfo.title ?? "Desconhecido")\"", type: .success)
            log("👤 Canal: \(videoInfo.channelName)", type: .info)

            // Step 3: Get transcript with progress callback
            updateStep(.fetchingTranscript)

            if includeTimestamps {
                log("📝 Incluindo timestamps na transcrição...", type: .info)
            }

            log("⏳ Baixando transcrição do YouTube...", type: .progress)
            log("ℹ Isso pode levar alguns minutos para vídeos longos", type: .info)

            let (transcript, source) = try await MCPService.shared.getTranscriptWithWhisperFallback(
                url: youtubeURL,
                language: selectedLanguage,
                timed: includeTimestamps,
                timeout: AppConfig.Network.transcriptTimeout,
                progress: { [weak self] message in
                    Task { @MainActor in
                        self?.log(message, type: .progress)
                    }
                }
            )

            // Step 4: Process
            updateStep(.processing)
            stopProgressTimer()

            let wordCount = transcript.split(separator: " ").count
            let charCount = transcript.count
            let estimatedReadingTime = AppFormatters.estimateReadingTime(wordCount: wordCount)

            log("✅ Transcrição recebida!", type: .success)
            log("📊 Palavras: \(wordCount.formattedNumber) | Caracteres: \(charCount.formattedNumber)", type: .info)
            log("📖 Tempo de leitura estimado: ~\(estimatedReadingTime) min", type: .info)
            log("⏱ Tempo total de download: \(AppFormatters.formatElapsed(elapsedTime))", type: .success)

            // Step 5: Save
            updateStep(.saving)
            log("💾 Salvando no banco de dados local...", type: .progress)

            let savedVideo = SavedVideo(
                youtubeURL: youtubeURL,
                videoID: videoID,
                title: videoInfo.title ?? "Vídeo sem título",
                channelName: videoInfo.channelName,
                transcript: transcript,
                language: selectedLanguage,
                duration: nil,
                thumbnailURL: videoInfo.thumbnail_url
            )

            // Set transcript source metadata
            savedVideo.transcriptSource = source == .whisperTranscription ? "whisper" : "youtube"

            // Log source information
            if source == .whisperTranscription {
                log("🤖 Transcrição gerada via Whisper (áudio)", type: .info)
            } else {
                log("📝 Legendas obtidas do YouTube", type: .info)
            }

            modelContext.insert(savedVideo)
            try modelContext.save()

            log("✅ Salvo com sucesso!", type: .success)

            // Track success
            endTimedEvent("transcript_fetch", event: .transcriptFetchCompleted, properties: [
                "video_id": videoID,
                "language": selectedLanguage,
                "word_count": String(wordCount),
                "char_count": String(charCount)
            ])

            // Complete
            updateStep(.complete)
            log("🎉 Tudo pronto! Abrindo transcrição...", type: .success)

            // Small delay to show completion
            try? await Task.sleep(nanoseconds: 500_000_000)

            isLoading = false
            stopProgressTimer()
            onSave(savedVideo)

        } catch {
            stopProgressTimer()
            updateStep(.failed)

            // Track failure
            trackEvent(.transcriptFetchFailed, properties: [
                "video_id": videoID,
                "error": error.localizedDescription
            ])

            if let mcpError = error as? MCPError, case .timeout = mcpError {
                log("⚠ Timeout: A operação excedeu o tempo limite", type: .error)
                log("💡 Dica: Verifique sua conexão e tente novamente", type: .info)
            } else {
                log("❌ Erro: \(error.localizedDescription)", type: .error)
            }

            isLoading = false
        }
    }
}

// MARK: - Transcript Step

enum TranscriptStep: String {
    case idle = "Ready"
    case validating = "Validating URL..."
    case fetchingInfo = "Fetching video info..."
    case fetchingTranscript = "Fetching transcript..."
    case processing = "Processing text..."
    case saving = "Saving to database..."
    case complete = "Complete!"
    case failed = "Failed"
}

// MARK: - Input Log Entry

struct InputLogEntry: Identifiable {
    let id = UUID()
    let timestamp: Date
    let message: String
    let type: InputLogType

    enum InputLogType: String {
        case info = "INFO"
        case success = "OK"
        case warning = "WARN"
        case error = "ERROR"
        case progress = "..."

        var icon: String {
            switch self {
            case .info: return "info.circle.fill"
            case .success: return "checkmark.circle.fill"
            case .warning: return "exclamationmark.triangle.fill"
            case .error: return "xmark.circle.fill"
            case .progress: return "arrow.trianglehead.2.clockwise.rotate.90"
            }
        }

        var color: Color {
            switch self {
            case .info: return .blue
            case .success: return .green
            case .warning: return .orange
            case .error: return .red
            case .progress: return .purple
            }
        }
    }

    var plainText: String {
        "[\(AppFormatters.formatTime(timestamp))] [\(type.rawValue)] \(message)"
    }
}


     1→import Foundation
     2→import SwiftUI
     3→import UniformTypeIdentifiers
     4→
     5→// MARK: - Sentence Info for Auto-Scroll
     6→
     7→struct SentenceInfo: Identifiable {
     8→    let id: Int
     9→    let text: String
    10→    let wordRange: Range<Int>  // Índices de palavras nesta sentença
    11→    let characterRange: NSRange
    12→}
    13→
    14→// MARK: - Transcript Reading ViewModel
    15→
    16→@MainActor
    17→final class TranscriptReadingViewModel: ObservableObject {
    18→
    19→    // MARK: - Published State
    20→
    21→    @Published var showingCopiedAlert = false
    22→    @Published var searchText = ""
    23→    @Published var isSearching = false
    24→    @Published var selectedRange: NSRange? = nil
    25→    @Published var showingNotesPanel = false
    26→
    27→    /// Stores the last valid selection to use when toolbar button is clicked
    28→    /// (clicking toolbar causes text view to lose focus and clear selection)
    29→    private var lastValidSelection: NSRange? = nil
    30→
    31→    // Advanced Search State
    32→    @Published var searchState = SearchState()
    33→    @Published var showAdvancedSearch = false
    34→
    35→    // AI Formatting State
    36→    @Published var isFormatting = false
    37→    @Published var formattingError: String?
    38→    @Published var showOriginalTranscript = false
    39→    @Published var showFormatSheet = false
    40→
    41→    // TTS State
    42→    let ttsService = TTSService.shared
    43→
    44→    // Auto-Scroll State
    45→    @Published var sentences: [SentenceInfo] = []
    46→    @Published var currentSentenceIndex: Int = 0
    47→    @AppStorage("tts_auto_scroll") var autoScrollEnabled = true
    48→
    49→    // MARK: - Video Reference
    50→
    51→    let video: SavedVideo
    52→
    53→    // MARK: - Search Cache
    54→
    55→    private var cachedMatchCount: Int?
    56→    private var cachedSearchText: String = ""
    57→    private var cachedWordCount: Int?
    58→    private var cachedTranscriptHash: Int?
    59→
    60→    // MARK: - Computed Properties
    61→
    62→    var wordCount: Int {
    63→        // Cache word count since transcript doesn't change
    64→        let hash = video.transcript.hashValue
    65→        if cachedTranscriptHash == hash, let cached = cachedWordCount {
    66→            return cached
    67→        }
    68→        cachedTranscriptHash = hash
    69→        cachedWordCount = video.transcript.split(separator: " ").count
    70→        return cachedWordCount!
    71→    }
    72→
    73→    var characterCount: Int {
    74→        video.transcript.count
    75→    }
    76→
    77→    var readingTime: Int {
    78→        AppFormatters.estimateReadingTime(wordCount: wordCount)
    79→    }
    80→
    81→    var matchCount: Int {
    82→        guard !searchText.isEmpty else { return 0 }
    83→
    84→        // Return cached value if search hasn't changed
    85→        if searchText == cachedSearchText, let cached = cachedMatchCount {
    86→            return cached
    87→        }
    88→
    89→        // Calculate and cache
    90→        cachedSearchText = searchText
    91→        let pattern = searchText.lowercased()
    92→        let text = video.transcript.lowercased()
    93→        var count = 0
    94→        var searchRange = text.startIndex..<text.endIndex
    95→        while let range = text.range(of: pattern, range: searchRange) {
    96→            count += 1
    97→            searchRange = range.upperBound..<text.endIndex
    98→        }
    99→        cachedMatchCount = count
   100→        return count
   101→    }
   102→
   103→    var hasAnnotations: Bool {
   104→        !video.annotations.isEmpty
   105→    }
   106→
   107→    var annotationCount: Int {
   108→        video.annotations.count
   109→    }
   110→
   111→    var hasNotes: Bool {
   112→        !video.notes.isEmpty
   113→    }
   114→
   115→    var hasSummary: Bool {
   116→        video.aiSummary != nil
   117→    }
   118→
   119→    /// Transcript to display (AI-formatted if available, otherwise TextFormatter)
   120→    var formattedTranscript: String {
   121→        // If user wants to see original, use TextFormatter only
   122→        if showOriginalTranscript {
   123→            return TextFormatter.shared.formatForReading(video.transcript)
   124→        }
   125→
   126→        // If AI-formatted version exists, use it
   127→        if let aiFormatted = video.formattedTranscript {
   128→            return aiFormatted
   129→        }
   130→
   131→        // Fallback to TextFormatter
   132→        return TextFormatter.shared.formatForReading(video.transcript)
   133→    }
   134→
   135→    var hasAIFormatting: Bool {
   136→        video.hasFormattedTranscript
   137→    }
   138→
   139→    var formattingModeDisplay: String? {
   140→        guard let mode = video.formattingMode else { return nil }
   141→        return FormattingMode(rawValue: mode)?.displayName
   142→    }
   143→
   144→    // MARK: - Initialization
   145→
   146→    init(video: SavedVideo) {
   147→        self.video = video
   148→    }
   149→
   150→    // MARK: - Search Actions
   151→
   152→    func toggleSearch() {
   153→        withAnimation {
   154→            isSearching.toggle()
   155→            showAdvancedSearch = isSearching
   156→            if !isSearching {
   157→                searchText = ""
   158→                searchState.reset()
   159→                searchState.options.query = ""
   160→            }
   161→        }
   162→    }
   163→
   164→    func clearSearch() {
   165→        searchText = ""
   166→        searchState.reset()
   167→        searchState.options.query = ""
   168→    }
   169→
   170→    // MARK: - Advanced Search
   171→
   172→    func performAdvancedSearch() {
   173→        guard !searchState.options.isEmpty else {
   174→            searchState.reset()
   175→            return
   176→        }
   177→
   178→        searchState.isSearching = true
   179→
   180→        Task {
   181→            let results = await SearchService.shared.search(
   182→                in: video.transcript,
   183→                options: searchState.options
   184→            )
   185→
   186→            await MainActor.run {
   187→                searchState.results = results
   188→                searchState.currentIndex = 0
   189→                searchState.isSearching = false
   190→
   191→                // Track search event
   192→                if results.count > 0 {
   193→                    trackEvent(.transcriptSearched, properties: [
   194→                        "query_length": String(searchState.options.query.count),
   195→                        "result_count": String(results.count),
   196→                        "use_regex": String(searchState.options.useRegex),
   197→                        "case_sensitive": String(searchState.options.caseSensitive)
   198→                    ])
   199→                }
   200→            }
   201→        }
   202→    }
   203→
   204→    func navigateToNextResult() {
   205→        searchState.nextResult()
   206→    }
   207→
   208→    func navigateToPreviousResult() {
   209→        searchState.previousResult()
   210→    }
   211→
   212→    func selectSearchResult(at index: Int) {
   213→        guard index >= 0, index < searchState.results.count else { return }
   214→        searchState.currentIndex = index
   215→    }
   216→
   217→    var currentSearchRange: NSRange? {
   218→        searchState.currentResult?.range
   219→    }
   220→
   221→    // MARK: - Notes Panel
   222→
   223→    func toggleNotesPanel() {
   224→        withAnimation {
   225→            showingNotesPanel.toggle()
   226→        }
   227→    }
   228→
   229→    func showNotesPanelForAI() {
   230→        showingNotesPanel = true
   231→    }
   232→
   233→    // MARK: - AI Formatting
   234→
   235→    func formatWithAI(mode: FormattingMode) {
   236→        logInfo("formatWithAI() started with mode: \(mode.rawValue)", category: .ai)
   237→        logDebug("Transcript length: \(video.transcript.count) chars", category: .ai)
   238→
   239→        isFormatting = true
   240→        formattingError = nil
   241→
   242→        Task {
   243→            do {
   244→                logInfo("Calling AIService.formatTranscript()...", category: .ai)
   245→
   246→                let formatted = try await AIService.shared.formatTranscript(
   247→                    video.transcript,
   248→                    mode: mode
   249→                )
   250→
   251→                logInfo("AI formatting succeeded. Result length: \(formatted.count) chars", category: .ai)
   252→
   253→                await MainActor.run {
   254→                    video.formattedTranscript = formatted
   255→                    video.formattedAt = Date()
   256→                    video.formattingMode = mode.rawValue
   257→                    isFormatting = false
   258→                    showOriginalTranscript = false
   259→                    logInfo("Formatted transcript saved to video model", category: .ai)
   260→                }
   261→
   262→                trackEvent(.aiSummaryCompleted, properties: [
   263→                    "type": "formatting",
   264→                    "mode": mode.rawValue,
   265→                    "video_id": video.videoID
   266→                ])
   267→            } catch {
   268→                logError("AI formatting failed: \(error)", category: .ai)
   269→                logError("Error type: \(type(of: error))", category: .ai)
   270→
   271→                await MainActor.run {
   272→                    formattingError = error.localizedDescription
   273→                    isFormatting = false
   274→                }
   275→            }
   276→        }
   277→    }
   278→
   279→    func toggleOriginalTranscript() {
   280→        withAnimation {
   281→            showOriginalTranscript.toggle()
   282→        }
   283→    }
   284→
   285→    func clearAIFormatting() {
   286→        video.clearFormatting()
   287→        showOriginalTranscript = false
   288→    }
   289→
   290→    // MARK: - TTS Actions
   291→
   292→    var isTTSPlaying: Bool {
   293→        ttsService.isPlaying || ttsService.isPaused
   294→    }
   295→
   296→    var currentTTSWordRange: NSRange? {
   297→        ttsService.currentWordRange
   298→    }
   299→
   300→    func startTTSPlayback() {
   301→        let textToSpeak = formattedTranscript
   302→
   303→        // Passar idioma do vídeo para seleção correta de voz
   304→        ttsService.speak(text: textToSpeak, language: video.language)
   305→
   306→        trackEvent(.ttsStarted, properties: [
   307→            "video_id": video.videoID,
   308→            "word_count": String(wordCount),
   309→            "language": video.language
   310→        ])
   311→    }
   312→
   313→    func stopTTSPlayback() {
   314→        ttsService.stop()
   315→    }
   316→
   317→    // MARK: - Auto-Scroll / Sentence Tracking
   318→
   319→    /// Parse transcript into sentences for auto-scroll feature
   320→    func parseSentences() {
   321→        let text = formattedTranscript
   322→        guard !text.isEmpty else {
   323→            sentences = []
   324→            return
   325→        }
   326→
   327→        var result: [SentenceInfo] = []
   328→        var wordIndex = 0
   329→        var sentenceId = 0
   330→
   331→        // Split by sentence-ending punctuation
   332→        let sentencePattern = "[^.!?]+[.!?]*"
   333→        guard let regex = try? NSRegularExpression(pattern: sentencePattern, options: []) else {
   334→            // Fallback: treat entire text as one sentence
   335→            let wordCount = text.split(separator: " ").count
   336→            sentences = [SentenceInfo(
   337→                id: 0,
   338→                text: text,
   339→                wordRange: 0..<wordCount,
   340→                characterRange: NSRange(location: 0, length: text.count)
   341→            )]
   342→            return
   343→        }
   344→
   345→        let nsText = text as NSString
   346→        let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
   347→
   348→        for match in matches {
   349→            let sentenceText = nsText.substring(with: match.range).trimmingCharacters(in: .whitespaces)
   350→            guard !sentenceText.isEmpty else { continue }
   351→
   352→            // Count words in this sentence
   353→            let words = sentenceText.split(separator: " ")
   354→            let wordCountInSentence = words.count
   355→
   356→            guard wordCountInSentence > 0 else { continue }
   357→
   358→            let sentenceInfo = SentenceInfo(
   359→                id: sentenceId,
   360→                text: sentenceText,
   361→                wordRange: wordIndex..<(wordIndex + wordCountInSentence),
   362→                characterRange: match.range
   363→            )
   364→
   365→            result.append(sentenceInfo)
   366→            wordIndex += wordCountInSentence
   367→            sentenceId += 1
   368→        }
   369→
   370→        sentences = result
   371→        logDebug("Parsed \(result.count) sentences for auto-scroll", category: .user)
   372→    }
   373→
   374→    /// Update current sentence based on word index from TTS
   375→    func updateCurrentSentence(wordIndex: Int) {
   376→        guard autoScrollEnabled else { return }
   377→
   378→        // Find sentence containing this word index
   379→        if let idx = sentences.firstIndex(where: { $0.wordRange.contains(wordIndex) }) {
   380→            if idx != currentSentenceIndex {
   381→                currentSentenceIndex = idx
   382→            }
   383→        }
   384→    }
   385→
   386→    /// Reset sentence tracking (call when TTS stops)
   387→    func resetSentenceTracking() {
   388→        currentSentenceIndex = 0
   389→    }
   390→
   391→    // MARK: - Selection Actions
   392→
   393→    func updateSelection(_ range: NSRange?) {
   394→        // Dispatch to next run loop to avoid "Publishing changes from within view updates" warning
   395→        Task { @MainActor in
   396→            // Deduplicate: skip if selection hasn't actually changed
   397→            guard range != self.selectedRange else { return }
   398→
   399→            if let range = range, range.length > 0 {
   400→                logDebug("Selection updated: location=\(range.location), length=\(range.length)", category: .user)
   401→                // Store as last valid selection for toolbar button clicks
   402→                self.lastValidSelection = range
   403→            }
   404→            // Note: removed "Selection cleared" log to reduce noise
   405→
   406→            withAnimation(.easeInOut(duration: 0.2)) {
   407→                self.selectedRange = range
   408→            }
   409→        }
   410→    }
   411→
   412→    func clearSelection() {
   413→        logDebug("clearSelection() called", category: .user)
   414→        selectedRange = nil
   415→        lastValidSelection = nil
   416→    }
   417→
   418→    // MARK: - Annotation Actions
   419→
   420→    func addHighlight(color: String) {
   421→        logInfo("addHighlight() called with color: \(color)", category: .user)
   422→        logDebug("selectedRange=\(String(describing: selectedRange)), lastValidSelection=\(String(describing: lastValidSelection))", category: .user)
   423→
   424→        // Use selectedRange if available, otherwise fall back to lastValidSelection
   425→        // (toolbar click causes text view to lose focus and clear selection)
   426→        let range = selectedRange ?? lastValidSelection
   427→
   428→        guard let range = range, range.length > 0 else {
   429→            logWarning("addHighlight() failed: no selection available", category: .user)
   430→            return
   431→        }
   432→
   433→        logInfo("Adding highlight: location=\(range.location), length=\(range.length), color=\(color)", category: .user)
   434→
   435→        let annotation = TextAnnotation(
   436→            startIndex: range.location,
   437→            endIndex: range.location + range.length,
   438→            type: .highlight,
   439→            color: color
   440→        )
   441→
   442→        video.addAnnotation(annotation)
   443→        logInfo("Annotation added. Total annotations: \(video.annotations.count)", category: .user)
   444→
   445→        // Clear both selections
   446→        selectedRange = nil
   447→        lastValidSelection = nil
   448→
   449→        // Track event
   450→        trackEvent(.annotationAdded, properties: [
   451→            "type": "highlight",
   452→            "color": color,
   453→            "video_id": video.videoID
   454→        ])
   455→    }
   456→
   457→    func addUnderline() {
   458→        logInfo("addUnderline() called", category: .user)
   459→        logDebug("selectedRange=\(String(describing: selectedRange)), lastValidSelection=\(String(describing: lastValidSelection))", category: .user)
   460→
   461→        // Use selectedRange if available, otherwise fall back to lastValidSelection
   462→        let range = selectedRange ?? lastValidSelection
   463→
   464→        guard let range = range, range.length > 0 else {
   465→            logWarning("addUnderline() failed: no selection available", category: .user)
   466→            return
   467→        }
   468→
   469→        logInfo("Adding underline: location=\(range.location), length=\(range.length)", category: .user)
   470→
   471→        let annotation = TextAnnotation(
   472→            startIndex: range.location,
   473→            endIndex: range.location + range.length,
   474→            type: .underline
   475→        )
   476→
   477→        video.addAnnotation(annotation)
   478→        logInfo("Annotation added. Total annotations: \(video.annotations.count)", category: .user)
   479→
   480→        // Clear both selections
   481→        selectedRange = nil
   482→        lastValidSelection = nil
   483→
   484→        // Track event
   485→        trackEvent(.annotationAdded, properties: [
   486→            "type": "underline",
   487→            "video_id": video.videoID
   488→        ])
   489→    }
   490→
   491→    func clearAllAnnotations() {
   492→        let count = video.annotations.count
   493→        logInfo("clearAllAnnotations() called. Clearing \(count) annotations", category: .user)
   494→        video.clearAnnotations()
   495→
   496→        // Track event
   497→        trackEvent(.annotationRemoved, properties: [
   498→            "video_id": video.videoID,
   499→            "cleared_all": "true"
   500→        ])
   501→    }
   502→
   503→    // MARK: - Copy/Export Actions
   504→
   505→    func copyTranscript() {
   506→        NSPasteboard.general.clearContents()
   507→        NSPasteboard.general.setString(video.transcript, forType: .string)
   508→
   509→        withAnimation {
   510→            showingCopiedAlert = true
   511→        }
   512→
   513→        DispatchQueue.main.asyncAfter(deadline: .now() + AppConfig.UI.toastDuration) { [weak self] in
   514→            withAnimation {
   515→                self?.showingCopiedAlert = false
   516→            }
   517→        }
   518→
   519→        // Track event
   520→        trackEvent(.transcriptCopied, properties: [
   521→            "video_id": video.videoID,
   522→            "word_count": String(wordCount)
   523→        ])
   524→    }
   525→
   526→    func exportTranscript() {
   527→        let panel = NSSavePanel()
   528→        panel.allowedContentTypes = [.plainText]
   529→        panel.nameFieldStringValue = "\(video.title).txt"
   530→        panel.title = "Export Transcript"
   531→        panel.message = "Choose where to save the transcript"
   532→
   533→        panel.begin { [weak self] response in
   534→            guard let self = self else { return }
   535→
   536→            if response == .OK, let url = panel.url {
   537→                let content = """
   538→                # \(self.video.title)
   539→                Channel: \(self.video.channelName)
   540→                URL: \(self.video.youtubeURL)
   541→                Language: \(self.video.language.uppercased())
   542→                Exported: \(Date().formatted())
   543→
   544→                ---
   545→
   546→                \(self.video.transcript)
   547→                """
   548→
   549→                do {
   550→                    try content.write(to: url, atomically: true, encoding: .utf8)
   551→
   552→                    // Track event
   553→                    trackEvent(.transcriptExported, properties: [
   554→                        "video_id": self.video.videoID,
   555→                        "format": "txt"
   556→                    ])
   557→                } catch {
   558→                    logError("Export failed: \(error.localizedDescription)", category: .user)
   559→                }
   560→            }
   561→        }
   562→    }
   563→
   564→    func exportMarkdown() {
   565→        let panel = NSSavePanel()
   566→        panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
   567→        panel.nameFieldStringValue = "\(video.title).md"
   568→        panel.title = "Export as Markdown"
   569→        panel.message = "Choose where to save the Markdown file"
   570→
   571→        panel.begin { [weak self] response in
   572→            guard let self = self else { return }
   573→
   574→            if response == .OK, let url = panel.url {
   575→                var content = """
   576→                # \(self.video.title)
   577→
   578→                > **Channel:** \(self.video.channelName)
   579→                > **URL:** \(self.video.youtubeURL)
   580→                > **Language:** \(self.video.language.uppercased())
   581→                > **Saved:** \(self.video.savedAt.formatted())
   582→                > **Words:** \(self.wordCount) | **Reading time:** ~\(self.readingTime) min
   583→
   584→                ---
   585→
   586→                """
   587→
   588→                // Add AI Summary if exists
   589→                if let summary = self.video.aiSummary, !summary.isEmpty {
   590→                    content += """
   591→
   592→                    ## AI Summary
   593→
   594→                    \(summary)
   595→
   596→                    ---
   597→
   598→                    """
   599→                }
   600→
   601→                // Add transcript with highlights
   602→                content += """
   603→
   604→                ## Transcript
   605→
   606→                \(self.transcriptWithMarkdownHighlights())
   607→
   608→                ---
   609→
   610→                """
   611→
   612→                // Add personal notes if exists
   613→                if !self.video.notes.isEmpty {
   614→                    content += """
   615→
   616→                    ## Personal Notes
   617→
   618→                    \(self.video.notes)
   619→
   620→                    ---
   621→
   622→                    """
   623→                }
   624→
   625→                content += "*Exported from YouTube Transcripts on \(Date().formatted())*"
   626→
   627→                do {
   628→                    try content.write(to: url, atomically: true, encoding: .utf8)
   629→
   630→                    // Track event
   631→                    trackEvent(.transcriptExported, properties: [
   632→                        "video_id": self.video.videoID,
   633→                        "format": "markdown"
   634→                    ])
   635→                } catch {
   636→                    logError("Export failed: \(error.localizedDescription)", category: .user)
   637→                }
   638→            }
   639→        }
   640→    }
   641→
   642→    // MARK: - Open in Browser (HTML Export)
   643→
   644→    func openInBrowser() {
   645→        let html = generateHTML()
   646→        let tempURL = FileManager.default.temporaryDirectory
   647→            .appendingPathComponent("\(video.videoID)_transcript.html")
   648→
   649→        do {
   650→            try html.write(to: tempURL, atomically: true, encoding: .utf8)
   651→            NSWorkspace.shared.open(tempURL)
   652→
   653→            // Track event
   654→            trackEvent(.transcriptExported, properties: [
   655→                "video_id": video.videoID,
   656→                "format": "html_browser"
   657→            ])
   658→        } catch {
   659→            logError("Failed to open in browser: \(error.localizedDescription)", category: .user)
   660→        }
   661→    }
   662→
   663→    // MARK: - HTML Generation
   664→
   665→    private func generateHTML() -> String {
   666→        let summarySection: String
   667→        if let summary = video.aiSummary, !summary.isEmpty {
   668→            summarySection = """
   669→            <section class="summary">
   670→                <h2>AI Summary</h2>
   671→                <p>\(escapeHTML(summary))</p>
   672→            </section>
   673→            """
   674→        } else {
   675→            summarySection = ""
   676→        }
   677→
   678→        let notesSection: String
   679→        if !video.notes.isEmpty {
   680→            notesSection = """
   681→            <section class="notes">
   682→                <h3>Personal Notes</h3>
   683→                <p>\(escapeHTML(video.notes))</p>
   684→            </section>
   685→            """
   686→        } else {
   687→            notesSection = ""
   688→        }
   689→
   690→        return """
   691→        <!DOCTYPE html>
   692→        <html lang="pt-BR">
   693→        <head>
   694→            <meta charset="UTF-8">
   695→            <meta name="viewport" content="width=device-width, initial-scale=1.0">
   696→            <title>\(escapeHTML(video.title)) - Transcrição</title>
   697→            <style>
   698→                :root {
   699→                    --bg: #ffffff;
   700→                    --text: #1a1a1a;
   701→                    --text-secondary: #666666;
   702→                    --accent: #e74c3c;
   703→                    --border: #e0e0e0;
   704→                    --summary-bg: #f5f5f5;
   705→                }
   706→                @media (prefers-color-scheme: dark) {
   707→                    :root {
   708→                        --bg: #1a1a1a;
   709→                        --text: #f0f0f0;
   710→                        --text-secondary: #999999;
   711→                        --accent: #ff6b6b;
   712→                        --border: #333333;
   713→                        --summary-bg: #2a2a2a;
   714→                    }
   715→                }
   716→                * { box-sizing: border-box; margin: 0; padding: 0; }
   717→                body {
   718→                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
   719→                    max-width: 800px;
   720→                    margin: 0 auto;
   721→                    padding: 2rem;
   722→                    background: var(--bg);
   723→                    color: var(--text);
   724→                    line-height: 1.8;
   725→                }
   726→                header {
   727→                    border-bottom: 3px solid var(--accent);
   728→                    padding-bottom: 1.5rem;
   729→                    margin-bottom: 2rem;
   730→                }
   731→                h1 {
   732→                    font-size: 1.8rem;
   733→                    font-weight: 600;
   734→                    margin-bottom: 0.75rem;
   735→                    line-height: 1.3;
   736→                }
   737→                .meta {
   738→                    color: var(--text-secondary);
   739→                    font-size: 0.9rem;
   740→                    line-height: 1.6;
   741→                }
   742→                .meta a {
   743→                    color: var(--accent);
   744→                    text-decoration: none;
   745→                    font-weight: 500;
   746→                }
   747→                .meta a:hover { text-decoration: underline; }
   748→                .summary {
   749→                    background: var(--summary-bg);
   750→                    padding: 1.25rem;
   751→                    border-radius: 8px;
   752→                    margin-bottom: 2rem;
   753→                }
   754→                .summary h2 {
   755→                    font-size: 1rem;
   756→                    color: var(--accent);
   757→                    margin-bottom: 0.75rem;
   758→                    text-transform: uppercase;
   759→                    letter-spacing: 0.5px;
   760→                }
   761→                .summary p { font-size: 0.95rem; }
   762→                .transcript {
   763→                    font-size: 1.1rem;
   764→                    text-align: justify;
   765→                }
   766→                .highlight-yellow { background: rgba(255, 235, 59, 0.4); padding: 1px 3px; border-radius: 2px; }
   767→                .highlight-green { background: rgba(76, 175, 80, 0.35); padding: 1px 3px; border-radius: 2px; }
   768→                .highlight-blue { background: rgba(33, 150, 243, 0.35); padding: 1px 3px; border-radius: 2px; }
   769→                .highlight-pink { background: rgba(233, 30, 99, 0.35); padding: 1px 3px; border-radius: 2px; }
   770→                .highlight-orange { background: rgba(255, 152, 0, 0.4); padding: 1px 3px; border-radius: 2px; }
   771→                .highlight-purple { background: rgba(156, 39, 176, 0.35); padding: 1px 3px; border-radius: 2px; }
   772→                .underline { text-decoration: underline; text-decoration-color: var(--accent); }
   773→                .notes {
   774→                    border-left: 4px solid var(--accent);
   775→                    padding: 1rem 1.25rem;
   776→                    margin: 2rem 0;
   777→                    background: var(--summary-bg);
   778→                    border-radius: 0 8px 8px 0;
   779→                }
   780→                .notes h3 {
   781→                    font-size: 0.9rem;
   782→                    color: var(--accent);
   783→                    margin-bottom: 0.5rem;
   784→                    text-transform: uppercase;
   785→                }
   786→                .notes p { font-style: italic; font-size: 0.95rem; }
   787→                footer {
   788→                    margin-top: 3rem;
   789→                    padding-top: 1.5rem;
   790→                    border-top: 1px solid var(--border);
   791→                    font-size: 0.8rem;
   792→                    color: var(--text-secondary);
   793→                    text-align: center;
   794→                }
   795→            </style>
   796→        </head>
   797→        <body>
   798→            <header>
   799→                <h1>\(escapeHTML(video.title))</h1>
   800→                <p class="meta">
   801→                    <strong>\(escapeHTML(video.channelName))</strong> ·
   802→                    \(video.language.uppercased()) ·
   803→                    \(wordCount) palavras ·
   804→                    ~\(readingTime) min de leitura
   805→                    <br>
   806→                    <a href="\(video.youtubeURL)" target="_blank">▶ Assistir no YouTube</a>
   807→                </p>
   808→            </header>
   809→
   810→            \(summarySection)
   811→
   812→            <main class="transcript">
   813→                \(transcriptWithHTMLHighlights())
   814→            </main>
   815→
   816→            \(notesSection)
   817→
   818→            <footer>
   819→                Exportado em \(Date().formatted()) via YouTube Transcripts
   820→            </footer>
   821→        </body>
   822→        </html>
   823→        """
   824→    }
   825→
   826→    // MARK: - Export Helpers
   827→
   828→    private func transcriptWithHTMLHighlights() -> String {
   829→        var result = escapeHTML(video.transcript)
   830→
   831→        // Sort annotations by start index (descending) to apply from end to beginning
   832→        let sortedAnnotations = video.annotations.sorted { $0.startIndex > $1.startIndex }
   833→
   834→        for annotation in sortedAnnotations {
   835→            guard annotation.startIndex >= 0,
   836→                  annotation.endIndex <= video.transcript.count,
   837→                  annotation.startIndex < annotation.endIndex else { continue }
   838→
   839→            let startIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.startIndex)
   840→            let endIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.endIndex)
   841→            let originalText = String(video.transcript[startIdx..<endIdx])
   842→            let escapedText = escapeHTML(originalText)
   843→
   844→            let cssClass: String
   845→            switch annotation.type {
   846→            case .highlight:
   847→                cssClass = "highlight-\(annotation.color)"
   848→            case .underline:
   849→                cssClass = "underline"
   850→            }
   851→
   852→            let replacement = "<span class=\"\(cssClass)\">\(escapedText)</span>"
   853→
   854→            // Find and replace in the result string
   855→            if let range = result.range(of: escapedText) {
   856→                result = result.replacingCharacters(in: range, with: replacement)
   857→            }
   858→        }
   859→
   860→        return result
   861→    }
   862→
   863→    private func transcriptWithMarkdownHighlights() -> String {
   864→        var result = video.transcript
   865→
   866→        // Sort annotations by start index (descending) to apply from end to beginning
   867→        let sortedAnnotations = video.annotations.sorted { $0.startIndex > $1.startIndex }
   868→
   869→        for annotation in sortedAnnotations {
   870→            guard annotation.startIndex >= 0,
   871→                  annotation.endIndex <= video.transcript.count,
   872→                  annotation.startIndex < annotation.endIndex else { continue }
   873→
   874→            let startIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.startIndex)
   875→            let endIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.endIndex)
   876→            let range = startIdx..<endIdx
   877→            let text = String(video.transcript[range])
   878→
   879→            let replacement: String
   880→            switch annotation.type {
   881→            case .highlight:
   882→                // Use ==text== syntax for highlights (common Markdown extension)
   883→                replacement = "==\(text)== <!-- \(annotation.color) -->"
   884→            case .underline:
   885→                // Use *text* for underline (italic as fallback)
   886→                replacement = "_\(text)_"
   887→            }
   888→
   889→            result = result.replacingCharacters(in: range, with: replacement)
   890→        }
   891→
   892→        return result
   893→    }
   894→
   895→    private func escapeHTML(_ text: String) -> String {
   896→        text
   897→            .replacingOccurrences(of: "&", with: "&")
   898→            .replacingOccurrences(of: "<", with: "<")
   899→            .replacingOccurrences(of: ">", with: ">")
   900→            .replacingOccurrences(of: "\"", with: """)
   901→            .replacingOccurrences(of: "'", with: "'")
   902→            .replacingOccurrences(of: "\n", with: "<br>")
   903→    }
   904→
   905→    // MARK: - YouTube
   906→
   907→    func openYouTube() {
   908→        if let url = URL(string: video.youtubeURL) {
   909→            NSWorkspace.shared.open(url)
   910→        }
   911→    }
   912→
   913→    // MARK: - Font Size
   914→
   915→    func increaseFontSize(_ currentSize: inout Double) {
   916→        if currentSize < AppConfig.UI.maxFontSize {
   917→            currentSize += 1
   918→            trackEvent(.fontSizeChanged, properties: ["direction": "increase"])
   919→        }
   920→    }
   921→
   922→    func decreaseFontSize(_ currentSize: inout Double) {
   923→        if currentSize > AppConfig.UI.minFontSize {
   924→            currentSize -= 1
   925→            trackEvent(.fontSizeChanged, properties: ["direction": "decrease"])
   926→        }
   927→    }
   928→
   929→    func resetFontSize(_ currentSize: inout Double) {
   930→        currentSize = AppConfig.UI.defaultFontSize
   931→        trackEvent(.fontSizeChanged, properties: ["direction": "reset"])
   932→    }
   933→
   934→    // MARK: - Lifecycle
   935→
   936→    func onAppear() {
   937→        trackEvent(.transcriptViewed, properties: [
   938→            "video_id": video.videoID,
   939→            "language": video.language,
   940→            "word_count": String(wordCount)
   941→        ])
   942→    }
   943→}
   944→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import SwiftUI
import UniformTypeIdentifiers

// MARK: - Sentence Info for Auto-Scroll

struct SentenceInfo: Identifiable {
    let id: Int
    let text: String
    let wordRange: Range<Int>  // Índices de palavras nesta sentença
    let characterRange: NSRange
}

// MARK: - Transcript Reading ViewModel

@MainActor
final class TranscriptReadingViewModel: ObservableObject {

    // MARK: - Published State

    @Published var showingCopiedAlert = false
    @Published var searchText = ""
    @Published var isSearching = false
    @Published var selectedRange: NSRange? = nil
    @Published var showingNotesPanel = false

    /// Stores the last valid selection to use when toolbar button is clicked
    /// (clicking toolbar causes text view to lose focus and clear selection)
    private var lastValidSelection: NSRange? = nil

    // Advanced Search State
    @Published var searchState = SearchState()
    @Published var showAdvancedSearch = false

    // AI Formatting State
    @Published var isFormatting = false
    @Published var formattingError: String?
    @Published var showOriginalTranscript = false
    @Published var showFormatSheet = false

    // TTS State
    let ttsService = TTSService.shared

    // Auto-Scroll State
    @Published var sentences: [SentenceInfo] = []
    @Published var currentSentenceIndex: Int = 0
    @AppStorage("tts_auto_scroll") var autoScrollEnabled = true

    // MARK: - Video Reference

    let video: SavedVideo

    // MARK: - Search Cache

    private var cachedMatchCount: Int?
    private var cachedSearchText: String = ""
    private var cachedWordCount: Int?
    private var cachedTranscriptHash: Int?

    // MARK: - Computed Properties

    var wordCount: Int {
        // Cache word count since transcript doesn't change
        let hash = video.transcript.hashValue
        if cachedTranscriptHash == hash, let cached = cachedWordCount {
            return cached
        }
        cachedTranscriptHash = hash
        cachedWordCount = video.transcript.split(separator: " ").count
        return cachedWordCount!
    }

    var characterCount: Int {
        video.transcript.count
    }

    var readingTime: Int {
        AppFormatters.estimateReadingTime(wordCount: wordCount)
    }

    var matchCount: Int {
        guard !searchText.isEmpty else { return 0 }

        // Return cached value if search hasn't changed
        if searchText == cachedSearchText, let cached = cachedMatchCount {
            return cached
        }

        // Calculate and cache
        cachedSearchText = searchText
        let pattern = searchText.lowercased()
        let text = video.transcript.lowercased()
        var count = 0
        var searchRange = text.startIndex..<text.endIndex
        while let range = text.range(of: pattern, range: searchRange) {
            count += 1
            searchRange = range.upperBound..<text.endIndex
        }
        cachedMatchCount = count
        return count
    }

    var hasAnnotations: Bool {
        !video.annotations.isEmpty
    }

    var annotationCount: Int {
        video.annotations.count
    }

    var hasNotes: Bool {
        !video.notes.isEmpty
    }

    var hasSummary: Bool {
        video.aiSummary != nil
    }

    /// Transcript to display (AI-formatted if available, otherwise TextFormatter)
    var formattedTranscript: String {
        // If user wants to see original, use TextFormatter only
        if showOriginalTranscript {
            return TextFormatter.shared.formatForReading(video.transcript)
        }

        // If AI-formatted version exists, use it
        if let aiFormatted = video.formattedTranscript {
            return aiFormatted
        }

        // Fallback to TextFormatter
        return TextFormatter.shared.formatForReading(video.transcript)
    }

    var hasAIFormatting: Bool {
        video.hasFormattedTranscript
    }

    var formattingModeDisplay: String? {
        guard let mode = video.formattingMode else { return nil }
        return FormattingMode(rawValue: mode)?.displayName
    }

    // MARK: - Initialization

    init(video: SavedVideo) {
        self.video = video
    }

    // MARK: - Search Actions

    func toggleSearch() {
        withAnimation {
            isSearching.toggle()
            showAdvancedSearch = isSearching
            if !isSearching {
                searchText = ""
                searchState.reset()
                searchState.options.query = ""
            }
        }
    }

    func clearSearch() {
        searchText = ""
        searchState.reset()
        searchState.options.query = ""
    }

    // MARK: - Advanced Search

    func performAdvancedSearch() {
        guard !searchState.options.isEmpty else {
            searchState.reset()
            return
        }

        searchState.isSearching = true

        Task {
            let results = await SearchService.shared.search(
                in: video.transcript,
                options: searchState.options
            )

            await MainActor.run {
                searchState.results = results
                searchState.currentIndex = 0
                searchState.isSearching = false

                // Track search event
                if results.count > 0 {
                    trackEvent(.transcriptSearched, properties: [
                        "query_length": String(searchState.options.query.count),
                        "result_count": String(results.count),
                        "use_regex": String(searchState.options.useRegex),
                        "case_sensitive": String(searchState.options.caseSensitive)
                    ])
                }
            }
        }
    }

    func navigateToNextResult() {
        searchState.nextResult()
    }

    func navigateToPreviousResult() {
        searchState.previousResult()
    }

    func selectSearchResult(at index: Int) {
        guard index >= 0, index < searchState.results.count else { return }
        searchState.currentIndex = index
    }

    var currentSearchRange: NSRange? {
        searchState.currentResult?.range
    }

    // MARK: - Notes Panel

    func toggleNotesPanel() {
        withAnimation {
            showingNotesPanel.toggle()
        }
    }

    func showNotesPanelForAI() {
        showingNotesPanel = true
    }

    // MARK: - AI Formatting

    func formatWithAI(mode: FormattingMode) {
        logInfo("formatWithAI() started with mode: \(mode.rawValue)", category: .ai)
        logDebug("Transcript length: \(video.transcript.count) chars", category: .ai)

        isFormatting = true
        formattingError = nil

        Task {
            do {
                logInfo("Calling AIService.formatTranscript()...", category: .ai)

                let formatted = try await AIService.shared.formatTranscript(
                    video.transcript,
                    mode: mode
                )

                logInfo("AI formatting succeeded. Result length: \(formatted.count) chars", category: .ai)

                await MainActor.run {
                    video.formattedTranscript = formatted
                    video.formattedAt = Date()
                    video.formattingMode = mode.rawValue
                    isFormatting = false
                    showOriginalTranscript = false
                    logInfo("Formatted transcript saved to video model", category: .ai)
                }

                trackEvent(.aiSummaryCompleted, properties: [
                    "type": "formatting",
                    "mode": mode.rawValue,
                    "video_id": video.videoID
                ])
            } catch {
                logError("AI formatting failed: \(error)", category: .ai)
                logError("Error type: \(type(of: error))", category: .ai)

                await MainActor.run {
                    formattingError = error.localizedDescription
                    isFormatting = false
                }
            }
        }
    }

    func toggleOriginalTranscript() {
        withAnimation {
            showOriginalTranscript.toggle()
        }
    }

    func clearAIFormatting() {
        video.clearFormatting()
        showOriginalTranscript = false
    }

    // MARK: - TTS Actions

    var isTTSPlaying: Bool {
        ttsService.isPlaying || ttsService.isPaused
    }

    var currentTTSWordRange: NSRange? {
        ttsService.currentWordRange
    }

    func startTTSPlayback() {
        let textToSpeak = formattedTranscript

        // Passar idioma do vídeo para seleção correta de voz
        ttsService.speak(text: textToSpeak, language: video.language)

        trackEvent(.ttsStarted, properties: [
            "video_id": video.videoID,
            "word_count": String(wordCount),
            "language": video.language
        ])
    }

    func stopTTSPlayback() {
        ttsService.stop()
    }

    // MARK: - Auto-Scroll / Sentence Tracking

    /// Parse transcript into sentences for auto-scroll feature
    func parseSentences() {
        let text = formattedTranscript
        guard !text.isEmpty else {
            sentences = []
            return
        }

        var result: [SentenceInfo] = []
        var wordIndex = 0
        var sentenceId = 0

        // Split by sentence-ending punctuation
        let sentencePattern = "[^.!?]+[.!?]*"
        guard let regex = try? NSRegularExpression(pattern: sentencePattern, options: []) else {
            // Fallback: treat entire text as one sentence
            let wordCount = text.split(separator: " ").count
            sentences = [SentenceInfo(
                id: 0,
                text: text,
                wordRange: 0..<wordCount,
                characterRange: NSRange(location: 0, length: text.count)
            )]
            return
        }

        let nsText = text as NSString
        let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))

        for match in matches {
            let sentenceText = nsText.substring(with: match.range).trimmingCharacters(in: .whitespaces)
            guard !sentenceText.isEmpty else { continue }

            // Count words in this sentence
            let words = sentenceText.split(separator: " ")
            let wordCountInSentence = words.count

            guard wordCountInSentence > 0 else { continue }

            let sentenceInfo = SentenceInfo(
                id: sentenceId,
                text: sentenceText,
                wordRange: wordIndex..<(wordIndex + wordCountInSentence),
                characterRange: match.range
            )

            result.append(sentenceInfo)
            wordIndex += wordCountInSentence
            sentenceId += 1
        }

        sentences = result
        logDebug("Parsed \(result.count) sentences for auto-scroll", category: .user)
    }

    /// Update current sentence based on word index from TTS
    func updateCurrentSentence(wordIndex: Int) {
        guard autoScrollEnabled else { return }

        // Find sentence containing this word index
        if let idx = sentences.firstIndex(where: { $0.wordRange.contains(wordIndex) }) {
            if idx != currentSentenceIndex {
                currentSentenceIndex = idx
            }
        }
    }

    /// Reset sentence tracking (call when TTS stops)
    func resetSentenceTracking() {
        currentSentenceIndex = 0
    }

    // MARK: - Selection Actions

    func updateSelection(_ range: NSRange?) {
        // Dispatch to next run loop to avoid "Publishing changes from within view updates" warning
        Task { @MainActor in
            // Deduplicate: skip if selection hasn't actually changed
            guard range != self.selectedRange else { return }

            if let range = range, range.length > 0 {
                logDebug("Selection updated: location=\(range.location), length=\(range.length)", category: .user)
                // Store as last valid selection for toolbar button clicks
                self.lastValidSelection = range
            }
            // Note: removed "Selection cleared" log to reduce noise

            withAnimation(.easeInOut(duration: 0.2)) {
                self.selectedRange = range
            }
        }
    }

    func clearSelection() {
        logDebug("clearSelection() called", category: .user)
        selectedRange = nil
        lastValidSelection = nil
    }

    // MARK: - Annotation Actions

    func addHighlight(color: String) {
        logInfo("addHighlight() called with color: \(color)", category: .user)
        logDebug("selectedRange=\(String(describing: selectedRange)), lastValidSelection=\(String(describing: lastValidSelection))", category: .user)

        // Use selectedRange if available, otherwise fall back to lastValidSelection
        // (toolbar click causes text view to lose focus and clear selection)
        let range = selectedRange ?? lastValidSelection

        guard let range = range, range.length > 0 else {
            logWarning("addHighlight() failed: no selection available", category: .user)
            return
        }

        logInfo("Adding highlight: location=\(range.location), length=\(range.length), color=\(color)", category: .user)

        let annotation = TextAnnotation(
            startIndex: range.location,
            endIndex: range.location + range.length,
            type: .highlight,
            color: color
        )

        video.addAnnotation(annotation)
        logInfo("Annotation added. Total annotations: \(video.annotations.count)", category: .user)

        // Clear both selections
        selectedRange = nil
        lastValidSelection = nil

        // Track event
        trackEvent(.annotationAdded, properties: [
            "type": "highlight",
            "color": color,
            "video_id": video.videoID
        ])
    }

    func addUnderline() {
        logInfo("addUnderline() called", category: .user)
        logDebug("selectedRange=\(String(describing: selectedRange)), lastValidSelection=\(String(describing: lastValidSelection))", category: .user)

        // Use selectedRange if available, otherwise fall back to lastValidSelection
        let range = selectedRange ?? lastValidSelection

        guard let range = range, range.length > 0 else {
            logWarning("addUnderline() failed: no selection available", category: .user)
            return
        }

        logInfo("Adding underline: location=\(range.location), length=\(range.length)", category: .user)

        let annotation = TextAnnotation(
            startIndex: range.location,
            endIndex: range.location + range.length,
            type: .underline
        )

        video.addAnnotation(annotation)
        logInfo("Annotation added. Total annotations: \(video.annotations.count)", category: .user)

        // Clear both selections
        selectedRange = nil
        lastValidSelection = nil

        // Track event
        trackEvent(.annotationAdded, properties: [
            "type": "underline",
            "video_id": video.videoID
        ])
    }

    func clearAllAnnotations() {
        let count = video.annotations.count
        logInfo("clearAllAnnotations() called. Clearing \(count) annotations", category: .user)
        video.clearAnnotations()

        // Track event
        trackEvent(.annotationRemoved, properties: [
            "video_id": video.videoID,
            "cleared_all": "true"
        ])
    }

    // MARK: - Copy/Export Actions

    func copyTranscript() {
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(video.transcript, forType: .string)

        withAnimation {
            showingCopiedAlert = true
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + AppConfig.UI.toastDuration) { [weak self] in
            withAnimation {
                self?.showingCopiedAlert = false
            }
        }

        // Track event
        trackEvent(.transcriptCopied, properties: [
            "video_id": video.videoID,
            "word_count": String(wordCount)
        ])
    }

    func exportTranscript() {
        let panel = NSSavePanel()
        panel.allowedContentTypes = [.plainText]
        panel.nameFieldStringValue = "\(video.title).txt"
        panel.title = "Export Transcript"
        panel.message = "Choose where to save the transcript"

        panel.begin { [weak self] response in
            guard let self = self else { return }

            if response == .OK, let url = panel.url {
                let content = """
                # \(self.video.title)
                Channel: \(self.video.channelName)
                URL: \(self.video.youtubeURL)
                Language: \(self.video.language.uppercased())
                Exported: \(Date().formatted())

                ---

                \(self.video.transcript)
                """

                do {
                    try content.write(to: url, atomically: true, encoding: .utf8)

                    // Track event
                    trackEvent(.transcriptExported, properties: [
                        "video_id": self.video.videoID,
                        "format": "txt"
                    ])
                } catch {
                    logError("Export failed: \(error.localizedDescription)", category: .user)
                }
            }
        }
    }

    func exportMarkdown() {
        let panel = NSSavePanel()
        panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
        panel.nameFieldStringValue = "\(video.title).md"
        panel.title = "Export as Markdown"
        panel.message = "Choose where to save the Markdown file"

        panel.begin { [weak self] response in
            guard let self = self else { return }

            if response == .OK, let url = panel.url {
                var content = """
                # \(self.video.title)

                > **Channel:** \(self.video.channelName)
                > **URL:** \(self.video.youtubeURL)
                > **Language:** \(self.video.language.uppercased())
                > **Saved:** \(self.video.savedAt.formatted())
                > **Words:** \(self.wordCount) | **Reading time:** ~\(self.readingTime) min

                ---

                """

                // Add AI Summary if exists
                if let summary = self.video.aiSummary, !summary.isEmpty {
                    content += """

                    ## AI Summary

                    \(summary)

                    ---

                    """
                }

                // Add transcript with highlights
                content += """

                ## Transcript

                \(self.transcriptWithMarkdownHighlights())

                ---

                """

                // Add personal notes if exists
                if !self.video.notes.isEmpty {
                    content += """

                    ## Personal Notes

                    \(self.video.notes)

                    ---

                    """
                }

                content += "*Exported from YouTube Transcripts on \(Date().formatted())*"

                do {
                    try content.write(to: url, atomically: true, encoding: .utf8)

                    // Track event
                    trackEvent(.transcriptExported, properties: [
                        "video_id": self.video.videoID,
                        "format": "markdown"
                    ])
                } catch {
                    logError("Export failed: \(error.localizedDescription)", category: .user)
                }
            }
        }
    }

    // MARK: - Open in Browser (HTML Export)

    func openInBrowser() {
        let html = generateHTML()
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("\(video.videoID)_transcript.html")

        do {
            try html.write(to: tempURL, atomically: true, encoding: .utf8)
            NSWorkspace.shared.open(tempURL)

            // Track event
            trackEvent(.transcriptExported, properties: [
                "video_id": video.videoID,
                "format": "html_browser"
            ])
        } catch {
            logError("Failed to open in browser: \(error.localizedDescription)", category: .user)
        }
    }

    // MARK: - HTML Generation

    private func generateHTML() -> String {
        let summarySection: String
        if let summary = video.aiSummary, !summary.isEmpty {
            summarySection = """
            <section class="summary">
                <h2>AI Summary</h2>
                <p>\(escapeHTML(summary))</p>
            </section>
            """
        } else {
            summarySection = ""
        }

        let notesSection: String
        if !video.notes.isEmpty {
            notesSection = """
            <section class="notes">
                <h3>Personal Notes</h3>
                <p>\(escapeHTML(video.notes))</p>
            </section>
            """
        } else {
            notesSection = ""
        }

        return """
        <!DOCTYPE html>
        <html lang="pt-BR">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>\(escapeHTML(video.title)) - Transcrição</title>
            <style>
                :root {
                    --bg: #ffffff;
                    --text: #1a1a1a;
                    --text-secondary: #666666;
                    --accent: #e74c3c;
                    --border: #e0e0e0;
                    --summary-bg: #f5f5f5;
                }
                @media (prefers-color-scheme: dark) {
                    :root {
                        --bg: #1a1a1a;
                        --text: #f0f0f0;
                        --text-secondary: #999999;
                        --accent: #ff6b6b;
                        --border: #333333;
                        --summary-bg: #2a2a2a;
                    }
                }
                * { box-sizing: border-box; margin: 0; padding: 0; }
                body {
                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif;
                    max-width: 800px;
                    margin: 0 auto;
                    padding: 2rem;
                    background: var(--bg);
                    color: var(--text);
                    line-height: 1.8;
                }
                header {
                    border-bottom: 3px solid var(--accent);
                    padding-bottom: 1.5rem;
                    margin-bottom: 2rem;
                }
                h1 {
                    font-size: 1.8rem;
                    font-weight: 600;
                    margin-bottom: 0.75rem;
                    line-height: 1.3;
                }
                .meta {
                    color: var(--text-secondary);
                    font-size: 0.9rem;
                    line-height: 1.6;
                }
                .meta a {
                    color: var(--accent);
                    text-decoration: none;
                    font-weight: 500;
                }
                .meta a:hover { text-decoration: underline; }
                .summary {
                    background: var(--summary-bg);
                    padding: 1.25rem;
                    border-radius: 8px;
                    margin-bottom: 2rem;
                }
                .summary h2 {
                    font-size: 1rem;
                    color: var(--accent);
                    margin-bottom: 0.75rem;
                    text-transform: uppercase;
                    letter-spacing: 0.5px;
                }
                .summary p { font-size: 0.95rem; }
                .transcript {
                    font-size: 1.1rem;
                    text-align: justify;
                }
                .highlight-yellow { background: rgba(255, 235, 59, 0.4); padding: 1px 3px; border-radius: 2px; }
                .highlight-green { background: rgba(76, 175, 80, 0.35); padding: 1px 3px; border-radius: 2px; }
                .highlight-blue { background: rgba(33, 150, 243, 0.35); padding: 1px 3px; border-radius: 2px; }
                .highlight-pink { background: rgba(233, 30, 99, 0.35); padding: 1px 3px; border-radius: 2px; }
                .highlight-orange { background: rgba(255, 152, 0, 0.4); padding: 1px 3px; border-radius: 2px; }
                .highlight-purple { background: rgba(156, 39, 176, 0.35); padding: 1px 3px; border-radius: 2px; }
                .underline { text-decoration: underline; text-decoration-color: var(--accent); }
                .notes {
                    border-left: 4px solid var(--accent);
                    padding: 1rem 1.25rem;
                    margin: 2rem 0;
                    background: var(--summary-bg);
                    border-radius: 0 8px 8px 0;
                }
                .notes h3 {
                    font-size: 0.9rem;
                    color: var(--accent);
                    margin-bottom: 0.5rem;
                    text-transform: uppercase;
                }
                .notes p { font-style: italic; font-size: 0.95rem; }
                footer {
                    margin-top: 3rem;
                    padding-top: 1.5rem;
                    border-top: 1px solid var(--border);
                    font-size: 0.8rem;
                    color: var(--text-secondary);
                    text-align: center;
                }
            </style>
        </head>
        <body>
            <header>
                <h1>\(escapeHTML(video.title))</h1>
                <p class="meta">
                    <strong>\(escapeHTML(video.channelName))</strong> ·
                    \(video.language.uppercased()) ·
                    \(wordCount) palavras ·
                    ~\(readingTime) min de leitura
                    <br>
                    <a href="\(video.youtubeURL)" target="_blank">▶ Assistir no YouTube</a>
                </p>
            </header>

            \(summarySection)

            <main class="transcript">
                \(transcriptWithHTMLHighlights())
            </main>

            \(notesSection)

            <footer>
                Exportado em \(Date().formatted()) via YouTube Transcripts
            </footer>
        </body>
        </html>
        """
    }

    // MARK: - Export Helpers

    private func transcriptWithHTMLHighlights() -> String {
        var result = escapeHTML(video.transcript)

        // Sort annotations by start index (descending) to apply from end to beginning
        let sortedAnnotations = video.annotations.sorted { $0.startIndex > $1.startIndex }

        for annotation in sortedAnnotations {
            guard annotation.startIndex >= 0,
                  annotation.endIndex <= video.transcript.count,
                  annotation.startIndex < annotation.endIndex else { continue }

            let startIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.startIndex)
            let endIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.endIndex)
            let originalText = String(video.transcript[startIdx..<endIdx])
            let escapedText = escapeHTML(originalText)

            let cssClass: String
            switch annotation.type {
            case .highlight:
                cssClass = "highlight-\(annotation.color)"
            case .underline:
                cssClass = "underline"
            }

            let replacement = "<span class=\"\(cssClass)\">\(escapedText)</span>"

            // Find and replace in the result string
            if let range = result.range(of: escapedText) {
                result = result.replacingCharacters(in: range, with: replacement)
            }
        }

        return result
    }

    private func transcriptWithMarkdownHighlights() -> String {
        var result = video.transcript

        // Sort annotations by start index (descending) to apply from end to beginning
        let sortedAnnotations = video.annotations.sorted { $0.startIndex > $1.startIndex }

        for annotation in sortedAnnotations {
            guard annotation.startIndex >= 0,
                  annotation.endIndex <= video.transcript.count,
                  annotation.startIndex < annotation.endIndex else { continue }

            let startIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.startIndex)
            let endIdx = video.transcript.index(video.transcript.startIndex, offsetBy: annotation.endIndex)
            let range = startIdx..<endIdx
            let text = String(video.transcript[range])

            let replacement: String
            switch annotation.type {
            case .highlight:
                // Use ==text== syntax for highlights (common Markdown extension)
                replacement = "==\(text)== <!-- \(annotation.color) -->"
            case .underline:
                // Use *text* for underline (italic as fallback)
                replacement = "_\(text)_"
            }

            result = result.replacingCharacters(in: range, with: replacement)
        }

        return result
    }

    private func escapeHTML(_ text: String) -> String {
        text
            .replacingOccurrences(of: "&", with: "&")
            .replacingOccurrences(of: "<", with: "<")
            .replacingOccurrences(of: ">", with: ">")
            .replacingOccurrences(of: "\"", with: """)
            .replacingOccurrences(of: "'", with: "'")
            .replacingOccurrences(of: "\n", with: "<br>")
    }

    // MARK: - YouTube

    func openYouTube() {
        if let url = URL(string: video.youtubeURL) {
            NSWorkspace.shared.open(url)
        }
    }

    // MARK: - Font Size

    func increaseFontSize(_ currentSize: inout Double) {
        if currentSize < AppConfig.UI.maxFontSize {
            currentSize += 1
            trackEvent(.fontSizeChanged, properties: ["direction": "increase"])
        }
    }

    func decreaseFontSize(_ currentSize: inout Double) {
        if currentSize > AppConfig.UI.minFontSize {
            currentSize -= 1
            trackEvent(.fontSizeChanged, properties: ["direction": "decrease"])
        }
    }

    func resetFontSize(_ currentSize: inout Double) {
        currentSize = AppConfig.UI.defaultFontSize
        trackEvent(.fontSizeChanged, properties: ["direction": "reset"])
    }

    // MARK: - Lifecycle

    func onAppear() {
        trackEvent(.transcriptViewed, properties: [
            "video_id": video.videoID,
            "language": video.language,
            "word_count": String(wordCount)
        ])
    }
}


Now let me examine the MCPService which handles transcript fetching: