Add local source ingest and podcasts

This commit is contained in:
Krishna Kumar
2026-07-02 01:11:03 -05:00
parent 4d875e12d6
commit f8693f4be0
18 changed files with 1044 additions and 31 deletions

View 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"
}
}