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

@@ -3,7 +3,7 @@ import AppIntents
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
enum AppTab: Hashable {
case bookmarks, tags, podcasts, search
case bookmarks, tags, sources, podcasts, search
}
/// Bridges App Intents (which run in the main app process, since there is no

View File

@@ -39,6 +39,7 @@ struct MainContainer: View {
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
@State private var library = PodcastLibrary.shared
@State private var sourceLibrary = IngestedSourceLibrary()
@Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
@@ -56,6 +57,14 @@ struct MainContainer: View {
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
TagsView(viewModel: viewModel)
}
Tab("Sources", systemImage: "tray.full", value: AppTab.sources) {
SourcesView(
library: sourceLibrary,
podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator,
claude: viewModel.claude
)
}
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
}

View File

@@ -82,16 +82,35 @@ struct BookmarkCheck: Codable, Sendable {
struct BookmarkCreate: Codable, Sendable {
let url: String
let title: String
let description: String
let tagNames: [String]
let isArchived: Bool
let unread: Bool
let shared: Bool
enum CodingKeys: String, CodingKey {
case url, title, unread, shared
case url, title, description, unread, shared
case tagNames = "tag_names"
case isArchived = "is_archived"
}
init(
url: String,
title: String,
description: String = "",
tagNames: [String],
isArchived: Bool,
unread: Bool,
shared: Bool
) {
self.url = url
self.title = title
self.description = description
self.tagNames = tagNames
self.isArchived = isArchived
self.unread = unread
self.shared = shared
}
}
struct BookmarkUpdate: Codable, Sendable {

View File

@@ -0,0 +1,95 @@
import Foundation
struct IngestPayload: Equatable, Sendable {
let url: String
let title: String?
let notes: String
}
enum IngestPayloadParser {
static func parse(item: Any?, typeIdentifier: String? = nil) -> IngestPayload? {
if let url = item as? URL {
return payload(from: url.absoluteString, notes: "")
}
if let attributed = item as? NSAttributedString {
return payload(from: attributed.string, notes: attributed.string)
}
if let string = item as? String {
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
}
if let data = item as? Data,
let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) {
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
}
return nil
}
private static func payload(from text: String, notes: String) -> IngestPayload? {
guard let url = firstWebURL(in: text) else { return nil }
return IngestPayload(url: url, title: nil, notes: cleanedNotes(notes, excluding: url))
}
static func firstWebURL(in text: String) -> String? {
if let direct = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
isBookmarkable(direct) {
return direct.absoluteString
}
let decoded = decodeHTML(text)
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(decoded.startIndex..<decoded.endIndex, in: decoded)
let match = detector?.matches(in: decoded, range: range).first { result in
guard let url = result.url else { return false }
return isBookmarkable(url)
}
guard let matchRange = match?.range,
let swiftRange = Range(matchRange, in: decoded) else {
return nil
}
let candidate = String(decoded[swiftRange])
.trimmingCharacters(in: CharacterSet(charactersIn: " \n\t\r<>\"'.,;:)]】」"))
guard let url = URL(string: candidate), isBookmarkable(url) else { return nil }
return url.absoluteString
}
private static func isBookmarkable(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
url.host?.isEmpty == false else {
return false
}
return true
}
private static func cleanedNotes(_ notes: String, excluding url: String) -> String {
let trimmed = decodeHTML(notes)
.replacingOccurrences(of: url, with: "")
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: "\n")
return String(trimmed.prefix(1800))
}
private static func decodeHTML(_ text: String) -> String {
text
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
}
private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool {
guard let typeIdentifier else { return true }
return !typeIdentifier.lowercased().contains("url")
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
enum IngestedSourceKind: String, Codable, Sendable, CaseIterable {
case text
case pdf
case file
var label: String {
switch self {
case .text: "Text"
case .pdf: "PDF"
case .file: "File"
}
}
}
struct IngestedSource: Identifiable, Codable, Sendable, Hashable {
let id: UUID
var kind: IngestedSourceKind
var title: String
var bodyText: String
var originalFilename: String?
var localFilename: String?
var tags: [String]
var createdAt: Date
var modifiedAt: Date
init(
id: UUID = UUID(),
kind: IngestedSourceKind,
title: String,
bodyText: String,
originalFilename: String? = nil,
localFilename: String? = nil,
tags: [String] = [],
createdAt: Date = Date(),
modifiedAt: Date = Date()
) {
self.id = id
self.kind = kind
self.title = title
self.bodyText = bodyText
self.originalFilename = originalFilename
self.localFilename = localFilename
self.tags = tags
self.createdAt = createdAt
self.modifiedAt = modifiedAt
}
var displayTitle: String {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
return originalFilename ?? kind.label
}
var excerpt: String {
bodyText
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\n", with: " ")
}
var podcastURL: String {
"marks-source://\(id.uuidString)"
}
var podcastText: String {
String(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100_000))
}
}

View File

@@ -56,9 +56,13 @@ struct ClaudeService: Sendable {
// MARK: - Podcast
func generatePodcast(url: String) async throws -> String {
func generatePodcast(url: String, title: String? = nil, sourceText: String? = nil, sourceKind: String? = nil) async throws -> String {
struct Response: Decodable { let job_id: String }
let data = try await post("/v1/podcast/generate", body: ["url": url])
var body: [String: Any] = ["url": url]
if let title, !title.isEmpty { body["title"] = title }
if let sourceText, !sourceText.isEmpty { body["text"] = sourceText }
if let sourceKind, !sourceKind.isEmpty { body["source_kind"] = sourceKind }
let data = try await post("/v1/podcast/generate", body: body)
return try JSONDecoder().decode(Response.self, from: data).job_id
}

View File

@@ -0,0 +1,203 @@
import Foundation
import Observation
import PDFKit
import UniformTypeIdentifiers
@MainActor
@Observable
final class IngestedSourceLibrary {
private(set) var sources: [IngestedSource] = []
var error: String?
private let store: IngestedSourceStore
init(store: IngestedSourceStore = IngestedSourceStore()) {
self.store = store
}
func load() {
do {
sources = try store.load()
Task { await SourceSpotlightIndexer.index(sources) }
} catch {
self.error = error.localizedDescription
}
}
func addText(title: String, body: String, tags: [String]) {
let source = IngestedSource(
kind: .text,
title: title,
bodyText: body.trimmingCharacters(in: .whitespacesAndNewlines),
tags: tags
)
upsert(source)
}
func importFile(from url: URL, tags: [String]) {
do {
let source = try store.importFile(from: url, tags: tags)
upsert(source)
} catch {
self.error = error.localizedDescription
}
}
func delete(_ source: IngestedSource) {
do {
try store.delete(source)
sources.removeAll { $0.id == source.id }
Task { await SourceSpotlightIndexer.remove(ids: [source.id]) }
} catch {
self.error = error.localizedDescription
}
}
func fileURL(for source: IngestedSource) -> URL? {
store.fileURL(for: source)
}
func search(_ query: String) -> [IngestedSource] {
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !q.isEmpty else { return sources }
return sources.filter { source in
source.displayTitle.lowercased().contains(q) ||
source.bodyText.lowercased().contains(q) ||
source.tags.contains { $0.lowercased().contains(q) } ||
(source.originalFilename ?? "").lowercased().contains(q)
}
}
private func upsert(_ source: IngestedSource) {
sources.removeAll { $0.id == source.id }
sources.insert(source, at: 0)
do {
try store.save(sources)
Task { await SourceSpotlightIndexer.index([source]) }
} catch {
self.error = error.localizedDescription
}
}
}
struct IngestedSourceStore {
private let rootURL: URL
private let metadataURL: URL
private let filesURL: URL
init(rootURL: URL? = nil) {
let base = rootURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
self.rootURL = base.appendingPathComponent("Sources", isDirectory: true)
self.metadataURL = self.rootURL.appendingPathComponent("sources.json")
self.filesURL = self.rootURL.appendingPathComponent("files", isDirectory: true)
}
func load() throws -> [IngestedSource] {
guard FileManager.default.fileExists(atPath: metadataURL.path) else { return [] }
let data = try Data(contentsOf: metadataURL)
return try JSONDecoder.sourceDecoder.decode([IngestedSource].self, from: data)
.sorted { $0.createdAt > $1.createdAt }
}
func save(_ sources: [IngestedSource]) throws {
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
let data = try JSONEncoder.sourceEncoder.encode(sources)
try data.write(to: metadataURL, options: [.atomic])
}
func importFile(from url: URL, tags: [String]) throws -> IngestedSource {
try FileManager.default.createDirectory(at: filesURL, withIntermediateDirectories: true)
let accessed = url.startAccessingSecurityScopedResource()
defer {
if accessed { url.stopAccessingSecurityScopedResource() }
}
let id = UUID()
let originalFilename = url.lastPathComponent
let ext = url.pathExtension.isEmpty ? "dat" : url.pathExtension
let localFilename = "\(id.uuidString).\(ext)"
let destination = filesURL.appendingPathComponent(localFilename)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
let kind = url.pathExtension.lowercased() == "pdf" ? IngestedSourceKind.pdf : .file
let bodyText = kind == .pdf
? PDFTextExtractor.extractText(from: destination)
: PlainTextExtractor.extractText(from: destination)
return IngestedSource(
id: id,
kind: kind,
title: originalFilename.removingFileExtension,
bodyText: bodyText,
originalFilename: originalFilename,
localFilename: localFilename,
tags: tags
)
}
func delete(_ source: IngestedSource) throws {
if let url = fileURL(for: source), FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
var loaded = try load()
loaded.removeAll { $0.id == source.id }
try save(loaded)
}
func fileURL(for source: IngestedSource) -> URL? {
guard let localFilename = source.localFilename else { return nil }
return filesURL.appendingPathComponent(localFilename)
}
}
private enum PDFTextExtractor {
static func extractText(from url: URL) -> String {
guard let document = PDFDocument(url: url) else { return "" }
var pages: [String] = []
for index in 0..<document.pageCount {
if let text = document.page(at: index)?.string?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
pages.append(text)
}
}
return pages.joined(separator: "\n\n")
}
}
private enum PlainTextExtractor {
static func extractText(from url: URL) -> String {
guard let data = try? Data(contentsOf: url) else { return "" }
return String(data: data, encoding: .utf8)
?? String(data: data, encoding: .ascii)
?? ""
}
}
private extension String {
var removingFileExtension: String {
let ns = self as NSString
let name = ns.deletingPathExtension
return name.isEmpty ? self : name
}
}
private extension JSONEncoder {
static var sourceEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
private extension JSONDecoder {
static var sourceDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}

View File

@@ -37,6 +37,8 @@ final class PodcastGenerationManager {
func enqueue(articleUrl: String,
title: String,
parentBookmarkUrl: String? = nil,
sourceText: String? = nil,
sourceKind: String? = nil,
onReady: ((URL, String) -> Void)? = nil) {
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
@@ -52,7 +54,12 @@ final class PodcastGenerationManager {
let task = Task { [weak self] in
guard let self else { return }
do {
let jobId = try await claude.generatePodcast(url: articleUrl)
let jobId = try await claude.generatePodcast(
url: articleUrl,
title: title,
sourceText: sourceText,
sourceKind: sourceKind
)
while !Task.isCancelled {
let status = try await claude.podcastStatus(jobId: jobId)
update(articleUrl) {

View File

@@ -0,0 +1,40 @@
import CoreSpotlight
import Foundation
import UniformTypeIdentifiers
enum SourceSpotlightIndexer {
private static let domainIdentifier = "com.magicive.marks.sources"
static func index(_ sources: [IngestedSource]) async {
guard CSSearchableIndex.isIndexingAvailable(), !sources.isEmpty else { return }
let items = sources.map { source in
let attributes = CSSearchableItemAttributeSet(contentType: source.kind == .pdf ? .pdf : .text)
attributes.title = source.displayTitle
attributes.contentDescription = source.bodyText
attributes.keywords = source.tags + [source.kind.label, source.originalFilename].compactMap { $0 }
attributes.displayName = source.originalFilename ?? source.displayTitle
return CSSearchableItem(
uniqueIdentifier: source.id.uuidString,
domainIdentifier: domainIdentifier,
attributeSet: attributes
)
}
do {
try await CSSearchableIndex.default().indexSearchableItems(items)
Log.spotlight.info("Indexed \(sources.count, privacy: .public) sources")
} catch {
Log.spotlight.error("Source index failed: \(error.localizedDescription, privacy: .public)")
}
}
static func remove(ids: [UUID]) async {
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
do {
try await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids.map(\.uuidString))
Log.spotlight.info("Removed \(ids.count, privacy: .public) sources")
} catch {
Log.spotlight.error("Source delete failed: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -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

View File

@@ -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]) }

View File

@@ -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(

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