Add local source ingest and podcasts
This commit is contained in:
@@ -6,13 +6,44 @@ struct AddBookmarkView: View {
|
||||
|
||||
@State private var url = ""
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var importText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var error: String?
|
||||
@State private var importMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $importText)
|
||||
.frame(minHeight: 96)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
extractImportText()
|
||||
} label: {
|
||||
Label("Extract Link", systemImage: "link.badge.plus")
|
||||
}
|
||||
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let importMessage {
|
||||
Text(importMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import Text")
|
||||
} footer: {
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.keyboardType(.URL)
|
||||
@@ -28,6 +59,13 @@ struct AddBookmarkView: View {
|
||||
Text("Title")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -63,6 +101,23 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractImportText() {
|
||||
guard let payload = IngestPayloadParser.parse(item: importText, typeIdentifier: "public.plain-text") else {
|
||||
importMessage = "No link found"
|
||||
return
|
||||
}
|
||||
|
||||
url = payload.url
|
||||
if let extractedTitle = payload.title, title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
title = extractedTitle
|
||||
}
|
||||
if description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
description = payload.notes
|
||||
}
|
||||
importMessage = "Link extracted"
|
||||
error = nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let urlString = url.normalizedURL
|
||||
guard URL(string: urlString) != nil else {
|
||||
@@ -74,7 +129,12 @@ struct AddBookmarkView: View {
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||
try await viewModel.addBookmark(
|
||||
url: urlString,
|
||||
title: title.trimmingCharacters(in: .whitespaces),
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
tags: tags
|
||||
)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
@@ -128,8 +128,16 @@ final class BookmarksViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||
func addBookmark(url: String, title: String, description: String = "", tags: [String]) async throws {
|
||||
let create = BookmarkCreate(
|
||||
url: url,
|
||||
title: title,
|
||||
description: description,
|
||||
tagNames: tags,
|
||||
isArchived: false,
|
||||
unread: false,
|
||||
shared: false
|
||||
)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
bookmarks.insert(bookmark, at: 0)
|
||||
Task { await SpotlightIndexer.index([bookmark]) }
|
||||
|
||||
@@ -76,7 +76,14 @@ final class PodcastPlayerViewModel {
|
||||
private var interruptionObserver: Any?
|
||||
private var currentPodcastTitle: String = "Marks Podcast"
|
||||
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
|
||||
func start(
|
||||
articleUrl: String,
|
||||
articleTitle: String = "",
|
||||
claude: ClaudeService,
|
||||
parentBookmarkUrl: String? = nil,
|
||||
sourceText: String? = nil,
|
||||
sourceKind: String? = nil
|
||||
) {
|
||||
// Idempotent — don't restart if already generating or playing this article
|
||||
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
|
||||
|
||||
@@ -99,7 +106,12 @@ final class PodcastPlayerViewModel {
|
||||
|
||||
pollingTask = Task {
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(url: articleUrl)
|
||||
let jobId = try await claude.generatePodcast(
|
||||
url: articleUrl,
|
||||
title: articleTitle,
|
||||
sourceText: sourceText,
|
||||
sourceKind: sourceKind
|
||||
)
|
||||
while !Task.isCancelled {
|
||||
let job = try await claude.podcastStatus(jobId: jobId)
|
||||
phase = .generating(
|
||||
|
||||
355
Marks/Views/SourcesView.swift
Normal file
355
Marks/Views/SourcesView.swift
Normal file
@@ -0,0 +1,355 @@
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SourcesView: View {
|
||||
@Bindable var library: IngestedSourceLibrary
|
||||
let podcastPlayer: PodcastPlayerViewModel
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var showTextImport = false
|
||||
@State private var showFileImporter = false
|
||||
@State private var selectedSource: IngestedSource?
|
||||
@State private var showFullPlayer = false
|
||||
|
||||
private var results: [IngestedSource] {
|
||||
library.search(searchText)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { source in
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
selectedSource = source
|
||||
} label: {
|
||||
SourceRow(source: source)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Image(systemName: podcastIcon(for: source))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(source.podcastText.isEmpty)
|
||||
}
|
||||
.swipeActions {
|
||||
Button {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Label("Podcast", systemImage: "headphones")
|
||||
}
|
||||
.tint(.blue)
|
||||
|
||||
Button(role: .destructive) {
|
||||
library.delete(source)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
showTextImport = true
|
||||
} label: {
|
||||
Label("Import Text", systemImage: "doc.text")
|
||||
}
|
||||
Button {
|
||||
showFileImporter = true
|
||||
} label: {
|
||||
Label("Import File", systemImage: "doc.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "No Sources" : "No Results",
|
||||
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
|
||||
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search sources")
|
||||
}
|
||||
.task { library.load() }
|
||||
.sheet(isPresented: $showTextImport) {
|
||||
ImportTextSourceView(library: library)
|
||||
}
|
||||
.sheet(item: $selectedSource) { source in
|
||||
SourceDetailView(
|
||||
source: source,
|
||||
fileURL: library.fileURL(for: source),
|
||||
podcastCached: podcastCached(for: source),
|
||||
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
|
||||
onPodcast: {
|
||||
selectedSource = nil
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
playOrGeneratePodcast(for: source)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: podcastPlayer,
|
||||
articleUrl: podcastPlayer.currentArticleUrl,
|
||||
articleTitle: podcastPlayer.currentArticleTitle,
|
||||
claude: claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.pdf, .plainText, .text, .data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
for url in urls {
|
||||
library.importFile(from: url, tags: [])
|
||||
}
|
||||
case .failure(let error):
|
||||
library.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("Source Error", isPresented: Binding(
|
||||
get: { library.error != nil },
|
||||
set: { if !$0 { library.error = nil } }
|
||||
)) {
|
||||
Button("OK") { library.error = nil }
|
||||
} message: {
|
||||
Text(library.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func playOrGeneratePodcast(for source: IngestedSource) {
|
||||
let podcastURL = source.podcastURL
|
||||
let title = source.displayTitle
|
||||
let existing = PodcastIndex.find(for: podcastURL).first
|
||||
|
||||
if let existing {
|
||||
podcastPlayer.start(articleUrl: existing.articleUrl, articleTitle: existing.title ?? title, claude: claude)
|
||||
showFullPlayer = true
|
||||
return
|
||||
}
|
||||
|
||||
if podcastPlayer.currentArticleUrl.isEmpty {
|
||||
podcastPlayer.start(
|
||||
articleUrl: podcastURL,
|
||||
articleTitle: title,
|
||||
claude: claude,
|
||||
sourceText: source.podcastText,
|
||||
sourceKind: source.kind.rawValue
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else {
|
||||
podcastGenerator.enqueue(
|
||||
articleUrl: podcastURL,
|
||||
title: title,
|
||||
sourceText: source.podcastText,
|
||||
sourceKind: source.kind.rawValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func podcastCached(for source: IngestedSource) -> Bool {
|
||||
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: source.podcastURL).path)
|
||||
}
|
||||
|
||||
private func podcastIcon(for source: IngestedSource) -> String {
|
||||
if podcastGenerator.isGenerating(source.podcastURL) { return "waveform.circle.fill" }
|
||||
if podcastCached(for: source) { return "headphones.circle.fill" }
|
||||
return "headphones.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceRow: View {
|
||||
let source: IngestedSource
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(source.displayTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if !source.excerpt.isEmpty {
|
||||
Text(source.excerpt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(source.kind.label)
|
||||
Text(source.createdAt, style: .date)
|
||||
if !source.tags.isEmpty {
|
||||
Text(source.tags.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch source.kind {
|
||||
case .text: "doc.text"
|
||||
case .pdf: "doc.richtext"
|
||||
case .file: "doc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImportTextSourceView: View {
|
||||
@Bindable var library: IngestedSourceLibrary
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@State private var bodyText = ""
|
||||
@State private var tagsText = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Title") {
|
||||
TextField("Optional", text: $title)
|
||||
}
|
||||
Section("Text") {
|
||||
TextEditor(text: $bodyText)
|
||||
.frame(minHeight: 180)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Import Text")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
library.addText(title: title, body: 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 onPodcast: () -> Void
|
||||
|
||||
@State private var previewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(source.displayTitle)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(source.kind.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !source.tags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(source.tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.blue.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let fileURL {
|
||||
HStack {
|
||||
Button {
|
||||
previewURL = fileURL
|
||||
} label: {
|
||||
Label("Open Original", systemImage: "doc.viewfinder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
onPodcast()
|
||||
} label: {
|
||||
Label(podcastTitle, systemImage: "headphones")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(source.podcastText.isEmpty || podcastGenerating)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
onPodcast()
|
||||
} label: {
|
||||
Label(podcastTitle, systemImage: "headphones")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(source.podcastText.isEmpty || podcastGenerating)
|
||||
}
|
||||
|
||||
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.quickLookPreview($previewURL)
|
||||
}
|
||||
|
||||
private var podcastTitle: String {
|
||||
if podcastGenerating { return "Generating" }
|
||||
if podcastCached { return "Play Podcast" }
|
||||
return "Create Podcast"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user