2.5.1 #11

Merged
rune merged 10 commits from 2.5.1 into main 2026-08-04 14:12:17 +02:00
10 changed files with 362 additions and 27 deletions
Showing only changes of commit 6480a50eee - Show all commits
+6 -6
View File
@@ -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"
}
}
}
+4 -1
View File
@@ -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
}
}
+18
View File
@@ -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 &amp; 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>
+51 -6
View File
@@ -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
)
}
+107 -6
View File
@@ -196,6 +196,28 @@ class GitSyncService {
log.info("Removed orphaned export for deleted conversation: \(filename)")
}
// Export the folder tree + conversationfolder 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
+5
View File
@@ -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
+11 -7
View File
@@ -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()
+92
View File
@@ -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()
+67
View File
@@ -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)
}
}