2.5.1 #11
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1615,7 +1615,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
|
||||
<li>Syncing happens automatically after every explicit save — no separate auto-save toggle needed. See <a href="#unsaved-changes">Unsaved Changes & Crash Recovery</a>.</li>
|
||||
<li><strong>Manual Sync</strong>:
|
||||
<ul>
|
||||
<li><strong>Initialize Repository</strong> - Clone repository for first-time setup</li>
|
||||
<li><strong>Clone Repository</strong> - Clone repository for first-time setup</li>
|
||||
<li><strong>Sync Now</strong> - Full sync (export + pull + import + push)</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
localFolderIds: Set<String>
|
||||
) -> [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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user