Files
oai-swift/oAI/Services/ConversationNotesService.swift
T
rune d93c233453 Sync conversation notes via Git Sync, add discard shortcut to crash-recovery prompt
Notes files now export to notes/ + notes.json alongside conversations.json,
matching folders.json's manifest pattern: matched by conversation ID, never
overwrites notes a machine already has locally, same empty-state orphan-
cleanup safety guard as the existing conversation/folder sync code. Also
adds Cmd+D to the "Discard" button on the crash-recovery restore prompt.

Fixes two Swift 6 actor-isolation build warnings surfaced along the way:
ConversationNotesService and the String filename-sanitizing extension are
pure, state-free helpers called from nonisolated contexts (DatabaseService,
GitSyncService) but defaulted to @MainActor — marked nonisolated.
2026-08-04 08:31:16 +02:00

108 lines
4.6 KiB
Swift

//
// 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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
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")
}
}