smlflg's picture
Initial public upload from Projekte/PromtGarageMac
4977db2 verified
Raw History Blame Contribute Delete
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
}
@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<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 @Sendable () throws -> Value
) async throws -> Value {
try await Task.detached(priority: .userInitiated, operation: operation).value
}