Compare commits

1 Commits

Author SHA1 Message Date
Krishna Kumar
8e1d94a4ea Add source editing to ingest
All checks were successful
CI / build-and-deploy (push) Successful in 25s
2026-07-02 16:35:02 -05:00
4 changed files with 119 additions and 1 deletions

View File

@@ -43,6 +43,15 @@ final class IngestedSourceLibrary {
}
}
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)

View File

@@ -12,6 +12,7 @@ struct SourcesView: View {
@State private var showTextImport = false
@State private var showFileImporter = false
@State private var selectedSource: IngestedSource?
@State private var editingSource: IngestedSource?
@State private var showFullPlayer = false
private var results: [IngestedSource] {
@@ -98,6 +99,13 @@ struct SourcesView: View {
fileURL: library.fileURL(for: source),
podcastCached: podcastCached(for: source),
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
onEdit: {
selectedSource = nil
Task {
try? await Task.sleep(for: .milliseconds(250))
editingSource = source
}
},
onPodcast: {
selectedSource = nil
Task {
@@ -107,6 +115,9 @@ struct SourcesView: View {
}
)
}
.sheet(item: $editingSource) { source in
EditSourceView(source: source, library: library)
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: podcastPlayer,
@@ -272,11 +283,76 @@ private struct ImportTextSourceView: View {
}
}
private struct EditSourceView: View {
let source: IngestedSource
@Bindable var library: IngestedSourceLibrary
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var bodyText: String
@State private var tagsText: String
init(source: IngestedSource, library: IngestedSourceLibrary) {
self.source = source
self.library = library
_title = State(initialValue: source.title)
_bodyText = State(initialValue: source.bodyText)
_tagsText = State(initialValue: source.tags.joined(separator: ", "))
}
var body: some View {
NavigationStack {
Form {
Section("Title") {
TextField("Optional", text: $title)
}
Section {
TextEditor(text: $bodyText)
.frame(minHeight: 220)
.textInputAutocapitalization(.sentences)
} header: {
Text(source.kind == .text ? "Text" : "Extracted Text")
} footer: {
if source.kind != .text {
Text("Editing extracted text does not modify the original file.")
}
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
}
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
library.update(source, title: title, bodyText: bodyText, tags: tagsText.parsedTags)
dismiss()
}
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct SourceDetailView: View {
let source: IngestedSource
let fileURL: URL?
let podcastCached: Bool
let podcastGenerating: Bool
let onEdit: () -> Void
let onPodcast: () -> Void
@State private var previewURL: URL?
@@ -343,6 +419,15 @@ private struct SourceDetailView: View {
}
.navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
onEdit()
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
}
.quickLookPreview($previewURL)
}

View File

@@ -176,4 +176,26 @@ final class AppIntentsTests: XCTestCase {
XCTAssertEqual(source.tags, ["offline"])
XCTAssertNotNil(store.fileURL(for: source))
}
@MainActor
func testSourceLibraryUpdatesMetadataAndSearch() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
let library = IngestedSourceLibrary(store: IngestedSourceStore(rootURL: root))
library.addText(title: "Draft", body: "Original extracted text", tags: ["raw"])
let source = try XCTUnwrap(library.sources.first)
library.update(source, title: "Cleaned Notes", bodyText: "Cleaned source body", tags: ["clean", "notes"])
let updated = try XCTUnwrap(library.sources.first)
XCTAssertEqual(updated.title, "Cleaned Notes")
XCTAssertEqual(updated.bodyText, "Cleaned source body")
XCTAssertEqual(updated.tags, ["clean", "notes"])
XCTAssertGreaterThanOrEqual(updated.modifiedAt, source.modifiedAt)
XCTAssertEqual(library.search("clean").first?.id, source.id)
let reloaded = IngestedSourceStore(rootURL: root)
XCTAssertEqual(try reloaded.load().first?.title, "Cleaned Notes")
}
}

View File

@@ -107,18 +107,20 @@ The Sources tab stores non-web material in Marks. Users can:
- import typed/pasted text with an optional title and tags,
- import PDFs, plain text files, generic text/data files through the document picker,
- edit source title, tags, and stored body/extracted text,
- search across source title, body text, tags, and original filename,
- open imported originals through QuickLook,
- delete sources and copied files,
- create or play a podcast from extractable source text.
`IngestedSourceLibrary.load()` reads `Sources/sources.json` from Application Support and indexes sources into Spotlight. Text imports write metadata/body text directly. File imports copy the selected security-scoped file into `Sources/files/`; PDFs extract text with PDFKit, while other files attempt UTF-8/ASCII extraction.
Editing an imported file source updates Marks' stored extracted text and metadata, but does not modify the copied original file.
Change guidance:
- Empty or image-only PDFs may import with no extractable text; podcast actions are disabled when `podcastText` is empty.
- Source podcasts use `marks-source://{uuid}` as the cache/index URL and send extracted text plus `source_kind` to the backend.
- If changing source storage, update deletion, Spotlight removal, QuickLook file lookup, and source podcast cache behavior together.
- If changing source storage, update editing, deletion, Spotlight removal, QuickLook file lookup, and source podcast cache behavior together.
## Podcasts