diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index ea62ccd25..9030972dd 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "app.omnivore.omnivore" minSdk = 26 targetSdk = 34 - versionCode = 200004 - versionName = "0.200.4" + versionCode = 2000050 + versionName = "0.200.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift new file mode 100644 index 000000000..d2472344f --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift @@ -0,0 +1,303 @@ +import SwiftUI +import Models +import Services + +public class FullScreenDigestViewModel: ObservableObject { + @Published var isLoading = false + @Published var digest: DigestResult? + + func load(dataService: DataService) async { + isLoading = true + if digest == nil { + do { + digest = try await dataService.getLatestDigest(timeoutInterval: 10) + } catch { + print("ERROR WITH DIGEST: ", error) + } + } + isLoading = false + } +} + +struct DigestAudioItem: AudioItemProperties { + let audioItemType = Models.AudioItemType.digest + + var itemID = "" + + var title = "TITLE" + + var byline: String? = "byline" + + var imageURL: URL? = nil + + var language: String? + + var startIndex: Int = 0 + var startOffset: Double = 0.0 +} + +@available(iOS 17.0, *) +@MainActor +struct FullScreenDigestView: View { + let viewModel: DigestViewModel = DigestViewModel() + let dataService: DataService + let audioController: AudioController + + @Environment(\.dismiss) private var dismiss + + let textBody = "In a significant political turn, the SOTU response faces unexpected collapse, " + + "marking a stark contrast to Trump's latest downturn, alongside an unprecedented " + + "surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and " + + "the potential implications for future electoral strategies. Based on the information " + + "you provided, the video seems to discuss a recent event where former President " + + "Donald Trump made a controversial statement that shocked even his own audience. " + + "The video likely covers Trump's response to the State of the Union (SOTU) address " + + "and how it received negative feedback, possibly leading to a decline in his support " + + "or approval ratings. Additionally, it appears that the video touches upon a surge " + + "in fundraising for President Joe Biden's administration around March 11, 2024." + + public init(dataService: DataService, audioController: AudioController) { + self.dataService = dataService + self.audioController = audioController + } + + var body: some View { + // ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) { + Group { + if viewModel.isLoading { + ProgressView() + } else { + itemBody + .task { + await viewModel.load(dataService: dataService) + }.onAppear { + self.audioController.play(itemAudioProperties: DigestAudioItem()) + } + } + } .navigationTitle("Omnivore digest") + .navigationBarTitleDisplayMode(.inline) + +// HStack(alignment: .top) { +// Spacer() +// closeButton +// } +// .padding(20) + // } + } + + var closeButton: some View { + Button(action: { + dismiss() + }, label: { + ZStack { + Circle() + .foregroundColor(Color.appGrayText) + .frame(width: 36, height: 36) + .opacity(0.1) + + Image(systemName: "xmark") + .font(.appCallout) + .frame(width: 36, height: 36) + } + }) + .buttonStyle(.plain) + } + + @available(iOS 17.0, *) + var itemBody: some View { + VStack { + ScrollView(.vertical) { + VStack(spacing: 20) { + Text("SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast") + .font(.title) + Text(textBody) + .font(.body) + } + } + // .scrollTargetBehavior(.paging) + // .ignoresSafeArea() + MiniPlayerViewer() + .padding(.top, 10) + .padding(.bottom, 40) + .background(Color.themeTabBarColor) + .onTapGesture { + // showExpandedAudioPlayer = true + } + } + } +} + +@MainActor +public class PreviewItemViewModel: ObservableObject { + let dataService: DataService + @Published var item: DigestItem + let showSwipeHint: Bool + + @Published var isLoading = false + @Published var resultText: String? + @Published var promptDisplayText: String? + + init(dataService: DataService, item: DigestItem, showSwipeHint: Bool) { + self.dataService = dataService + self.item = item + self.showSwipeHint = showSwipeHint + } + + func loadResult() async { +// isLoading = true +// let taskId = try? await dataService.createAITask( +// extraText: extraText, +// libraryItemId: item?.id ?? "", +// promptName: "summarize-001" +// ) +// +// if let taskId = taskId { +// do { +// let fetchedText = try await dataService.pollAITask(jobId: taskId, timeoutInterval: 30) +// resultText = fetchedText +// } catch { +// print("ERROR WITH RESULT TEXT: ", error) +// } +// } else { +// print("NO TASK ID: ", taskId) +// } +// isLoading = false + } +} + +@MainActor +struct PreviewItemView: View { + @StateObject var viewModel: PreviewItemViewModel + + var body: some View { + VStack(spacing: 10) { + HStack { + AsyncImage(url: viewModel.item.siteIcon) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 20, height: 20, alignment: .center) + } else { + Color.appButtonBackground + .frame(width: 20, height: 20, alignment: .center) + } + } + Text(viewModel.item.site) + .font(Font.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.top, 10) + Text(viewModel.item.title) + // .font(.body) + // .fontWeight(.semibold) + .font(Font.system(size: 18, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Text(viewModel.item.author) + .font(Font.system(size: 14)) + .foregroundColor(Color(hex: "898989")) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Color(hex: "2A2A2A") + .frame(height: 1) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + + if viewModel.isLoading { + ProgressView() + .task { + await viewModel.loadResult() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text(viewModel.item.summaryText) + .font(Font.system(size: 16)) + // .font(.body) + .lineSpacing(12.0) + .frame(maxWidth: .infinity, alignment: .topLeading) + HStack { + Button(action: {}, label: { + HStack(alignment: .center) { + Text("Start listening") + .font(Font.system(size: 14)) + .frame(height: 42, alignment: .center) + Image(systemName: "play.fill") + .resizable() + .frame(width: 10, height: 10) + } + .padding(.horizontal, 15) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(18) + }) + Spacer() + } + .padding(.top, 20) + } + Spacer() + if viewModel.showSwipeHint { + VStack { + Image.doubleChevronUp + Text("Swipe up for next article") + .foregroundColor(Color(hex: "898989")) + } + .padding(.bottom, 50) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + .padding(.horizontal, 15) + + } +} + +struct RatingView: View { + @State private var rating: Int = 0 + + var body: some View { + VStack(spacing: 30) { + Text("Rate today's digest") + .font(.title) + .padding(.vertical, 40) + Text("I liked the stories picked for today's digest") + RatingWidget() + + Text("The stories were interesting") + RatingWidget() + + Text("The voices sounded good") + RatingWidget() + + Text("I liked the music") + RatingWidget() + Spacer() + }.padding(.top, 60) + } +} + +struct StarView: View { + var isFilled: Bool + var body: some View { + Image(systemName: isFilled ? "star.fill" : "star") + .foregroundColor(isFilled ? Color.yellow : Color.gray) + } +} + +struct RatingWidget: View { + @State private var rating: Int = 0 + var body: some View { + HStack { + ForEach(1...5, id: \.self) { index in + StarView(isFilled: index <= rating) + .onTapGesture { + rating = index + } + } + } + .padding() + .background(Color(hex: "313131")) + .cornerRadius(8) + // .shadow(radius: 3) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedAudioPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedAudioPlayer.swift index bf23ae8af..2a0a32049 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedAudioPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedAudioPlayer.swift @@ -45,7 +45,7 @@ if audioController.playbackError { return AnyView(Color.clear) } - if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) { + if audioController.isLoadingItem(audioController.itemAudioProperties) { return AnyView(ProgressView()) } else { return AnyView(Button( @@ -98,11 +98,11 @@ } ).padding(.trailing, 5) - if !(audioController.itemAudioProperties?.isArchived ?? false) { + if !((audioController.itemAudioProperties as? LinkedItemAudioProperties)?.isArchived ?? false) { Button( action: { performArchive() }, label: { - if audioController.itemAudioProperties?.isArchived ?? false { + if (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.isArchived ?? false { Image .toolbarUnarchive .foregroundColor(Color.toolbarItemForeground) @@ -130,20 +130,20 @@ } } - func performViewArticle() { - if let objectID = audioController.itemAudioProperties?.objectID { - viewArticle(objectID) - } - } +// func performViewArticle() { +// if let objectID = audioController.itemAudioProperties?.objectID { +// viewArticle(objectID) +// } +// } func performDelete() { - if let objectID = audioController.itemAudioProperties?.objectID { + if let objectID = (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.objectID { delete(objectID) } } func performArchive() { - if let objectID = audioController.itemAudioProperties?.objectID { + if let objectID = (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.objectID { archive(objectID) } } @@ -467,7 +467,7 @@ } public var innerBody: some View { - if let itemAudioProperties = self.audioController.itemAudioProperties { + if let itemAudioProperties = self.audioController.itemAudioProperties as? LinkedItemAudioProperties { return AnyView( playerContent(itemAudioProperties) .tint(.appGrayTextContrast) diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index e86a00351..8f194d5ae 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -23,14 +23,14 @@ public var body: some View { ZStack(alignment: .center) { presentingView - if let itemAudioProperties = self.audioController.itemAudioProperties { + if self.audioController.itemAudioProperties != nil { ZStack(alignment: .bottom) { Color.systemBackground.edgesIgnoringSafeArea(.bottom) .frame(height: expanded ? 0 : 110, alignment: .bottom) VStack { Spacer(minLength: 0) - MiniPlayerViewer(itemAudioProperties: itemAudioProperties) + MiniPlayerViewer() } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayerViewer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayerViewer.swift index 75f41b36a..fb0c31b20 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayerViewer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayerViewer.swift @@ -13,8 +13,6 @@ @State var expanded = true - let itemAudioProperties: LinkedItemAudioProperties - var playPauseButtonImage: String { switch audioController.state { case .playing: @@ -32,7 +30,7 @@ if audioController.playbackError { return AnyView(Color.clear) } - if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) { + if audioController.isLoadingItem(audioController.itemAudioProperties) { return AnyView(ProgressView()) } else { return AnyView(Button( @@ -84,8 +82,8 @@ .buttonStyle(PlainButtonStyle()) } - func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View { - if let imageURL = itemAudioProperties.imageURL { + func artwork(_ itemAudioProperties: AudioItemProperties?, forDimensions dim: Double) -> some View { + if let imageURL = itemAudioProperties?.imageURL { return AnyView(AsyncImage(url: imageURL) { phase in if let image = phase.image { image @@ -125,9 +123,9 @@ Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote) Spacer(minLength: 0) } else { - artwork(itemAudioProperties, forDimensions: 50) + artwork(audioController.itemAudioProperties, forDimensions: 50) - Text(itemAudioProperties.title) + Text(audioController.itemAudioProperties?.title ?? "") .font(Font.system(size: 17, weight: .medium)) .fixedSize(horizontal: false, vertical: true) .lineLimit(2) diff --git a/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift index 4b9691d12..67efbb9f8 100644 --- a/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift +++ b/apple/OmnivoreKit/Sources/App/Views/BottomBars/CustomTabBar.swift @@ -2,16 +2,31 @@ import Foundation import SwiftUI struct CustomTabBar: View { + let displayTabs: [String] @Binding var selectedTab: String - let hideFollowingTab: Bool var body: some View { HStack(spacing: 0) { - if !hideFollowingTab { - TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab) + if displayTabs.contains("following") { + TabBarButton(key: "following", + image: Image.tabFollowing, + selectedTab: $selectedTab, + selectionColor: Color(hex: "EE8232")) + } + if displayTabs.contains("digest") { + TabBarButton(key: "digest", + image: Image.tabDigest, + selectedTab: $selectedTab, + selectedImage: Image.tabDigestSelected) + } + if displayTabs.contains("inbox") { + TabBarButton(key: "inbox", + image: Image.tabLibrary, + selectedTab: $selectedTab) + } + if displayTabs.contains("profile") { + TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab) } - TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab) - TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab) } .padding(.top, 10) .padding(.bottom, 10) @@ -23,6 +38,8 @@ struct TabBarButton: View { let key: String let image: Image @Binding var selectedTab: String + var selectedImage: Image? + var selectionColor: Color? var body: some View { Button(action: { @@ -31,13 +48,20 @@ struct TabBarButton: View { } selectedTab = key }, label: { - image - .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) + tabImage .frame(width: 28, height: 28) - .foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor) .frame(maxWidth: .infinity) }).buttonStyle(.plain) } + + var tabImage: some View { + if let selectedImage = selectedImage, selectedTab == key { + return AnyView(selectedImage + .resizable() + .aspectRatio(contentMode: .fit)) + } else { + return AnyView(image + .foregroundColor(selectedTab == key ? selectionColor ?? Color.blue : Color.themeTabButtonColor)) + } + } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 5611537e9..3b22ac24e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -191,6 +191,7 @@ struct AnimatingCellHeight: AnimatableModifier { @State var isListScrolled = false @State var listTitle = "" @State var showExpandedAudioPlayer = false + @State var showLibraryDigest = false @Binding var isEditMode: EditMode @@ -203,6 +204,9 @@ struct AnimatingCellHeight: AnimatableModifier { @ObservedObject var viewModel: HomeFeedViewModel @State private var selection = Set() + @AppStorage("LibraryList::digestEnabled") var digestEnabled = false + @AppStorage("LibraryList::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false + init(viewModel: HomeFeedViewModel, isEditMode: Binding) { _viewModel = ObservedObject(wrappedValue: viewModel) _isEditMode = isEditMode @@ -224,7 +228,7 @@ struct AnimatingCellHeight: AnimatableModifier { } var body: some View { - ZStack { + ZStack { HomeFeedView( listTitle: $listTitle, isListScrolled: $isListScrolled, @@ -264,8 +268,8 @@ struct AnimatingCellHeight: AnimatableModifier { VStack(spacing: 0) { Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, 20) .background(Color.themeTabBarColor) @@ -316,6 +320,15 @@ struct AnimatingCellHeight: AnimatableModifier { } ) } + .fullScreenCover(isPresented: $showLibraryDigest) { + if #available(iOS 17.0, *) { + NavigationView { + FullScreenDigestView(dataService: dataService, audioController: audioController) + } + } else { + Text("Sorry digest is only available on iOS 17 and above") + } + } .toolbar { toolbarItems } @@ -338,6 +351,20 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.stopUsingFollowingPrimer = true } } + .task { + do { + if let viewer = try await dataService.fetchViewer() { + digestEnabled = viewer.digestEnabled ?? false + if !hasCheckedForDigestFeature { + hasCheckedForDigestFeature = true + // selectedTab = "digest" + } + } + } catch { + print("ERROR FETCHING VIEWER: ", error) + print("") + } + } .environment(\.editMode, self.$isEditMode) .navigationBarTitleDisplayMode(.inline) } @@ -382,6 +409,14 @@ struct AnimatingCellHeight: AnimatableModifier { if isEditMode == .active { Button(action: { isEditMode = .inactive }, label: { Text("Cancel") }) } else { + if #available(iOS 17.0, *) { + Button( + action: { showLibraryDigest = true }, + label: { Image.tabDigestSelected } + ) + .buttonStyle(.plain) + .padding(.trailing, 4) + } if prefersListLayout { Button( action: { isEditMode = isEditMode == .active ? .inactive : .active }, @@ -471,12 +506,12 @@ struct AnimatingCellHeight: AnimatableModifier { let showFeatureCards: Bool var slideTransition: PresentationLinkTransition { PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true - ) - )) + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true + ) + )) } var body: some View { @@ -484,12 +519,12 @@ struct AnimatingCellHeight: AnimatableModifier { if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false { PresentationLink( transition: PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true, - preferredPresentationBackgroundColor: ThemeManager.currentBgColor - ))), + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true, + preferredPresentationBackgroundColor: ThemeManager.currentBgColor + ))), isPresented: $viewModel.presentWebContainer, destination: { WebReaderLoadingContainer(requestID: linkRequest.serverID) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index f8a16d598..e98786b5a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 6/29/23. -// - import Foundation import Models import Services @@ -76,6 +69,30 @@ struct LibraryTabView: View { @State var operationStatus: OperationStatus = .none @State var operationMessage: String? + @State var digestEnabled = false + + var showDigest: Bool { + if digestEnabled, #available(iOS 17.0, *) { + return true + } + return false + } + + var displayTabs: [String] { + var res = [String]() + if !hideFollowingTab { + res.append("following") + } + if showDigest { + res.append("digest") + } + res.append("inbox") + if !showDigest { + res.append("profile") + } + return res + } + var body: some View { VStack(spacing: 0) { WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) { @@ -116,19 +133,32 @@ struct LibraryTabView: View { }.tag("following") } - NavigationView { - HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode) - .navigationBarTitleDisplayMode(.inline) - .navigationViewStyle(.stack) - }.tag("inbox") + if showDigest, #available(iOS 17.0, *) { + NavigationView { + DigestView(dataService: dataService) + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + }.tag("digest") + NavigationView { + HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode) + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + }.tag("inbox") + } else { + NavigationView { + HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode) + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(.stack) + }.tag("inbox") + NavigationView { + ProfileView() + .navigationViewStyle(.stack) + }.tag("profile") + } - NavigationView { - ProfileView() - .navigationViewStyle(.stack) - }.tag("profile") } - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .onTapGesture { showExpandedAudioPlayer = true } @@ -138,7 +168,9 @@ struct LibraryTabView: View { .frame(maxWidth: .infinity) } if isEditMode != .active { - CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab) + CustomTabBar( + displayTabs: displayTabs, + selectedTab: $selectedTab) .padding(0) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 634553453..34de3e874 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -52,7 +52,7 @@ import Transmission return AnyView(splitView) #endif } - + func startTimer(amount: Int) { self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in DispatchQueue.main.async { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index 8c5468f16..beaaa9cc7 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -11,6 +11,7 @@ struct WebReader: PlatformViewRepresentable { let articleContent: ArticleContent let openLinkAction: (URL) -> Void let tapHandler: () -> Void + let explainHandler: ((String) -> Void)? let scrollPercentHandler: (Int) -> Void let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void let navBarVisibilityUpdater: (Bool) -> Void @@ -51,6 +52,7 @@ struct WebReader: PlatformViewRepresentable { let contentController = WKUserContentController() webView.tapHandler = tapHandler + webView.explainHandler = explainHandler webView.navigationDelegate = context.coordinator webView.configuration.userContentController = contentController webView.configuration.userContentController.removeAllScriptMessageHandlers() diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 1cb4aab3d..93491b275 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -41,6 +41,8 @@ struct WebReaderContainerView: View { @State var displayLinkSheet = false @State var linkToOpen: URL? + @State var showExplainSheet = false + @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @Environment(\.openURL) var openURL @@ -92,6 +94,11 @@ struct WebReaderContainerView: View { } } + private func explainHandler(text: String) { + viewModel.explainText = String(text) + showExplainSheet = true + } + private func handleHighlightAction(message: WKScriptMessage) { guard let messageBody = message.body as? [String: String] else { return } guard let actionID = messageBody["actionID"] else { return } @@ -126,7 +133,7 @@ struct WebReaderContainerView: View { #if os(iOS) var audioNavbarItem: some View { - if !audioController.playbackError, audioController.isLoadingItem(itemID: item.unwrappedID) { + if audioController.isLoadingItem(audioController.itemAudioProperties) { return AnyView(ProgressView() .padding(.horizontal)) } else { @@ -271,6 +278,13 @@ struct WebReaderContainerView: View { Spacer() #endif +// Button( +// action: { showExplainSheet = true }, +// label: { Image(systemName: "sparkles") } +// ) +// .buttonStyle(.plain) +// .padding(.trailing, 4) + Button( action: { showLabelsModal = true }, label: { @@ -378,6 +392,7 @@ struct WebReaderContainerView: View { #endif }, tapHandler: tapHandler, + explainHandler: explainHandler, scrollPercentHandler: scrollPercentHandler, webViewActionHandler: webViewActionHandler, navBarVisibilityUpdater: { visible in @@ -600,8 +615,8 @@ struct WebReaderContainerView: View { .offset(y: navBarVisible ? 0 : -150) Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, showBottomBar ? 10 : 40) .background(Color.themeTabBarColor) @@ -633,7 +648,6 @@ struct WebReaderContainerView: View { try? WebViewManager.shared().dispatchEvent(.saveReadPosition) } .onDisappear { - // WebViewManager.shared().loadHTMLString("", baseURL: nil) WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil) } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in @@ -674,7 +688,7 @@ struct WebReaderContainerView: View { shareActionID = UUID() } - func print() { + func printReader() { shareActionID = UUID() } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 1a70626cf..7d5071194 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -21,6 +21,8 @@ struct SafariWebLink: Identifiable { @Published var showOperationToast: Bool = false @Published var operationStatus: OperationStatus = .none + @Published var explainText: String? + func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool { if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host { if host == "omnivore.app" { diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 07612b47e..57cf07b2e 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -137,6 +137,7 @@ + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift new file mode 100644 index 000000000..2bccfc724 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift @@ -0,0 +1,10 @@ + +public struct FeatureInternal { + public let name: String + public let enabled: Bool + + public init(name: String, enabled: Bool) { + self.name = name + self.enabled = enabled + } +} diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 53aa7a504..cbf918195 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -27,8 +27,7 @@ public struct LinkedItemSyncResult { hasMore: Bool, mostRecentUpdatedAt: Date?, oldestUpdatedAt: Date?, - isEmpty: Bool) - { + isEmpty: Bool) { self.updatedItemIDs = updatedItemIDs self.cursor = cursor self.hasMore = hasMore @@ -38,7 +37,41 @@ public struct LinkedItemSyncResult { } } -public struct LinkedItemAudioProperties { +public enum AudioItemType { + case digest + case libraryItem +} + +public protocol AudioItemProperties { + var audioItemType: AudioItemType { + get + } + var itemID: String { + get + } + var title: String { + get + } + var byline: String? { + get + } + var imageURL: URL? { + get + } + var language: String? { + get + } + var startIndex: Int { + get + } + var startOffset: Double { + get + } +} + +public struct LinkedItemAudioProperties: AudioItemProperties { + public let audioItemType = AudioItemType.libraryItem + public let itemID: String public let objectID: NSManagedObjectID public let title: String diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 318c3388a..313265e66 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -33,7 +33,7 @@ @Published public var currentAudioIndex: Int = 0 @Published public var readText: String = "" @Published public var unreadText: String = "" - @Published public var itemAudioProperties: LinkedItemAudioProperties? + @Published public var itemAudioProperties: AudioItemProperties? @Published public var timeElapsed: TimeInterval = 0 @Published public var duration: TimeInterval = 0 @@ -72,7 +72,7 @@ observer = nil } - public func play(itemAudioProperties: LinkedItemAudioProperties) { + public func play(itemAudioProperties: AudioItemProperties) { stop() playbackError = false @@ -539,11 +539,14 @@ state == .playing } - public func isLoadingItem(itemID: String) -> Bool { + public func isLoadingItem(_ audioItem: AudioItemProperties?) -> Bool { if state == .reachedEnd { return false } - return itemAudioProperties?.itemID == itemID && isLoading + if audioItem?.itemID == nil { + return false + } + return itemAudioProperties?.itemID == audioItem?.itemID && isLoading } public func isPlayingItem(itemID: String) -> Bool { @@ -860,8 +863,19 @@ } return "" } - + func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + switch(self.itemAudioProperties?.audioItemType) { + case .digest: + return try await downloadDigestItemSpeechFile(itemID: itemID, priority: priority) + case .libraryItem: + return try await downloadLibraryItemSpeechFile(itemID: itemID, priority: priority) + case .none: + return nil + } + } + + func downloadLibraryItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { let decoder = JSONDecoder() let speechFileUrl = pathForSpeechFile(itemID: itemID) @@ -910,6 +924,60 @@ return nil } + func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + let decoder = JSONDecoder() + let speechFileUrl = URL.om_documentsDirectory.appendingPathComponent("digest").appendingPathComponent("speech-\(currentVoice).json") + + if FileManager.default.fileExists(atPath: speechFileUrl.path) { + let data = try Data(contentsOf: speechFileUrl) + document = try decoder.decode(SpeechDocument.self, from: data) + // If we can't load it from disk we make the API call + if let document = document { + return document + } + } + + let path = "/api/digest/v1/" + guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else { + throw BasicError.message(messageText: "Invalid audio URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (header, value) in dataService.networker.defaultHeaders { + request.setValue(value, forHTTPHeaderField: header) + } + + let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request) + guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else { + throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.") + } + + guard let data = result?.0 else { + throw BasicError.message(messageText: "audioFetch failed. no data received.") + } + + let str = String(decoding: data, as: UTF8.self) + print("result digest file: ", str) + + do { + let digest = try JSONDecoder().decode(DigestResult.self, from: data) + let directory = URL.om_documentsDirectory.appendingPathComponent("digest") + // do { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: speechFileUrl) + return digest.speechFile +// } catch { +// print("error writing file", error) +// } + // } + } catch { + print("error with digest file", error) + } + + return nil + } + func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { document = try await downloadSpeechFile(itemID: itemID, priority: priority) return document diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift index 2939c2cf4..2946c0fb0 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift @@ -20,7 +20,7 @@ struct UtteranceRequest: Codable { let isOpenAIVoice: Bool } -struct Utterance: Decodable { +public struct Utterance: Decodable { public let idx: String public let text: String public let voice: String? @@ -39,7 +39,7 @@ struct Utterance: Decodable { } } -struct SpeechDocument: Decodable { +public struct SpeechDocument: Decodable { static let averageWPM: Double = 195 public let pageId: String diff --git a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift index 550389c56..fb450b0d6 100644 --- a/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift +++ b/apple/OmnivoreKit/Sources/Services/Authentication/AuthModels.swift @@ -75,7 +75,7 @@ extension LoginError { return .unauthorized case .unknown: return .unknown - case .pendingEmailVerification: + case .pendingEmailVerification, .stillProcessing: return .pendingEmailVerification } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift new file mode 100644 index 000000000..7ea58022c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift @@ -0,0 +1,118 @@ +import CoreData +import Foundation +import Models +import Utils + +struct AITaskRequest: Decodable { + public let requestId: String +} + +public struct DigestResult: Decodable { + public let id: String + public let title: String + public let content: String + public let urlsToAudio: [String] + public let speechFile: SpeechDocument + + public let jobState: String +} + +public struct DigestItem: Decodable { + public let id: String + public let site: String + public let siteIcon: URL? + public let author: String + public let title: String + public let summaryText: String + public let keyPointsText: String + public let highlightsText: String + public init(id: String, site: String, siteIcon: URL?, + author: String, title: String, summaryText: String, + keyPointsText: String, highlightsText: String) { + self.id = id + self.site = site + self.siteIcon = siteIcon + self.author = author + self.title = title + self.summaryText = summaryText + self.keyPointsText = keyPointsText + self.highlightsText = highlightsText + } +} + +extension DataService { +// public func createAITask(extraText: String?, libraryItemId: String, promptName: String) async throws -> String? { +// let jsonData = try JSONSerialization.data(withJSONObject: [ +// "libraryItemId": libraryItemId, +// "promptName": promptName, +// "extraText": extraText +// ]) +// +// let urlRequest = URLRequest.create( +// baseURL: appEnvironment.serverBaseURL, +// urlPath: "/api/ai-task", +// requestMethod: .post(params: jsonData), +// includeAuthToken: true +// ) +// let resource = ServerResource( +// urlRequest: urlRequest, +// decode: AITaskRequest.decode +// ) +// +// do { +// let taskRequest = try await networker.urlSession.performRequest(resource: resource) +// return taskRequest.requestId +// } catch { +// return nil +// } +// } + + // Function to poll the status of the AI task with timeout + public func getLatestDigest(timeoutInterval: TimeInterval) async throws -> DigestResult? { + var count = 0 + let startTime = Date() + while true { + count += 1 + if count > 3 { + return nil + } + do { + // Check if timeout has occurred + if -startTime.timeIntervalSinceNow >= timeoutInterval { + throw NSError(domain: "Timeout Error", code: -1, userInfo: nil) + } + + let urlRequest = URLRequest.create( + baseURL: appEnvironment.serverBaseURL, + urlPath: "/api/digest/v1/", + requestMethod: .get, + includeAuthToken: true + ) + + let resource = ServerResource( + urlRequest: urlRequest, + decode: DigestResult.decode + ) + + do { + let digest = try await networker.urlSession.performRequest(resource: resource) + print("GOT RESPONSE: ", digest) + return digest + } catch { + print("ERROR FETCHING TASK: ", error) +// if let response = error as? ServerError { +// if response != .stillProcessing { +// return nil +// } +// } + } + // Wait for some time before polling again + try? await Task.sleep(nanoseconds: 3_000_000_000) + } catch let error { + throw error + } + } + } +} + + diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift index 601b2e4cf..7e51b2a1a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Errors/ServerError.swift @@ -10,6 +10,7 @@ public enum ServerError: String, Error { case unauthenticated case timeout case unknown + case stillProcessing case pendingEmailVerification } @@ -22,6 +23,8 @@ extension ServerError { case 401?, 403?: self = .unauthenticated return + case 202: + self = .stillProcessing default: break } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index 49979b626..cd3cf2b2c 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -20,6 +20,137 @@ extension Objects.Subscription: GraphQLWebSocketOperation { // MARK: - Objects enum Objects {} +extension Objects { + struct AddDiscoverFeedError { + let __typename: TypeName = .addDiscoverFeedError + let errorCodes: [String: [Enums.AddDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case addDiscoverFeedError = "AddDiscoverFeedError" + } + } +} + +extension Objects.AddDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.AddDiscoverFeedError { + func errorCodes() throws -> [Enums.AddDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedError = Selection +} + +extension Objects { + struct AddDiscoverFeedSuccess { + let __typename: TypeName = .addDiscoverFeedSuccess + let feed: [String: Objects.DiscoverFeed] + + enum TypeName: String, Codable { + case addDiscoverFeedSuccess = "AddDiscoverFeedSuccess" + } + } +} + +extension Objects.AddDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feed": + if let value = try container.decode(Objects.DiscoverFeed?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feed = map["feed"] + } +} + +extension Fields where TypeLock == Objects.AddDiscoverFeedSuccess { + func feed(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feed", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedSuccess = Selection +} + extension Objects { struct AddPopularReadError { let __typename: TypeName = .addPopularReadError @@ -3921,6 +4052,266 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountSuccess = Selection } +extension Objects { + struct DeleteDiscoverArticleError { + let __typename: TypeName = .deleteDiscoverArticleError + let errorCodes: [String: [Enums.DeleteDiscoverArticleErrorCode]] + + enum TypeName: String, Codable { + case deleteDiscoverArticleError = "DeleteDiscoverArticleError" + } + } +} + +extension Objects.DeleteDiscoverArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverArticleError { + func errorCodes() throws -> [Enums.DeleteDiscoverArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleError = Selection +} + +extension Objects { + struct DeleteDiscoverArticleSuccess { + let __typename: TypeName = .deleteDiscoverArticleSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverArticleSuccess = "DeleteDiscoverArticleSuccess" + } + } +} + +extension Objects.DeleteDiscoverArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverArticleSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleSuccess = Selection +} + +extension Objects { + struct DeleteDiscoverFeedError { + let __typename: TypeName = .deleteDiscoverFeedError + let errorCodes: [String: [Enums.DeleteDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case deleteDiscoverFeedError = "DeleteDiscoverFeedError" + } + } +} + +extension Objects.DeleteDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverFeedError { + func errorCodes() throws -> [Enums.DeleteDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedError = Selection +} + +extension Objects { + struct DeleteDiscoverFeedSuccess { + let __typename: TypeName = .deleteDiscoverFeedSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverFeedSuccess = "DeleteDiscoverFeedSuccess" + } + } +} + +extension Objects.DeleteDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.DeleteDiscoverFeedSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedSuccess = Selection +} + extension Objects { struct DeleteFilterError { let __typename: TypeName = .deleteFilterError @@ -5475,6 +5866,867 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeviceTokensSuccess = Selection } +extension Objects { + struct DiscoverFeed { + let __typename: TypeName = .discoverFeed + let description: [String: String] + let id: [String: String] + let image: [String: String] + let link: [String: String] + let title: [String: String] + let type: [String: String] + let visibleName: [String: String] + + enum TypeName: String, Codable { + case discoverFeed = "DiscoverFeed" + } + } +} + +extension Objects.DiscoverFeed: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "link": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "type": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "visibleName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + description = map["description"] + id = map["id"] + image = map["image"] + link = map["link"] + title = map["title"] + type = map["type"] + visibleName = map["visibleName"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeed { + func description() throws -> String? { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.description[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func link() throws -> String { + let field = GraphQLField.leaf( + name: "link", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.link[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func type() throws -> String { + let field = GraphQLField.leaf( + name: "type", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.type[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func visibleName() throws -> String? { + let field = GraphQLField.leaf( + name: "visibleName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.visibleName[field.alias!] + case .mocking: + return nil + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeed = Selection +} + +extension Objects { + struct DiscoverFeedArticle { + let __typename: TypeName = .discoverFeedArticle + let author: [String: String] + let description: [String: String] + let feed: [String: String] + let id: [String: String] + let image: [String: String] + let publishedDate: [String: DateTime] + let savedId: [String: String] + let savedLinkUrl: [String: String] + let siteName: [String: String] + let slug: [String: String] + let title: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case discoverFeedArticle = "DiscoverFeedArticle" + } + } +} + +extension Objects.DiscoverFeedArticle: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "author": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feed": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "image": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "publishedDate": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "savedId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "savedLinkUrl": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "siteName": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "slug": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "title": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + author = map["author"] + description = map["description"] + feed = map["feed"] + id = map["id"] + image = map["image"] + publishedDate = map["publishedDate"] + savedId = map["savedId"] + savedLinkUrl = map["savedLinkUrl"] + siteName = map["siteName"] + slug = map["slug"] + title = map["title"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedArticle { + func author() throws -> String? { + let field = GraphQLField.leaf( + name: "author", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.author[field.alias!] + case .mocking: + return nil + } + } + + func description() throws -> String { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.description[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func feed() throws -> String { + let field = GraphQLField.leaf( + name: "feed", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feed[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func image() throws -> String? { + let field = GraphQLField.leaf( + name: "image", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.image[field.alias!] + case .mocking: + return nil + } + } + + func publishedDate() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "publishedDate", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.publishedDate[field.alias!] + case .mocking: + return nil + } + } + + func savedId() throws -> String? { + let field = GraphQLField.leaf( + name: "savedId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.savedId[field.alias!] + case .mocking: + return nil + } + } + + func savedLinkUrl() throws -> String? { + let field = GraphQLField.leaf( + name: "savedLinkUrl", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.savedLinkUrl[field.alias!] + case .mocking: + return nil + } + } + + func siteName() throws -> String? { + let field = GraphQLField.leaf( + name: "siteName", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.siteName[field.alias!] + case .mocking: + return nil + } + } + + func slug() throws -> String { + let field = GraphQLField.leaf( + name: "slug", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.slug[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func title() throws -> String { + let field = GraphQLField.leaf( + name: "title", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.title[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedArticle = Selection +} + +extension Objects { + struct DiscoverFeedError { + let __typename: TypeName = .discoverFeedError + let errorCodes: [String: [Enums.DiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case discoverFeedError = "DiscoverFeedError" + } + } +} + +extension Objects.DiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedError { + func errorCodes() throws -> [Enums.DiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedError = Selection +} + +extension Objects { + struct DiscoverFeedSuccess { + let __typename: TypeName = .discoverFeedSuccess + let feeds: [String: [Objects.DiscoverFeed?]] + + enum TypeName: String, Codable { + case discoverFeedSuccess = "DiscoverFeedSuccess" + } + } +} + +extension Objects.DiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "feeds": + if let value = try container.decode([Objects.DiscoverFeed?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Objects.DiscoverFeedSuccess { + func feeds(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "feeds", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.feeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedSuccess = Selection +} + +extension Objects { + struct DiscoverTopic { + let __typename: TypeName = .discoverTopic + let description: [String: String] + let name: [String: String] + + enum TypeName: String, Codable { + case discoverTopic = "DiscoverTopic" + } + } +} + +extension Objects.DiscoverTopic: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "description": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + description = map["description"] + name = map["name"] + } +} + +extension Fields where TypeLock == Objects.DiscoverTopic { + func description() throws -> String { + let field = GraphQLField.leaf( + name: "description", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.description[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverTopic = Selection +} + +extension Objects { + struct EditDiscoverFeedError { + let __typename: TypeName = .editDiscoverFeedError + let errorCodes: [String: [Enums.EditDiscoverFeedErrorCode]] + + enum TypeName: String, Codable { + case editDiscoverFeedError = "EditDiscoverFeedError" + } + } +} + +extension Objects.EditDiscoverFeedError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.EditDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.EditDiscoverFeedError { + func errorCodes() throws -> [Enums.EditDiscoverFeedErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedError = Selection +} + +extension Objects { + struct EditDiscoverFeedSuccess { + let __typename: TypeName = .editDiscoverFeedSuccess + let id: [String: String] + + enum TypeName: String, Codable { + case editDiscoverFeedSuccess = "EditDiscoverFeedSuccess" + } + } +} + +extension Objects.EditDiscoverFeedSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + id = map["id"] + } +} + +extension Fields where TypeLock == Objects.EditDiscoverFeedSuccess { + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedSuccess = Selection +} + extension Objects { struct EmptyTrashError { let __typename: TypeName = .emptyTrashError @@ -5602,6 +6854,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias EmptyTrashSuccess = Selection } +extension Objects { + struct ExportToIntegrationError { + let __typename: TypeName = .exportToIntegrationError + let errorCodes: [String: [Enums.ExportToIntegrationErrorCode]] + + enum TypeName: String, Codable { + case exportToIntegrationError = "ExportToIntegrationError" + } + } +} + +extension Objects.ExportToIntegrationError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ExportToIntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.ExportToIntegrationError { + func errorCodes() throws -> [Enums.ExportToIntegrationErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationError = Selection +} + +extension Objects { + struct ExportToIntegrationSuccess { + let __typename: TypeName = .exportToIntegrationSuccess + let task: [String: Objects.Task] + + enum TypeName: String, Codable { + case exportToIntegrationSuccess = "ExportToIntegrationSuccess" + } + } +} + +extension Objects.ExportToIntegrationSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "task": + if let value = try container.decode(Objects.Task?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + task = map["task"] + } +} + +extension Fields where TypeLock == Objects.ExportToIntegrationSuccess { + func task(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "task", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.task[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationSuccess = Selection +} + extension Objects { struct Feature { let __typename: TypeName = .feature @@ -7500,6 +8883,287 @@ extension Selection where TypeLock == Never, Type == Never { typealias GenerateApiKeySuccess = Selection } +extension Objects { + struct GetDiscoverFeedArticleError { + let __typename: TypeName = .getDiscoverFeedArticleError + let errorCodes: [String: [Enums.GetDiscoverFeedArticleErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleError = "GetDiscoverFeedArticleError" + } + } +} + +extension Objects.GetDiscoverFeedArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverFeedArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverFeedArticleError { + func errorCodes() throws -> [Enums.GetDiscoverFeedArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleError = Selection +} + +extension Objects { + struct GetDiscoverFeedArticleSuccess { + let __typename: TypeName = .getDiscoverFeedArticleSuccess + let discoverArticles: [String: [Objects.DiscoverFeedArticle?]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleSuccess = "GetDiscoverFeedArticleSuccess" + } + } +} + +extension Objects.GetDiscoverFeedArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverArticles": + if let value = try container.decode([Objects.DiscoverFeedArticle?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + discoverArticles = map["discoverArticles"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverFeedArticleSuccess { + func discoverArticles(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverArticles", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.discoverArticles[field.alias!]) + case .mocking: + return selection.mock() + } + } + + func pageInfo(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "pageInfo", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.pageInfo[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleSuccess = Selection +} + +extension Objects { + struct GetDiscoverTopicError { + let __typename: TypeName = .getDiscoverTopicError + let errorCodes: [String: [Enums.GetDiscoverTopicErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverTopicError = "GetDiscoverTopicError" + } + } +} + +extension Objects.GetDiscoverTopicError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverTopicErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverTopicError { + func errorCodes() throws -> [Enums.GetDiscoverTopicErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicError = Selection +} + +extension Objects { + struct GetDiscoverTopicSuccess { + let __typename: TypeName = .getDiscoverTopicSuccess + let discoverTopics: [String: [Objects.DiscoverTopic]] + + enum TypeName: String, Codable { + case getDiscoverTopicSuccess = "GetDiscoverTopicSuccess" + } + } +} + +extension Objects.GetDiscoverTopicSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverTopics": + if let value = try container.decode([Objects.DiscoverTopic]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + discoverTopics = map["discoverTopics"] + } +} + +extension Fields where TypeLock == Objects.GetDiscoverTopicSuccess { + func discoverTopics(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverTopics", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.discoverTopics[field.alias!]) + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicSuccess = Selection +} + extension Objects { struct GetFollowersError { let __typename: TypeName = .getFollowersError @@ -9292,6 +10956,137 @@ extension Selection where TypeLock == Never, Type == Never { typealias Integration = Selection } +extension Objects { + struct IntegrationError { + let __typename: TypeName = .integrationError + let errorCodes: [String: [Enums.IntegrationErrorCode]] + + enum TypeName: String, Codable { + case integrationError = "IntegrationError" + } + } +} + +extension Objects.IntegrationError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.IntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.IntegrationError { + func errorCodes() throws -> [Enums.IntegrationErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationError = Selection +} + +extension Objects { + struct IntegrationSuccess { + let __typename: TypeName = .integrationSuccess + let integration: [String: Objects.Integration] + + enum TypeName: String, Codable { + case integrationSuccess = "IntegrationSuccess" + } + } +} + +extension Objects.IntegrationSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "integration": + if let value = try container.decode(Objects.Integration?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + integration = map["integration"] + } +} + +extension Fields where TypeLock == Objects.IntegrationSuccess { + func integration(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "integration", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.integration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationSuccess = Selection +} + extension Objects { struct IntegrationsError { let __typename: TypeName = .integrationsError @@ -11440,6 +13235,7 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct Mutation { let __typename: TypeName = .mutation + let addDiscoverFeed: [String: Unions.AddDiscoverFeedResult] let addPopularRead: [String: Unions.AddPopularReadResult] let bulkAction: [String: Unions.BulkActionResult] let createArticle: [String: Unions.CreateArticleResult] @@ -11449,6 +13245,8 @@ extension Objects { let createLabel: [String: Unions.CreateLabelResult] let createNewsletterEmail: [String: Unions.CreateNewsletterEmailResult] let deleteAccount: [String: Unions.DeleteAccountResult] + let deleteDiscoverArticle: [String: Unions.DeleteDiscoverArticleResult] + let deleteDiscoverFeed: [String: Unions.DeleteDiscoverFeedResult] let deleteFilter: [String: Unions.DeleteFilterResult] let deleteHighlight: [String: Unions.DeleteHighlightResult] let deleteIntegration: [String: Unions.DeleteIntegrationResult] @@ -11456,7 +13254,9 @@ extension Objects { let deleteNewsletterEmail: [String: Unions.DeleteNewsletterEmailResult] let deleteRule: [String: Unions.DeleteRuleResult] let deleteWebhook: [String: Unions.DeleteWebhookResult] + let editDiscoverFeed: [String: Unions.EditDiscoverFeedResult] let emptyTrash: [String: Unions.EmptyTrashResult] + let exportToIntegration: [String: Unions.ExportToIntegrationResult] let fetchContent: [String: Unions.FetchContentResult] let generateApiKey: [String: Unions.GenerateApiKeyResult] let googleLogin: [String: Unions.LoginResult] @@ -11476,6 +13276,7 @@ extension Objects { let reportItem: [String: Objects.ReportItemResult] let revokeApiKey: [String: Unions.RevokeApiKeyResult] let saveArticleReadingProgress: [String: Unions.SaveArticleReadingProgressResult] + let saveDiscoverArticle: [String: Unions.SaveDiscoverArticleResult] let saveFile: [String: Unions.SaveResult] let saveFilter: [String: Unions.SaveFilterResult] let savePage: [String: Unions.SaveResult] @@ -11522,6 +13323,10 @@ extension Objects.Mutation: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "addDiscoverFeed": + if let value = try container.decode(Unions.AddDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "addPopularRead": if let value = try container.decode(Unions.AddPopularReadResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11558,6 +13363,14 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteAccountResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "deleteDiscoverArticle": + if let value = try container.decode(Unions.DeleteDiscoverArticleResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "deleteDiscoverFeed": + if let value = try container.decode(Unions.DeleteDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "deleteFilter": if let value = try container.decode(Unions.DeleteFilterResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11586,10 +13399,18 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.DeleteWebhookResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "editDiscoverFeed": + if let value = try container.decode(Unions.EditDiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "emptyTrash": if let value = try container.decode(Unions.EmptyTrashResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "exportToIntegration": + if let value = try container.decode(Unions.ExportToIntegrationResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "fetchContent": if let value = try container.decode(Unions.FetchContentResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11666,6 +13487,10 @@ extension Objects.Mutation: Decodable { if let value = try container.decode(Unions.SaveArticleReadingProgressResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "saveDiscoverArticle": + if let value = try container.decode(Unions.SaveDiscoverArticleResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "saveFile": if let value = try container.decode(Unions.SaveResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -11784,6 +13609,7 @@ extension Objects.Mutation: Decodable { } } + addDiscoverFeed = map["addDiscoverFeed"] addPopularRead = map["addPopularRead"] bulkAction = map["bulkAction"] createArticle = map["createArticle"] @@ -11793,6 +13619,8 @@ extension Objects.Mutation: Decodable { createLabel = map["createLabel"] createNewsletterEmail = map["createNewsletterEmail"] deleteAccount = map["deleteAccount"] + deleteDiscoverArticle = map["deleteDiscoverArticle"] + deleteDiscoverFeed = map["deleteDiscoverFeed"] deleteFilter = map["deleteFilter"] deleteHighlight = map["deleteHighlight"] deleteIntegration = map["deleteIntegration"] @@ -11800,7 +13628,9 @@ extension Objects.Mutation: Decodable { deleteNewsletterEmail = map["deleteNewsletterEmail"] deleteRule = map["deleteRule"] deleteWebhook = map["deleteWebhook"] + editDiscoverFeed = map["editDiscoverFeed"] emptyTrash = map["emptyTrash"] + exportToIntegration = map["exportToIntegration"] fetchContent = map["fetchContent"] generateApiKey = map["generateApiKey"] googleLogin = map["googleLogin"] @@ -11820,6 +13650,7 @@ extension Objects.Mutation: Decodable { reportItem = map["reportItem"] revokeApiKey = map["revokeApiKey"] saveArticleReadingProgress = map["saveArticleReadingProgress"] + saveDiscoverArticle = map["saveDiscoverArticle"] saveFile = map["saveFile"] saveFilter = map["saveFilter"] savePage = map["savePage"] @@ -11851,6 +13682,25 @@ extension Objects.Mutation: Decodable { } extension Fields where TypeLock == Objects.Mutation { + func addDiscoverFeed(input: InputObjects.AddDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "addDiscoverFeed", + arguments: [Argument(name: "input", type: "AddDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.addDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func addPopularRead(name: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "addPopularRead", @@ -12022,6 +13872,44 @@ extension Fields where TypeLock == Objects.Mutation { } } + func deleteDiscoverArticle(input: InputObjects.DeleteDiscoverArticleInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteDiscoverArticle", + arguments: [Argument(name: "input", type: "DeleteDiscoverArticleInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteDiscoverArticle[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func deleteDiscoverFeed(input: InputObjects.DeleteDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "deleteDiscoverFeed", + arguments: [Argument(name: "input", type: "DeleteDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.deleteDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func deleteFilter(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "deleteFilter", @@ -12155,6 +14043,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func editDiscoverFeed(input: InputObjects.EditDiscoverFeedInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "editDiscoverFeed", + arguments: [Argument(name: "input", type: "EditDiscoverFeedInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.editDiscoverFeed[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func emptyTrash(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "emptyTrash", @@ -12174,6 +14081,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func exportToIntegration(integrationId: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "exportToIntegration", + arguments: [Argument(name: "integrationId", type: "ID!", value: integrationId)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.exportToIntegration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func fetchContent(id: String, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "fetchContent", @@ -12535,6 +14461,25 @@ extension Fields where TypeLock == Objects.Mutation { } } + func saveDiscoverArticle(input: InputObjects.SaveDiscoverArticleInput, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "saveDiscoverArticle", + arguments: [Argument(name: "input", type: "SaveDiscoverArticleInput!", value: input)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveDiscoverArticle[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func saveFile(input: InputObjects.SaveFileInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "saveFile", @@ -14218,11 +16163,15 @@ extension Objects { let article: [String: Unions.ArticleResult] let articleSavingRequest: [String: Unions.ArticleSavingRequestResult] let deviceTokens: [String: Unions.DeviceTokensResult] + let discoverFeeds: [String: Unions.DiscoverFeedResult] + let discoverTopics: [String: Unions.GetDiscoverTopicResults] let feeds: [String: Unions.FeedsResult] let filters: [String: Unions.FiltersResult] + let getDiscoverFeedArticles: [String: Unions.GetDiscoverFeedArticleResults] let getUserPersonalization: [String: Unions.GetUserPersonalizationResult] let groups: [String: Unions.GroupsResult] let hello: [String: String] + let integration: [String: Unions.IntegrationResult] let integrations: [String: Unions.IntegrationsResult] let labels: [String: Unions.LabelsResult] let me: [String: Objects.User] @@ -14276,6 +16225,14 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.DeviceTokensResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "discoverFeeds": + if let value = try container.decode(Unions.DiscoverFeedResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "discoverTopics": + if let value = try container.decode(Unions.GetDiscoverTopicResults?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "feeds": if let value = try container.decode(Unions.FeedsResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14284,6 +16241,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(Unions.FiltersResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "getDiscoverFeedArticles": + if let value = try container.decode(Unions.GetDiscoverFeedArticleResults?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "getUserPersonalization": if let value = try container.decode(Unions.GetUserPersonalizationResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14296,6 +16257,10 @@ extension Objects.Query: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "integration": + if let value = try container.decode(Unions.IntegrationResult?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "integrations": if let value = try container.decode(Unions.IntegrationsResult?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -14382,11 +16347,15 @@ extension Objects.Query: Decodable { article = map["article"] articleSavingRequest = map["articleSavingRequest"] deviceTokens = map["deviceTokens"] + discoverFeeds = map["discoverFeeds"] + discoverTopics = map["discoverTopics"] feeds = map["feeds"] filters = map["filters"] + getDiscoverFeedArticles = map["getDiscoverFeedArticles"] getUserPersonalization = map["getUserPersonalization"] groups = map["groups"] hello = map["hello"] + integration = map["integration"] integrations = map["integrations"] labels = map["labels"] me = map["me"] @@ -14485,6 +16454,44 @@ extension Fields where TypeLock == Objects.Query { } } + func discoverFeeds(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverFeeds", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.discoverFeeds[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + + func discoverTopics(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "discoverTopics", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.discoverTopics[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func feeds(input: InputObjects.FeedsInput, selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "feeds", @@ -14523,6 +16530,25 @@ extension Fields where TypeLock == Objects.Query { } } + func getDiscoverFeedArticles(after: OptionalArgument = .absent(), discoverTopicId: String, feedId: OptionalArgument = .absent(), first: OptionalArgument = .absent(), selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "getDiscoverFeedArticles", + arguments: [Argument(name: "after", type: "String", value: after), Argument(name: "discoverTopicId", type: "String!", value: discoverTopicId), Argument(name: "feedId", type: "ID", value: feedId), Argument(name: "first", type: "Int", value: first)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.getDiscoverFeedArticles[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func getUserPersonalization(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "getUserPersonalization", @@ -14576,6 +16602,25 @@ extension Fields where TypeLock == Objects.Query { } } + func integration(name: String, selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "integration", + arguments: [Argument(name: "name", type: "String!", value: name)], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.integration[field.alias!] { + return try selection.decode(data: data) + } + throw HttpError.badpayload + case .mocking: + return selection.mock() + } + } + func integrations(selection: Selection) throws -> Type { let field = GraphQLField.composite( name: "integrations", @@ -17133,6 +19178,7 @@ extension Objects { let createdAt: [String: DateTime] let enabled: [String: Bool] let eventTypes: [String: [Enums.RuleEventType]] + let failedAt: [String: DateTime] let filter: [String: String] let id: [String: String] let name: [String: String] @@ -17172,6 +19218,10 @@ extension Objects.Rule: Decodable { if let value = try container.decode([Enums.RuleEventType]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "failedAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "filter": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -17202,6 +19252,7 @@ extension Objects.Rule: Decodable { createdAt = map["createdAt"] enabled = map["enabled"] eventTypes = map["eventTypes"] + failedAt = map["failedAt"] filter = map["filter"] id = map["id"] name = map["name"] @@ -17283,6 +19334,21 @@ extension Fields where TypeLock == Objects.Rule { } } + func failedAt() throws -> DateTime? { + let field = GraphQLField.leaf( + name: "failedAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.failedAt[field.alias!] + case .mocking: + return nil + } + } + func filter() throws -> String { let field = GraphQLField.leaf( name: "filter", @@ -17708,6 +19774,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressSuccess = Selection } +extension Objects { + struct SaveDiscoverArticleError { + let __typename: TypeName = .saveDiscoverArticleError + let errorCodes: [String: [Enums.SaveDiscoverArticleErrorCode]] + + enum TypeName: String, Codable { + case saveDiscoverArticleError = "SaveDiscoverArticleError" + } + } +} + +extension Objects.SaveDiscoverArticleError: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SaveDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Objects.SaveDiscoverArticleError { + func errorCodes() throws -> [Enums.SaveDiscoverArticleErrorCode] { + let field = GraphQLField.leaf( + name: "errorCodes", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.errorCodes[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return [] + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleError = Selection +} + +extension Objects { + struct SaveDiscoverArticleSuccess { + let __typename: TypeName = .saveDiscoverArticleSuccess + let saveId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case saveDiscoverArticleSuccess = "SaveDiscoverArticleSuccess" + } + } +} + +extension Objects.SaveDiscoverArticleSuccess: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "saveId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + saveId = map["saveId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Objects.SaveDiscoverArticleSuccess { + func saveId() throws -> String { + let field = GraphQLField.leaf( + name: "saveId", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.saveId[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func url() throws -> String { + let field = GraphQLField.leaf( + name: "url", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.url[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleSuccess = Selection +} + extension Objects { struct SaveError { let __typename: TypeName = .saveError @@ -22200,6 +24420,227 @@ extension Selection where TypeLock == Never, Type == Never { typealias SyncUpdatedItemEdge = Selection } +extension Objects { + struct Task { + let __typename: TypeName = .task + let cancellable: [String: Bool] + let createdAt: [String: DateTime] + let failedReason: [String: String] + let id: [String: String] + let name: [String: String] + let progress: [String: Double] + let runningTime: [String: Int] + let state: [String: Enums.TaskState] + + enum TypeName: String, Codable { + case task = "Task" + } + } +} + +extension Objects.Task: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "cancellable": + if let value = try container.decode(Bool?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "createdAt": + if let value = try container.decode(DateTime?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "failedReason": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "name": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "progress": + if let value = try container.decode(Double?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "runningTime": + if let value = try container.decode(Int?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "state": + if let value = try container.decode(Enums.TaskState?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + cancellable = map["cancellable"] + createdAt = map["createdAt"] + failedReason = map["failedReason"] + id = map["id"] + name = map["name"] + progress = map["progress"] + runningTime = map["runningTime"] + state = map["state"] + } +} + +extension Fields where TypeLock == Objects.Task { + func cancellable() throws -> Bool? { + let field = GraphQLField.leaf( + name: "cancellable", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.cancellable[field.alias!] + case .mocking: + return nil + } + } + + func createdAt() throws -> DateTime { + let field = GraphQLField.leaf( + name: "createdAt", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.createdAt[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return DateTime.mockValue + } + } + + func failedReason() throws -> String? { + let field = GraphQLField.leaf( + name: "failedReason", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.failedReason[field.alias!] + case .mocking: + return nil + } + } + + func id() throws -> String { + let field = GraphQLField.leaf( + name: "id", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.id[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func name() throws -> String { + let field = GraphQLField.leaf( + name: "name", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.name[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return String.mockValue + } + } + + func progress() throws -> Double? { + let field = GraphQLField.leaf( + name: "progress", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.progress[field.alias!] + case .mocking: + return nil + } + } + + func runningTime() throws -> Int? { + let field = GraphQLField.leaf( + name: "runningTime", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.runningTime[field.alias!] + case .mocking: + return nil + } + } + + func state() throws -> Enums.TaskState { + let field = GraphQLField.leaf( + name: "state", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.state[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.TaskState.allCases.first! + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias Task = Selection +} + extension Objects { struct TypeaheadSearchError { let __typename: TypeName = .typeaheadSearchError @@ -24845,6 +27286,7 @@ extension Objects { struct User { let __typename: TypeName = .user let email: [String: String] + let featureList: [String: [Objects.Feature]] let features: [String: [String?]] let followersCount: [String: Int] let friendsCount: [String: Int] @@ -24884,6 +27326,10 @@ extension Objects.User: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "featureList": + if let value = try container.decode([Objects.Feature]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "features": if let value = try container.decode([String?]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -24959,6 +27405,7 @@ extension Objects.User: Decodable { } email = map["email"] + featureList = map["featureList"] features = map["features"] followersCount = map["followersCount"] friendsCount = map["friendsCount"] @@ -24994,6 +27441,22 @@ extension Fields where TypeLock == Objects.User { } } + func featureList(selection: Selection) throws -> Type { + let field = GraphQLField.composite( + name: "featureList", + arguments: [], + selection: selection.selection + ) + select(field) + + switch response { + case let .decoding(data): + return try selection.decode(data: data.featureList[field.alias!]) + case .mocking: + return selection.mock() + } + } + func features() throws -> [String?]? { let field = GraphQLField.leaf( name: "features", @@ -26308,6 +28771,80 @@ enum Interfaces {} // MARK: - Unions enum Unions {} +extension Unions { + struct AddDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.AddDiscoverFeedErrorCode]] + let feed: [String: Objects.DiscoverFeed] + + enum TypeName: String, Codable { + case addDiscoverFeedError = "AddDiscoverFeedError" + case addDiscoverFeedSuccess = "AddDiscoverFeedSuccess" + } + } +} + +extension Unions.AddDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.AddDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feed": + if let value = try container.decode(Objects.DiscoverFeed?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feed = map["feed"] + } +} + +extension Fields where TypeLock == Unions.AddDiscoverFeedResult { + func on(addDiscoverFeedError: Selection, addDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "AddDiscoverFeedError", selection: addDiscoverFeedError.selection), GraphQLField.fragment(type: "AddDiscoverFeedSuccess", selection: addDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .addDiscoverFeedError: + let data = Objects.AddDiscoverFeedError(errorCodes: data.errorCodes) + return try addDiscoverFeedError.decode(data: data) + case .addDiscoverFeedSuccess: + let data = Objects.AddDiscoverFeedSuccess(feed: data.feed) + return try addDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return addDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias AddDiscoverFeedResult = Selection +} + extension Unions { struct AddPopularReadResult { let __typename: TypeName @@ -27590,6 +30127,154 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeleteAccountResult = Selection } +extension Unions { + struct DeleteDiscoverArticleResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteDiscoverArticleErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverArticleError = "DeleteDiscoverArticleError" + case deleteDiscoverArticleSuccess = "DeleteDiscoverArticleSuccess" + } + } +} + +extension Unions.DeleteDiscoverArticleResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.DeleteDiscoverArticleResult { + func on(deleteDiscoverArticleError: Selection, deleteDiscoverArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteDiscoverArticleError", selection: deleteDiscoverArticleError.selection), GraphQLField.fragment(type: "DeleteDiscoverArticleSuccess", selection: deleteDiscoverArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteDiscoverArticleError: + let data = Objects.DeleteDiscoverArticleError(errorCodes: data.errorCodes) + return try deleteDiscoverArticleError.decode(data: data) + case .deleteDiscoverArticleSuccess: + let data = Objects.DeleteDiscoverArticleSuccess(id: data.id) + return try deleteDiscoverArticleSuccess.decode(data: data) + } + case .mocking: + return deleteDiscoverArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverArticleResult = Selection +} + +extension Unions { + struct DeleteDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DeleteDiscoverFeedErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case deleteDiscoverFeedError = "DeleteDiscoverFeedError" + case deleteDiscoverFeedSuccess = "DeleteDiscoverFeedSuccess" + } + } +} + +extension Unions.DeleteDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DeleteDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.DeleteDiscoverFeedResult { + func on(deleteDiscoverFeedError: Selection, deleteDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DeleteDiscoverFeedError", selection: deleteDiscoverFeedError.selection), GraphQLField.fragment(type: "DeleteDiscoverFeedSuccess", selection: deleteDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .deleteDiscoverFeedError: + let data = Objects.DeleteDiscoverFeedError(errorCodes: data.errorCodes) + return try deleteDiscoverFeedError.decode(data: data) + case .deleteDiscoverFeedSuccess: + let data = Objects.DeleteDiscoverFeedSuccess(id: data.id) + return try deleteDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return deleteDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DeleteDiscoverFeedResult = Selection +} + extension Unions { struct DeleteFilterResult { let __typename: TypeName @@ -28404,6 +31089,154 @@ extension Selection where TypeLock == Never, Type == Never { typealias DeviceTokensResult = Selection } +extension Unions { + struct DiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.DiscoverFeedErrorCode]] + let feeds: [String: [Objects.DiscoverFeed?]] + + enum TypeName: String, Codable { + case discoverFeedError = "DiscoverFeedError" + case discoverFeedSuccess = "DiscoverFeedSuccess" + } + } +} + +extension Unions.DiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.DiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feeds": + if let value = try container.decode([Objects.DiscoverFeed?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + feeds = map["feeds"] + } +} + +extension Fields where TypeLock == Unions.DiscoverFeedResult { + func on(discoverFeedError: Selection, discoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "DiscoverFeedError", selection: discoverFeedError.selection), GraphQLField.fragment(type: "DiscoverFeedSuccess", selection: discoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .discoverFeedError: + let data = Objects.DiscoverFeedError(errorCodes: data.errorCodes) + return try discoverFeedError.decode(data: data) + case .discoverFeedSuccess: + let data = Objects.DiscoverFeedSuccess(feeds: data.feeds) + return try discoverFeedSuccess.decode(data: data) + } + case .mocking: + return discoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias DiscoverFeedResult = Selection +} + +extension Unions { + struct EditDiscoverFeedResult { + let __typename: TypeName + let errorCodes: [String: [Enums.EditDiscoverFeedErrorCode]] + let id: [String: String] + + enum TypeName: String, Codable { + case editDiscoverFeedError = "EditDiscoverFeedError" + case editDiscoverFeedSuccess = "EditDiscoverFeedSuccess" + } + } +} + +extension Unions.EditDiscoverFeedResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.EditDiscoverFeedErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "id": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + id = map["id"] + } +} + +extension Fields where TypeLock == Unions.EditDiscoverFeedResult { + func on(editDiscoverFeedError: Selection, editDiscoverFeedSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "EditDiscoverFeedError", selection: editDiscoverFeedError.selection), GraphQLField.fragment(type: "EditDiscoverFeedSuccess", selection: editDiscoverFeedSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .editDiscoverFeedError: + let data = Objects.EditDiscoverFeedError(errorCodes: data.errorCodes) + return try editDiscoverFeedError.decode(data: data) + case .editDiscoverFeedSuccess: + let data = Objects.EditDiscoverFeedSuccess(id: data.id) + return try editDiscoverFeedSuccess.decode(data: data) + } + case .mocking: + return editDiscoverFeedError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias EditDiscoverFeedResult = Selection +} + extension Unions { struct EmptyTrashResult { let __typename: TypeName @@ -28478,6 +31311,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias EmptyTrashResult = Selection } +extension Unions { + struct ExportToIntegrationResult { + let __typename: TypeName + let errorCodes: [String: [Enums.ExportToIntegrationErrorCode]] + let task: [String: Objects.Task] + + enum TypeName: String, Codable { + case exportToIntegrationError = "ExportToIntegrationError" + case exportToIntegrationSuccess = "ExportToIntegrationSuccess" + } + } +} + +extension Unions.ExportToIntegrationResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.ExportToIntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "task": + if let value = try container.decode(Objects.Task?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + task = map["task"] + } +} + +extension Fields where TypeLock == Unions.ExportToIntegrationResult { + func on(exportToIntegrationError: Selection, exportToIntegrationSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "ExportToIntegrationError", selection: exportToIntegrationError.selection), GraphQLField.fragment(type: "ExportToIntegrationSuccess", selection: exportToIntegrationSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .exportToIntegrationError: + let data = Objects.ExportToIntegrationError(errorCodes: data.errorCodes) + return try exportToIntegrationError.decode(data: data) + case .exportToIntegrationSuccess: + let data = Objects.ExportToIntegrationSuccess(task: data.task) + return try exportToIntegrationSuccess.decode(data: data) + } + case .mocking: + return exportToIntegrationError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias ExportToIntegrationResult = Selection +} + extension Unions { struct FeedArticlesResult { let __typename: TypeName @@ -28860,6 +31767,160 @@ extension Selection where TypeLock == Never, Type == Never { typealias GenerateApiKeyResult = Selection } +extension Unions { + struct GetDiscoverFeedArticleResults { + let __typename: TypeName + let discoverArticles: [String: [Objects.DiscoverFeedArticle?]] + let errorCodes: [String: [Enums.GetDiscoverFeedArticleErrorCode]] + let pageInfo: [String: Objects.PageInfo] + + enum TypeName: String, Codable { + case getDiscoverFeedArticleError = "GetDiscoverFeedArticleError" + case getDiscoverFeedArticleSuccess = "GetDiscoverFeedArticleSuccess" + } + } +} + +extension Unions.GetDiscoverFeedArticleResults: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverArticles": + if let value = try container.decode([Objects.DiscoverFeedArticle?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverFeedArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "pageInfo": + if let value = try container.decode(Objects.PageInfo?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + discoverArticles = map["discoverArticles"] + errorCodes = map["errorCodes"] + pageInfo = map["pageInfo"] + } +} + +extension Fields where TypeLock == Unions.GetDiscoverFeedArticleResults { + func on(getDiscoverFeedArticleError: Selection, getDiscoverFeedArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GetDiscoverFeedArticleError", selection: getDiscoverFeedArticleError.selection), GraphQLField.fragment(type: "GetDiscoverFeedArticleSuccess", selection: getDiscoverFeedArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .getDiscoverFeedArticleError: + let data = Objects.GetDiscoverFeedArticleError(errorCodes: data.errorCodes) + return try getDiscoverFeedArticleError.decode(data: data) + case .getDiscoverFeedArticleSuccess: + let data = Objects.GetDiscoverFeedArticleSuccess(discoverArticles: data.discoverArticles, pageInfo: data.pageInfo) + return try getDiscoverFeedArticleSuccess.decode(data: data) + } + case .mocking: + return getDiscoverFeedArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverFeedArticleResults = Selection +} + +extension Unions { + struct GetDiscoverTopicResults { + let __typename: TypeName + let discoverTopics: [String: [Objects.DiscoverTopic]] + let errorCodes: [String: [Enums.GetDiscoverTopicErrorCode]] + + enum TypeName: String, Codable { + case getDiscoverTopicError = "GetDiscoverTopicError" + case getDiscoverTopicSuccess = "GetDiscoverTopicSuccess" + } + } +} + +extension Unions.GetDiscoverTopicResults: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "discoverTopics": + if let value = try container.decode([Objects.DiscoverTopic]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "errorCodes": + if let value = try container.decode([Enums.GetDiscoverTopicErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + discoverTopics = map["discoverTopics"] + errorCodes = map["errorCodes"] + } +} + +extension Fields where TypeLock == Unions.GetDiscoverTopicResults { + func on(getDiscoverTopicError: Selection, getDiscoverTopicSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "GetDiscoverTopicError", selection: getDiscoverTopicError.selection), GraphQLField.fragment(type: "GetDiscoverTopicSuccess", selection: getDiscoverTopicSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .getDiscoverTopicError: + let data = Objects.GetDiscoverTopicError(errorCodes: data.errorCodes) + return try getDiscoverTopicError.decode(data: data) + case .getDiscoverTopicSuccess: + let data = Objects.GetDiscoverTopicSuccess(discoverTopics: data.discoverTopics) + return try getDiscoverTopicSuccess.decode(data: data) + } + case .mocking: + return getDiscoverTopicError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias GetDiscoverTopicResults = Selection +} + extension Unions { struct GetFollowersResult { let __typename: TypeName @@ -29304,6 +32365,80 @@ extension Selection where TypeLock == Never, Type == Never { typealias ImportFromIntegrationResult = Selection } +extension Unions { + struct IntegrationResult { + let __typename: TypeName + let errorCodes: [String: [Enums.IntegrationErrorCode]] + let integration: [String: Objects.Integration] + + enum TypeName: String, Codable { + case integrationError = "IntegrationError" + case integrationSuccess = "IntegrationSuccess" + } + } +} + +extension Unions.IntegrationResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.IntegrationErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "integration": + if let value = try container.decode(Objects.Integration?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + integration = map["integration"] + } +} + +extension Fields where TypeLock == Unions.IntegrationResult { + func on(integrationError: Selection, integrationSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "IntegrationError", selection: integrationError.selection), GraphQLField.fragment(type: "IntegrationSuccess", selection: integrationSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .integrationError: + let data = Objects.IntegrationError(errorCodes: data.errorCodes) + return try integrationError.decode(data: data) + case .integrationSuccess: + let data = Objects.IntegrationSuccess(integration: data.integration) + return try integrationSuccess.decode(data: data) + } + case .mocking: + return integrationError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias IntegrationResult = Selection +} + extension Unions { struct IntegrationsResult { let __typename: TypeName @@ -30864,6 +33999,86 @@ extension Selection where TypeLock == Never, Type == Never { typealias SaveArticleReadingProgressResult = Selection } +extension Unions { + struct SaveDiscoverArticleResult { + let __typename: TypeName + let errorCodes: [String: [Enums.SaveDiscoverArticleErrorCode]] + let saveId: [String: String] + let url: [String: String] + + enum TypeName: String, Codable { + case saveDiscoverArticleError = "SaveDiscoverArticleError" + case saveDiscoverArticleSuccess = "SaveDiscoverArticleSuccess" + } + } +} + +extension Unions.SaveDiscoverArticleResult: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKeys.self) + + var map = HashMap() + for codingKey in container.allKeys { + if codingKey.isTypenameKey { continue } + + let alias = codingKey.stringValue + let field = GraphQLField.getFieldNameFromAlias(alias) + + switch field { + case "errorCodes": + if let value = try container.decode([Enums.SaveDiscoverArticleErrorCode]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "saveId": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "url": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown key \(field)." + ) + ) + } + } + + __typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!) + + errorCodes = map["errorCodes"] + saveId = map["saveId"] + url = map["url"] + } +} + +extension Fields where TypeLock == Unions.SaveDiscoverArticleResult { + func on(saveDiscoverArticleError: Selection, saveDiscoverArticleSuccess: Selection) throws -> Type { + select([GraphQLField.fragment(type: "SaveDiscoverArticleError", selection: saveDiscoverArticleError.selection), GraphQLField.fragment(type: "SaveDiscoverArticleSuccess", selection: saveDiscoverArticleSuccess.selection)]) + + switch response { + case let .decoding(data): + switch data.__typename { + case .saveDiscoverArticleError: + let data = Objects.SaveDiscoverArticleError(errorCodes: data.errorCodes) + return try saveDiscoverArticleError.decode(data: data) + case .saveDiscoverArticleSuccess: + let data = Objects.SaveDiscoverArticleSuccess(saveId: data.saveId, url: data.url) + return try saveDiscoverArticleSuccess.decode(data: data) + } + case .mocking: + return saveDiscoverArticleError.mock() + } + } +} + +extension Selection where TypeLock == Never, Type == Never { + typealias SaveDiscoverArticleResult = Selection +} + extension Unions { struct SaveFilterResult { let __typename: TypeName @@ -33967,6 +37182,19 @@ extension Selection where TypeLock == Never, Type == Never { // MARK: - Enums enum Enums {} +extension Enums { + /// AddDiscoverFeedErrorCode + enum AddDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case conflict = "CONFLICT" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// AddPopularReadErrorCode enum AddPopularReadErrorCode: String, CaseIterable, Codable { @@ -34201,6 +37429,30 @@ extension Enums { } } +extension Enums { + /// DeleteDiscoverArticleErrorCode + enum DeleteDiscoverArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// DeleteDiscoverFeedErrorCode + enum DeleteDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case conflict = "CONFLICT" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// DeleteFilterErrorCode enum DeleteFilterErrorCode: String, CaseIterable, Codable { @@ -34331,6 +37583,26 @@ extension Enums { } } +extension Enums { + /// DiscoverFeedErrorCode + enum DiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// EditDiscoverFeedErrorCode + enum EditDiscoverFeedErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// EmptyTrashErrorCode enum EmptyTrashErrorCode: String, CaseIterable, Codable { @@ -34338,6 +37610,15 @@ extension Enums { } } +extension Enums { + /// ExportToIntegrationErrorCode + enum ExportToIntegrationErrorCode: String, CaseIterable, Codable { + case failedToCreateTask = "FAILED_TO_CREATE_TASK" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// FeedArticlesErrorCode enum FeedArticlesErrorCode: String, CaseIterable, Codable { @@ -34394,6 +37675,24 @@ extension Enums { } } +extension Enums { + /// GetDiscoverFeedArticleErrorCode + enum GetDiscoverFeedArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + +extension Enums { + /// GetDiscoverTopicErrorCode + enum GetDiscoverTopicErrorCode: String, CaseIterable, Codable { + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// GetFollowersErrorCode enum GetFollowersErrorCode: String, CaseIterable, Codable { @@ -34457,6 +37756,13 @@ extension Enums { } } +extension Enums { + /// IntegrationErrorCode + enum IntegrationErrorCode: String, CaseIterable, Codable { + case notFound = "NOT_FOUND" + } +} + extension Enums { /// IntegrationType enum IntegrationType: String, CaseIterable, Codable { @@ -34744,15 +38050,25 @@ extension Enums { case delete = "DELETE" + case export = "EXPORT" + case markAsRead = "MARK_AS_READ" case sendNotification = "SEND_NOTIFICATION" + + case webhook = "WEBHOOK" } } extension Enums { /// RuleEventType enum RuleEventType: String, CaseIterable, Codable { + case highlightCreated = "HIGHLIGHT_CREATED" + + case highlightUpdated = "HIGHLIGHT_UPDATED" + + case labelCreated = "LABEL_CREATED" + case pageCreated = "PAGE_CREATED" case pageUpdated = "PAGE_UPDATED" @@ -34779,6 +38095,17 @@ extension Enums { } } +extension Enums { + /// SaveDiscoverArticleErrorCode + enum SaveDiscoverArticleErrorCode: String, CaseIterable, Codable { + case badRequest = "BAD_REQUEST" + + case notFound = "NOT_FOUND" + + case unauthorized = "UNAUTHORIZED" + } +} + extension Enums { /// SaveErrorCode enum SaveErrorCode: String, CaseIterable, Codable { @@ -35045,6 +38372,21 @@ extension Enums { } } +extension Enums { + /// TaskState + enum TaskState: String, CaseIterable, Codable { + case cancelled = "CANCELLED" + + case failed = "FAILED" + + case pending = "PENDING" + + case running = "RUNNING" + + case succeeded = "SUCCEEDED" + } +} + extension Enums { /// TypeaheadSearchErrorCode enum TypeaheadSearchErrorCode: String, CaseIterable, Codable { @@ -35342,6 +38684,21 @@ extension Enums { // MARK: - Input Objects enum InputObjects {} +extension InputObjects { + struct AddDiscoverFeedInput: Encodable, Hashable { + var url: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(url, forKey: .url) + } + + enum CodingKeys: String, CodingKey { + case url + } + } +} + extension InputObjects { struct ArchiveLinkInput: Encodable, Hashable { var archived: Bool @@ -35679,6 +39036,55 @@ extension InputObjects { } } +extension InputObjects { + struct DeleteDiscoverArticleInput: Encodable, Hashable { + var discoverArticleId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(discoverArticleId, forKey: .discoverArticleId) + } + + enum CodingKeys: String, CodingKey { + case discoverArticleId + } + } +} + +extension InputObjects { + struct DeleteDiscoverFeedInput: Encodable, Hashable { + var feedId: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feedId, forKey: .feedId) + } + + enum CodingKeys: String, CodingKey { + case feedId + } + } +} + +extension InputObjects { + struct EditDiscoverFeedInput: Encodable, Hashable { + var feedId: String + + var name: String + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(feedId, forKey: .feedId) + try container.encode(name, forKey: .name) + } + + enum CodingKeys: String, CodingKey { + case feedId + case name + } + } +} + extension InputObjects { struct FeedsInput: Encodable, Hashable { var after: OptionalArgument = .absent() @@ -36159,6 +39565,29 @@ extension InputObjects { } } +extension InputObjects { + struct SaveDiscoverArticleInput: Encodable, Hashable { + var discoverArticleId: String + + var locale: OptionalArgument = .absent() + + var timezone: OptionalArgument = .absent() + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(discoverArticleId, forKey: .discoverArticleId) + if locale.hasValue { try container.encode(locale, forKey: .locale) } + if timezone.hasValue { try container.encode(timezone, forKey: .timezone) } + } + + enum CodingKeys: String, CodingKey { + case discoverArticleId + case locale + case timezone + } + } +} + extension InputObjects { struct SaveFileInput: Encodable, Hashable { var clientRequestId: String diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift index 9dea39b9a..161df3925 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift @@ -15,6 +15,8 @@ public enum RuleActionType { case delete case markAsRead case sendNotification + case export + case webhook static func from(_ other: Enums.RuleActionType) -> RuleActionType { switch other { @@ -28,6 +30,10 @@ public enum RuleActionType { return .sendNotification case .delete: return .delete + case Enums.RuleActionType.export: + return .export + case Enums.RuleActionType.webhook: + return .webhook } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift index 78117f8a0..f1251fdc0 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ViewerFetcher.swift @@ -17,7 +17,9 @@ public extension DataService { profileImageURL: try $0.profile( selection: .init { try $0.pictureUrl() } ), - intercomHash: try $0.intercomHash() + intercomHash: try $0.intercomHash(), + digestEnabled: true // (try $0.featureList(selection: featureSelection.list.nullable)? + // .filter { $0.enabled && $0.name == "digest" } ?? []).count > 0 ) } @@ -65,6 +67,7 @@ public struct ViewerInternal { public let name: String public let profileImageURL: String? public let intercomHash: String? + public let digestEnabled: Bool? func persist(context: NSManagedObjectContext) throws { try context.performAndWait { @@ -73,6 +76,7 @@ public struct ViewerInternal { viewer.username = username viewer.name = name viewer.profileImageURL = profileImageURL + viewer.digestEnabled = digestEnabled ?? false do { try context.save() diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift new file mode 100644 index 000000000..b72a783a1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/FeatureSelection.swift @@ -0,0 +1,6 @@ +import Models +import SwiftGraphQL + +let featureSelection = Selection.Feature { + FeatureInternal(name: try $0.name(), enabled: try $0.grantedAt() != nil) +} diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index af128dd9b..05dcfb44d 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -23,6 +23,7 @@ public final class OmnivoreWebView: WKWebView { #endif public var tapHandler: (() -> Void)? + public var explainHandler: ((String) -> Void)? private var currentMenu: ContextMenu = .defaultMenu @@ -298,6 +299,7 @@ public final class OmnivoreWebView: WKWebView { case #selector(removeSelection): return true case #selector(copy(_:)): return true case #selector(setLabels(_:)): return true + case #selector(explainSelection): return true case Selector(("_lookup:")): return (currentMenu == .defaultMenu) case Selector(("_define:")): return (currentMenu == .defaultMenu) @@ -334,6 +336,18 @@ public final class OmnivoreWebView: WKWebView { hideMenu() } + @objc private func explainSelection() { + Task { + let selection = try? await self.evaluateJavaScript("window.getSelection().toString()") + if let selection = selection as? String, let explainHandler = explainHandler { + print("Explaining \(selection)") + explainHandler(selection) + } else { + showInReaderSnackbar("Error getting text to explain") + } + } + } + @objc private func shareSelection() { do { try dispatchEvent(.share) @@ -386,7 +400,8 @@ public final class OmnivoreWebView: WKWebView { return } let highlight = UICommand(title: LocalText.genericHighlight, action: #selector(highlightSelection)) - items = [highlight, annotate] + // let explain = UICommand(title: "Explain", action: #selector(explainSelection)) + items = [highlight, /* explain, */ annotate] } else { let remove = UICommand(title: "Remove", action: #selector(removeSelection)) let setLabels = UICommand(title: LocalText.labelsGeneric, action: #selector(setLabels)) diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.swift b/apple/OmnivoreKit/Sources/Views/Images/Images.swift index f90d34ede..9b684783e 100644 --- a/apple/OmnivoreKit/Sources/Views/Images/Images.swift +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.swift @@ -9,6 +9,9 @@ public extension Image { static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) } static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) } + static var tabDigest: Image { Image("_tab_digest", bundle: .module).renderingMode(.template) } + static var tabDigestSelected: Image { Image("_tab_digest_selected", bundle: .module) } + static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) } static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) } static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) } @@ -49,4 +52,7 @@ public extension Image { static var flairNewsletter: Image { Image("flair-newsletter", bundle: .module) } static var flairPinned: Image { Image("flair-pinned", bundle: .module) } static var flairRecommended: Image { Image("flair-recommended", bundle: .module) } + + static var doubleChevronUp: Image { Image("double_chevron_up", bundle: .module) } + } diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json new file mode 100644 index 000000000..83aca2101 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "_tab_digest.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg new file mode 100644 index 000000000..ccf49775d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json new file mode 100644 index 000000000..e5fed4197 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "_tab_digest_selected.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg new file mode 100644 index 000000000..dfc6d541d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest_selected.imageset/_tab_digest_selected.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json new file mode 100644 index 000000000..340a487c3 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-archive-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg new file mode 100644 index 000000000..ecdbd0d54 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-archive-button.imageset/digest-archive-button.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json new file mode 100644 index 000000000..d8d84c76c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-dots-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg new file mode 100644 index 000000000..41032d9ee --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/digest-dots-button.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json new file mode 100644 index 000000000..04686abac --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-play-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg new file mode 100644 index 000000000..45875dbb5 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/digest-play-button.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json new file mode 100644 index 000000000..fa3f18ec0 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "digest-trash-button.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg new file mode 100644 index 000000000..23cef56cb --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-trash-button.imageset/digest-trash-button.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json new file mode 100644 index 000000000..6a11721b2 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "double_chevron_up.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg new file mode 100644 index 000000000..7ac7be15d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/double_chevron_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings index 8d223097a..251667073 100644 --- a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings +++ b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings @@ -198,6 +198,7 @@ "clubsGeneric" = "阅读俱乐部"; "filterGeneric" = "特色功能"; "errorGeneric" = "哦!出现了小问题,请您重试。"; +"readerSettingsGeneric" = "阅读设置"; "pushNotificationsGeneric" = "推送通知"; "dismissButton" = "返回或撤销"; "errorNetwork" = "我们在连接到互联网时遇到问题。"; diff --git a/apple/OmnivoreKit/Sources/Views/Theme.swift b/apple/OmnivoreKit/Sources/Views/Theme.swift index e4b9147a9..69ebecf70 100644 --- a/apple/OmnivoreKit/Sources/Views/Theme.swift +++ b/apple/OmnivoreKit/Sources/Views/Theme.swift @@ -31,6 +31,22 @@ public enum Theme: String, CaseIterable { } } + public var fgColor: Color { + let prefersHighContrastText = UserDefaults.standard.bool(forKey: UserDefaultKey.prefersHighContrastWebFont.rawValue) + switch self { + case .system: + return Color.isDarkMode ? .white : .black + case .light: + return .black + case .dark: + return Color.white + case .sepia: + return prefersHighContrastText ? Color.black : (Color(hex: "#5F4B32") ?? Color.black) + case .apollo: + return prefersHighContrastText ? Color.white : (Color(hex: "#F3F3F3") ?? Color.white) + } + } + public var toolbarColor: Color { ThemeManager.currentTheme.isDark ? Color.themeDarkWhiteGray : Color.themeMiddleGray } @@ -98,6 +114,10 @@ public enum ThemeManager { currentTheme.bgColor } + public static var currentFgColor: Color { + currentTheme.fgColor + } + public static var currentHighlightColor: Color { currentTheme.highlightColor } diff --git a/packages/api/package.json b/packages/api/package.json index c3ec7bd1b..cea023808 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,7 +113,8 @@ "voca": "^1.4.0", "winston": "^3.3.3", "word-counting": "^1.1.4", - "youtubei": "1.3.7" + "yaml": "^2.4.1", + "youtubei": "1.4.0" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -124,7 +125,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/chai-string": "^1.4.2", "@types/cookie": "^0.4.0", - "@types/cookie-parser": "^1.4.2", + "@types/cookie-parser": "^1.4.7", "@types/csv-stringify": "^3.1.0", "@types/diff-match-patch": "^1.0.32", "@types/dompurify": "^2.0.4", diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index 2559197ba..26d255e5d 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -28,6 +28,11 @@ import { SetClaimsRole } from './utils/dictionary' import { logger } from './utils/logger' import { ReadingProgressDataSource } from './datasources/reading_progress_data_source' import { createPrometheusExporterPlugin } from '@bmatei/apollo-prometheus-exporter' +import { ApolloServerPlugin } from 'apollo-server-plugin-base' +import { + countDailyServiceUsage, + createServiceUsage, +} from './services/service_usage' const signToken = promisify(jwt.sign) const pubsub = createPubSubClient() @@ -112,10 +117,59 @@ export function makeApolloServer(app: Express): ApolloServer { }, }) + // enforce usage limits for the API + const usageLimitPlugin = (): ApolloServerPlugin => { + // TODO: load the limit from the DB into memory when the server starts + // hardcode the limit for now + const MAX_SENT_EMAIL_PER_DAY = 3 + + return { + async requestDidStart(contextValue) { + // get graphql query from the request + const query = contextValue.request.query + // get the user id from the claims + const userId = contextValue.context.claims?.uid + const action = 'replyToEmail' + if (userId && query?.includes(action)) { + logger.info('checking usage limit for user', { userId, action }) + // get the user's email sent count from the DB + const emailSentCount = await countDailyServiceUsage(userId, action) + if (emailSentCount >= MAX_SENT_EMAIL_PER_DAY) { + logger.info('user has reached the daily email limit', { + userId, + action, + }) + // if the user has reached the limit, throw an error + throw new Error('You have reached the daily email limit') + } + } + + return { + // track usage of the API + async willSendResponse(requestContext) { + // if the request was successful, increment the user's email sent count + if ( + userId && + query?.includes(action) && + !requestContext.response.errors && + !requestContext.response.data?.replyToEmail?.errorCodes + ) { + logger.info('incrementing usage count for user', { + userId, + action, + }) + await createServiceUsage(userId, action) + } + }, + } + }, + } + } + const apollo = new ApolloServer({ schema: schema, context: contextFunc, - plugins: [promExporter], + plugins: [promExporter, usageLimitPlugin], formatError: (err) => { logger.info('server error', err) Sentry.captureException(err) @@ -124,6 +178,7 @@ export function makeApolloServer(app: Express): ApolloServer { }, introspection: env.dev.isLocal, persistedQueries: false, + stopOnTerminationSignals: false, // we handle this ourselves }) return apollo diff --git a/packages/api/src/entity/received_email.ts b/packages/api/src/entity/received_email.ts index b68289989..cb5e5937c 100644 --- a/packages/api/src/entity/received_email.ts +++ b/packages/api/src/entity/received_email.ts @@ -33,6 +33,12 @@ export class ReceivedEmail { @Column('text') html!: string + @Column('text') + replyTo?: string + + @Column('text') + reply?: string + @Column('text') type!: 'article' | 'non-article' diff --git a/packages/api/src/entity/service_usage.ts b/packages/api/src/entity/service_usage.ts new file mode 100644 index 000000000..056a51fc0 --- /dev/null +++ b/packages/api/src/entity/service_usage.ts @@ -0,0 +1,25 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm' +import { User } from './user' + +@Entity('service_usage') +export class ServiceUsage { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('varchar') + action!: string + + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt!: Date +} diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 93afb8908..cd4b67b31 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -58,6 +58,13 @@ export type AddPopularReadSuccess = { pageId: Scalars['String']; }; +export enum AllowedReply { + Confirm = 'CONFIRM', + Okay = 'OKAY', + Subscribe = 'SUBSCRIBE', + Yes = 'YES' +} + export type ApiKey = { __typename?: 'ApiKey'; createdAt: Scalars['Date']; @@ -1616,6 +1623,7 @@ export type Mutation = { optInFeature: OptInFeatureResult; recommend: RecommendResult; recommendHighlights: RecommendHighlightsResult; + replyToEmail: ReplyToEmailResult; reportItem: ReportItemResult; revokeApiKey: RevokeApiKeyResult; saveArticleReadingProgress: SaveArticleReadingProgressResult; @@ -1836,6 +1844,12 @@ export type MutationRecommendHighlightsArgs = { }; +export type MutationReplyToEmailArgs = { + recentEmailId: Scalars['ID']; + reply: AllowedReply; +}; + + export type MutationReportItemArgs = { input: ReportItemInput; }; @@ -2275,6 +2289,8 @@ export type RecentEmail = { from: Scalars['String']; html?: Maybe; id: Scalars['ID']; + reply?: Maybe; + replyTo?: Maybe; subject: Scalars['String']; text: Scalars['String']; to: Scalars['String']; @@ -2430,6 +2446,22 @@ export type ReminderSuccess = { reminder: Reminder; }; +export type ReplyToEmailError = { + __typename?: 'ReplyToEmailError'; + errorCodes: Array; +}; + +export enum ReplyToEmailErrorCode { + Unauthorized = 'UNAUTHORIZED' +} + +export type ReplyToEmailResult = ReplyToEmailError | ReplyToEmailSuccess; + +export type ReplyToEmailSuccess = { + __typename?: 'ReplyToEmailSuccess'; + success: Scalars['Boolean']; +}; + export type ReportItemInput = { itemUrl: Scalars['String']; pageId: Scalars['ID']; @@ -3908,6 +3940,7 @@ export type ResolversTypes = { AddPopularReadErrorCode: AddPopularReadErrorCode; AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess']; AddPopularReadSuccess: ResolverTypeWrapper; + AllowedReply: AllowedReply; ApiKey: ResolverTypeWrapper; ApiKeysError: ResolverTypeWrapper; ApiKeysErrorCode: ApiKeysErrorCode; @@ -4245,6 +4278,10 @@ export type ResolversTypes = { ReminderErrorCode: ReminderErrorCode; ReminderResult: ResolversTypes['ReminderError'] | ResolversTypes['ReminderSuccess']; ReminderSuccess: ResolverTypeWrapper; + ReplyToEmailError: ResolverTypeWrapper; + ReplyToEmailErrorCode: ReplyToEmailErrorCode; + ReplyToEmailResult: ResolversTypes['ReplyToEmailError'] | ResolversTypes['ReplyToEmailSuccess']; + ReplyToEmailSuccess: ResolverTypeWrapper; ReportItemInput: ReportItemInput; ReportItemResult: ResolverTypeWrapper; ReportType: ReportType; @@ -4763,6 +4800,9 @@ export type ResolversParentTypes = { ReminderError: ReminderError; ReminderResult: ResolversParentTypes['ReminderError'] | ResolversParentTypes['ReminderSuccess']; ReminderSuccess: ReminderSuccess; + ReplyToEmailError: ReplyToEmailError; + ReplyToEmailResult: ResolversParentTypes['ReplyToEmailError'] | ResolversParentTypes['ReplyToEmailSuccess']; + ReplyToEmailSuccess: ReplyToEmailSuccess; ReportItemInput: ReportItemInput; ReportItemResult: ReportItemResult; RevokeApiKeyError: RevokeApiKeyError; @@ -6128,6 +6168,7 @@ export type MutationResolvers>; recommend?: Resolver>; recommendHighlights?: Resolver>; + replyToEmail?: Resolver>; reportItem?: Resolver>; revokeApiKey?: Resolver>; saveArticleReadingProgress?: Resolver>; @@ -6293,6 +6334,8 @@ export type RecentEmailResolvers; html?: Resolver, ParentType, ContextType>; id?: Resolver; + reply?: Resolver, ParentType, ContextType>; + replyTo?: Resolver, ParentType, ContextType>; subject?: Resolver; text?: Resolver; to?: Resolver; @@ -6417,6 +6460,20 @@ export type ReminderSuccessResolvers; }; +export type ReplyToEmailErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type ReplyToEmailResultResolvers = { + __resolveType: TypeResolveFn<'ReplyToEmailError' | 'ReplyToEmailSuccess', ParentType, ContextType>; +}; + +export type ReplyToEmailSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ReportItemResultResolvers = { message?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -7488,6 +7545,9 @@ export type Resolvers = { ReminderError?: ReminderErrorResolvers; ReminderResult?: ReminderResultResolvers; ReminderSuccess?: ReminderSuccessResolvers; + ReplyToEmailError?: ReplyToEmailErrorResolvers; + ReplyToEmailResult?: ReplyToEmailResultResolvers; + ReplyToEmailSuccess?: ReplyToEmailSuccessResolvers; ReportItemResult?: ReportItemResultResolvers; RevokeApiKeyError?: RevokeApiKeyErrorResolvers; RevokeApiKeyResult?: RevokeApiKeyResultResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index ba442c3a7..e48fe3cff 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -37,6 +37,13 @@ type AddPopularReadSuccess { pageId: String! } +enum AllowedReply { + CONFIRM + OKAY + SUBSCRIBE + YES +} + type ApiKey { createdAt: Date! expiresAt: Date! @@ -1454,6 +1461,7 @@ type Mutation { optInFeature(input: OptInFeatureInput!): OptInFeatureResult! recommend(input: RecommendInput!): RecommendResult! recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult! + replyToEmail(recentEmailId: ID!, reply: AllowedReply!): ReplyToEmailResult! reportItem(input: ReportItemInput!): ReportItemResult! revokeApiKey(id: ID!): RevokeApiKeyResult! saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! @@ -1671,6 +1679,8 @@ type RecentEmail { from: String! html: String id: ID! + reply: String + replyTo: String subject: String! text: String! to: String! @@ -1811,6 +1821,20 @@ type ReminderSuccess { reminder: Reminder! } +type ReplyToEmailError { + errorCodes: [ReplyToEmailErrorCode!]! +} + +enum ReplyToEmailErrorCode { + UNAUTHORIZED +} + +union ReplyToEmailResult = ReplyToEmailError | ReplyToEmailSuccess + +type ReplyToEmailSuccess { + success: Boolean! +} + input ReportItemInput { itemUrl: String! pageId: ID! diff --git a/packages/api/src/jobs/ai/create_digest.ts b/packages/api/src/jobs/ai/create_digest.ts new file mode 100644 index 000000000..b6470d402 --- /dev/null +++ b/packages/api/src/jobs/ai/create_digest.ts @@ -0,0 +1,439 @@ +import { logger } from '../../utils/logger' +import { v4 as uuid } from 'uuid' + +import { OpenAI } from '@langchain/openai' +import { PromptTemplate } from '@langchain/core/prompts' +import { LibraryItem } from '../../entity/library_item' +import { + htmlToSpeechFile, + SpeechFile, + SSMLOptions, +} from '@omnivore/text-to-speech-handler' +import axios from 'axios' +import { + findLibraryItemsByIds, + searchLibraryItems, +} from '../../services/library_item' +import { redisDataSource } from '../../redis_data_source' +import { htmlToMarkdown } from '../../utils/parser' +import yaml from 'yaml' +import { JsonOutputParser } from '@langchain/core/output_parsers' +import showdown from 'showdown' +import { Digest, writeDigest } from '../../services/digest' +import { TaskState } from '../../generated/graphql' + +export type CreateDigestJobSchedule = 'daily' | 'weekly' + +export interface CreateDigestJobData { + id: string + userId: string + voices?: string[] + language?: string + rate?: string + libraryItemIds?: string[] +} + +export interface CreateDigestJobResponse { + jobId: string + jobState: TaskState +} +interface Selector { + query: string + count: number + reason: string +} + +interface ZeroShotDefinition { + userPreferencesProfilePrompt: string + rankPrompt: string +} + +interface DigestDefinition { + name: string + preferenceSelectors: Selector[] + candidateSelectors: Selector[] + contentFeaturesPrompt: string + contentRatingPrompt: string + summaryPrompt: string + assemblePrompt: string + + zeroShot: ZeroShotDefinition +} + +interface RankedItem { + topic: string + summary: string + libraryItem: LibraryItem +} + +interface RankedTitle { + topic: string + id: string + title: string +} + +export const CREATE_DIGEST_JOB = 'create-digest' + +let digestDefinition: DigestDefinition + +const fetchDigestDefinition = async (): Promise => { + const promptFileUrl = process.env.PROMPT_FILE_URL + if (!promptFileUrl) { + const msg = 'PROMPT_FILE_URL not set' + logger.error(msg) + throw new Error(msg) + } + + // fetch the yaml file + const response = await axios.get(promptFileUrl) + + // parse the yaml file + return yaml.parse(response.data) as DigestDefinition +} + +// Makes multiple DB queries and combines the results +const getPreferencesList = async (userId: string): Promise => { + // use the queries from the digest definitions to lookup preferences + // There should be a list of multiple queries we use. For now we can + // hardcode these queries: + // - query: "in:all is:read OR has:highlights sort:updated-desc wordsCount:>=20" + // count: 21 + // reason: "recently read or highlighted items that are not part of the digest" + // - query: "in:all is:read OR has:highlights sort:saved-asc wordsCount:>=20" + // count: 4 + // reason: "some older items that were interacted with" + + const preferences = await Promise.all( + digestDefinition.preferenceSelectors.map(async (selector) => { + // use the selector to fetch items + const results = await searchLibraryItems( + { + query: selector.query, + size: selector.count, + }, + userId + ) + + return results.libraryItems + }) + ) + + // deduplicate and flatten the items + const dedupedPreferences = preferences + .flat() + .filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id) + ) + + return dedupedPreferences +} + +// Makes multiple DB queries and combines the results +const getCandidatesList = async ( + userId: string, + libraryItemIds?: string[] +): Promise => { + // use the queries from the digest definitions to lookup preferences + // There should be a list of multiple queries we use. For now we can + // hardcode these queries: + // - query: "in:all is:unread saved:last24hrs sort:saved-desc wordsCount:>=500" + // count: 100 + // reason: "most recent 100 items saved over 500 words + + if (libraryItemIds) { + logger.info('Using libraryItemIds') + return findLibraryItemsByIds(libraryItemIds, userId) + } + + const candidates = await Promise.all( + digestDefinition.candidateSelectors.map(async (selector) => { + // use the selector to fetch items + const results = await searchLibraryItems( + { + includeContent: true, + query: selector.query, + size: selector.count, + }, + userId + ) + + return results.libraryItems + }) + ) + + // deduplicate and flatten the items + const dedupedCandidates = candidates + .flat() + .filter( + (item, index, self) => index === self.findIndex((t) => t.id === item.id) + ) + .map((item) => ({ + ...item, + readableContent: htmlToMarkdown(item.readableContent), + })) // convert the html content to markdown + + return dedupedCandidates +} + +// Takes a list of library items, and uses a prompt to generate +// a text representation of a user profile +const createUserProfile = async ( + preferences: LibraryItem[] +): Promise => { + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.zeroShot.userPreferencesProfilePrompt + ) + + const chain = contextualTemplate.pipe(llm) + const result = await chain.invoke({ + titles: preferences.map((item) => `* ${item.title}`).join('\n'), + }) + + return result +} + +// Checks redis for a user profile, if not found creates one and writes +// it to redis +const findOrCreateUserProfile = async (userId: string): Promise => { + // check redis for user profile, return if found + const key = `userProfile:${userId}` + const existingProfile = await redisDataSource.redisClient?.get(key) + if (existingProfile) { + return existingProfile + } + + // if not found + const preferences = await getPreferencesList(userId) + const profile = await createUserProfile(preferences) + + // write to redis here and ttl is 1 week + await redisDataSource.redisClient?.set(key, profile, 'EX', 60 * 60 * 24 * 7) + + return profile +} + +// Uses OpenAI to rank all the titles based on the user profiles +const rankCandidates = async ( + candidates: LibraryItem[], + userProfile: string +): Promise => { + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.zeroShot.rankPrompt + ) + + const outputParser = new JsonOutputParser() + const chain = contextualTemplate.pipe(llm).pipe(outputParser) + const contextStr = await chain.invoke({ + userProfile, + titles: JSON.stringify( + candidates.map((item) => ({ + id: item.id, + title: item.title, + })) + ), + }) + + logger.info('contextStr: ', contextStr) + // convert the json output to an array of ranked candidates + const rankedCandidate = contextStr as RankedTitle[] + + // map the ranked titles to the library items based on id + const rankedItems = rankedCandidate + .map((item) => { + const libraryItem = candidates.find((t) => t.id === item.id) + return { + topic: item.topic, + libraryItem, + summary: '', + } + }) + .filter((item) => item.libraryItem !== undefined) as RankedItem[] + + return rankedItems +} + +// Does some grouping by topic while trying to maintain ranking +// adds some basic topic diversity +const chooseRankedSelections = (rankedCandidates: RankedItem[]) => { + const selected = [] + const rankedTopics = [] + const topicCount = {} as Record + + for (const item of rankedCandidates) { + if (selected.length >= 5) { + break + } + + topicCount[item.topic] = (topicCount[item.topic] || 0) + 1 + + if (topicCount[item.topic] <= 2) { + selected.push(item) + if (rankedTopics.indexOf(item.topic) === -1) { + rankedTopics.push(item.topic) + } + } + } + + logger.info('rankedTopics: ', rankedTopics) + logger.info('finalSelections: ', selected) + + const finalSelections = [] + + for (const topic of rankedTopics) { + const matches = selected.filter((item) => item.topic == topic) + finalSelections.push(...matches) + } + + logger.info('finalSelections: ', finalSelections) + + return { finalSelections, rankedTopics } +} + +const summarizeItems = async ( + rankedCandidates: RankedItem[] +): Promise => { + const llm = new OpenAI({ + modelName: 'gpt-4-0125-preview', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + + const contextualTemplate = PromptTemplate.fromTemplate( + digestDefinition.summaryPrompt + ) + const chain = contextualTemplate.pipe(llm) + + // send all the ranked candidates to openAI at once in a batch + const summaries = await chain.batch( + rankedCandidates.map((item) => ({ + title: item.libraryItem.title, + author: item.libraryItem.author ?? '', + content: item.libraryItem.readableContent, // markdown content + })) + ) + + summaries.forEach( + (summary, index) => (rankedCandidates[index].summary = summary) + ) + + return rankedCandidates +} + +// generate speech files from the summaries +const generateSpeechFiles = ( + rankedItems: RankedItem[], + options: SSMLOptions +): SpeechFile[] => { + // convert the summaries from markdown to HTML + const converter = new showdown.Converter({ + backslashEscapesHTMLTags: true, + }) + + const speechFiles = rankedItems.map((item) => { + const html = ` +
+
+ ${converter.makeHtml(item.summary)} +
+
` + return htmlToSpeechFile({ + content: html, + options, + }) + }) + + return speechFiles +} + +// we should have a QA step here that does some +// basic checks to make sure the summaries are good. +const filterSummaries = (summaries: RankedItem[]): RankedItem[] => { + return summaries.filter((item) => item.summary.length > 100) +} + +// we can use something more sophisticated to generate titles +const generateTitle = (summaries: RankedItem[]): string => + 'Omnivore digest: ' + + summaries.map((item) => item.libraryItem.title).join(', ') + +// generate description based on the summaries +const generateDescription = ( + summaries: RankedItem[], + rankedTopics: string[] +): string => + `We selected ${ + summaries.length + } articles from your last 24 hours of saved items, covering ${rankedTopics.join( + ', ' + )}.` + +// generate content based on the summaries +const generateContent = (summaries: RankedItem[]): string => + summaries + .map((summary) => `### ${summary.libraryItem.title}\n ${summary.summary}`) + .join('\n\n') + +const generateByline = (summaries: RankedItem[]): string => + summaries + .filter((summary) => !!summary.libraryItem.author) + .map((item) => item.libraryItem.author) + .join(', ') + +export const createDigestJob = async (jobData: CreateDigestJobData) => { + digestDefinition = await fetchDigestDefinition() + + const candidates = await getCandidatesList( + jobData.userId, + jobData.libraryItemIds + ) + const userProfile = await findOrCreateUserProfile(jobData.userId) + const rankedCandidates = await rankCandidates(candidates, userProfile) + const { finalSelections, rankedTopics } = + chooseRankedSelections(rankedCandidates) + + const summaries = await summarizeItems(finalSelections) + + const filteredSummaries = filterSummaries(summaries) + + const speechFiles = generateSpeechFiles(filteredSummaries, { + ...jobData, + primaryVoice: jobData.voices?.[0], + secondaryVoice: jobData.voices?.[1], + }) + const title = generateTitle(summaries) + const digest: Digest = { + id: jobData.id, + title, + content: generateContent(summaries), + urlsToAudio: [], + jobState: TaskState.Succeeded, + speechFiles, + chapters: filteredSummaries.map((item, index) => ({ + title: item.libraryItem.title, + id: item.libraryItem.id, + url: item.libraryItem.originalUrl, + thumbnail: item.libraryItem.thumbnail ?? undefined, + wordCount: speechFiles[index].wordCount, + })), + createdAt: new Date(), + description: generateDescription(summaries, rankedTopics), + byline: generateByline(summaries), + } + + await writeDigest(jobData.userId, digest) +} diff --git a/packages/api/src/jobs/email/inbound_emails.ts b/packages/api/src/jobs/email/inbound_emails.ts new file mode 100644 index 000000000..ab953e6af --- /dev/null +++ b/packages/api/src/jobs/email/inbound_emails.ts @@ -0,0 +1,304 @@ +import { handleNewsletter } from '@omnivore/content-handler' +import { Converter } from 'showdown' +import { ContentReaderType, LibraryItemState } from '../../entity/library_item' +import { SubscriptionStatus } from '../../entity/subscription' +import { UploadFile } from '../../entity/upload_file' +import { env } from '../../env' +import { PageType, UploadFileStatus } from '../../generated/graphql' +import { authTrx } from '../../repository' +import { createOrUpdateLibraryItem } from '../../services/library_item' +import { + findNewsletterEmailByAddress, + updateConfirmationCode, +} from '../../services/newsletters' +import { + saveReceivedEmail, + updateReceivedEmail, +} from '../../services/received_emails' +import { saveNewsletter } from '../../services/save_newsletter_email' +import { saveUrlFromEmail } from '../../services/save_url' +import { getSubscriptionByName } from '../../services/subscriptions' +import { analytics } from '../../utils/analytics' +import { enqueueSendEmail } from '../../utils/createTask' +import { generateSlug, isUrl } from '../../utils/helpers' +import { logger } from '../../utils/logger' +import { + parseEmailAddress, + isProbablyArticle, + getTitleFromEmailSubject, + generateUniqueUrl, +} from '../../utils/parser' +import { + generateUploadFilePathName, + getStorageFileDetails, +} from '../../utils/uploads' + +interface EmailJobData { + from: string + to: string + subject: string + html: string + text: string + headers: Record + unsubMailTo?: string + unsubHttpUrl?: string + forwardedFrom?: string + replyTo?: string + confirmationCode?: string + uploadFile?: { + fileName: string + contentType: string + id: string + } +} + +const converter = new Converter() +export const FORWARD_EMAIL_JOB = 'forward-email' +export const SAVE_NEWSLETTER_JOB = 'save-newsletter' +export const CONFIRM_EMAIL_JOB = 'confirmation-email' +export const SAVE_ATTACHMENT_JOB = 'save-attachment' + +export const plainTextToHtml = (text: string): string => { + return converter.makeHtml(text) +} + +export const forwardEmailJob = async (data: EmailJobData) => { + const { from, to, subject, html, text, replyTo, forwardedFrom } = data + + // get user from newsletter email + const newsletterEmail = await findNewsletterEmailByAddress(to) + + if (!newsletterEmail) { + logger.error(`newsletter email not found: ${to}`) + return false + } + + const user = newsletterEmail.user + const parsedFrom = parseEmailAddress(from) + + const { id: receivedEmailId } = await saveReceivedEmail( + from, + to, + subject, + text, + html, + user.id, + 'non-article', + replyTo + ) + + if ( + await isProbablyArticle( + forwardedFrom || parsedFrom.address || from, + subject + ) + ) { + logger.info('handling as article') + return saveNewsletter( + { + title: getTitleFromEmailSubject(subject), + author: parsedFrom.name || from, + url: generateUniqueUrl(), + content: html || text, + receivedEmailId, + email: newsletterEmail.address, + }, + newsletterEmail + ) + } + + analytics.capture({ + distinctId: user.id, + event: 'non_newsletter_email_received', + properties: { + env: env.server.apiEnv, + }, + }) + + // forward non-newsletter emails to the registered email address + const result = await enqueueSendEmail({ + from: env.sender.message, + to: user.email, + subject: `Fwd: ${subject}`, + html, + text, + replyTo: replyTo || from, + }) + + return !!result +} + +export const saveNewsletterJob = async (data: EmailJobData) => { + const { + from, + to, + subject, + html, + text, + replyTo, + headers, + unsubMailTo, + unsubHttpUrl, + } = data + + // get user from newsletter email + const newsletterEmail = await findNewsletterEmailByAddress(to) + if (!newsletterEmail) { + logger.error(`newsletter email not found: ${to}`) + + return false + } + + const user = newsletterEmail.user + const { id: receivedEmailId } = await saveReceivedEmail( + from, + to, + subject, + text, + html, + user.id, + 'non-article', // default to non-article + replyTo + ) + + if (isUrl(subject)) { + // save url if the title is a parsable url + const result = await saveUrlFromEmail( + subject, + receivedEmailId, + newsletterEmail.user.id + ) + + if (result) { + // update received email type + await updateReceivedEmail(receivedEmailId, 'article', user.id) + } + + return result + } + + // convert text to html if html is not available + const content = html || plainTextToHtml(text) + const newsletter = await handleNewsletter({ + from, + to, + subject, + html: content, + headers, + }) + + const parsedFrom = parseEmailAddress(from) + const author = parsedFrom.name || from + + // do not subscribe if subscription already exists and is unsubscribed + const existingSubscription = await getSubscriptionByName( + author, + newsletterEmail.user.id + ) + if (existingSubscription?.status === SubscriptionStatus.Unsubscribed) { + logger.info(`newsletter already unsubscribed: ${from}`) + return false + } + + // save newsletter instead + return saveNewsletter( + { + email: newsletterEmail.address, + content, + url: generateUniqueUrl(), + title: subject, + author, + unsubMailTo, + unsubHttpUrl, + receivedEmailId, + ...newsletter, + }, + newsletterEmail + ) +} + +export const saveAttachmentJob = async (data: EmailJobData) => { + const { from, to, subject, html, text, replyTo, uploadFile } = data + + // get user from newsletter email + const newsletterEmail = await findNewsletterEmailByAddress(to) + if (!newsletterEmail) { + logger.error(`newsletter email not found: ${to}`) + + return false + } + + const user = newsletterEmail.user + const receivedEmail = await saveReceivedEmail( + from, + to, + subject, + text, + html, + user.id, + 'non-article', + replyTo + ) + + const uploadFileData = await authTrx( + (tx) => + tx.getRepository(UploadFile).save({ + ...uploadFile, + url: '', // no url for email attachments + status: UploadFileStatus.Completed, + user: { id: user.id }, + }), + undefined, + user.id + ) + + const uploadFileDetails = await getStorageFileDetails( + uploadFileData.id, + uploadFileData.fileName + ) + + const uploadFilePathName = generateUploadFilePathName( + uploadFileData.id, + uploadFileData.fileName + ) + + const uploadFileUrlOverride = `https://omnivore.app/attachments/${uploadFilePathName}` + const uploadFileHash = uploadFileDetails.md5Hash + const itemType = + uploadFileData.contentType === 'application/pdf' + ? PageType.File + : PageType.Book + const title = subject || uploadFileData.fileName + const itemToCreate = { + originalUrl: uploadFileUrlOverride, + itemType, + textContentHash: uploadFileHash, + uploadFile: { id: uploadFileData.id }, + title, + readableContent: '', + slug: generateSlug(title), + state: LibraryItemState.Succeeded, + user: { id: user.id }, + contentReader: + itemType === PageType.File + ? ContentReaderType.PDF + : ContentReaderType.EPUB, + } + + await createOrUpdateLibraryItem(itemToCreate, user.id) + + // update received email type + await updateReceivedEmail(receivedEmail.id, 'article', user.id) + + return true +} + +export const confirmEmailJob = async (data: EmailJobData) => { + const { confirmationCode, to } = data + if (!confirmationCode) { + logger.error('confirmation code not provided') + return false + } + + return updateConfirmationCode(to, confirmationCode) +} diff --git a/packages/api/src/jobs/send_email.ts b/packages/api/src/jobs/email/send_email.ts similarity index 61% rename from packages/api/src/jobs/send_email.ts rename to packages/api/src/jobs/email/send_email.ts index 8831fd970..130e2b14a 100644 --- a/packages/api/src/jobs/send_email.ts +++ b/packages/api/src/jobs/email/send_email.ts @@ -1,27 +1,29 @@ -import { env } from '../env' -import { sendWithMailJet } from '../services/send_emails' -import { Merge } from '../util' -import { logger } from '../utils/logger' -import { sendEmail } from '../utils/sendEmail' +import { env } from '../../env' +import { sendWithMailJet } from '../../services/send_emails' +import { Merge } from '../../util' +import { logger } from '../../utils/logger' +import { sendEmail } from '../../utils/sendEmail' export const SEND_EMAIL_JOB = 'send-email' type ContentType = { html: string } | { text: string } | { templateId: string } export type SendEmailJobData = Merge< { - emailAddress: string + to: string + from?: string subject?: string html?: string text?: string templateId?: string dynamicTemplateData?: Record + replyTo?: string }, ContentType > export const sendEmailJob = async (data: SendEmailJobData) => { if (process.env.USE_MAILJET && data.dynamicTemplateData) { - return sendWithMailJet(data.emailAddress, data.dynamicTemplateData.link) + return sendWithMailJet(data.to, data.dynamicTemplateData.link) } if (!data.html && !data.text && !data.templateId) { @@ -31,7 +33,6 @@ export const sendEmailJob = async (data: SendEmailJobData) => { return sendEmail({ ...data, - from: env.sender.message, - to: data.emailAddress, + from: data.from || env.sender.message, }) } diff --git a/packages/api/src/jobs/rss/refreshFeed.ts b/packages/api/src/jobs/rss/refreshFeed.ts index 76078a8f4..5afdab813 100644 --- a/packages/api/src/jobs/rss/refreshFeed.ts +++ b/packages/api/src/jobs/rss/refreshFeed.ts @@ -192,22 +192,40 @@ export const fetchAndChecksum = async (url: string) => { const parseFeed = async (url: string, content: string) => { try { - // check if url is a telegram channel - const telegramRegex = /https:\/\/t\.me\/([a-zA-Z0-9_]+)/ + // check if url is a telegram channel or preview + const telegramRegex = /t\.me\/([^/]+)/ const telegramMatch = url.match(telegramRegex) if (telegramMatch) { + let channel = telegramMatch[1] + if (channel.startsWith('s/')) { + channel = channel.slice(2) + } else { + // open the preview page to get the data + const fetchResult = await fetchAndChecksum(`https://t.me/s/${channel}`) + if (!fetchResult) { + return null + } + + content = fetchResult.content + } + const dom = parseHTML(content).document - const title = dom.querySelector('meta[property="og:title"]') + const title = + dom + .querySelector('meta[property="og:title"]') + ?.getAttribute('content') || dom.title // post has attribute data-post const posts = dom.querySelectorAll('[data-post]') const items = Array.from(posts) .map((post) => { - const id = post.getAttribute('data-post') + const id = post.getAttribute('data-post')?.split('/')[1] if (!id) { return null } - const url = `https://t.me/${telegramMatch[1]}/${id}` + const url = `https://t.me/s/${channel}/${id}` + const content = post.outerHTML + // find the