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.
108 lines
4.6 KiB
Swift
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")
|
|
}
|
|
}
|