Files
oai-swift/oAI/Models/SyncModels.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

275 lines
9.7 KiB
Swift

import Foundation
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI 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 oAI 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://oai.pm>.
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..<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
)
}
}