Add source editing to ingest
All checks were successful
CI / build-and-deploy (push) Successful in 25s
All checks were successful
CI / build-and-deploy (push) Successful in 25s
This commit is contained in:
@@ -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) {
|
func delete(_ source: IngestedSource) {
|
||||||
do {
|
do {
|
||||||
try store.delete(source)
|
try store.delete(source)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SourcesView: View {
|
|||||||
@State private var showTextImport = false
|
@State private var showTextImport = false
|
||||||
@State private var showFileImporter = false
|
@State private var showFileImporter = false
|
||||||
@State private var selectedSource: IngestedSource?
|
@State private var selectedSource: IngestedSource?
|
||||||
|
@State private var editingSource: IngestedSource?
|
||||||
@State private var showFullPlayer = false
|
@State private var showFullPlayer = false
|
||||||
|
|
||||||
private var results: [IngestedSource] {
|
private var results: [IngestedSource] {
|
||||||
@@ -98,6 +99,13 @@ struct SourcesView: View {
|
|||||||
fileURL: library.fileURL(for: source),
|
fileURL: library.fileURL(for: source),
|
||||||
podcastCached: podcastCached(for: source),
|
podcastCached: podcastCached(for: source),
|
||||||
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
|
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
|
||||||
|
onEdit: {
|
||||||
|
selectedSource = nil
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
editingSource = source
|
||||||
|
}
|
||||||
|
},
|
||||||
onPodcast: {
|
onPodcast: {
|
||||||
selectedSource = nil
|
selectedSource = nil
|
||||||
Task {
|
Task {
|
||||||
@@ -107,6 +115,9 @@ struct SourcesView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editingSource) { source in
|
||||||
|
EditSourceView(source: source, library: library)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showFullPlayer) {
|
.sheet(isPresented: $showFullPlayer) {
|
||||||
PodcastPlayerView(
|
PodcastPlayerView(
|
||||||
vm: podcastPlayer,
|
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 {
|
private struct SourceDetailView: View {
|
||||||
let source: IngestedSource
|
let source: IngestedSource
|
||||||
let fileURL: URL?
|
let fileURL: URL?
|
||||||
let podcastCached: Bool
|
let podcastCached: Bool
|
||||||
let podcastGenerating: Bool
|
let podcastGenerating: Bool
|
||||||
|
let onEdit: () -> Void
|
||||||
let onPodcast: () -> Void
|
let onPodcast: () -> Void
|
||||||
|
|
||||||
@State private var previewURL: URL?
|
@State private var previewURL: URL?
|
||||||
@@ -343,6 +419,15 @@ private struct SourceDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Source")
|
.navigationTitle("Source")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
onEdit()
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.quickLookPreview($previewURL)
|
.quickLookPreview($previewURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,4 +176,26 @@ final class AppIntentsTests: XCTestCase {
|
|||||||
XCTAssertEqual(source.tags, ["offline"])
|
XCTAssertEqual(source.tags, ["offline"])
|
||||||
XCTAssertNotNil(store.fileURL(for: source))
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 typed/pasted text with an optional title and tags,
|
||||||
- import PDFs, plain text files, generic text/data files through the document picker,
|
- 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,
|
- search across source title, body text, tags, and original filename,
|
||||||
- open imported originals through QuickLook,
|
- open imported originals through QuickLook,
|
||||||
- delete sources and copied files,
|
- delete sources and copied files,
|
||||||
- create or play a podcast from extractable source text.
|
- 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.
|
`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:
|
Change guidance:
|
||||||
|
|
||||||
- Empty or image-only PDFs may import with no extractable text; podcast actions are disabled when `podcastText` is empty.
|
- 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.
|
- 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
|
## Podcasts
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user