Download Sources/PromptGarageApp/AppModel.swift from smlflg/PromtGarageMac: direct link, hf CLI and curl.
- Browser
- Download file 29 kB
-
https://huggingface.co/smlflg/PromtGarageMac/resolve/main/Sources/PromptGarageApp/AppModel.swift
- Command line
-
hf download hf://smlflg/PromtGarageMac/Sources/PromptGarageApp/AppModel.swift
-
curl -L -o AppModel.swift https://huggingface.co/smlflg/PromtGarageMac/resolve/main/Sources/PromptGarageApp/AppModel.swift
29 kB
| 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 | |
| } | |
| 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<Void, Never>? | |
| 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<Value: Sendable>( | |
| _ operation: @escaping () throws -> Value | |
| ) async throws -> Value { | |
| try await Task.detached(priority: .userInitiated, operation: operation).value | |
| } | |