import Foundation import Observation import PDFKit import UniformTypeIdentifiers @MainActor @Observable final class IngestedSourceLibrary { private(set) var sources: [IngestedSource] = [] var error: String? private let store: IngestedSourceStore init(store: IngestedSourceStore = IngestedSourceStore()) { self.store = store } func load() { do { sources = try store.load() Task { await SourceSpotlightIndexer.index(sources) } } catch { self.error = error.localizedDescription } } func addText(title: String, body: String, tags: [String]) { let source = IngestedSource( kind: .text, title: title, bodyText: body.trimmingCharacters(in: .whitespacesAndNewlines), tags: tags ) upsert(source) } func importFile(from url: URL, tags: [String]) { do { let source = try store.importFile(from: url, tags: tags) upsert(source) } catch { self.error = error.localizedDescription } } func update(_ source: IngestedSource, title: String, bodyText: String, tags: [String]) { var updated = source updated.title = title.trimmingCharacters(in: .whitespacesAndNewlines) updated.bodyText = bodyText.trimmingCharacters(in: .whitespacesAndNewlines) updated.tags = tags updated.modifiedAt = Date() upsert(updated) } func delete(_ source: IngestedSource) { do { try store.delete(source) sources.removeAll { $0.id == source.id } Task { await SourceSpotlightIndexer.remove(ids: [source.id]) } } catch { self.error = error.localizedDescription } } func fileURL(for source: IngestedSource) -> URL? { store.fileURL(for: source) } func search(_ query: String) -> [IngestedSource] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !q.isEmpty else { return sources } return sources.filter { source in source.displayTitle.lowercased().contains(q) || source.bodyText.lowercased().contains(q) || source.tags.contains { $0.lowercased().contains(q) } || (source.originalFilename ?? "").lowercased().contains(q) } } private func upsert(_ source: IngestedSource) { sources.removeAll { $0.id == source.id } sources.insert(source, at: 0) do { try store.save(sources) Task { await SourceSpotlightIndexer.index([source]) } } catch { self.error = error.localizedDescription } } } struct IngestedSourceStore { private let rootURL: URL private let metadataURL: URL private let filesURL: URL init(rootURL: URL? = nil) { let base = rootURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.rootURL = base.appendingPathComponent("Sources", isDirectory: true) self.metadataURL = self.rootURL.appendingPathComponent("sources.json") self.filesURL = self.rootURL.appendingPathComponent("files", isDirectory: true) } func load() throws -> [IngestedSource] { guard FileManager.default.fileExists(atPath: metadataURL.path) else { return [] } let data = try Data(contentsOf: metadataURL) return try JSONDecoder.sourceDecoder.decode([IngestedSource].self, from: data) .sorted { $0.createdAt > $1.createdAt } } func save(_ sources: [IngestedSource]) throws { try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) let data = try JSONEncoder.sourceEncoder.encode(sources) try data.write(to: metadataURL, options: [.atomic]) } func importFile(from url: URL, tags: [String]) throws -> IngestedSource { try FileManager.default.createDirectory(at: filesURL, withIntermediateDirectories: true) let accessed = url.startAccessingSecurityScopedResource() defer { if accessed { url.stopAccessingSecurityScopedResource() } } let id = UUID() let originalFilename = url.lastPathComponent let ext = url.pathExtension.isEmpty ? "dat" : url.pathExtension let localFilename = "\(id.uuidString).\(ext)" let destination = filesURL.appendingPathComponent(localFilename) if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.copyItem(at: url, to: destination) let kind = url.pathExtension.lowercased() == "pdf" ? IngestedSourceKind.pdf : .file let bodyText = kind == .pdf ? PDFTextExtractor.extractText(from: destination) : PlainTextExtractor.extractText(from: destination) return IngestedSource( id: id, kind: kind, title: originalFilename.removingFileExtension, bodyText: bodyText, originalFilename: originalFilename, localFilename: localFilename, tags: tags ) } func delete(_ source: IngestedSource) throws { if let url = fileURL(for: source), FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } var loaded = try load() loaded.removeAll { $0.id == source.id } try save(loaded) } func fileURL(for source: IngestedSource) -> URL? { guard let localFilename = source.localFilename else { return nil } return filesURL.appendingPathComponent(localFilename) } } private enum PDFTextExtractor { static func extractText(from url: URL) -> String { guard let document = PDFDocument(url: url) else { return "" } var pages: [String] = [] for index in 0.. String { guard let data = try? Data(contentsOf: url) else { return "" } return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) ?? "" } } private extension String { var removingFileExtension: String { let ns = self as NSString let name = ns.deletingPathExtension return name.isEmpty ? self : name } } private extension JSONEncoder { static var sourceEncoder: JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] return encoder } } private extension JSONDecoder { static var sourceDecoder: JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } }