Add local source ingest and podcasts
This commit is contained in:
203
Marks/Services/IngestedSourceStore.swift
Normal file
203
Marks/Services/IngestedSourceStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user