Add local source ingest and podcasts

This commit is contained in:
Krishna Kumar
2026-07-02 01:11:03 -05:00
parent 4d875e12d6
commit f8693f4be0
18 changed files with 1044 additions and 31 deletions

View File

@@ -0,0 +1,203 @@
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 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..<document.pageCount {
if let text = document.page(at: index)?.string?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
pages.append(text)
}
}
return pages.joined(separator: "\n\n")
}
}
private enum PlainTextExtractor {
static func extractText(from url: URL) -> 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
}
}