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:
@@ -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