diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 98a2ae7..9b661ba 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; + 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; @@ -156,6 +157,7 @@ CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = ""; }; D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = ""; }; + D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; @@ -251,6 +253,7 @@ 7ABBFDF43A1EB773E0A49CFE /* Services */ = { isa = PBXGroup; children = ( + D2860AAA865515225FB9FC65 /* AISummaryStore.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */, C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */, @@ -515,6 +518,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 8afcd53..19dfb05 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -57,7 +57,7 @@ struct MainContainer: View { TagsView(viewModel: viewModel) } Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { - PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) + PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) } .badge(library.unplayedCount) Tab(value: AppTab.search, role: .search) { diff --git a/Marks/Services/AISummaryStore.swift b/Marks/Services/AISummaryStore.swift new file mode 100644 index 0000000..84922e5 --- /dev/null +++ b/Marks/Services/AISummaryStore.swift @@ -0,0 +1,27 @@ +import Foundation + +/// URL-keyed cache of AI summaries so views that only know a URL — e.g. the +/// deliberately-decoupled Podcasts tab — can show a bookmark's summary without +/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches +/// and loads bookmarks; read by the episode detail sheet. +enum AISummaryStore { + private static let key = "aiSummaryByURL" + + static func summary(for url: String) -> String? { + let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:] + let value = store[url] + return (value?.isEmpty ?? true) ? nil : value + } + + static func set(_ summary: String?, for url: String) { + var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:] + if let summary, !summary.isEmpty { + guard store[url] != summary else { return } // no-op if unchanged + store[url] = summary + } else { + guard store[url] != nil else { return } + store.removeValue(forKey: url) + } + UserDefaults.standard.set(store, forKey: key) + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index a5a9545..1e19e2e 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -85,7 +85,7 @@ struct BookmarkListRow: View { ) } .sheet(item: $episodePickerBookmark) { b in - EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude) + EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) } } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 110b825..80a662e 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -299,6 +299,8 @@ final class BookmarksViewModel { "tags": bookmark.aiTags ?? [] ] UserDefaults.standard.set(store, forKey: "aiData") + // Mirror by URL so the decoupled Podcasts tab can show summaries. + AISummaryStore.set(bookmark.aiSummary, for: bookmark.url) } private func restoreAIData() { @@ -311,6 +313,8 @@ final class BookmarksViewModel { if let d = store["\(list[i].id)"] { list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 } list[i].aiTags = d["tags"] as? [String] + // Keep the URL-keyed summary cache populated for existing bookmarks. + if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) } } } } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 5dcb013..778df8c 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -797,6 +797,7 @@ struct MiniPlayerView: View { struct PodcastLibraryView: View { let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @State private var library = PodcastLibrary.shared @State private var showFullPlayer = false @@ -858,7 +859,7 @@ struct PodcastLibraryView: View { ) } .sheet(item: $detailEntry) { entry in - EpisodeDetailView(entry: entry, vm: vm, claude: claude) + EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator) } } .onAppear { library.reload() } @@ -979,6 +980,7 @@ struct EpisodePickerView: View { let bookmark: Bookmark let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @State private var entries: [PodcastEntry] = [] @State private var detailEntry: PodcastEntry? @@ -1035,7 +1037,7 @@ struct EpisodePickerView: View { } } .sheet(item: $detailEntry) { entry in - EpisodeDetailView(entry: entry, vm: vm, claude: claude) + EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator) } } .onAppear { entries = PodcastIndex.find(for: bookmark.url) } @@ -1057,10 +1059,12 @@ struct EpisodeDetailView: View { let entry: PodcastEntry let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var library = PodcastLibrary.shared + @State private var showBrowser = false private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl } private var isPlayed: Bool { @@ -1068,6 +1072,10 @@ struct EpisodeDetailView: View { } private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) } private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl } + private var summary: String? { + AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl) + ?? AISummaryStore.summary(for: entry.articleUrl) + } var body: some View { NavigationStack { @@ -1094,6 +1102,14 @@ struct EpisodeDetailView: View { .padding(.vertical, 4) } + if let summary { + Section("Summary") { + Text(summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Section { Button { if isCurrent { @@ -1116,10 +1132,15 @@ struct EpisodeDetailView: View { if let sourceURL { Section { + Button { + showBrowser = true + } label: { + Label("Open Bookmark", systemImage: "book") + } Button { openURL(sourceURL) } label: { - Label("Open Source", systemImage: "safari") + Label("Open in Safari", systemImage: "safari") } ShareLink(item: sourceURL) { Label("Share Episode", systemImage: "square.and.arrow.up") @@ -1144,6 +1165,15 @@ struct EpisodeDetailView: View { Button("Done") { dismiss() } } } + .sheet(isPresented: $showBrowser) { + if let sourceURL { + BrowserView(url: sourceURL, + title: entry.title ?? domain, + claude: claude, + podcastPlayer: vm, + podcastGenerator: podcastGenerator) + } + } } } }