Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
conversationId→folderId assignments, imported before conversation
files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
guarded the same way conversation-orphan cleanup already is against
an empty/stale manifest wiping everything.
Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
directly into the database — only reloaded on launch or when the
advanced conversation list closed, with no equivalent hook for the
Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
as an untracked file that then collided with the remote's tracked
copy on the next pull ("untracked working tree files would be
overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
import, so any conversation already synced to a machine before this
feature existed never got filed — which in practice is every
conversation on a second machine, not an edge case. Now backfills
a folder assignment for existing conversations that aren't filed
anywhere locally yet, without clobbering an already-set folderId.
Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
293 lines
10 KiB
Swift
293 lines
10 KiB
Swift
import Foundation
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
enum SyncAuthMethod: String, CaseIterable, Codable {
|
|
case ssh = "ssh"
|
|
case password = "password"
|
|
case token = "token"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .ssh: return "SSH Key"
|
|
case .password: return "Username + Password"
|
|
case .token: return "Access Token"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SyncError: LocalizedError {
|
|
case notConfigured
|
|
case missingCredentials
|
|
case gitNotFound
|
|
case gitFailed(String)
|
|
case repoNotCloned
|
|
case secretsDetected([String])
|
|
case parseError(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notConfigured:
|
|
return "Git sync not configured. Check Settings > Sync."
|
|
case .missingCredentials:
|
|
return "Missing credentials. Check authentication method in Settings > Sync."
|
|
case .gitNotFound:
|
|
return "Git not found. Install Xcode Command Line Tools."
|
|
case .gitFailed(let message):
|
|
return "Git command failed: \(message)"
|
|
case .repoNotCloned:
|
|
return "Repository not cloned. Click 'Clone Repository' first."
|
|
case .secretsDetected(let secrets):
|
|
return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing."
|
|
case .parseError(let message):
|
|
return "Failed to parse conversation: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SyncStatus: Equatable {
|
|
var lastSyncTime: Date?
|
|
var uncommittedChanges: Int = 0
|
|
var isCloned: Bool = false
|
|
var currentBranch: String?
|
|
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
|
|
let createdAt: Date
|
|
let updatedAt: Date
|
|
let primaryModel: String? // Primary model used in conversation
|
|
let messages: [MessageExport]
|
|
|
|
nonisolated struct MessageExport {
|
|
let role: String
|
|
let content: String
|
|
let timestamp: Date
|
|
let tokens: Int?
|
|
let cost: Double?
|
|
let modelId: String? // Model that generated this message
|
|
}
|
|
|
|
nonisolated func toMarkdown() -> String {
|
|
var md = "# \(name)\n\n"
|
|
md += "**ID**: `\(id)`\n"
|
|
md += "**Created**: \(ISO8601DateFormatter().string(from: createdAt))\n"
|
|
md += "**Updated**: \(ISO8601DateFormatter().string(from: updatedAt))\n"
|
|
|
|
// Add primary model if available
|
|
if let primaryModel = primaryModel {
|
|
md += "**Primary Model**: \(primaryModel)\n"
|
|
}
|
|
|
|
// Calculate all unique models used
|
|
let uniqueModels = Set(messages.compactMap { $0.modelId })
|
|
if !uniqueModels.isEmpty {
|
|
md += "**Models Used**: \(uniqueModels.sorted().joined(separator: ", "))\n"
|
|
}
|
|
|
|
md += "\n---\n\n"
|
|
|
|
for message in messages {
|
|
md += "## \(message.role.capitalized)\n\n"
|
|
md += message.content + "\n\n"
|
|
|
|
var meta: [String] = []
|
|
if let modelId = message.modelId {
|
|
meta.append("Model: \(modelId)")
|
|
}
|
|
if let tokens = message.tokens {
|
|
meta.append("Tokens: \(tokens)")
|
|
}
|
|
if let cost = message.cost {
|
|
meta.append(String(format: "Cost: $%.4f", cost))
|
|
}
|
|
if !meta.isEmpty {
|
|
md += "*\(meta.joined(separator: " | "))*\n\n"
|
|
}
|
|
|
|
md += "---\n\n"
|
|
}
|
|
|
|
return md
|
|
}
|
|
|
|
/// Parse markdown back to ConversationExport
|
|
nonisolated static func fromMarkdown(_ markdown: String) throws -> ConversationExport {
|
|
let lines = markdown.components(separatedBy: .newlines)
|
|
var lineIndex = 0
|
|
|
|
// Parse title (first line should be "# Title")
|
|
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("# ") else {
|
|
throw SyncError.parseError("Missing title")
|
|
}
|
|
let name = String(lines[lineIndex].dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
|
lineIndex += 1
|
|
|
|
// Skip empty line
|
|
while lineIndex < lines.count && lines[lineIndex].trimmingCharacters(in: .whitespaces).isEmpty {
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Parse ID
|
|
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**ID**: `") else {
|
|
throw SyncError.parseError("Missing ID")
|
|
}
|
|
let idLine = lines[lineIndex]
|
|
let idStart = idLine.index(idLine.startIndex, offsetBy: 9)
|
|
let idEnd = idLine.lastIndex(of: "`") ?? idLine.endIndex
|
|
let id = String(idLine[idStart..<idEnd])
|
|
lineIndex += 1
|
|
|
|
// Parse Created date
|
|
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**Created**: ") else {
|
|
throw SyncError.parseError("Missing Created date")
|
|
}
|
|
let createdStr = String(lines[lineIndex].dropFirst(13))
|
|
guard let createdAt = ISO8601DateFormatter().date(from: createdStr) else {
|
|
throw SyncError.parseError("Invalid Created date format")
|
|
}
|
|
lineIndex += 1
|
|
|
|
// Parse Updated date
|
|
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**Updated**: ") else {
|
|
throw SyncError.parseError("Missing Updated date")
|
|
}
|
|
let updatedStr = String(lines[lineIndex].dropFirst(13))
|
|
guard let updatedAt = ISO8601DateFormatter().date(from: updatedStr) else {
|
|
throw SyncError.parseError("Invalid Updated date format")
|
|
}
|
|
lineIndex += 1
|
|
|
|
// Parse optional Primary Model
|
|
var primaryModel: String? = nil
|
|
if lineIndex < lines.count && lines[lineIndex].hasPrefix("**Primary Model**: ") {
|
|
primaryModel = String(lines[lineIndex].dropFirst(19)).trimmingCharacters(in: .whitespaces)
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Skip optional Models Used line
|
|
if lineIndex < lines.count && lines[lineIndex].hasPrefix("**Models Used**: ") {
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Skip to first message (past the ---)
|
|
while lineIndex < lines.count && !lines[lineIndex].hasPrefix("## ") {
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Parse messages
|
|
var messages: [MessageExport] = []
|
|
while lineIndex < lines.count {
|
|
// Parse role (## User or ## Assistant)
|
|
guard lines[lineIndex].hasPrefix("## ") else {
|
|
lineIndex += 1
|
|
continue
|
|
}
|
|
let role = String(lines[lineIndex].dropFirst(3)).lowercased().trimmingCharacters(in: .whitespaces)
|
|
lineIndex += 1
|
|
|
|
// Skip empty line
|
|
while lineIndex < lines.count && lines[lineIndex].trimmingCharacters(in: .whitespaces).isEmpty {
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Parse content (until metadata line or ---)
|
|
var content = ""
|
|
while lineIndex < lines.count &&
|
|
!lines[lineIndex].hasPrefix("*Tokens:") &&
|
|
!lines[lineIndex].hasPrefix("---") &&
|
|
!lines[lineIndex].hasPrefix("## ") {
|
|
if !content.isEmpty {
|
|
content += "\n"
|
|
}
|
|
content += lines[lineIndex]
|
|
lineIndex += 1
|
|
}
|
|
content = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
// Parse metadata if present
|
|
var modelId: String? = nil
|
|
var tokens: Int? = nil
|
|
var cost: Double? = nil
|
|
if lineIndex < lines.count && lines[lineIndex].hasPrefix("*") {
|
|
let metaLine = lines[lineIndex]
|
|
// Extract modelId: "Model: gpt-4"
|
|
if let modelMatch = metaLine.range(of: "Model: ([^|\\*]+)", options: .regularExpression) {
|
|
let modelStr = String(metaLine[modelMatch]).dropFirst(7)
|
|
modelId = modelStr.trimmingCharacters(in: .whitespaces)
|
|
}
|
|
// Extract tokens: "Tokens: 50"
|
|
if let tokensMatch = metaLine.range(of: "Tokens: (\\d+)", options: .regularExpression) {
|
|
let tokensStr = String(metaLine[tokensMatch]).dropFirst(8)
|
|
tokens = Int(tokensStr)
|
|
}
|
|
// Extract cost: "Cost: $0.0001"
|
|
if let costMatch = metaLine.range(of: "Cost: \\$([0-9.]+)", options: .regularExpression) {
|
|
let costStr = String(metaLine[costMatch]).dropFirst(7)
|
|
cost = Double(costStr)
|
|
}
|
|
lineIndex += 1
|
|
}
|
|
|
|
// Create message
|
|
messages.append(MessageExport(
|
|
role: role,
|
|
content: content,
|
|
timestamp: Date(), // Use current time as we don't store timestamps in markdown yet
|
|
tokens: tokens,
|
|
cost: cost,
|
|
modelId: modelId
|
|
))
|
|
|
|
// Skip to next message or end
|
|
while lineIndex < lines.count && !lines[lineIndex].hasPrefix("## ") {
|
|
lineIndex += 1
|
|
}
|
|
}
|
|
|
|
return ConversationExport(
|
|
id: id,
|
|
name: name,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt,
|
|
primaryModel: primaryModel,
|
|
messages: messages
|
|
)
|
|
}
|
|
}
|