From 8e1d94a4ead1da24500aa7909b499586da74ee56 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Thu, 2 Jul 2026 16:35:02 -0500 Subject: [PATCH] Add source editing to ingest --- Marks/Services/IngestedSourceStore.swift | 9 +++ Marks/Views/SourcesView.swift | 85 ++++++++++++++++++++++++ MarksTests/AppIntentsTests.swift | 22 ++++++ openwiki/workflows/product-workflows.md | 4 +- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/Marks/Services/IngestedSourceStore.swift b/Marks/Services/IngestedSourceStore.swift index 49e614c..cc78e1a 100644 --- a/Marks/Services/IngestedSourceStore.swift +++ b/Marks/Services/IngestedSourceStore.swift @@ -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) diff --git a/Marks/Views/SourcesView.swift b/Marks/Views/SourcesView.swift index 13dd6f7..6b82cf5 100644 --- a/Marks/Views/SourcesView.swift +++ b/Marks/Views/SourcesView.swift @@ -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) } diff --git a/MarksTests/AppIntentsTests.swift b/MarksTests/AppIntentsTests.swift index 413be8d..4867adc 100644 --- a/MarksTests/AppIntentsTests.swift +++ b/MarksTests/AppIntentsTests.swift @@ -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") + } } diff --git a/openwiki/workflows/product-workflows.md b/openwiki/workflows/product-workflows.md index 6c6fc2f..16fcd6a 100644 --- a/openwiki/workflows/product-workflows.md +++ b/openwiki/workflows/product-workflows.md @@ -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