diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index eaccd3e..7e88e58 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -8059,36 +8059,36 @@ } } }, - "Initialize Repository" : { + "Clone Repository" : { "localizations" : { "da" : { "stringUnit" : { "state" : "translated", - "value" : "Initialiser repository" + "value" : "Klon repository" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Repository initialisieren" + "value" : "Repository klonen" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Initialiser le dépôt" + "value" : "Cloner le dépôt" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Initialiser repositorium" + "value" : "Klon repositorium" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Initiera förvar" + "value" : "Klona förvar" } } } diff --git a/oAI/Models/Folder.swift b/oAI/Models/Folder.swift index 29f4f6a..a51fe98 100644 --- a/oAI/Models/Folder.swift +++ b/oAI/Models/Folder.swift @@ -29,19 +29,22 @@ struct Folder: Identifiable, Codable, Sendable { var sortOrder: Int let createdAt: Date var parentId: UUID? + var updatedAt: Date nonisolated init( id: UUID = UUID(), name: String, sortOrder: Int = 0, createdAt: Date = Date(), - parentId: UUID? = nil + parentId: UUID? = nil, + updatedAt: Date? = nil ) { self.id = id self.name = name self.sortOrder = sortOrder self.createdAt = createdAt self.parentId = parentId + self.updatedAt = updatedAt ?? createdAt } } diff --git a/oAI/Models/SyncModels.swift b/oAI/Models/SyncModels.swift index fbaed12..eebcdba 100644 --- a/oAI/Models/SyncModels.swift +++ b/oAI/Models/SyncModels.swift @@ -68,6 +68,24 @@ struct SyncStatus: Equatable { var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc. } +/// Serialized as `folders.json` at the sync repo root. Unlike conversation exports, this is a +/// manifest meant to be machine-written/read only (the repo's README already warns against manual +/// edits), so plain JSON is used rather than the hand-rolled markdown format — no need for that +/// format's human-readability tradeoffs here. +nonisolated struct FolderSyncManifest: Codable { + nonisolated struct FolderEntry: Codable { + let id: String + let name: String + let parentId: String? + let createdAt: Date + let updatedAt: Date + } + + var folders: [FolderEntry] + /// conversationId -> folderId. Only present for conversations actually filed in a folder. + var assignments: [String: String] +} + nonisolated struct ConversationExport { let id: String let name: String diff --git a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html index 6195e5c..61edd79 100644 --- a/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/Confab.help/Contents/Resources/en.lproj/index.html @@ -1615,7 +1615,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
  • Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See Unsaved Changes & Crash Recovery.
  • Manual Sync:
  • diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 631d2bb..ebf5d04 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -46,6 +46,7 @@ struct FolderRecord: Codable, FetchableRecord, PersistableRecord, Sendable { var sortOrder: Int var createdAt: String var parentId: String? + var updatedAt: String? } struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { @@ -381,6 +382,16 @@ final class DatabaseService: Sendable { try db.create(index: "idx_folders_parentId", on: "folders", columns: ["parentId"]) } + migrator.registerMigration("v11") { db in + // Needed to resolve folder renames/reparents last-write-wins when Git Sync brings in + // folder state from another machine — without a timestamp there's no way to tell whose + // version of a rename is newer. Backfilled from createdAt for existing rows. + try db.alter(table: "folders") { t in + t.add(column: "updatedAt", .text) + } + try db.execute(sql: "UPDATE folders SET updatedAt = createdAt WHERE updatedAt IS NULL") + } + return migrator } @@ -632,7 +643,8 @@ final class DatabaseService: Sendable { name: folder.name, sortOrder: folder.sortOrder, createdAt: Self.isoString(from: folder.createdAt), - parentId: parentId?.uuidString + parentId: parentId?.uuidString, + updatedAt: Self.isoString(from: folder.updatedAt) ) try dbQueue.write { db in try record.insert(db) @@ -651,8 +663,8 @@ final class DatabaseService: Sendable { nonisolated func renameFolder(id: UUID, name: String) throws { try dbQueue.write { db in try db.execute( - sql: "UPDATE folders SET name = ? WHERE id = ?", - arguments: [name, id.uuidString] + sql: "UPDATE folders SET name = ?, updatedAt = ? WHERE id = ?", + arguments: [name, Self.isoString(from: Date()), id.uuidString] ) } } @@ -668,8 +680,38 @@ final class DatabaseService: Sendable { throw FolderError.wouldCreateCycle } } - try db.execute(sql: "UPDATE folders SET parentId = ? WHERE id = ?", - arguments: [parentId?.uuidString, id.uuidString]) + try db.execute(sql: "UPDATE folders SET parentId = ?, updatedAt = ? WHERE id = ?", + arguments: [parentId?.uuidString, Self.isoString(from: Date()), id.uuidString]) + } + } + + /// Creates or updates a folder with an externally-supplied identity and timestamps, for Git + /// Sync import — as opposed to `createFolder`, which is for user-initiated creation and always + /// mints a fresh id/timestamps. Last-write-wins: if the folder already exists locally, only + /// overwrites name/parentId when `updatedAt` is strictly newer than the local row's. + nonisolated func upsertSyncedFolder(id: UUID, name: String, parentId: UUID?, createdAt: Date, updatedAt: Date) throws { + let existing = try dbQueue.read { db in + try FolderRecord.fetchOne(db, key: id.uuidString) + } + if let existing { + guard let existingUpdatedAt = existing.updatedAt.flatMap(Self.isoDate(from:)), + existingUpdatedAt < updatedAt + else { return } + try dbQueue.write { db in + try db.execute( + sql: "UPDATE folders SET name = ?, parentId = ?, updatedAt = ? WHERE id = ?", + arguments: [name, parentId?.uuidString, Self.isoString(from: updatedAt), id.uuidString] + ) + } + } else { + let record = FolderRecord( + id: id.uuidString, name: name, sortOrder: try nextFolderSortOrder(), + createdAt: Self.isoString(from: createdAt), parentId: parentId?.uuidString, + updatedAt: Self.isoString(from: updatedAt) + ) + try dbQueue.write { db in + try record.insert(db) + } } } @@ -702,9 +744,12 @@ final class DatabaseService: Sendable { guard let id = UUID(uuidString: record.id), let createdAt = Self.isoDate(from: record.createdAt) else { return nil } + // Falls back to createdAt if updatedAt is somehow missing (shouldn't happen post-v11 + // migration, which backfills every existing row) rather than failing the whole fetch. + let updatedAt = record.updatedAt.flatMap(Self.isoDate(from:)) ?? createdAt return Folder( id: id, name: record.name, sortOrder: record.sortOrder, createdAt: createdAt, - parentId: record.parentId.flatMap { UUID(uuidString: $0) } + parentId: record.parentId.flatMap { UUID(uuidString: $0) }, updatedAt: updatedAt ) } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 4086029..673b7ed 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -196,6 +196,28 @@ class GitSyncService { log.info("Removed orphaned export for deleted conversation: \(filename)") } + // Export the folder tree + conversation→folder assignments alongside the conversations + // themselves, so a fresh machine's import can restore folder structure too. See + // upsertSyncedFolder/orphanedLocalFolderIds for how the import side consumes this. + let allFolders = try db.listFolders() + let manifest = FolderSyncManifest( + folders: allFolders.map { + FolderSyncManifest.FolderEntry( + id: $0.id.uuidString, name: $0.name, parentId: $0.parentId?.uuidString, + createdAt: $0.createdAt, updatedAt: $0.updatedAt + ) + }, + assignments: Dictionary(uniqueKeysWithValues: conversations.compactMap { conv in + conv.folderId.map { (conv.id.uuidString, $0.uuidString) } + }) + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let manifestData = try encoder.encode(manifest) + try manifestData.write(to: URL(fileURLWithPath: localPath + "/folders.json")) + log.debug("Exported folders.json (\(allFolders.count) folders)") + await updateStatus() } @@ -221,6 +243,21 @@ class GitSyncService { } } + /// Given the folder ids present in a just-pulled `folders.json` manifest and the folder ids + /// that exist locally, returns the local ids that should be deleted (folder was removed + /// upstream since the last sync). Same empty-manifest safety guard as + /// `orphanedExportFilenames` — an empty manifest is indistinguishable from "haven't imported + /// folders.json yet" (e.g. an older sync repo with no manifest at all, or a fresh clone before + /// the first export), so treating it as "delete every local folder" would be exactly the same + /// class of mass-deletion bug that hit conversation sync. + nonisolated static func orphanedLocalFolderIds( + manifestFolderIds: Set, + localFolderIds: Set + ) -> [String] { + guard !manifestFolderIds.isEmpty else { return [] } + return localFolderIds.filter { !manifestFolderIds.contains($0) } + } + /// Import conversations from markdown files func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) { try ensureCloned() @@ -233,6 +270,49 @@ class GitSyncService { return (0, 0, 0) } + // Import the folder tree + assignments before any conversation, so a brand-new + // conversation created below can immediately reference a folder that already exists + // locally. Missing/unparsable folders.json (older sync repos, or a fresh clone before the + // first export) is treated as "no folders to import," not an error. + var folderAssignments: [String: String] = [:] + let manifestPath = localPath + "/folders.json" + if let manifestData = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + if let manifest = try? decoder.decode(FolderSyncManifest.self, from: manifestData) { + folderAssignments = manifest.assignments + + // Insert parents before children so parentId's foreign key reference (folders. + // parentId references folders(id)) is always satisfied. + let manifestFolders = manifest.folders.compactMap { entry -> Folder? in + guard let id = UUID(uuidString: entry.id) else { return nil } + return Folder( + id: id, name: entry.name, createdAt: entry.createdAt, + parentId: entry.parentId.flatMap { UUID(uuidString: $0) }, + updatedAt: entry.updatedAt + ) + } + for (folder, _) in Folder.orderedTree(from: manifestFolders) { + try? db.upsertSyncedFolder( + id: folder.id, name: folder.name, parentId: folder.parentId, + createdAt: folder.createdAt, updatedAt: folder.updatedAt + ) + } + + // Delete local folders no longer present upstream — reparents their contents up + // one level via the existing deleteFolder semantics. + let manifestFolderIds = Set(manifest.folders.map { $0.id }) + let localFolderIds = Set((try? db.listFolders())?.map { $0.id.uuidString } ?? []) + for idString in Self.orphanedLocalFolderIds(manifestFolderIds: manifestFolderIds, localFolderIds: localFolderIds) { + if let id = UUID(uuidString: idString) { + try? db.deleteFolder(id: id) + log.info("Removed local folder no longer present in sync repo: \(idString)") + } + } + log.debug("Imported folders.json (\(manifest.folders.count) folders)") + } + } + let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir) let mdFiles = files.filter { $0.hasSuffix(".md") } @@ -254,8 +334,20 @@ class GitSyncService { // Check if conversation already exists (by ID) if let existingId = UUID(uuidString: export.id) { - if (try? db.loadConversation(id: existingId)) != nil { - // Already exists - skip + if let (existingConversation, _) = try? db.loadConversation(id: existingId) { + // Already exists - skip re-importing its content, but still backfill a + // folder assignment if the manifest has one and this conversation isn't + // filed anywhere locally yet. Without this, a conversation that was synced + // to this machine before folder sync existed (or before it was ever put in + // a folder on any machine) would never get filed here — every conversation + // in a multi-machine setup already exists locally by the time folders.json + // starts carrying assignments, so this isn't an edge case, it's the normal + // case. Never overwrites an existing local folderId, so a conversation + // already filed (by this machine or a prior import) isn't silently moved. + if existingConversation.folderId == nil, + let assignedFolderId = folderAssignments[export.id].flatMap(UUID.init) { + try? db.moveConversation(id: existingId, toFolder: assignedFolderId) + } log.debug("Skipping existing conversation: \(export.name)") skipped += 1 continue @@ -282,13 +374,18 @@ class GitSyncService { ) } - // Import to database with primaryModel + // Import to database with primaryModel, plus its folder assignment (if any) from + // folders.json — only applies here at first-import; an existing local conversation + // that's later moved to a different folder on another machine doesn't get updated, + // matching how its content/name aren't updated either once already imported. let conversationId = UUID(uuidString: export.id) ?? UUID() + let folderId = folderAssignments[export.id].flatMap { UUID(uuidString: $0) } _ = try db.saveConversation( id: conversationId, name: export.name, messages: messages, - primaryModel: export.primaryModel + primaryModel: export.primaryModel, + folderId: folderId ) log.info("Imported: \(export.name)") imported += 1 @@ -337,23 +434,27 @@ class GitSyncService { ### Export (Automatic) - 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` - Files are committed and pushed to this git repository ### Import (On New Machine) - Clone this repository on a new machine - - Confab imports markdown files into its database - - Your conversation history is restored + - Confab imports markdown files and folders.json into its database + - Your conversation history and folder structure are restored ### Sync Across Machines - Machine A: Chat → Auto-save → Export → Push to git - Machine B: Pull from git → Auto-import → Database updated - 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 ## File Structure ``` / ├── README.md # This file + ├── folders.json # Your folder structure (auto-managed, don't edit) └── conversations/ # Your conversations ├── conversation-1.md ├── conversation-2.md diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift index 8c35ea8..fa16e59 100644 --- a/oAI/Views/Main/SidebarView.swift +++ b/oAI/Views/Main/SidebarView.swift @@ -231,6 +231,11 @@ struct SidebarView: View { // conversation list modal live in its own @State — refresh ours once it closes. if !isShowing { loadData() } } + .onChange(of: chatViewModel.showSettings) { _, isShowing in + // Git Sync (Settings → Sync) can import conversations/folders directly into the + // database — refresh ours once the sheet closes so they show up without a relaunch. + if !isShowing { loadData() } + } } @ViewBuilder diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index ca68823..ed69256 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -1737,7 +1737,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } else { Image(systemName: "arrow.down.circle") } - Text("Initialize Repository") + Text("Clone Repository") } .frame(minWidth: 160) } @@ -3023,18 +3023,22 @@ It's better to admit "I need more information" or "I cannot do that" than to fak syncTestResult = nil do { - // Step 1: Export all conversations - syncTestResult = "Exporting conversations..." - try await gitSync.exportAllConversations() - - // Step 2: Pull from remote + // Step 1: Pull from remote first, into a working tree that hasn't been touched by + // this round's export yet. Exporting before pulling can write files (e.g. + // folders.json, which every machine writes to the same path) that git then refuses + // to merge over: "untracked working tree files would be overwritten by merge". syncTestResult = "Pulling changes..." try await gitSync.pull() - // Step 3: Import any new conversations + // Step 2: Import any new/updated conversations and folders from what was just pulled syncTestResult = "Importing conversations..." let result = try await gitSync.importAllConversations() + // Step 3: Export — now safe, re-derives working tree files from the local DB state, + // which already reflects whatever was just imported plus this machine's own changes + syncTestResult = "Exporting conversations..." + try await gitSync.exportAllConversations() + // Step 4: Push to remote syncTestResult = "Pushing changes..." try await gitSync.push() diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index 5d58eb3..1f26b2d 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -237,6 +237,98 @@ struct DatabaseServiceFolderTests { #expect(db.columnNames(in: "folders").contains("parentId")) } + @Test("v11 adds updatedAt to folders") + func v11AddsUpdatedAt() { + let db = DatabaseService.makeInMemory() + #expect(db.columnNames(in: "folders").contains("updatedAt")) + } + + @Test("renameFolder bumps updatedAt") + func renameFolderBumpsUpdatedAt() throws { + let db = DatabaseService.makeInMemory() + let folder = try db.createFolder(name: "Work") + // Round-trip through the DB for the "before" value too, so both sides go through the same + // fractional-seconds truncation as the "after" read below — comparing a raw in-memory + // Date() (full precision) against a DB-round-tripped one can flake when both timestamps + // land in the same millisecond window. + let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == folder.id })?.updatedAt) + + try db.renameFolder(id: folder.id, name: "Projects") + + let updated = try db.listFolders().first(where: { $0.id == folder.id }) + #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt) + } + + @Test("moveFolder bumps updatedAt") + func moveFolderBumpsUpdatedAt() throws { + let db = DatabaseService.makeInMemory() + let parent = try db.createFolder(name: "Work") + let child = try db.createFolder(name: "Personal") + // See renameFolderBumpsUpdatedAt's comment: round-trip through the DB for the "before" + // value so it's truncated the same way as the "after" read. + let originalUpdatedAt = try #require(db.listFolders().first(where: { $0.id == child.id })?.updatedAt) + + try db.moveFolder(id: child.id, toParent: parent.id) + + let updated = try db.listFolders().first(where: { $0.id == child.id }) + #expect(updated?.updatedAt ?? .distantPast >= originalUpdatedAt) + } + + @Test("upsertSyncedFolder creates a folder that doesn't exist locally yet") + func upsertSyncedFolderCreatesNew() throws { + let db = DatabaseService.makeInMemory() + let id = UUID() + let createdAt = Date(timeIntervalSince1970: 1_000) + let updatedAt = Date(timeIntervalSince1970: 2_000) + + try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: updatedAt) + + let folders = try db.listFolders() + let created = try #require(folders.first(where: { $0.id == id })) + #expect(created.name == "Work") + #expect(created.parentId == nil) + #expect(created.updatedAt == updatedAt) + } + + @Test("upsertSyncedFolder is a no-op when the local version is the same age or newer") + func upsertSyncedFolderNoOpWhenLocalNotOlder() throws { + let db = DatabaseService.makeInMemory() + let id = UUID() + let createdAt = Date(timeIntervalSince1970: 1_000) + let localUpdatedAt = Date(timeIntervalSince1970: 5_000) + try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt) + + // Incoming manifest entry is older than what's already local. + let staleIncomingUpdatedAt = Date(timeIntervalSince1970: 2_000) + try db.upsertSyncedFolder(id: id, name: "Renamed Elsewhere", parentId: nil, createdAt: createdAt, updatedAt: staleIncomingUpdatedAt) + + let folders = try db.listFolders() + let unchanged = try #require(folders.first(where: { $0.id == id })) + #expect(unchanged.name == "Work") + #expect(unchanged.updatedAt == localUpdatedAt) + } + + @Test("upsertSyncedFolder updates name and parent when the incoming version is newer") + func upsertSyncedFolderUpdatesWhenIncomingNewer() throws { + let db = DatabaseService.makeInMemory() + let id = UUID() + let otherParent = try db.createFolder(name: "Other Parent") + let createdAt = Date(timeIntervalSince1970: 1_000) + let localUpdatedAt = Date(timeIntervalSince1970: 2_000) + try db.upsertSyncedFolder(id: id, name: "Work", parentId: nil, createdAt: createdAt, updatedAt: localUpdatedAt) + + let newerIncomingUpdatedAt = Date(timeIntervalSince1970: 9_000) + try db.upsertSyncedFolder( + id: id, name: "Projects", parentId: otherParent.id, createdAt: createdAt, updatedAt: newerIncomingUpdatedAt + ) + + let folders = try db.listFolders() + let updated = try #require(folders.first(where: { $0.id == id })) + #expect(updated.name == "Projects") + #expect(updated.parentId == otherParent.id) + #expect(updated.updatedAt == newerIncomingUpdatedAt) + } + @Test("createFolder(parentId:) nests the new folder under its parent") func createFolderWithParent() throws { let db = DatabaseService.makeInMemory() diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index 5bc12da..9d3211f 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -165,4 +165,71 @@ struct GitSyncServiceTests { let orphans = GitSyncService.orphanedExportFilenames(currentIds: [], files: files) #expect(orphans.isEmpty) } + + // MARK: - orphanedLocalFolderIds + + @Test("A local folder still present in the manifest is not orphaned") + func keepsLocalFoldersStillInManifest() { + let id = UUID().uuidString + let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [id], localFolderIds: [id]) + #expect(orphans.isEmpty) + } + + @Test("A local folder missing from the manifest is orphaned") + func flagsLocalFoldersMissingFromManifest() { + let keptId = UUID().uuidString + let deletedId = UUID().uuidString + let orphans = GitSyncService.orphanedLocalFolderIds( + manifestFolderIds: [keptId], localFolderIds: [keptId, deletedId] + ) + #expect(orphans == [deletedId]) + } + + @Test("An empty manifest never orphans existing local folders") + func emptyManifestNeverOrphansLocalFolders() { + // Same safety guard as orphanedExportFilenames: an empty manifest is indistinguishable + // from "folders.json hasn't been imported yet" (older sync repo, or a fresh clone before + // the first export), so treating it as "delete every local folder" would repeat the exact + // class of mass-deletion bug that hit conversation sync. + let orphans = GitSyncService.orphanedLocalFolderIds( + manifestFolderIds: [], localFolderIds: [UUID().uuidString, UUID().uuidString] + ) + #expect(orphans.isEmpty) + } + + @Test("Empty local folder list produces no orphans") + func emptyLocalFolderListProducesNoOrphans() { + let orphans = GitSyncService.orphanedLocalFolderIds(manifestFolderIds: [UUID().uuidString], localFolderIds: []) + #expect(orphans.isEmpty) + } + + // MARK: - FolderSyncManifest round-trip + + @Test("FolderSyncManifest round-trips through JSON encode/decode") + func folderSyncManifestRoundTrips() throws { + let folderId = UUID().uuidString + let conversationId = UUID().uuidString + let manifest = FolderSyncManifest( + folders: [ + FolderSyncManifest.FolderEntry( + id: folderId, name: "Work", parentId: nil, + createdAt: Date(timeIntervalSince1970: 1_000), updatedAt: Date(timeIntervalSince1970: 2_000) + ) + ], + assignments: [conversationId: folderId] + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let data = try encoder.encode(manifest) + let decoded = try decoder.decode(FolderSyncManifest.self, from: data) + + #expect(decoded.folders.count == 1) + #expect(decoded.folders.first?.id == folderId) + #expect(decoded.folders.first?.name == "Work") + #expect(decoded.assignments[conversationId] == folderId) + } }