diff --git a/oAI/Models/SyncModels.swift b/oAI/Models/SyncModels.swift index eebcdba..96c1124 100644 --- a/oAI/Models/SyncModels.swift +++ b/oAI/Models/SyncModels.swift @@ -86,6 +86,20 @@ nonisolated struct FolderSyncManifest: Codable { var assignments: [String: String] } +/// Serialized as `notes.json` at the sync repo root, alongside a `notes/` directory holding the +/// raw note file content (same format as `~/Library/Application Support/oAI/notes/`, see +/// ConversationNotesService). Matching is by conversationId via this manifest, not by parsing the +/// embedded `**ID**:` header inside each note file — same approach as `FolderSyncManifest`. +nonisolated struct NotesSyncManifest: Codable { + nonisolated struct Entry: Codable { + let filename: String + let enabled: Bool + } + + /// conversationId -> notes entry. Only present for conversations that have ever had notes. + var notes: [String: Entry] +} + nonisolated struct ConversationExport { let id: String let name: String diff --git a/oAI/Services/ConversationNotesService.swift b/oAI/Services/ConversationNotesService.swift index 9dcb899..3cece76 100644 --- a/oAI/Services/ConversationNotesService.swift +++ b/oAI/Services/ConversationNotesService.swift @@ -33,8 +33,8 @@ import AppKit /// All operations are best-effort — a missing or unreadable file is never an error, /// since notes files are explicitly meant to tolerate being renamed, edited, or /// deleted by hand outside the app. -final class ConversationNotesService { - nonisolated static let shared = ConversationNotesService() +nonisolated final class ConversationNotesService { + static let shared = ConversationNotesService() private let baseDirectory: URL = { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, @@ -62,6 +62,21 @@ final class ConversationNotesService { return Self.stripIDHeader(from: content) } + /// Returns the file's exact on-disk content, ID header included — used by GitSyncService to + /// export the note byte-for-byte without needing to know the header format. + func readRaw(filename: String) -> String? { + let url = baseDirectory.appendingPathComponent(filename) + return try? String(contentsOf: url, encoding: .utf8) + } + + /// Writes content exactly as given, with no header wrapping — used by GitSyncService to import + /// a pulled note file byte-for-byte (it already carries its own embedded ID header). + func writeRaw(content: String, filename: String) { + ensureDirectory() + let url = baseDirectory.appendingPathComponent(filename) + try? content.write(to: url, atomically: true, encoding: .utf8) + } + /// Writes the full notes body, prefixed with the conversation's embedded ID header. func write(body: String, filename: String, conversationId: UUID) { ensureDirectory() diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 873b89e..ba29cd1 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -218,9 +218,50 @@ class GitSyncService { try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json")) log.debug("Exported folders.json (\(allFolders.count) folders)") + // Export per-conversation notes (see ConversationNotesService), same manifest + directory + // shape as folders.json: notes/ holds the raw file content byte-for-byte (its + // embedded **ID** header included), notes.json maps conversationId -> {filename, enabled} + // so import can match without parsing file content. + let notesDir = localPath + "/notes" + try FileManager.default.createDirectory(atPath: notesDir, withIntermediateDirectories: true) + + var notesEntries: [String: NotesSyncManifest.Entry] = [:] + for conversation in conversations { + guard let filename = conversation.notesFilename, + let content = ConversationNotesService.shared.readRaw(filename: filename) + else { continue } + try content.write(toFile: notesDir + "/" + filename, atomically: true, encoding: .utf8) + notesEntries[conversation.id.uuidString] = NotesSyncManifest.Entry(filename: filename, enabled: conversation.notesEnabled) + } + let notesManifest = NotesSyncManifest(notes: notesEntries) + let notesManifestData = try encoder.encode(notesManifest) + try notesManifestData.write(to: URL(fileURLWithPath: localPath + "/notes.json")) + log.debug("Exported notes.json (\(notesEntries.count) notes)") + + // Remove sync-repo note files for conversations that no longer exist locally — same + // orphan cleanup and empty-state safety guard as orphanedExportFilenames above. + let existingNoteFiles = (try? FileManager.default.contentsOfDirectory(atPath: notesDir)) ?? [] + let currentNoteFilenames = Set(notesEntries.values.map { $0.filename }) + for filename in Self.orphanedNoteFilenames(currentFilenames: currentNoteFilenames, existingFiles: existingNoteFiles) { + try? FileManager.default.removeItem(atPath: notesDir + "/" + filename) + log.info("Removed orphaned note file: \(filename)") + } + await updateStatus() } + /// Given the note filenames currently referenced by local conversations and the filenames + /// found on disk in the sync repo's notes directory, returns the filenames safe to delete + /// because no local conversation references them anymore (conversation deleted, or its notes + /// file was replaced). Same empty-state guard as orphanedExportFilenames/orphanedLocalFolderIds + /// — an empty currentFilenames set is indistinguishable from "haven't loaded local + /// conversations yet" (e.g. right after a fresh clone), so treating it as "every note file was + /// orphaned" would repeat the exact class of mass-deletion bug that hit conversation sync. + nonisolated static func orphanedNoteFilenames(currentFilenames: Set, existingFiles: [String]) -> [String] { + guard !currentFilenames.isEmpty else { return [] } + return existingFiles.filter { $0.hasSuffix(".md") && !currentFilenames.contains($0) } + } + /// Given the current conversation IDs and the (filename, markdown content) pairs found in /// the sync repo's conversations directory, returns the filenames whose export ID doesn't /// match any current conversation — i.e. files safe to delete because their conversation @@ -396,6 +437,40 @@ class GitSyncService { } } + // Import per-conversation notes (see ConversationNotesService) — runs after the + // conversations loop above so a conversation created in this same import pass already + // exists locally by the time we try to match notes.json against it. Missing/unparsable + // notes.json (older sync repos, or a fresh clone before the first export) is treated as + // "no notes to import," not an error. + let notesManifestPath = localPath + "/notes.json" + if let notesManifestData = try? Data(contentsOf: URL(fileURLWithPath: notesManifestPath)), + let notesManifest = try? JSONDecoder().decode(NotesSyncManifest.self, from: notesManifestData) { + var notesImported = 0 + for (conversationIdString, entry) in notesManifest.notes { + guard let conversationId = UUID(uuidString: conversationIdString), + let (existingConversation, _) = try? db.loadConversation(id: conversationId) + else { continue } + + // Never overwrite notes the user has already touched locally — same + // never-clobber-existing-content philosophy as skipping already-imported + // conversation content, and the folder-assignment backfill-only-if-unset above. + guard existingConversation.notesFilename == nil, !existingConversation.notesEnabled else { + continue + } + + guard let content = try? String(contentsOfFile: localPath + "/notes/" + entry.filename, encoding: .utf8) else { + continue + } + ConversationNotesService.shared.writeRaw(content: content, filename: entry.filename) + try? db.setNotesFilename(id: conversationId, filename: entry.filename) + try? db.setNotesEnabled(id: conversationId, enabled: entry.enabled) + notesImported += 1 + } + if notesImported > 0 { + log.info("Imported notes for \(notesImported) conversations") + } + } + log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors") return (imported, skipped, errors) } @@ -435,12 +510,13 @@ class GitSyncService { - Confab saves conversations to its local database - Auto-sync exports conversations to `conversations/*.md` - Your folder structure (if you organize conversations into folders) is exported to `folders.json` + - Per-conversation notes (if you've turned on `/notes on` for a conversation) are exported to `notes/*.md`, tracked in `notes.json` - Files are committed and pushed to this git repository ### Import (On New Machine) - Clone this repository on a new machine - - Confab imports markdown files and folders.json into its database - - Your conversation history and folder structure are restored + - Confab imports markdown files, folders.json, and notes.json into its database + - Your conversation history, folder structure, and conversation notes are restored ### Sync Across Machines - Machine A: Chat → Auto-save → Export → Push to git @@ -448,6 +524,9 @@ class GitSyncService { - Conversations stay in sync across all machines - Folder renames/moves also sync between machines; a conversation's folder is only set the first time it's imported onto a new machine + - Conversation notes sync the same way; a conversation's notes are only adopted the first + time it's imported onto a new machine — if that machine already has its own notes for the + same conversation, they're left alone rather than overwritten ## File Structure @@ -455,6 +534,9 @@ class GitSyncService { / ├── README.md # This file ├── folders.json # Your folder structure (auto-managed, don't edit) + ├── notes.json # Per-conversation notes index (auto-managed, don't edit) + ├── notes/ # Per-conversation notes files + │ └── ... └── conversations/ # Your conversations ├── conversation-1.md ├── conversation-2.md diff --git a/oAI/Utilities/Extensions/String+Extensions.swift b/oAI/Utilities/Extensions/String+Extensions.swift index f3443d2..774cbf2 100644 --- a/oAI/Utilities/Extensions/String+Extensions.swift +++ b/oAI/Utilities/Extensions/String+Extensions.swift @@ -23,7 +23,7 @@ import Foundation -extension String { +nonisolated extension String { // MARK: - Command Parsing var isSlashCommand: Bool { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 00108fb..3d5e93f 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -2294,7 +2294,9 @@ Don't narrate future actions ("Let me...") - just use the tools. alert.messageText = "Restore unsaved conversation?" alert.informativeText = "Confab didn't close properly last time. Would you like to restore the conversation you were working on?" alert.addButton(withTitle: "Restore") - alert.addButton(withTitle: "Discard") + let discardButton = alert.addButton(withTitle: "Discard") + discardButton.keyEquivalent = "d" + discardButton.keyEquivalentModifierMask = .command if alert.runModal() == .alertFirstButtonReturn { messages = draft.messages diff --git a/oAITests/ConversationNotesServiceTests.swift b/oAITests/ConversationNotesServiceTests.swift index 565ef94..b110c18 100644 --- a/oAITests/ConversationNotesServiceTests.swift +++ b/oAITests/ConversationNotesServiceTests.swift @@ -51,6 +51,29 @@ struct ConversationNotesServiceTests { #expect(body == nil) } + @Test("readRaw returns the exact on-disk content, ID header included") + func readRawIncludesHeader() { + let id = UUID() + let filename = "test-notes-\(UUID().uuidString).md" + defer { ConversationNotesService.shared.delete(filename: filename) } + + ConversationNotesService.shared.write(body: "User prefers dark roast coffee.", filename: filename, conversationId: id) + let raw = ConversationNotesService.shared.readRaw(filename: filename) + + #expect(raw == "**ID**: `\(id.uuidString)`\n\nUser prefers dark roast coffee.") + } + + @Test("writeRaw then readRaw round-trips content byte-for-byte, with no header wrapping added") + func writeRawReadRawRoundTrip() { + let filename = "test-notes-\(UUID().uuidString).md" + defer { ConversationNotesService.shared.delete(filename: filename) } + let content = "**ID**: `12345678-1234-1234-1234-123456789012`\n\nImported from another machine." + + ConversationNotesService.shared.writeRaw(content: content, filename: filename) + + #expect(ConversationNotesService.shared.readRaw(filename: filename) == content) + } + @Test("stripIDHeader removes the embedded ID line and following blank line") func stripIDHeaderRemovesHeader() { let content = "**ID**: `12345678-1234-1234-1234-123456789012`\n\nActual notes content." diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index 9d3211f..9c2f880 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -232,4 +232,57 @@ struct GitSyncServiceTests { #expect(decoded.folders.first?.name == "Work") #expect(decoded.assignments[conversationId] == folderId) } + + // MARK: - orphanedNoteFilenames + + @Test("A note file still referenced by a current conversation is not orphaned") + func keepsNoteFilesForExistingConversations() { + let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["chat-a3f2.md"], existingFiles: ["chat-a3f2.md"]) + #expect(orphans.isEmpty) + } + + @Test("A note file no longer referenced by any current conversation is orphaned") + func flagsNoteFilesForDeletedConversations() { + let orphans = GitSyncService.orphanedNoteFilenames( + currentFilenames: ["kept-b1c2.md"], + existingFiles: ["kept-b1c2.md", "deleted-d3e4.md"] + ) + #expect(orphans == ["deleted-d3e4.md"]) + } + + @Test("Non-markdown files in the notes directory are never flagged as orphans") + func nonMarkdownFilesAreNotFlaggedAsOrphans() { + let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: ["kept.md"], existingFiles: [".DS_Store"]) + #expect(orphans.isEmpty) + } + + @Test("Empty existing file list produces no orphans") + func emptyExistingNoteFilesProducesNoOrphans() { + #expect(GitSyncService.orphanedNoteFilenames(currentFilenames: ["a.md"], existingFiles: []).isEmpty) + } + + @Test("Empty current filenames set never orphans existing note files, even when files exist") + func emptyCurrentNoteFilenamesNeverOrphansExistingFiles() { + // Same class of regression guard as emptyCurrentIdsNeverOrphansExistingFiles / + // emptyManifestNeverOrphansLocalFolders: an empty local-state snapshot must never be + // read as "delete everything in the sync repo." + let orphans = GitSyncService.orphanedNoteFilenames(currentFilenames: [], existingFiles: ["a.md", "b.md"]) + #expect(orphans.isEmpty) + } + + // MARK: - NotesSyncManifest round-trip + + @Test("NotesSyncManifest round-trips through JSON encode/decode") + func notesSyncManifestRoundTrips() throws { + let conversationId = UUID().uuidString + let manifest = NotesSyncManifest( + notes: [conversationId: NotesSyncManifest.Entry(filename: "Chat-a3f2.md", enabled: true)] + ) + + let data = try JSONEncoder().encode(manifest) + let decoded = try JSONDecoder().decode(NotesSyncManifest.self, from: data) + + #expect(decoded.notes[conversationId]?.filename == "Chat-a3f2.md") + #expect(decoded.notes[conversationId]?.enabled == true) + } }