Compare commits
1 Commits
f7735431a8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1d94a4ea |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user