Sync conversation notes via Git Sync, add discard shortcut to crash-recovery prompt
Notes files now export to notes/ + notes.json alongside conversations.json, matching folders.json's manifest pattern: matched by conversation ID, never overwrites notes a machine already has locally, same empty-state orphan- cleanup safety guard as the existing conversation/folder sync code. Also adds Cmd+D to the "Discard" button on the crash-recovery restore prompt. Fixes two Swift 6 actor-isolation build warnings surfaced along the way: ConversationNotesService and the String filename-sanitizing extension are pure, state-free helpers called from nonisolated contexts (DatabaseService, GitSyncService) but defaulted to @MainActor — marked nonisolated.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<filename> 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<String>, 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
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
nonisolated extension String {
|
||||
// MARK: - Command Parsing
|
||||
|
||||
var isSlashCommand: Bool {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user