441 lines
16 KiB
Swift
441 lines
16 KiB
Swift
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 editingSource: 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),
|
|
onEdit: {
|
|
selectedSource = nil
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
editingSource = source
|
|
}
|
|
},
|
|
onPodcast: {
|
|
selectedSource = nil
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
playOrGeneratePodcast(for: source)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.sheet(item: $editingSource) { source in
|
|
EditSourceView(source: source, library: library)
|
|
}
|
|
.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 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?
|
|
|
|
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)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
onEdit()
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.quickLookPreview($previewURL)
|
|
}
|
|
|
|
private var podcastTitle: String {
|
|
if podcastGenerating { return "Generating" }
|
|
if podcastCached { return "Play Podcast" }
|
|
return "Create Podcast"
|
|
}
|
|
}
|