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