// // ConversationNotesService.swift // Confab // // Manages per-conversation notes.md files in Application Support/oAI/notes/ // // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . import Foundation import AppKit /// Stores each conversation's notes.md as a single file under /// `~/Library/Application Support/oAI/notes/`. The filename is a human-readable /// courtesy for anyone browsing in Finder; the conversation's UUID is embedded in the /// file content itself (a `**ID**:` header, same convention as `ConversationExport`) so /// identity never depends on the filename surviving a conversation rename. /// /// All operations are best-effort — a missing or unreadable file is never an error, /// since notes files are explicitly meant to tolerate being renamed, edited, or /// deleted by hand outside the app. nonisolated final class ConversationNotesService { static let shared = ConversationNotesService() private let baseDirectory: URL = { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("oAI/notes", isDirectory: true) }() private func ensureDirectory() { try? FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) } /// A human-readable filename derived from the conversation's name, with a short /// ID suffix so two same-named conversations never collide. func makeFilename(conversationName: String, conversationId: UUID) -> String { let base = conversationName.sanitizedForFilename().nonEmptyOrNil ?? "Untitled" let suffix = conversationId.uuidString.prefix(4).lowercased() return "\(base)-\(suffix).md" } /// Returns the notes body (with the embedded ID header stripped), or nil if the /// file doesn't exist or can't be read. func readBody(filename: String) -> String? { let url = baseDirectory.appendingPathComponent(filename) guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } return Self.stripIDHeader(from: content) } /// Returns the file's exact on-disk content, ID header included — used by GitSyncService to /// export the note byte-for-byte without needing to know the header format. func readRaw(filename: String) -> String? { let url = baseDirectory.appendingPathComponent(filename) return try? String(contentsOf: url, encoding: .utf8) } /// Writes content exactly as given, with no header wrapping — used by GitSyncService to import /// a pulled note file byte-for-byte (it already carries its own embedded ID header). func writeRaw(content: String, filename: String) { ensureDirectory() let url = baseDirectory.appendingPathComponent(filename) try? content.write(to: url, atomically: true, encoding: .utf8) } /// Writes the full notes body, prefixed with the conversation's embedded ID header. func write(body: String, filename: String, conversationId: UUID) { ensureDirectory() let content = "**ID**: `\(conversationId.uuidString)`\n\n\(body)" let url = baseDirectory.appendingPathComponent(filename) try? content.write(to: url, atomically: true, encoding: .utf8) } func delete(filename: String) { let url = baseDirectory.appendingPathComponent(filename) try? FileManager.default.removeItem(at: url) } /// Opens the notes folder in Finder (Settings → Advanced → "Open Notes Folder"). func openNotesFolder() { ensureDirectory() NSWorkspace.shared.open(baseDirectory) } nonisolated static func stripIDHeader(from content: String) -> String { guard content.hasPrefix("**ID**: `") else { return content } var lines = content.components(separatedBy: "\n").dropFirst() if lines.first?.isEmpty == true { lines = lines.dropFirst() } return lines.joined(separator: "\n") } }