import AppKit import Foundation import Observation import PromptGarageCore import UniformTypeIdentifiers enum SidebarDestination: String, CaseIterable, Identifiable, Hashable, Sendable { case all case favorites case mostUsed case byDay case byTopic case importCenter case statistics var id: String { rawValue } } enum AppLaunchState: Equatable, Sendable { case checking case needsDatabase case validating(String) case ready case invalid(String) case readOnly(String) case failed(String) } struct AppErrorPresentation: Equatable, Sendable { let title: String let message: String let diagnostic: String } struct PromptEditorPresentation: Identifiable, Equatable, Sendable { enum Mode: Equatable, Sendable { case create case edit(Int64) } let id = UUID() let mode: Mode let initialDraft: PromptDraft var title: String { switch mode { case .create: "Neuer Prompt" case .edit: "Prompt bearbeiten" } } } struct LibraryQueryKey: Hashable, Sendable { let destination: SidebarDestination let text: String let tag: String? let sourceFamily: String? let selectedDay: String? let selectedTopic: PromptTopicSummary? let sort: PromptQuery.Sort let reloadToken: Int } @Observable @MainActor final class AppModel { var launchState: AppLaunchState = .checking var selectedBootstrapURL: URL? var destination: SidebarDestination = .all var searchText = "" var selectedTag: String? var selectedSourceFamily: String? var sort: PromptQuery.Sort = .updatedDescending var prompts: [PromptRecord] = [] var totalResults = 0 var selectedPromptID: Int64? var availableTags: [CountedValue] = [] var availableSourceFamilies: [CountedValue] = [] var dayGroups: [CountedValue] = [] var legacyUndatedCount = 0 var topicGroups: [PromptTopicSummary] = [] var selectedDay: String? var selectedTopic: PromptTopicSummary? var statistics: PromptStatistics = .empty var isLoadingLibrary = false var isLoadingMoreLibrary = false var isLoadingStatistics = false var selectedImportProvider: ImportProvider = .claude var selectedImportFormat: ImportFormat = .claudeJSONL var importSourceURL: URL? var importPreview: ImportPreview? var lastImportReport: ImportReport? var isAnalyzingImport = false var isCommittingImport = false var editor: PromptEditorPresentation? var promptPendingDeletion: PromptRecord? var isSavingEditor = false var errorPresentation: AppErrorPresentation? var successMessage: String? private var bootstrapService: DatabaseBootstrapService? private var repository: PromptRepository? private let importEngine = ImportEngine() private static let libraryPageSize = 500 private var didStart = false private var reloadToken = 0 private var libraryGeneration = 0 private var loadedLibraryOffset = 0 private var loadedLibraryQueryKey: LibraryQueryKey? private var activeLibraryRefreshID: UUID? private var activeLibraryLoadMoreID: UUID? private var bootstrapFailure: AppErrorPresentation? private var toastTask: Task? init() { do { bootstrapService = try DatabaseBootstrapService() } catch { let presentation = Self.errorPresentation(for: error, context: "App-Speicher vorbereiten") bootstrapFailure = presentation launchState = .failed(presentation.message) errorPresentation = presentation } } var selectedPrompt: PromptRecord? { guard let selectedPromptID else { return nil } return prompts.first { $0.id == selectedPromptID } } var isReadOnly: Bool { if case .readOnly = launchState { return true } return false } var canMutatePrompts: Bool { repository != nil && !isReadOnly && !isCommittingImport } var isBootstrapAvailable: Bool { bootstrapService != nil } var requiresManualDatabaseRecovery: Bool { guard let databaseURL = bootstrapService?.databaseURL, FileManager.default.fileExists(atPath: databaseURL.path) else { return false } switch launchState { case .invalid, .failed: return true default: return false } } var bootstrapFailureMessage: String? { switch launchState { case let .invalid(message), let .failed(message): message default: nil } } var canChooseBootstrapDatabase: Bool { guard isBootstrapAvailable else { return false } switch launchState { case .needsDatabase: return true case .invalid: return !requiresManualDatabaseRecovery default: return false } } var canCreateEmptyDatabase: Bool { canChooseBootstrapDatabase } var canInstallSelectedBootstrapDatabase: Bool { canChooseBootstrapDatabase && selectedBootstrapURL != nil } var hasMoreLibraryResults: Bool { loadedLibraryOffset < totalResults } var canLoadMoreLibraryResults: Bool { repository != nil && !isLoadingLibrary && !isLoadingMoreLibrary && hasMoreLibraryResults && loadedLibraryQueryKey == libraryQueryKey } var nextLibraryPageCount: Int { min(Self.libraryPageSize, max(0, totalResults - loadedLibraryOffset)) } var databaseDisplayName: String? { repository?.databaseURL?.lastPathComponent } var applicationDatabasePath: String { bootstrapService?.databaseURL.path(percentEncoded: false) ?? "~/Library/Application Support/PromptGarage/prompts.db" } var footerStatus: String { if isCommittingImport { return "Import wird atomar geschrieben …" } if isAnalyzingImport { return "Importquelle wird analysiert …" } if isLoadingLibrary || isLoadingMoreLibrary || isLoadingStatistics { return "Lokale Daten werden geladen …" } return "Bereit" } var libraryQueryKey: LibraryQueryKey { LibraryQueryKey( destination: destination, text: searchText, tag: selectedTag, sourceFamily: selectedSourceFamily, selectedDay: selectedDay, selectedTopic: selectedTopic, sort: sort, reloadToken: reloadToken ) } func start() async { guard !didStart else { return } didStart = true guard let service = requireBootstrapService(context: "App-Speicher prüfen") else { return } let state: DatabaseBootstrapState do { state = try await runDetached { service.inspect() } } catch { launchState = .failed(error.localizedDescription) showError(error, context: "Lokale Datenbank prüfen") return } switch state { case .needsDatabase: launchState = .needsDatabase case .validating: launchState = .needsDatabase case .ready: await openExisting(using: service, readOnlyMessage: nil) case let .invalid(error): launchState = .invalid(error.localizedDescription) case let .readOnly(report): await openExisting( using: service, readOnlyMessage: "Datenbank ist schreibgeschützt (\(report.promptCount) Prompts)." ) case let .failed(message): launchState = .failed(message) } } func chooseBootstrapDatabase() { guard requireBootstrapService(context: "Datenbank auswählen") != nil else { return } guard canChooseBootstrapDatabase else { showManualDatabaseRecoveryError(context: "Datenbank auswählen") return } let panel = NSOpenPanel() panel.title = "Vorhandene prompts.db auswählen" panel.message = "Die Quelle wird geprüft und anschließend kopiert. Das Original bleibt unverändert." panel.prompt = "Auswählen" panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.allowedContentTypes = [UTType(filenameExtension: "db") ?? .data] guard panel.runModal() == .OK, let url = panel.url else { return } selectedBootstrapURL = url } func importSelectedBootstrapDatabase() async { guard let service = requireBootstrapService(context: "Datenbank übernehmen") else { return } guard canChooseBootstrapDatabase else { showManualDatabaseRecoveryError(context: "Datenbank übernehmen") return } guard let sourceURL = selectedBootstrapURL else { showErrorMessage( "Wähle zuerst eine prompts.db aus.", context: "Datenbank übernehmen" ) return } launchState = .validating(sourceURL.lastPathComponent) dismissError() do { let openedRepository = try await runDetached { try service.installDatabase(from: sourceURL) } repository = openedRepository launchState = .ready await refreshAll() showSuccess("Sammlung wurde lokal übernommen.") } catch { launchState = .invalid(error.localizedDescription) showError(error, context: "Datenbank übernehmen") } } func startWithEmptyDatabase() async { guard let service = requireBootstrapService(context: "Leere Sammlung erstellen") else { return } guard canCreateEmptyDatabase else { showManualDatabaseRecoveryError(context: "Leere Sammlung erstellen") return } launchState = .validating("Neue Datenbank") dismissError() do { let openedRepository = try await runDetached { try service.startEmpty() } repository = openedRepository launchState = .ready await refreshAll() showSuccess("Leere lokale Sammlung ist bereit.") } catch { launchState = .failed(error.localizedDescription) showError(error, context: "Leere Sammlung erstellen") } } func refreshLibrary() async { libraryGeneration &+= 1 let generation = libraryGeneration let queryKey = libraryQueryKey activeLibraryLoadMoreID = nil isLoadingMoreLibrary = false loadedLibraryQueryKey = nil guard let repository else { activeLibraryRefreshID = nil isLoadingLibrary = false prompts = [] totalResults = 0 loadedLibraryOffset = 0 selectedPromptID = nil return } if destination == .byDay && selectedDay == nil { activeLibraryRefreshID = nil isLoadingLibrary = false prompts = [] totalResults = 0 loadedLibraryOffset = 0 selectedPromptID = nil return } if destination == .byTopic && selectedTopic == nil { activeLibraryRefreshID = nil isLoadingLibrary = false prompts = [] totalResults = 0 loadedLibraryOffset = 0 selectedPromptID = nil return } let query = makePromptQuery(limit: Self.libraryPageSize, offset: 0) let requestID = UUID() activeLibraryRefreshID = requestID isLoadingLibrary = true defer { if activeLibraryRefreshID == requestID { activeLibraryRefreshID = nil isLoadingLibrary = false } } do { let page = try await runDetached { try repository.page(matching: query) } guard generation == libraryGeneration, queryKey == libraryQueryKey, query == makePromptQuery(limit: Self.libraryPageSize, offset: 0) else { return } prompts = page.prompts totalResults = page.total loadedLibraryOffset = page.prompts.count loadedLibraryQueryKey = queryKey retainValidSelection() } catch is CancellationError { // A newer query owns the visible result. } catch { guard generation == libraryGeneration, queryKey == libraryQueryKey else { return } showError(error, context: "Bibliothek laden") } } func loadMoreLibraryResults() async { guard let repository, canLoadMoreLibraryResults else { return } let generation = libraryGeneration let queryKey = libraryQueryKey let offset = loadedLibraryOffset let limit = min(Self.libraryPageSize, totalResults - offset) guard limit > 0 else { return } let query = makePromptQuery(limit: limit, offset: offset) let requestID = UUID() activeLibraryLoadMoreID = requestID isLoadingMoreLibrary = true defer { if activeLibraryLoadMoreID == requestID { activeLibraryLoadMoreID = nil isLoadingMoreLibrary = false } } do { let page = try await runDetached { try repository.page(matching: query) } guard generation == libraryGeneration, queryKey == libraryQueryKey, offset == loadedLibraryOffset, query == makePromptQuery(limit: limit, offset: offset) else { return } var knownIDs = Set(prompts.map(\.id)) prompts.append(contentsOf: page.prompts.filter { knownIDs.insert($0.id).inserted }) loadedLibraryOffset = offset + page.prompts.count totalResults = page.total retainValidSelection() } catch is CancellationError { // A full refresh or a newer query owns the visible result. } catch { guard generation == libraryGeneration, queryKey == libraryQueryKey else { return } showError(error, context: "Weitere Prompts laden") } } func refreshStatistics() async { guard let repository else { return } isLoadingStatistics = true defer { isLoadingStatistics = false } do { statistics = try await runDetached { try repository.statistics() } } catch { showError(error, context: "Statistik laden") } } func clearLibraryFilters() { searchText = "" selectedTag = nil selectedSourceFamily = nil sort = destination == .mostUsed ? .mostUsed : .updatedDescending } func presentCreatePrompt() { guard canMutatePrompts else { return } editor = PromptEditorPresentation(mode: .create, initialDraft: PromptDraft()) } func presentEditPrompt(_ prompt: PromptRecord) { guard canMutatePrompts else { return } editor = PromptEditorPresentation(mode: .edit(prompt.id), initialDraft: PromptDraft(prompt: prompt)) } func saveEditor(_ draft: PromptDraft, presentation: PromptEditorPresentation) async -> Bool { guard let repository, canMutatePrompts else { return false } if let message = draft.validationMessage { showErrorMessage(message, context: "Prompt prüfen") return false } isSavingEditor = true defer { isSavingEditor = false } do { let previousSelection = selectedPromptID let savedPrompt = try await runDetached { switch presentation.mode { case .create: try repository.create(draft) case let .edit(id): try repository.update(id: id, with: draft) } } editor = nil reloadToken += 1 await refreshMetadataAndStatistics() await refreshLibrary() retainValidSelection(preferredID: savedPrompt.id, fallbackID: previousSelection) showSuccess(presentation.mode == .create ? "Prompt wurde erstellt." : "Änderungen wurden gespeichert.") return true } catch { showError(error, context: "Prompt speichern") return false } } func toggleStar(_ prompt: PromptRecord) async { guard let repository, canMutatePrompts else { return } do { let updated = try await runDetached { try repository.toggleStar(id: prompt.id) } replacePrompt(updated) if destination == .favorites && !updated.isStarred { reloadToken += 1 await refreshLibrary() } await refreshStatistics() } catch { showError(error, context: "Favorit ändern") } } func setRating(_ rating: Int?, for prompt: PromptRecord) async { guard let repository, canMutatePrompts else { return } do { let updated = try await runDetached { try repository.setRating(rating, for: prompt.id) } replacePrompt(updated) await refreshStatistics() } catch { showError(error, context: "Bewertung speichern") } } func copy(_ prompt: PromptRecord, countAsUse: Bool) async { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(prompt.content, forType: .string) guard countAsUse else { showSuccess("Prompt wurde kopiert.") return } guard let repository, canMutatePrompts else { showSuccess("Prompt wurde kopiert; Nutzungszähler blieb unverändert.") return } do { let updated = try await runDetached { try repository.markUsed(id: prompt.id) } replacePrompt(updated) await refreshStatistics() showSuccess("Kopiert und als verwendet gezählt.") } catch { showError(error, context: "Nutzung zählen") } } func requestDelete(_ prompt: PromptRecord) { guard canMutatePrompts else { return } promptPendingDeletion = prompt } func confirmDelete() async { guard let repository, let prompt = promptPendingDeletion, canMutatePrompts else { return } promptPendingDeletion = nil do { _ = try await runDetached { try repository.delete(id: prompt.id) } selectedPromptID = nil reloadToken += 1 await refreshAll() showSuccess("Prompt wurde gelöscht.") } catch { showError(error, context: "Prompt löschen") } } func selectImportProvider(_ provider: ImportProvider) { selectedImportProvider = provider selectedImportFormat = provider.formats.first ?? .claudeJSONL resetImportPreview() } func selectImportFormat(_ format: ImportFormat) { selectedImportFormat = format selectedImportProvider = format.provider resetImportPreview() } func chooseAndPreviewImportSource() async { guard let repository else { return } let format = selectedImportFormat let panel = NSOpenPanel() panel.title = "Quelle für \(format.displayName) auswählen" panel.prompt = "Analysieren" panel.canChooseFiles = true panel.canChooseDirectories = format.allowsDirectory panel.allowsMultipleSelection = false panel.allowedContentTypes = allowedContentTypes(for: format) guard panel.runModal() == .OK, let url = panel.url else { return } importSourceURL = url importPreview = nil lastImportReport = nil isAnalyzingImport = true dismissError() defer { isAnalyzingImport = false } let engine = importEngine do { let preview = try await runDetached { let dedupIndex = try repository.makeDedupIndex() return try engine.preview(format: format, sourceURL: url, dedupIndex: dedupIndex) } importPreview = preview if preview.candidates.isEmpty { showSuccess("Analyse abgeschlossen: keine neuen Prompts.") } } catch { showError(error, context: "Importquelle analysieren") } } func commitImport() async { guard let repository, let preview = importPreview, canMutatePrompts else { return } isCommittingImport = true dismissError() defer { isCommittingImport = false } do { let finalStatistics = try await runDetached { try repository.importCandidates( preview.candidates, baseStatistics: preview.statistics ) } lastImportReport = ImportReport(preview: preview, statistics: finalStatistics) importPreview = nil importSourceURL = nil reloadToken += 1 await refreshAll() showSuccess("\(finalStatistics.imported) Prompts wurden importiert.") } catch { showErrorMessage( "Keine Änderungen übernommen. \(error.localizedDescription)", context: "Import zurückgerollt" ) } } func resetImportPreview() { importSourceURL = nil importPreview = nil lastImportReport = nil } func dismissError() { errorPresentation = nil } func copyErrorDiagnostic() { guard let diagnostic = errorPresentation?.diagnostic else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(diagnostic, forType: .string) showSuccess("Diagnose wurde kopiert.") } private func openExisting( using service: DatabaseBootstrapService, readOnlyMessage: String? ) async { do { guard let openedRepository = try await runDetached({ try service.openExisting() }) else { launchState = .needsDatabase return } repository = openedRepository launchState = readOnlyMessage.map(AppLaunchState.readOnly) ?? .ready await refreshAll() } catch { launchState = .failed(error.localizedDescription) showError(error, context: "Lokale Datenbank öffnen") } } private func refreshAll() async { await refreshMetadataAndStatistics() await refreshLibrary() } private func refreshMetadataAndStatistics() async { guard let repository else { return } isLoadingStatistics = true defer { isLoadingStatistics = false } do { let result = try await runDetached { ( try repository.allTags(), try repository.sourceFamilies(), try repository.statistics(), try repository.dayGroups(), try repository.legacyUndatedCount(), try repository.browsingTopicGroups() ) } availableTags = result.0 availableSourceFamilies = result.1 statistics = result.2 dayGroups = result.3 legacyUndatedCount = result.4 topicGroups = result.5 let selectedDayStillExists = self.selectedDay.map { selected in dayGroups.contains(where: { $0.name == selected }) } ?? false if !selectedDayStillExists { self.selectedDay = dayGroups.first?.name } if let selectedTopicID = selectedTopic?.id { selectedTopic = topicGroups.first(where: { $0.id == selectedTopicID }) ?? topicGroups.first } else { selectedTopic = topicGroups.first } } catch { showError(error, context: "Filter und Statistik laden") } } private func makePromptQuery(limit: Int = AppModel.libraryPageSize, offset: Int = 0) -> PromptQuery { PromptQuery( text: searchText, tag: destination == .byTopic ? selectedTopic?.topic : selectedTag, sourceFamily: selectedSourceFamily, promptedDay: destination == .byDay ? selectedDay : nil, untaggedOnly: destination == .byTopic && selectedTopic?.isUntagged == true, starredOnly: destination == .favorites, usedOnly: destination == .mostUsed, sort: destination == .mostUsed ? .mostUsed : sort, limit: limit, offset: offset ) } private func retainValidSelection(preferredID: Int64? = nil, fallbackID: Int64? = nil) { if let preferredID, prompts.contains(where: { $0.id == preferredID }) { selectedPromptID = preferredID return } if let fallbackID, prompts.contains(where: { $0.id == fallbackID }) { selectedPromptID = fallbackID return } if let selectedPromptID, prompts.contains(where: { $0.id == selectedPromptID }) { return } selectedPromptID = prompts.first?.id } private func replacePrompt(_ prompt: PromptRecord) { guard let index = prompts.firstIndex(where: { $0.id == prompt.id }) else { return } prompts[index] = prompt } private func allowedContentTypes(for format: ImportFormat) -> [UTType] { switch format { case .claudeJSONL, .codexHistoryJSONL, .geminiJSONL: return [UTType(filenameExtension: "jsonl") ?? .json, .json] case .hermesStateDatabase: return [UTType(filenameExtension: "db") ?? .data] case .chatGPTAnalysisJSON, .chatGPTRawConversationsJSON: return [.json] case .geminiMarkdown: if let markdown = UTType(filenameExtension: "md") { return [markdown, .plainText] } return [.plainText] } } private func showError(_ error: Error, context: String) { errorPresentation = Self.errorPresentation(for: error, context: context) } private func requireBootstrapService(context: String) -> DatabaseBootstrapService? { guard let bootstrapService else { let presentation = bootstrapFailure ?? AppErrorPresentation( title: context, message: "Der lokale App-Speicher ist nicht verfügbar.", diagnostic: "PromptGarage | \(context)\nDatabaseBootstrapService unavailable" ) launchState = .failed(presentation.message) errorPresentation = presentation return nil } return bootstrapService } private func showErrorMessage(_ message: String, context: String) { errorPresentation = AppErrorPresentation( title: context, message: message, diagnostic: "PromptGarage | \(context)\n\(message)" ) } private func showManualDatabaseRecoveryError(context: String) { showErrorMessage( "Die vorhandene App-Datenbank wird niemals automatisch ersetzt. Beende PromptGarage, verschiebe oder benenne \(applicationDatabasePath) manuell um und starte die App danach erneut.", context: context ) } private static func errorPresentation(for error: Error, context: String) -> AppErrorPresentation { AppErrorPresentation( title: context, message: error.localizedDescription, diagnostic: "PromptGarage | \(context)\n\(String(reflecting: error))" ) } private func showSuccess(_ message: String) { toastTask?.cancel() successMessage = message toastTask = Task { [weak self] in try? await Task.sleep(for: .seconds(3)) guard !Task.isCancelled else { return } self?.successMessage = nil } } } private func runDetached( _ operation: @escaping @Sendable () throws -> Value ) async throws -> Value { try await Task.detached(priority: .userInitiated, operation: operation).value }