import Foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see .
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.
}
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]
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
}
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
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..