diff --git a/android/Omnivore/app/src/main/AndroidManifest.xml b/android/Omnivore/app/src/main/AndroidManifest.xml index b143c2f12..a989e07f2 100644 --- a/android/Omnivore/app/src/main/AndroidManifest.xml +++ b/android/Omnivore/app/src/main/AndroidManifest.xml @@ -4,7 +4,6 @@ - Void } } - func updateSubscription(dataService: DataService, subscription: Subscription, folder: String? = nil, fetchContent: Bool? = nil) async { + func updateSubscription(dataService: DataService, subscription: Subscription, folder: String? = nil, fetchContentType: FetchContentType? = nil) async { operationMessage = "Updating subscription..." operationStatus = .isPerforming do { - try await dataService.updateSubscription(subscription.subscriptionID, folder: folder, fetchContent: fetchContent) + try await dataService.updateSubscription(subscription.subscriptionID, folder: folder, fetchContentType: fetchContentType) operationMessage = "Subscription updated" operationStatus = .success } catch { @@ -240,23 +240,27 @@ struct SubscriptionsView: View { #endif } + private var emptyView: some View { + VStack(alignment: .center, spacing: 20) { + Text("You don't have any Feed items.") + .font(Font.system(size: 18, weight: .bold)) + + Text("Add an RSS/Atom feed") + .foregroundColor(Color.blue) + .onTapGesture { + showAddFeedView = true + } + } + .frame(minHeight: 80) + .frame(maxWidth: .infinity) + .padding() + } + private var innerBody: some View { - Group { + List { Section("Feeds") { if viewModel.feeds.count <= 0, !viewModel.isLoading { - VStack(alignment: .center, spacing: 20) { - Text("You don't have any Feed items.") - .font(Font.system(size: 18, weight: .bold)) - - Text("Add an RSS/Atom feed") - .foregroundColor(Color.blue) - .onTapGesture { - showAddFeedView = true - } - } - .frame(minHeight: 80) - .frame(maxWidth: .infinity) - .padding() + emptyView } else { ForEach(viewModel.feeds, id: \.subscriptionID) { subscription in PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) { @@ -264,7 +268,7 @@ struct SubscriptionsView: View { subscription: subscription, viewModel: viewModel, dataService: dataService, - prefetchContent: subscription.fetchContent, + fetchContentType: subscription.fetchContentType, folderSelection: subscription.folder, unsubscribe: { _ in viewModel.operationStatus = .isPerforming @@ -296,7 +300,7 @@ struct SubscriptionsView: View { subscription: subscription, viewModel: viewModel, dataService: dataService, - prefetchContent: subscription.fetchContent, + fetchContentType: subscription.fetchContentType, folderSelection: subscription.folder, unsubscribe: { _ in viewModel.operationStatus = .isPerforming @@ -389,7 +393,7 @@ struct SubscriptionSettingsView: View { let viewModel: SubscriptionsViewModel let dataService: DataService - @State var prefetchContent = false + @State var fetchContentType: FetchContentType @State var deleteConfirmationShown = false @State var showDeleteCompleted = false @State var folderSelection: String = "" @@ -428,6 +432,28 @@ struct SubscriptionSettingsView: View { return nil } + var fetchContentRow: some View { + Picker(selection: $fetchContentType, content: { + Text("Always").tag(FetchContentType.always) + Text("Never").tag(FetchContentType.never) + Text("When empty").tag(FetchContentType.whenEmpty) + }, label: { Text("Fetch link") }) + .pickerStyle(MenuPickerStyle()) + .onChange(of: fetchContentType) { newValue in + Task { + viewModel.showOperationToast = true + await viewModel.updateSubscription( + dataService: dataService, + subscription: subscription, + fetchContentType: newValue + ) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { + viewModel.showOperationToast = false + } + } + } + } + var folderRow: some View { HStack { Picker("Destination Folder", selection: $folderSelection) { @@ -444,19 +470,6 @@ struct SubscriptionSettingsView: View { } } } - .onChange(of: prefetchContent) { newValue in - Task { - viewModel.showOperationToast = true - await viewModel.updateSubscription( - dataService: dataService, - subscription: subscription, - fetchContent: newValue - ) - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) { - viewModel.showOperationToast = false - } - } - } } } @@ -570,12 +583,10 @@ struct SubscriptionSettingsView: View { .padding(.horizontal, 15) List { -// if subscription.type != .newsletter { -// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") }) -// .onAppear { -// prefetchContent = subscription.fetchContent -// } -// } + if subscription.type != .newsletter { + fetchContentRow + } + folderRow labelRuleRow diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift index ec64eac22..8c5468f16 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReader.swift @@ -66,6 +66,10 @@ struct WebReader: PlatformViewRepresentable { webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight webView.configuration.userContentController.add(webView, name: "viewerAction") + if #available(iOS 15.4, *) { + webView.configuration.preferences.isElementFullscreenEnabled = true + } + webView.scrollView.indicatorStyle = ThemeManager.currentTheme.isDark ? UIScrollView.IndicatorStyle.white : UIScrollView.IndicatorStyle.black diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift index 2a8ff19df..2b7493ca1 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift @@ -8,7 +8,7 @@ public struct Subscription { public let name: String public let type: SubscriptionType public let folder: String - public let fetchContent: Bool + public let fetchContentType: FetchContentType public let newsletterEmailAddress: String? public let status: SubscriptionStatus public let unsubscribeHttpUrl: String? @@ -24,7 +24,7 @@ public struct Subscription { name: String, type: SubscriptionType, folder: String, - fetchContent: Bool, + fetchContentType: FetchContentType, newsletterEmailAddress: String?, status: SubscriptionStatus, unsubscribeHttpUrl: String?, @@ -39,7 +39,7 @@ public struct Subscription { self.name = name self.type = type self.folder = folder - self.fetchContent = fetchContent + self.fetchContentType = fetchContentType self.newsletterEmailAddress = newsletterEmailAddress self.status = status self.unsubscribeHttpUrl = unsubscribeHttpUrl @@ -60,3 +60,9 @@ public enum SubscriptionType { case newsletter case feed } + +public enum FetchContentType { + case always + case never + case whenEmpty +} diff --git a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift index e051efd4c..49979b626 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/GQLSchema.swift @@ -667,6 +667,8 @@ extension Objects { let contentReader: [String: Enums.ContentReader] let createdAt: [String: DateTime] let description: [String: String] + let directionality: [String: Enums.DirectionalityType] + let feedContent: [String: String] let folder: [String: String] let hasContent: [String: Bool] let hash: [String: String] @@ -742,6 +744,14 @@ extension Objects.Article: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "directionality": + if let value = try container.decode(Enums.DirectionalityType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feedContent": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "folder": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -901,6 +911,8 @@ extension Objects.Article: Decodable { contentReader = map["contentReader"] createdAt = map["createdAt"] description = map["description"] + directionality = map["directionality"] + feedContent = map["feedContent"] folder = map["folder"] hasContent = map["hasContent"] hash = map["hash"] @@ -1025,6 +1037,36 @@ extension Fields where TypeLock == Objects.Article { } } + func directionality() throws -> Enums.DirectionalityType? { + let field = GraphQLField.leaf( + name: "directionality", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.directionality[field.alias!] + case .mocking: + return nil + } + } + + func feedContent() throws -> String? { + let field = GraphQLField.leaf( + name: "feedContent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.feedContent[field.alias!] + case .mocking: + return nil + } + } + func folder() throws -> String { let field = GraphQLField.leaf( name: "folder", @@ -8127,6 +8169,7 @@ extension Objects { let quote: [String: String] let reactions: [String: [Objects.Reaction]] let replies: [String: [Objects.HighlightReply]] + let representation: [String: Enums.RepresentationType] let sharedAt: [String: DateTime] let shortId: [String: String] let suffix: [String: String] @@ -8208,6 +8251,10 @@ extension Objects.Highlight: Decodable { if let value = try container.decode([Objects.HighlightReply]?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "representation": + if let value = try container.decode(Enums.RepresentationType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "sharedAt": if let value = try container.decode(DateTime?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -8256,6 +8303,7 @@ extension Objects.Highlight: Decodable { quote = map["quote"] reactions = map["reactions"] replies = map["replies"] + representation = map["representation"] sharedAt = map["sharedAt"] shortId = map["shortId"] suffix = map["suffix"] @@ -8494,6 +8542,24 @@ extension Fields where TypeLock == Objects.Highlight { } } + func representation() throws -> Enums.RepresentationType { + let field = GraphQLField.leaf( + name: "representation", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.representation[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.RepresentationType.allCases.first! + } + } + func sharedAt() throws -> DateTime? { let field = GraphQLField.leaf( name: "sharedAt", @@ -8985,6 +9051,7 @@ extension Objects { let enabled: [String: Bool] let id: [String: String] let name: [String: String] + let settings: [String: String] let taskName: [String: String] let token: [String: String] let type: [String: Enums.IntegrationType] @@ -9024,6 +9091,10 @@ extension Objects.Integration: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "settings": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "taskName": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -9054,6 +9125,7 @@ extension Objects.Integration: Decodable { enabled = map["enabled"] id = map["id"] name = map["name"] + settings = map["settings"] taskName = map["taskName"] token = map["token"] type = map["type"] @@ -9134,6 +9206,21 @@ extension Fields where TypeLock == Objects.Integration { } } + func settings() throws -> String? { + let field = GraphQLField.leaf( + name: "settings", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.settings[field.alias!] + case .mocking: + return nil + } + } + func taskName() throws -> String? { let field = GraphQLField.leaf( name: "taskName", @@ -18126,6 +18213,7 @@ extension Selection where TypeLock == Never, Type == Never { extension Objects { struct SearchItem { let __typename: TypeName = .searchItem + let aiSummary: [String: String] let annotation: [String: String] let archivedAt: [String: DateTime] let author: [String: String] @@ -18134,6 +18222,8 @@ extension Objects { let contentReader: [String: Enums.ContentReader] let createdAt: [String: DateTime] let description: [String: String] + let directionality: [String: Enums.DirectionalityType] + let feedContent: [String: String] let folder: [String: String] let highlights: [String: [Objects.Highlight]] let id: [String: String] @@ -18146,7 +18236,6 @@ extension Objects { let ownedByViewer: [String: Bool] let pageId: [String: String] let pageType: [String: Enums.PageType] - let previewContent: [String: String] let previewContentType: [String: String] let publishedAt: [String: DateTime] let quote: [String: String] @@ -18188,6 +18277,10 @@ extension Objects.SearchItem: Decodable { let field = GraphQLField.getFieldNameFromAlias(alias) switch field { + case "aiSummary": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "annotation": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -18220,6 +18313,14 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "directionality": + if let value = try container.decode(Enums.DirectionalityType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } + case "feedContent": + if let value = try container.decode(String?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "folder": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -18268,10 +18369,6 @@ extension Objects.SearchItem: Decodable { if let value = try container.decode(Enums.PageType?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } - case "previewContent": - if let value = try container.decode(String?.self, forKey: codingKey) { - map.set(key: field, hash: alias, value: value as Any) - } case "previewContentType": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -18370,6 +18467,7 @@ extension Objects.SearchItem: Decodable { } } + aiSummary = map["aiSummary"] annotation = map["annotation"] archivedAt = map["archivedAt"] author = map["author"] @@ -18378,6 +18476,8 @@ extension Objects.SearchItem: Decodable { contentReader = map["contentReader"] createdAt = map["createdAt"] description = map["description"] + directionality = map["directionality"] + feedContent = map["feedContent"] folder = map["folder"] highlights = map["highlights"] id = map["id"] @@ -18390,7 +18490,6 @@ extension Objects.SearchItem: Decodable { ownedByViewer = map["ownedByViewer"] pageId = map["pageId"] pageType = map["pageType"] - previewContent = map["previewContent"] previewContentType = map["previewContentType"] publishedAt = map["publishedAt"] quote = map["quote"] @@ -18417,6 +18516,21 @@ extension Objects.SearchItem: Decodable { } extension Fields where TypeLock == Objects.SearchItem { + func aiSummary() throws -> String? { + let field = GraphQLField.leaf( + name: "aiSummary", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.aiSummary[field.alias!] + case .mocking: + return nil + } + } + func annotation() throws -> String? { let field = GraphQLField.leaf( name: "annotation", @@ -18543,6 +18657,36 @@ extension Fields where TypeLock == Objects.SearchItem { } } + func directionality() throws -> Enums.DirectionalityType? { + let field = GraphQLField.leaf( + name: "directionality", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.directionality[field.alias!] + case .mocking: + return nil + } + } + + func feedContent() throws -> String? { + let field = GraphQLField.leaf( + name: "feedContent", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.feedContent[field.alias!] + case .mocking: + return nil + } + } + func folder() throws -> String { let field = GraphQLField.leaf( name: "folder", @@ -18737,21 +18881,6 @@ extension Fields where TypeLock == Objects.SearchItem { } } - func previewContent() throws -> String? { - let field = GraphQLField.leaf( - name: "previewContent", - arguments: [] - ) - select(field) - - switch response { - case let .decoding(data): - return data.previewContent[field.alias!] - case .mocking: - return nil - } - } - func previewContentType() throws -> String? { let field = GraphQLField.leaf( name: "previewContentType", @@ -21284,6 +21413,7 @@ extension Objects { let description: [String: String] let failedAt: [String: DateTime] let fetchContent: [String: Bool] + let fetchContentType: [String: Enums.FetchContentType] let folder: [String: String] let icon: [String: String] let id: [String: String] @@ -21342,6 +21472,10 @@ extension Objects.Subscription: Decodable { if let value = try container.decode(Bool?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) } + case "fetchContentType": + if let value = try container.decode(Enums.FetchContentType?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "folder": if let value = try container.decode(String?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -21418,6 +21552,7 @@ extension Objects.Subscription: Decodable { description = map["description"] failedAt = map["failedAt"] fetchContent = map["fetchContent"] + fetchContentType = map["fetchContentType"] folder = map["folder"] icon = map["icon"] id = map["id"] @@ -21536,6 +21671,24 @@ extension Fields where TypeLock == Objects.Subscription { } } + func fetchContentType() throws -> Enums.FetchContentType { + let field = GraphQLField.leaf( + name: "fetchContentType", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + if let data = data.fetchContentType[field.alias!] { + return data + } + throw HttpError.badpayload + case .mocking: + return Enums.FetchContentType.allCases.first! + } + } + func folder() throws -> String { let field = GraphQLField.leaf( name: "folder", @@ -24692,6 +24845,7 @@ extension Objects { struct User { let __typename: TypeName = .user let email: [String: String] + let features: [String: [String?]] let followersCount: [String: Int] let friendsCount: [String: Int] let id: [String: String] @@ -24730,6 +24884,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 "features": + if let value = try container.decode([String?]?.self, forKey: codingKey) { + map.set(key: field, hash: alias, value: value as Any) + } case "followersCount": if let value = try container.decode(Int?.self, forKey: codingKey) { map.set(key: field, hash: alias, value: value as Any) @@ -24801,6 +24959,7 @@ extension Objects.User: Decodable { } email = map["email"] + features = map["features"] followersCount = map["followersCount"] friendsCount = map["friendsCount"] id = map["id"] @@ -24835,6 +24994,21 @@ extension Fields where TypeLock == Objects.User { } } + func features() throws -> [String?]? { + let field = GraphQLField.leaf( + name: "features", + arguments: [] + ) + select(field) + + switch response { + case let .decoding(data): + return data.features[field.alias!] + case .mocking: + return nil + } + } + func followersCount() throws -> Int? { let field = GraphQLField.leaf( name: "followersCount", @@ -34148,6 +34322,15 @@ extension Enums { } } +extension Enums { + /// DirectionalityType + enum DirectionalityType: String, CaseIterable, Codable { + case ltr = "LTR" + + case rtl = "RTL" + } +} + extension Enums { /// EmptyTrashErrorCode enum EmptyTrashErrorCode: String, CaseIterable, Codable { @@ -34180,6 +34363,17 @@ extension Enums { } } +extension Enums { + /// FetchContentType + enum FetchContentType: String, CaseIterable, Codable { + case always = "ALWAYS" + + case never = "NEVER" + + case whenEmpty = "WHEN_EMPTY" + } +} + extension Enums { /// FiltersErrorCode enum FiltersErrorCode: String, CaseIterable, Codable { @@ -34521,6 +34715,15 @@ extension Enums { } } +extension Enums { + /// RepresentationType + enum RepresentationType: String, CaseIterable, Codable { + case content = "CONTENT" + + case feedContent = "FEED_CONTENT" + } +} + extension Enums { /// RevokeApiKeyErrorCode enum RevokeApiKeyErrorCode: String, CaseIterable, Codable { @@ -34539,6 +34742,8 @@ extension Enums { case archive = "ARCHIVE" + case delete = "DELETE" + case markAsRead = "MARK_AS_READ" case sendNotification = "SEND_NOTIFICATION" @@ -35306,6 +35511,8 @@ extension InputObjects { var quote: OptionalArgument = .absent() + var representation: OptionalArgument = .absent() + var sharedAt: OptionalArgument = .absent() var shortId: String @@ -35326,6 +35533,7 @@ extension InputObjects { if patch.hasValue { try container.encode(patch, forKey: .patch) } if prefix.hasValue { try container.encode(prefix, forKey: .prefix) } if quote.hasValue { try container.encode(quote, forKey: .quote) } + if representation.hasValue { try container.encode(representation, forKey: .representation) } if sharedAt.hasValue { try container.encode(sharedAt, forKey: .sharedAt) } try container.encode(shortId, forKey: .shortId) if suffix.hasValue { try container.encode(suffix, forKey: .suffix) } @@ -35343,6 +35551,7 @@ extension InputObjects { case patch case prefix case quote + case representation case sharedAt case shortId case suffix @@ -35602,6 +35811,8 @@ extension InputObjects { var quote: String + var representation: OptionalArgument = .absent() + var shortId: String var suffix: OptionalArgument = .absent() @@ -35619,6 +35830,7 @@ extension InputObjects { try container.encode(patch, forKey: .patch) if prefix.hasValue { try container.encode(prefix, forKey: .prefix) } try container.encode(quote, forKey: .quote) + if representation.hasValue { try container.encode(representation, forKey: .representation) } try container.encode(shortId, forKey: .shortId) if suffix.hasValue { try container.encode(suffix, forKey: .suffix) } } @@ -35635,6 +35847,7 @@ extension InputObjects { case patch case prefix case quote + case representation case shortId case suffix } @@ -36228,6 +36441,8 @@ extension InputObjects { var name: String + var settings: OptionalArgument = .absent() + var syncedAt: OptionalArgument = .absent() var taskName: OptionalArgument = .absent() @@ -36242,6 +36457,7 @@ extension InputObjects { if id.hasValue { try container.encode(id, forKey: .id) } if importItemState.hasValue { try container.encode(importItemState, forKey: .importItemState) } try container.encode(name, forKey: .name) + if settings.hasValue { try container.encode(settings, forKey: .settings) } if syncedAt.hasValue { try container.encode(syncedAt, forKey: .syncedAt) } if taskName.hasValue { try container.encode(taskName, forKey: .taskName) } try container.encode(token, forKey: .token) @@ -36253,6 +36469,7 @@ extension InputObjects { case id case importItemState case name + case settings case syncedAt case taskName case token @@ -36511,6 +36728,8 @@ extension InputObjects { var fetchContent: OptionalArgument = .absent() + var fetchContentType: OptionalArgument = .absent() + var folder: OptionalArgument = .absent() var isPrivate: OptionalArgument = .absent() @@ -36523,6 +36742,7 @@ extension InputObjects { var container = encoder.container(keyedBy: CodingKeys.self) if autoAddToLibrary.hasValue { try container.encode(autoAddToLibrary, forKey: .autoAddToLibrary) } if fetchContent.hasValue { try container.encode(fetchContent, forKey: .fetchContent) } + if fetchContentType.hasValue { try container.encode(fetchContentType, forKey: .fetchContentType) } if folder.hasValue { try container.encode(folder, forKey: .folder) } if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } if subscriptionType.hasValue { try container.encode(subscriptionType, forKey: .subscriptionType) } @@ -36532,6 +36752,7 @@ extension InputObjects { enum CodingKeys: String, CodingKey { case autoAddToLibrary case fetchContent + case fetchContentType case folder case isPrivate case subscriptionType @@ -36828,6 +37049,8 @@ extension InputObjects { var fetchContent: OptionalArgument = .absent() + var fetchContentType: OptionalArgument = .absent() + var folder: OptionalArgument = .absent() var id: String @@ -36852,6 +37075,7 @@ extension InputObjects { if description.hasValue { try container.encode(description, forKey: .description) } if failedAt.hasValue { try container.encode(failedAt, forKey: .failedAt) } if fetchContent.hasValue { try container.encode(fetchContent, forKey: .fetchContent) } + if fetchContentType.hasValue { try container.encode(fetchContentType, forKey: .fetchContentType) } if folder.hasValue { try container.encode(folder, forKey: .folder) } try container.encode(id, forKey: .id) if isPrivate.hasValue { try container.encode(isPrivate, forKey: .isPrivate) } @@ -36868,6 +37092,7 @@ extension InputObjects { case description case failedAt case fetchContent + case fetchContentType case folder case id case isPrivate diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift index a59fd43e8..9dea39b9a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/SetRuleMutation.swift @@ -12,6 +12,7 @@ public struct Rule { public enum RuleActionType { case addLabel case archive + case delete case markAsRead case sendNotification @@ -25,6 +26,8 @@ public enum RuleActionType { return .markAsRead case Enums.RuleActionType.sendNotification: return .sendNotification + case .delete: + return .delete } } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift index 1c16479b1..9cf26cb80 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Mutations/UpdateSubscription.swift @@ -4,7 +4,7 @@ import Models import SwiftGraphQL public extension DataService { - func updateSubscription(_ subscriptionID: String, folder: String? = nil, fetchContent: Bool? = nil) async throws { + func updateSubscription(_ subscriptionID: String, folder: String? = nil, fetchContentType: FetchContentType? = nil) async throws { enum MutationResult { case success(subscriptionID: String) case error(errorMessage: String) @@ -20,7 +20,7 @@ public extension DataService { let mutation = Selection.Mutation { try $0.updateSubscription( input: InputObjects.UpdateSubscriptionInput( - fetchContent: OptionalArgument(fetchContent), + fetchContentType: OptionalArgument(fetchContentType?.toGQLType()), folder: OptionalArgument(folder), id: subscriptionID ), diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift b/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift index f8e883e97..5ff68747a 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Selections/SubsciptionSelection.swift @@ -11,7 +11,7 @@ let subscriptionSelection = Selection.Subscription { name: try $0.name(), type: try SubscriptionType.from($0.type()), folder: try $0.folder(), - fetchContent: try $0.fetchContent(), + fetchContentType: try FetchContentType.from($0.fetchContentType()), newsletterEmailAddress: try $0.newsletterEmail(), status: try SubscriptionStatus.make(from: $0.status()), unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(), @@ -45,3 +45,26 @@ extension SubscriptionType { } } } + +extension FetchContentType { + static func from(_ other: Enums.FetchContentType) -> FetchContentType { + switch other { + case .always: + return .always + case .never: + return .never + case .whenEmpty: + return .whenEmpty + } + } + func toGQLType() -> Enums.FetchContentType { + switch self { + case .always: + return .always + case .never: + return .never + case .whenEmpty: + return .whenEmpty + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index 23c9bd1b3..465518fb1 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -301,7 +301,7 @@ public final class OmnivoreWebView: WKWebView { case Selector(("_lookup:")): return (currentMenu == .defaultMenu) case Selector(("_define:")): return (currentMenu == .defaultMenu) case Selector(("_translate:")): return (currentMenu == .defaultMenu) - case Selector(("_findSelected:")): return (currentMenu == .defaultMenu) + // case Selector(("_findSelected:")): return (currentMenu == .defaultMenu) default: return false } } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index 15a79b8e5..cd96cbcac 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -12,11 +12,13 @@ public enum GridCardAction { public struct GridCard: View { @ObservedObject var item: Models.LibraryItem + let savedAtStr: String public init( item: Models.LibraryItem ) { self.item = item + self.savedAtStr = savedDateString(item.savedAt) } var imageBox: some View { @@ -198,6 +200,10 @@ public struct GridCard: View { $0.icon } + Text(savedAtStr) + .font(.footnote) + .foregroundColor(Color.themeLibraryItemSubtle) ++ Text("\(estimatedReadingTime)") .font(.caption2).fontWeight(.medium) .foregroundColor(Color.themeLibraryItemSubtle) diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift index 261e45dc5..d301f13d6 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift @@ -44,14 +44,33 @@ public extension View { } } +func savedDateString(_ savedAt: Date?) -> String { + if let savedAt = savedAt { + let locale = Locale.current + let dateFormatter = DateFormatter() + if Calendar.current.isDateInToday(savedAt) { + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .short + } else { + dateFormatter.dateFormat = "MMM dd" + } + dateFormatter.locale = locale + return dateFormatter.string(from: savedAt) + " • " + } + return "" +} + public struct LibraryItemCard: View { let viewer: Viewer? @ObservedObject var item: Models.LibraryItem @State var noteLineLimit: Int? = 3 + let savedAtStr: String + public init(item: Models.LibraryItem, viewer: Viewer?) { self.item = item self.viewer = viewer + self.savedAtStr = savedDateString(item.savedAt) } public var body: some View { @@ -215,23 +234,28 @@ public struct LibraryItemCard: View { $0.icon } + Text(savedAtStr) + .font(.footnote) + .foregroundColor(Color.themeLibraryItemSubtle) + + + Text("\(estimatedReadingTime)") - .font(.caption2).fontWeight(.medium) + .font(.footnote) .foregroundColor(Color.themeLibraryItemSubtle) + Text("\(readingProgress)") - .font(.caption2).fontWeight(.medium) + .font(.footnote) .foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle) + Text("\(highlightsText)") - .font(.caption2).fontWeight(.medium) + .font(.footnote) .foregroundColor(Color.themeLibraryItemSubtle) + Text("\(notesText)") - .font(.caption2).fontWeight(.medium) + .font(.footnote) .foregroundColor(Color.themeLibraryItemSubtle) } .frame(maxWidth: .infinity, alignment: .leading) @@ -281,13 +305,13 @@ public struct LibraryItemCard: View { var byLine: some View { if let origin = cardSiteName(item.pageURLString) { Text(bylineStr + " | " + origin) - .font(.caption2) + .font(.footnote) .foregroundColor(Color.themeLibraryItemSubtle) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) } else { Text(bylineStr) - .font(.caption2) + .font(.footnote) .foregroundColor(Color.themeLibraryItemSubtle) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(1) @@ -295,7 +319,7 @@ public struct LibraryItemCard: View { } public var articleInfo: some View { - VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 7) { readInfo .dynamicTypeSize(.xSmall ... .medium) diff --git a/packages/api/package.json b/packages/api/package.json index 6e2920cd7..63cd8fb62 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,7 @@ "@google-cloud/tasks": "^4.0.0", "@graphql-tools/utils": "^9.1.1", "@langchain/openai": "^0.0.14", + "@notionhq/client": "^2.2.14", "@omnivore/content-handler": "1.0.0", "@omnivore/liqe": "1.0.0", "@omnivore/readability": "1.0.0", @@ -45,6 +46,7 @@ "@sentry/integrations": "^7.10.0", "@sentry/node": "^5.26.0", "@sentry/tracing": "^7.9.0", + "@types/showdown": "^2.0.6", "addressparser": "^1.0.1", "apollo-datasource": "^3.3.1", "apollo-server-express": "^3.6.3", @@ -97,6 +99,7 @@ "sanitize-html": "^2.3.2", "sax": "^1.3.0", "search-query-parser": "^1.6.0", + "showdown": "^2.1.0", "snake-case": "^3.0.3", "supertest": "^6.2.2", "ts-loader": "^9.3.0", @@ -107,7 +110,9 @@ "uuid": "^8.3.1", "voca": "^1.4.0", "winston": "^3.3.3", - "word-counting": "^1.1.4" + "word-counting": "^1.1.4", + "youtubei": "^1.3.4", + "youtubei.js": "^9.1.0" }, "devDependencies": { "@babel/register": "^7.14.5", @@ -136,6 +141,7 @@ "@types/private-ip": "^1.0.0", "@types/sanitize-html": "^1.27.1", "@types/sax": "^1.2.7", + "@types/showdown": "^2.0.6", "@types/sinon": "^10.0.13", "@types/sinon-chai": "^3.2.8", "@types/supertest": "^2.0.11", diff --git a/packages/api/src/entity/highlight.ts b/packages/api/src/entity/highlight.ts index 602c9f959..60b8eed3a 100644 --- a/packages/api/src/entity/highlight.ts +++ b/packages/api/src/entity/highlight.ts @@ -62,7 +62,7 @@ export class Highlight { createdAt!: Date @UpdateDateColumn() - updatedAt?: Date | null + updatedAt!: Date @Column('timestamp') sharedAt?: Date diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index e446f10c3..dcb7f03c1 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -59,4 +59,7 @@ export class Integration { @Column('enum', { enum: ImportItemState, nullable: true }) importItemState?: ImportItemState | null + + @Column('jsonb', { nullable: true }) + settings?: any } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index fc84f7c23..4e9c0735d 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1267,6 +1267,7 @@ export type Integration = { enabled: Scalars['Boolean']; id: Scalars['ID']; name: Scalars['String']; + settings?: Maybe; taskName?: Maybe; token: Scalars['String']; type: IntegrationType; @@ -2836,6 +2837,7 @@ export type SetIntegrationInput = { id?: InputMaybe; importItemState?: InputMaybe; name: Scalars['String']; + settings?: InputMaybe; syncedAt?: InputMaybe; taskName?: InputMaybe; token: Scalars['String']; @@ -5767,6 +5769,7 @@ export type IntegrationResolvers; id?: Resolver; name?: Resolver; + settings?: Resolver, ParentType, ContextType>; taskName?: Resolver, ParentType, ContextType>; token?: Resolver; type?: Resolver; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index f4d4f1a59..60ad805cd 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1133,6 +1133,7 @@ type Integration { enabled: Boolean! id: ID! name: String! + settings: JSON taskName: String token: String! type: IntegrationType! @@ -2199,6 +2200,7 @@ input SetIntegrationInput { id: ID importItemState: ImportItemState name: String! + settings: JSON syncedAt: Date taskName: String token: String! diff --git a/packages/api/src/jobs/integration/export_item.ts b/packages/api/src/jobs/integration/export_item.ts index 327f16206..318a94ec0 100644 --- a/packages/api/src/jobs/integration/export_item.ts +++ b/packages/api/src/jobs/integration/export_item.ts @@ -35,41 +35,54 @@ export const exportItem = async (jobData: ExportItemJobData) => { return } - // currently only readwise integration is supported - const integration = integrations[0] + await Promise.all( + integrations.map(async (integration) => { + try { + const logObject = { + userId, + integrationId: integration.id, + } + logger.info('exporting item...', logObject) - const logObject = { - userId, - integrationId: integration.id, - } - logger.info('exporting item...', logObject) + const client = getIntegrationClient( + integration.name, + integration.token, + integration + ) - const client = getIntegrationClient(integration.name) + const synced = await client.export(libraryItems) + if (!synced) { + logger.error('failed to export item', logObject) + return false + } - const synced = await client.export(integration.token, libraryItems) - if (!synced) { - logger.error('failed to export item', logObject) - return false - } + const syncedAt = new Date() + logger.info('updating integration...', { + ...logObject, + syncedAt, + }) - const syncedAt = new Date() - logger.info('updating integration...', { - ...logObject, - syncedAt, - }) - - // update integration syncedAt if successful - const updated = await updateIntegration( - integration.id, - { - syncedAt, - }, - userId + // update integration syncedAt if successful + const updated = await updateIntegration( + integration.id, + { + syncedAt, + }, + userId + ) + logger.info('integration updated', { + ...logObject, + updated, + }) + } catch (error) { + logger.error('failed to export item', { + userId, + integrationId: integration.id, + error, + }) + } + }) ) - logger.info('integration updated', { - ...logObject, - updated, - }) return true } diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts new file mode 100644 index 000000000..fa4edcc76 --- /dev/null +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -0,0 +1,521 @@ +import { logger } from '../utils/logger' +import { authTrx } from '../repository' +import { libraryItemRepository } from '../repository/library_item' +import { LibraryItem, LibraryItemState } from '../entity/library_item' + +import { Chapter, Client as YouTubeClient } from 'youtubei' +import showdown from 'showdown' +import { parseHTML } from 'linkedom' +import { parsePreparedContent } from '../utils/parser' +import { OpenAI } from '@langchain/openai' +import { PromptTemplate } from '@langchain/core/prompts' +import { enqueueProcessYouTubeTranscript } from '../utils/createTask' +import { env } from '../env' +import * as stream from 'stream' + +import { Storage } from '@google-cloud/storage' +import { stringToHash } from '../utils/helpers' +import { FeatureName, findFeatureByName } from '../services/features' + +export interface ProcessYouTubeVideoJobData { + userId: string + libraryItemId: string +} + +export const PROCESS_YOUTUBE_VIDEO_JOB_NAME = 'process-youtube-video' +export const PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME = 'process-youtube-transcript' + +const TRANSCRIPT_PLACEHOLDER_TEXT = + '* Omnivore is preparing a transcript for this video' + +const calculateWordCount = (durationInSeconds: number): number => { + // Calculate word count using the formula: word count = read time (in seconds) * words per second + // Assuming average reading speed is 235 words per minute (or about 3.92 words per second) + const wordsPerSecond = 3.92 + const wordCount = Math.round(durationInSeconds * wordsPerSecond) + return wordCount +} + +interface ChapterProperties { + title: string + start: number +} + +interface TranscriptProperties { + text: string + start: number + duration: number +} + +export const addTranscriptChapters = ( + chapters: ChapterProperties[], + transcript: TranscriptProperties[] +): TranscriptProperties[] => { + chapters.sort((a, b) => a.start - b.start) + + for (const chapter of chapters) { + const startOffset = chapter.start + const title = '\n\n## ' + chapter.title + '\n\n' + + const index = transcript.findIndex( + (textItem) => textItem.start > startOffset + ) + + if (index !== -1) { + transcript.splice(index, 0, { + text: title, + duration: 1, + start: startOffset, + }) + } else { + transcript.push({ text: title, duration: 0, start: startOffset }) + } + } + return transcript +} + +const createTranscriptHash = (transcript: TranscriptProperties[]): string => { + const rawTranscript = transcript.map((item) => item.text).join(' ') + return stringToHash(rawTranscript) +} + +export const createTranscriptHTML = async ( + videoId: string, + transcript: TranscriptProperties[] +): Promise => { + let transcriptMarkdown = '' + const transcriptHash = createTranscriptHash(transcript) + const promptHash = stringToHash(process.env.YOUTUBE_TRANSCRIPT_PROMPT ?? '') + + if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) { + const cachedTranscriptHTML = await fetchCachedYouTubeTranscript( + videoId, + transcriptHash, + promptHash + ) + if (cachedTranscriptHTML) { + return cachedTranscriptHTML + } + + const llm = new OpenAI({ + modelName: 'gpt-4', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + const promptTemplate = PromptTemplate.fromTemplate( + `${process.env.YOUTUBE_TRANSCRIPT_PROMPT} + + {transcriptData}` + ) + const chain = promptTemplate.pipe(llm) + + let transcriptChunkLength = 0 + let transcriptChunk: TranscriptProperties[] = [] + for (const item of transcript) { + if (transcriptChunkLength + item.text.length > 8000) { + const result = await chain.invoke({ + transcriptData: transcriptChunk.map((item) => item.text).join(' '), + }) + + transcriptMarkdown += result + + transcriptChunk = [] + transcriptChunkLength = 0 + } + + transcriptChunk.push(item) + transcriptChunkLength += item.text.length + } + + if (transcriptChunk.length > 0) { + const result = await chain.invoke({ + transcriptData: transcriptChunk.map((item) => item.text).join(' '), + }) + + transcriptMarkdown += result + } + } + + // If the LLM didn't give us enough data fallback to the raw template + if (transcriptMarkdown.length < 1) { + transcriptMarkdown = transcript.map((item) => item.text).join(' ') + } + + const converter = new showdown.Converter({ + backslashEscapesHTMLTags: true, + }) + const transcriptHTML = converter.makeHtml(transcriptMarkdown) + + if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) { + await cacheYouTubeTranscript( + videoId, + transcriptHash, + promptHash, + transcriptHTML + ) + } + + return transcriptHTML +} + +export const addTranscriptToReadableContent = async ( + originalUrl: string, + originalHTML: string, + transcriptHTML: string +): Promise => { + const html = parseHTML(originalHTML) + + const transcriptNode = html.document.querySelector( + '#_omnivore_youtube_transcript' + ) + + if (transcriptNode) { + transcriptNode.innerHTML = transcriptHTML + } else { + const div = html.document.createElement('div') + div.innerHTML = transcriptHTML + html.document.body.appendChild(div) + } + + const preparedDocument = { + document: html.document.toString(), + pageInfo: {}, + } + const updatedContent = await parsePreparedContent( + originalUrl, + preparedDocument, + true + ) + return updatedContent.parsedContent?.content +} + +export const addTranscriptPlaceholdReadableContent = async ( + originalUrl: string, + originalHTML: string +): Promise => { + const html = parseHTML(originalHTML) + + const transcriptNode = html.document.querySelector( + '#_omnivore_youtube_transcript' + ) + + if (transcriptNode) { + transcriptNode.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT + } else { + const div = html.document.createElement('div') + div.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT + html.document.body.appendChild(div) + } + + const preparedDocument = { + document: html.document.toString(), + pageInfo: {}, + } + const updatedContent = await parsePreparedContent( + originalUrl, + preparedDocument, + true + ) + return updatedContent.parsedContent?.content +} + +async function readStringFromStorage( + bucketName: string, + fileName: string +): Promise { + try { + const storage = env.fileUpload?.gcsUploadSAKeyFilePath + ? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath }) + : new Storage() + + const existsResponse = await storage + .bucket(bucketName) + .file(fileName) + .exists() + const exists = existsResponse[0] + + if (!exists) { + throw new Error( + `File '${fileName}' does not exist in bucket '${bucketName}'.` + ) + } + + // Download the file contents as a string + const fileContentResponse = await storage + .bucket(bucketName) + .file(fileName) + .download() + const fileContent = fileContentResponse[0].toString() + + console.log(`File '${fileName}' downloaded successfully as string.`) + return fileContent + } catch (error) { + console.error('Error downloading file:', error) + throw error + } +} + +const writeStringToStorage = async ( + bucketName: string, + fileName: string, + content: string +): Promise => { + try { + const storage = env.fileUpload?.gcsUploadSAKeyFilePath + ? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath }) + : new Storage() + + const writableStream = storage + .bucket(bucketName) + .file(fileName) + .createWriteStream() + + // Convert the string content to a readable stream + const readableStream = new stream.Readable() + readableStream.push(content) + readableStream.push(null) // Signal the end of the stream + + // Pipe the readable stream to the writable stream to upload the file content + await new Promise((resolve, reject) => { + readableStream + .pipe(writableStream) + .on('finish', resolve) + .on('error', reject) + }) + + console.log( + `File '${fileName}' uploaded successfully to bucket '${bucketName}'.` + ) + } catch (error) { + console.error('Error uploading file:', error) + throw error + } +} + +const fetchCachedYouTubeTranscript = async ( + videoId: string, + transcriptHash: string, + promptHash: string +): Promise => { + const bucketName = env.fileUpload.gcsUploadBucket + + try { + return await readStringFromStorage( + bucketName, + `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html` + ) + } catch (err) { + logger.info(`unable to fetch cached transcript`, { error: err }) + } + + return undefined +} + +const cacheYouTubeTranscript = async ( + videoId: string, + transcriptHash: string, + promptHash: string, + transcript: string +): Promise => { + const bucketName = env.fileUpload.gcsUploadBucket + + try { + await writeStringToStorage( + bucketName, + `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html`, + transcript + ) + } catch (err) { + logger.info(`unable to cache transcript`, { error: err }) + } +} + +export const processYouTubeVideo = async ( + jobData: ProcessYouTubeVideoJobData +) => { + try { + const libraryItem = await authTrx( + async (tx) => + tx + .withRepository(libraryItemRepository) + .findById(jobData.libraryItemId), + undefined, + jobData.userId + ) + if ( + !libraryItem || + libraryItem.state !== LibraryItemState.Succeeded || + !libraryItem.originalContent + ) { + logger.info( + `Not ready to get YouTube metadata job state: ${ + libraryItem?.state ?? 'null' + }` + ) + return + } + + const u = new URL(libraryItem.originalUrl) + const videoId = u.searchParams.get('v') + + if (!videoId) { + console.warn('no video id for supplied youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + let needsUpdate = false + const youtube = new YouTubeClient() + const video = await youtube.getVideo(videoId) + if (!video) { + console.warn('no video found for youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + if (video.description && libraryItem.description !== video.description) { + needsUpdate = true + libraryItem.description = video.description + } + + let duration = -1 + if ('duration' in video && video.duration > 0) { + needsUpdate = true + libraryItem.wordCount = calculateWordCount(video.duration) + duration = video.duration + } + + if ( + await findFeatureByName(FeatureName.YouTubeTranscripts, jobData.userId) + ) { + if ('getTranscript' in video && duration > 0 && duration < 1801) { + // If the video has a transcript available, put a placehold in and + // enqueue a job to process the full transcript + const updatedContent = await addTranscriptPlaceholdReadableContent( + libraryItem.originalUrl, + libraryItem.originalContent + ) + + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } + + await enqueueProcessYouTubeTranscript({ + videoId, + ...jobData, + }) + } + } + + if (needsUpdate) { + const updated = await authTrx( + async (t) => { + return t + .getRepository(LibraryItem) + .update(jobData.libraryItemId, libraryItem) + }, + undefined, + jobData.userId + ) + if (!updated) { + console.warn('could not updated library item') + } + } + } catch (err) { + console.warn('error creating summary: ', err) + } +} + +export interface ProcessYouTubeTranscriptJobData { + userId: string + videoId: string + libraryItemId: string +} + +export const processYouTubeTranscript = async ( + jobData: ProcessYouTubeTranscriptJobData +) => { + try { + const libraryItem = await authTrx( + async (tx) => + tx + .withRepository(libraryItemRepository) + .findById(jobData.libraryItemId), + undefined, + jobData.userId + ) + if ( + !libraryItem || + libraryItem.state !== LibraryItemState.Succeeded || + !libraryItem.originalContent + ) { + logger.info( + `Not ready to get YouTube metadata job state: ${ + libraryItem?.state ?? 'null' + }` + ) + return + } + + let needsUpdate = false + const youtube = new YouTubeClient() + const video = await youtube.getVideo(jobData.videoId) + if (!video) { + logger.warn('no video found for youtube url', { + url: libraryItem.originalUrl, + }) + return + } + + let chapters: Chapter[] = [] + if ('chapters' in video) { + chapters = video.chapters + } + + let transcript: TranscriptProperties[] | undefined = undefined + if ('getTranscript' in video) { + transcript = await video.getTranscript() + } + + if (transcript) { + if (chapters) { + transcript = addTranscriptChapters(chapters, transcript) + } + const transcriptHTML = await createTranscriptHTML( + jobData.videoId, + transcript + ) + const updatedContent = await addTranscriptToReadableContent( + libraryItem.originalUrl, + libraryItem.originalContent, + transcriptHTML + ) + + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } + } + + if (needsUpdate) { + const updated = await authTrx( + async (t) => { + return t + .getRepository(LibraryItem) + .update(jobData.libraryItemId, libraryItem) + }, + undefined, + jobData.userId + ) + if (!updated) { + console.warn('could not updated library item') + } + } + } catch (err) { + console.warn('error creating summary: ', err) + } +} diff --git a/packages/api/src/jobs/rss/refreshAllFeeds.ts b/packages/api/src/jobs/rss/refreshAllFeeds.ts index d68263400..8d49b6096 100644 --- a/packages/api/src/jobs/rss/refreshAllFeeds.ts +++ b/packages/api/src/jobs/rss/refreshAllFeeds.ts @@ -43,7 +43,7 @@ export const refreshAllFeeds = async (db: DataSource): Promise => { AND (s.scheduled_at <= NOW() OR s.scheduled_at IS NULL) AND u.status = $4 GROUP BY - s.url + url `, ['RSS', 'ACTIVE', 'following', 'ACTIVE'] )) as RssSubscriptionGroup[] @@ -76,7 +76,10 @@ const updateSubscriptionGroup = async ( refreshContext: RSSRefreshContext ) => { let feedURL = group.url - const userList = JSON.stringify(group.userIds.sort()) + const userIds = group.userIds + // sort the user ids so that the job id is consistent + // [...userIds] creates a shallow copy, so sort() does not mutate the original + const userList = JSON.stringify([...userIds].sort()) if (!feedURL) { logger.error('no url for feed group', group) return @@ -105,7 +108,7 @@ const updateSubscriptionGroup = async ( scheduledTimestamps: group.scheduledDates.map((timestamp) => timestamp.getTime() ), // unix timestamp in milliseconds - userIds: group.userIds, + userIds, fetchContentTypes: group.fetchContentTypes, folders: group.folders, } diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 6ad0762d7..f13bfe5e4 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -7,6 +7,7 @@ import { Merge } from './util' import { enqueueAISummarizeJob, enqueueExportItem, + enqueueProcessYouTubeVideo, enqueueTriggerRuleJob, enqueueWebhookJob, } from './utils/createTask' @@ -17,6 +18,7 @@ import { findFeatureByName, getFeatureName, } from './services/features' +import { processYouTubeVideo } from './jobs/process-youtube-video' const logger = buildLogger('pubsub') @@ -27,6 +29,18 @@ type EntityData> = Merge< { libraryItemId: string } > +const isYouTubeVideoURL = (url: string | undefined): boolean => { + if (!url) { + return false + } + const u = new URL(url) + if (!u.host.endsWith('youtube.com') && !u.host.endsWith('youtu.be')) { + return false + } + const videoId = u.searchParams.get('v') + return videoId != null +} + export const createPubSubClient = (): PubsubClient => { const fieldsToDelete = ['user'] as const @@ -92,7 +106,17 @@ export const createPubSubClient = (): PubsubClient => { }) if (await findFeatureByName(FeatureName.AISummaries, userId)) { - await enqueueAISummarizeJob({ + // await enqueueAISummarizeJob({ + // userId, + // libraryItemId, + // }) + } + + if ( + 'originalUrl' in data && + isYouTubeVideoURL(data['originalUrl'] as string | undefined) + ) { + await enqueueProcessYouTubeVideo({ userId, libraryItemId, }) diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 959c866fc..36bea979b 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -44,6 +44,12 @@ import { redisDataSource } from './redis_data_source' import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_position' import { getJobPriority } from './utils/createTask' import { logger } from './utils/logger' +import { + PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, + processYouTubeTranscript, + processYouTubeVideo, +} from './jobs/process-youtube-video' export const QUEUE_NAME = 'omnivore-backend-queue' export const JOB_VERSION = 'v001' @@ -116,8 +122,14 @@ export const createWorker = (connection: ConnectionOptions) => return exportItem(job.data) case AI_SUMMARIZE_JOB_NAME: return aiSummarize(job.data) + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: + return processYouTubeVideo(job.data) + case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: + return processYouTubeTranscript(job.data) case EXPORT_ALL_ITEMS_JOB_NAME: return exportAllItems(job.data) + default: + logger.warn(`[queue-processor] unhandled job: ${job.name}`) } }, { diff --git a/packages/api/src/repository/library_item.ts b/packages/api/src/repository/library_item.ts index f4003b7bc..fd933faf2 100644 --- a/packages/api/src/repository/library_item.ts +++ b/packages/api/src/repository/library_item.ts @@ -58,6 +58,8 @@ export const libraryItemRepository = appDataSource }, createByPopularRead(name: string, userId: string) { + // set read_at to now and reading_progress_bottom_percent to 2 + // so the items show up in continue reading section return this.query( ` INSERT INTO omnivore.library_item ( @@ -73,7 +75,9 @@ export const libraryItemRepository = appDataSource published_at, site_name, user_id, - word_count + word_count, + read_at, + reading_progress_bottom_percent ) SELECT slug, @@ -88,7 +92,9 @@ export const libraryItemRepository = appDataSource published_at, site_name, $2, - word_count + word_count, + NOW(), + 2 FROM omnivore.popular_read WHERE diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 90c118129..d0bfc45d1 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -34,7 +34,6 @@ import { import { analytics } from '../../utils/analytics' import { deleteTask, - enqueueExportAllItems, enqueueImportFromIntegration, } from '../../utils/createTask' import { authorized } from '../../utils/gql-utils' @@ -55,6 +54,8 @@ export const setIntegrationResolver = authorized< input.type === IntegrationType.Import ? input.importItemState || ImportItemState.Unarchived // default to unarchived : undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + settings: input.settings, } if (input.id) { // Update @@ -69,9 +70,9 @@ export const setIntegrationResolver = authorized< integrationToSave.taskName = existingIntegration.taskName } else { // Create - const integrationService = getIntegrationClient(input.name) + const integrationService = getIntegrationClient(input.name, input.token) // authorize and get access token - const token = await integrationService.accessToken(input.token) + const token = await integrationService.accessToken() if (!token) { return { errorCodes: [SetIntegrationErrorCode.InvalidToken], @@ -83,40 +84,6 @@ export const setIntegrationResolver = authorized< // save integration const integration = await saveIntegration(integrationToSave, uid) - if (integrationToSave.type === IntegrationType.Export && !input.id) { - const authToken = await createIntegrationToken({ - uid, - token: integration.token, - }) - if (!authToken) { - log.error('failed to create auth token', { - integrationId: integration.id, - }) - return { - errorCodes: [SetIntegrationErrorCode.BadRequest], - } - } - - // create a task to sync all the pages if new integration or enable integration (export type) - await enqueueExportAllItems(integration.id, uid) - } else if (integrationToSave.taskName) { - // delete the task if disable integration and task exists - const result = await deleteTask(integrationToSave.taskName) - if (result) { - log.info('task deleted', integrationToSave.taskName) - } - - // update task name in integration - await updateIntegration( - integration.id, - { - taskName: null, - }, - uid - ) - integration.taskName = null - } - analytics.capture({ distinctId: uid, event: 'integration_set', diff --git a/packages/api/src/routers/integration_router.ts b/packages/api/src/routers/integration_router.ts index cc6a520a7..7bf5cbdc6 100644 --- a/packages/api/src/routers/integration_router.ts +++ b/packages/api/src/routers/integration_router.ts @@ -2,6 +2,7 @@ import axios from 'axios' import cors from 'cors' import express from 'express' import { env } from '../env' +import { getIntegrationClient } from '../services/integrations' import { getClaimsByToken } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { logger } from '../utils/logger' @@ -10,10 +11,9 @@ export function integrationRouter() { const router = express.Router() // request token from pocket router.post( - '/pocket/auth', + '/:name/auth', cors(corsConfig), async (req: express.Request, res: express.Response) => { - logger.info('pocket/request-token') // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const token = (req.cookies.auth as string) || req.headers.authorization const claims = await getClaimsByToken(token) @@ -21,37 +21,19 @@ export function integrationRouter() { return res.status(401).send('UNAUTHORIZED') } - const consumerKey = env.pocket.consumerKey - const redirectUri = `${env.client.url}/settings/integrations` + const integrationClient = getIntegrationClient(req.params.name, '') + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const state = req.body.state as string try { - // make a POST request to Pocket to get a request token - const response = await axios.post<{ code: string }>( - 'https://getpocket.com/v3/oauth/request', - { - consumer_key: consumerKey, - redirect_uri: redirectUri, - }, - { - headers: { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', - }, - } - ) - const { code } = response.data + const redirectUri = await integrationClient.auth(state) // redirect the user to Pocket to authorize the request token - res.redirect( - `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( - `?pocketToken=${code}&state=${state}` - )}` - ) + res.redirect(redirectUri) } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) } else { - logger.error('pocket/request-token exception:', error) + logger.error(error) } res.redirect( diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 4a5a9473b..6c20fb51f 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2015,6 +2015,7 @@ const schema = gql` createdAt: Date! updatedAt: Date taskName: String + settings: JSON } enum IntegrationType { @@ -2050,6 +2051,7 @@ const schema = gql` syncedAt: Date importItemState: ImportItemState taskName: String + settings: JSON } union IntegrationsResult = IntegrationsSuccess | IntegrationsError diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 629abda93..16024bb3b 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -6,8 +6,12 @@ import { env } from '../env' import { getRepository } from '../repository' import { logger } from '../utils/logger' +const MAX_ULTRA_REALISTIC_USERS = 1500 +const MAX_YOUTUBE_TRANSCRIPT_USERS = 100 + export enum FeatureName { AISummaries = 'ai-summaries', + YouTubeTranscripts = 'youtube-transcripts', UltraRealisticVoice = 'ultra-realistic-voice', } @@ -19,16 +23,31 @@ export const optInFeature = async ( name: FeatureName, uid: string ): Promise => { - if (name === FeatureName.UltraRealisticVoice) { - return optInUltraRealisticVoice(uid) + switch (name) { + case FeatureName.UltraRealisticVoice: + return optInLimitedFeature( + FeatureName.UltraRealisticVoice, + uid, + MAX_ULTRA_REALISTIC_USERS + ) + case FeatureName.YouTubeTranscripts: + return optInLimitedFeature( + FeatureName.YouTubeTranscripts, + uid, + MAX_YOUTUBE_TRANSCRIPT_USERS + ) } return undefined } -const optInUltraRealisticVoice = async (uid: string): Promise => { +const optInLimitedFeature = async ( + featureName: string, + uid: string, + maxUsers: number +): Promise => { const feature = await getRepository(Feature).findOne({ where: { - name: FeatureName.UltraRealisticVoice, + name: featureName, grantedAt: Not(IsNull()), user: { id: uid }, }, @@ -40,8 +59,6 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { return feature } - const MAX_USERS = 1500 - // opt in to feature for the first 1500 users const optedInFeatures: Feature[] = (await appDataSource.query( `insert into omnivore.features (user_id, name, granted_at) select $1, $2, $3 from omnivore.features @@ -50,7 +67,7 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { on conflict (user_id, name) do update set granted_at = $3 returning *, granted_at as "grantedAt", created_at as "createdAt", updated_at as "updatedAt";`, - [uid, FeatureName.UltraRealisticVoice, new Date(), MAX_USERS] + [uid, featureName, new Date(), maxUsers] )) as Feature[] // if no new features were created then user has exceeded max users @@ -60,7 +77,7 @@ const optInUltraRealisticVoice = async (uid: string): Promise => { // create/update an opt-in record with null grantedAt const optInRecord = { user: { id: uid }, - name: FeatureName.UltraRealisticVoice, + name: featureName, grantedAt: null, } const result = await getRepository(Feature).upsert(optInRecord, [ diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 286ac59e7..2f2b99f0d 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -2,20 +2,25 @@ import { DeepPartial, FindOptionsWhere } from 'typeorm' import { Integration } from '../../entity/integration' import { authTrx } from '../../repository' import { IntegrationClient } from './integration' +import { NotionClient } from './notion' import { PocketClient } from './pocket' import { ReadwiseClient } from './readwise' -const integrations: IntegrationClient[] = [ - new ReadwiseClient(), - new PocketClient(), -] - -export const getIntegrationClient = (name: string): IntegrationClient => { - const service = integrations.find((s) => s.name === name) - if (!service) { - throw new Error(`Integration client not found: ${name}`) +export const getIntegrationClient = ( + name: string, + token: string, + integrationData?: Integration +): IntegrationClient => { + switch (name.toLowerCase()) { + case 'readwise': + return new ReadwiseClient(token) + case 'pocket': + return new PocketClient(token) + case 'notion': + return new NotionClient(token, integrationData) + default: + throw new Error(`Integration client not found: ${name}`) } - return service } export const deleteIntegrations = async ( @@ -75,7 +80,11 @@ export const saveIntegration = async ( userId: string ) => { return authTrx( - async (t) => t.getRepository(Integration).save(integration), + async (t) => { + const repo = t.getRepository(Integration) + const newIntegration = await repo.save(integration) + return repo.findOneByOrFail({ id: newIntegration.id }) + }, undefined, userId ) diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index e3f1edbc8..44c95e680 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -20,9 +20,11 @@ export interface RetrieveRequest { export interface IntegrationClient { name: string - apiUrl: string + token: string - accessToken(token: string): Promise + accessToken(): Promise - export(token: string, items: LibraryItem[]): Promise + auth(state: string): Promise + + export(items: LibraryItem[]): Promise } diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts new file mode 100644 index 000000000..4d823e031 --- /dev/null +++ b/packages/api/src/services/integrations/notion.ts @@ -0,0 +1,417 @@ +import { Client } from '@notionhq/client' +import axios from 'axios' +import { updateIntegration } from '.' +import { Integration } from '../../entity/integration' +import { LibraryItem } from '../../entity/library_item' +import { env } from '../../env' +import { Merge } from '../../util' +import { highlightUrl } from '../../utils/helpers' +import { logger } from '../../utils/logger' +import { IntegrationClient } from './integration' + +type AnnotationColor = + | 'default' + | 'gray' + | 'brown' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'red' + | 'gray_background' + | 'brown_background' + | 'orange_background' + | 'yellow_background' + | 'green_background' + | 'blue_background' + | 'purple_background' + | 'pink_background' + | 'red_background' + +interface NotionPage { + parent: { + database_id: string + } + cover?: { + external: { + url: string + } + } + icon?: { + external: { + url: string + } + } + properties: { + Title: { + title: [ + { + text: { + content: string + } + } + ] + } + Author: { + rich_text: Array<{ + text: { + content: string + } + }> + } + 'Original URL': { + url: string + } + 'Omnivore URL': { + url: string + } + 'Saved At': { + date: { + start: string + } + } + 'Last Updated': { + date: { + start: string + } + } + Tags?: { + multi_select: Array<{ name: string }> + } + } + children?: Array<{ + paragraph: { + rich_text: Array<{ + text: { + content: string + link?: { url: string } + } + annotations: { + code: boolean + color: AnnotationColor + } + }> + children?: Array<{ + paragraph: { + rich_text: Array<{ + text: { + content: string + } + }> + } + }> + } + }> +} + +type Property = 'highlights' + +interface Settings { + parentPageId: string + parentDatabaseId: string + properties: Property[] +} + +export class NotionClient implements IntegrationClient { + name = 'NOTION' + token: string + + private headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Notion-Version': '2022-06-28', + } + private timeout = 5000 // 5 seconds + private axiosInstance = axios.create({ + baseURL: 'https://api.notion.com/v1', + timeout: this.timeout, + }) + + private client: Client + private integrationData?: Merge + + constructor(token: string, integration?: Integration) { + this.token = token + this.client = new Client({ + auth: token, + timeoutMs: this.timeout, + }) + this.integrationData = integration + } + + accessToken = async (): Promise => { + try { + // encode in base 64 + const encoded = Buffer.from( + `${env.notion.clientId}:${env.notion.clientSecret}` + ).toString('base64') + + const response = await this.axiosInstance.post<{ access_token: string }>( + '/oauth/token', + { + grant_type: 'authorization_code', + code: this.token, + redirect_uri: `${env.client.url}/settings/integrations`, + }, + { + headers: { + ...this.headers, + Authorization: `Basic ${encoded}`, + }, + } + ) + return response.data.access_token + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error(error.response) + } else { + logger.error(error) + } + return null + } + } + + async auth(): Promise { + return Promise.resolve(env.notion.authUrl) + } + + private itemToNotionPage = ( + item: LibraryItem, + settings: Settings, + lastSync?: Date | null + ): NotionPage => { + return { + parent: { + database_id: settings.parentDatabaseId, + }, + icon: item.siteIcon + ? { + external: { + url: item.siteIcon, + }, + } + : undefined, + cover: item.thumbnail + ? { + external: { + url: item.thumbnail, + }, + } + : undefined, + properties: { + Title: { + title: [ + { + text: { + content: item.title, + }, + }, + ], + }, + Author: { + rich_text: [ + { + text: { + content: item.author || 'unknown', + }, + }, + ], + }, + 'Original URL': { + url: item.originalUrl, + }, + 'Omnivore URL': { + url: `${env.client.url}/me/${item.slug}`, + }, + 'Saved At': { + date: { + start: item.createdAt.toISOString(), + }, + }, + 'Last Updated': { + date: { + start: item.updatedAt.toISOString(), + }, + }, + Tags: item.labels + ? { + multi_select: item.labels.map((label) => ({ + name: label.name, + })), + } + : undefined, + }, + children: + settings.properties.includes('highlights') && item.highlights + ? item.highlights + .filter( + (highlight) => !lastSync || highlight.updatedAt > lastSync // only new highlights + ) + .map((highlight) => ({ + paragraph: { + rich_text: [ + { + text: { + content: highlight.quote || '', + link: { + url: highlightUrl(item.slug, highlight.id), + }, + }, + annotations: { + code: true, + color: highlight.color as AnnotationColor, + }, + }, + ], + children: highlight.annotation + ? [ + { + paragraph: { + rich_text: [ + { + text: { + content: highlight.annotation || '', + }, + }, + ], + }, + }, + ] + : undefined, + }, + })) + : undefined, + } + } + + private createPage = async (page: NotionPage) => { + await this.client.pages.create(page) + } + + private findPage = async (url: string, databaseId: string) => { + const response = await this.client.databases.query({ + database_id: databaseId, + page_size: 1, + filter: { + property: 'Omnivore URL', + url: { + equals: url, + }, + }, + }) + if (response.results.length > 0) { + return response.results[0] + } + + return null + } + + export = async (items: LibraryItem[]): Promise => { + const settings = this.integrationData?.settings + if (!this.integrationData || !settings) { + logger.error('Notion integration data not found') + return false + } + + const pageId = settings.parentPageId + if (!pageId) { + logger.error('Notion parent page id not found') + return false + } + + let databaseId = settings.parentDatabaseId + if (!databaseId) { + // create a database for the items + const database = await this.client.databases.create({ + parent: { + page_id: pageId, + }, + title: [ + { + text: { + content: 'Library', + }, + }, + ], + description: [ + { + text: { + content: 'Library of saved items from Omnivore', + }, + }, + ], + properties: { + Title: { + title: {}, + }, + Author: { + rich_text: {}, + }, + 'Original URL': { + url: {}, + }, + 'Omnivore URL': { + url: {}, + }, + 'Saved At': { + date: {}, + }, + 'Last Updated': { + date: {}, + }, + Tags: { + multi_select: {}, + }, + }, + }) + + // save the database id + databaseId = database.id + settings.parentDatabaseId = databaseId + await updateIntegration( + this.integrationData.id, + { + settings, + }, + this.integrationData.user.id + ) + } + + await Promise.all( + items.map(async (item) => { + const notionPage = this.itemToNotionPage( + item, + settings, + this.integrationData?.syncedAt + ) + const url = notionPage.properties['Omnivore URL'].url + + const existingPage = await this.findPage(url, databaseId) + if (existingPage) { + // update the page + await this.client.pages.update({ + page_id: existingPage.id, + properties: notionPage.properties, + }) + + // append the children incrementally + if (notionPage.children && notionPage.children.length > 0) { + await this.client.blocks.children.append({ + block_id: existingPage.id, + children: notionPage.children, + }) + } + + return + } + + // create the page + return this.createPage(notionPage) + }) + ) + + return true + } +} diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 517d3befa..9945c7a6e 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -5,24 +5,27 @@ import { IntegrationClient } from './integration' export class PocketClient implements IntegrationClient { name = 'POCKET' - apiUrl = 'https://getpocket.com/v3' - headers = { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', + token: string + _axios = axios.create({ + baseURL: 'https://getpocket.com/v3', + headers: { + 'Content-Type': 'application/json', + 'X-Accept': 'application/json', + }, + timeout: 5000, // 5 seconds + }) + + constructor(token: string) { + this.token = token } - accessToken = async (token: string): Promise => { - const url = `${this.apiUrl}/oauth/authorize` + accessToken = async (): Promise => { try { - const response = await axios.post<{ access_token: string }>( - url, + const response = await this._axios.post<{ access_token: string }>( + '/oauth/authorize', { consumer_key: env.pocket.consumerKey, - code: token, - }, - { - headers: this.headers, - timeout: 5000, // 5 seconds + code: this.token, } ) return response.data.access_token @@ -36,7 +39,26 @@ export class PocketClient implements IntegrationClient { } } - export = async (): Promise => { - return Promise.resolve(false) + export = () => { + throw new Error('Method not implemented.') + } + + async auth(state: string) { + const consumerKey = env.pocket.consumerKey + const redirectUri = `${env.client.url}/settings/integrations` + + // make a POST request to Pocket to get a request token + const response = await this._axios.post<{ code: string }>( + '/oauth/request', + { + consumer_key: consumerKey, + redirect_uri: redirectUri, + } + ) + const { code } = response.data + + return `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( + `?pocketToken=${code}&state=${state}` + )}` } } diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index ae23810bd..dfc5b43db 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -33,17 +33,29 @@ interface ReadwiseHighlight { export class ReadwiseClient implements IntegrationClient { name = 'READWISE' - apiUrl = 'https://readwise.io/api/v2' + token: string - accessToken = async (token: string): Promise => { - const authUrl = `${this.apiUrl}/auth` + _headers = { + 'Content-Type': 'application/json', + } + _axios = axios.create({ + baseURL: 'https://readwise.io/api/v2', + timeout: 5000, // 5 seconds + }) + + constructor(token: string) { + this.token = token + } + + accessToken = async (): Promise => { try { - const response = await axios.get(authUrl, { + const response = await this._axios.get('/auth', { headers: { - Authorization: `Token ${token}`, + ...this._headers, + Authorization: `Token ${this.token}`, }, }) - return response.status === 204 ? token : null + return response.status === 204 ? this.token : null } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) @@ -54,20 +66,26 @@ export class ReadwiseClient implements IntegrationClient { } } - export = async (token: string, items: LibraryItem[]): Promise => { + export = async (items: LibraryItem[]): Promise => { let result = true - const highlights = items.flatMap(this.itemToReadwiseHighlight) + const highlights = items.flatMap(this._itemToReadwiseHighlight) // If there are no highlights, we will skip the sync if (highlights.length > 0) { - result = await this.syncWithReadwise(token, highlights) + result = await this._syncWithReadwise(highlights) } return result } - itemToReadwiseHighlight = (item: LibraryItem): ReadwiseHighlight[] => { + auth = () => { + throw new Error('Method not implemented.') + } + + private _itemToReadwiseHighlight = ( + item: LibraryItem + ): ReadwiseHighlight[] => { const category = item.siteName === 'Twitter' ? 'tweets' : 'articles' return item.highlights ?.map((highlight) => { @@ -93,22 +111,19 @@ export class ReadwiseClient implements IntegrationClient { .filter((highlight) => highlight !== undefined) as ReadwiseHighlight[] } - syncWithReadwise = async ( - token: string, + private _syncWithReadwise = async ( highlights: ReadwiseHighlight[] ): Promise => { - const url = `${this.apiUrl}/highlights` - const response = await axios.post( - url, + const response = await this._axios.post( + '/highlights', { highlights, }, { headers: { - Authorization: `Token ${token}`, - 'Content-Type': 'application/json', + ...this._headers, + Authorization: `Token ${this.token}`, }, - timeout: 5000, // 5 seconds } ) return response.status === 200 diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 4a16c7c18..52d44f7ff 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -700,10 +700,16 @@ export const findRecentLibraryItems = async ( } export const findLibraryItemsByIds = async (ids: string[], userId: string) => { + const selectColumns = getColumns(libraryItemRepository) + .filter( + (column) => column !== 'readableContent' && column !== 'originalContent' + ) + .map((column) => `library_item.${column}`) return authTrx( async (tx) => tx .createQueryBuilder(LibraryItem, 'library_item') + .select(selectColumns) .leftJoinAndSelect('library_item.labels', 'labels') .leftJoinAndSelect('library_item.highlights', 'highlights') .where('library_item.id IN (:...ids)', { ids }) @@ -989,7 +995,7 @@ export const createOrUpdateLibraryItem = async ( ) } - if (skipPubSub) { + if (skipPubSub || libraryItem.state === LibraryItemState.Processing) { return newLibraryItem } diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index b03a19c15..d42f84ce1 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -113,6 +113,11 @@ export interface BackendEnv { mq: redisConfig cache: redisConfig } + notion: { + clientId: string + clientSecret: string + authUrl: string + } } const nullableEnvVars = [ @@ -165,6 +170,9 @@ const nullableEnvVars = [ 'MQ_REDIS_CERT', 'IMPORTER_METRICS_COLLECTOR_URL', 'INTERNAL_API_URL', + 'NOTION_CLIENT_ID', + 'NOTION_CLIENT_SECRET', + 'NOTION_AUTH_URL', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -311,6 +319,11 @@ export function getEnv(): BackendEnv { cert: parse('REDIS_CERT')?.replace(/\\n/g, '\n'), // replace \n with new line }, } + const notion = { + clientId: parse('NOTION_CLIENT_ID'), + clientSecret: parse('NOTION_CLIENT_SECRET'), + authUrl: parse('NOTION_AUTH_URL'), + } return { pg, @@ -333,6 +346,7 @@ export function getEnv(): BackendEnv { pocket, subscription, redis, + notion, } } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 56e209091..6d6c30baf 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -45,6 +45,12 @@ import { stringToHash } from './helpers' import { logger } from './logger' import View = google.cloud.tasks.v2.Task.View import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' +import { + PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, + ProcessYouTubeTranscriptJobData, + ProcessYouTubeVideoJobData, +} from '../jobs/process-youtube-video' // Instantiates a client. const client = new CloudTasksClient() @@ -67,10 +73,13 @@ export const getJobPriority = (jobName: string): number => { case TRIGGER_RULE_JOB_NAME: case CALL_WEBHOOK_JOB_NAME: case AI_SUMMARIZE_JOB_NAME: + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: return 5 case BULK_ACTION_JOB_NAME: case `${REFRESH_FEED_JOB_NAME}_high`: return 10 + case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME: + return 20 case `${REFRESH_FEED_JOB_NAME}_low`: case EXPORT_ITEM_JOB_NAME: return 50 @@ -78,6 +87,7 @@ export const getJobPriority = (jobName: string): number => { case REFRESH_ALL_FEEDS_JOB_NAME: case THUMBNAIL_JOB: return 100 + default: logger.error(`unknown job name: ${jobName}`) return 1 @@ -708,6 +718,36 @@ export const enqueueAISummarizeJob = async (data: AISummarizeJobData) => { }) } +export const enqueueProcessYouTubeVideo = async ( + data: ProcessYouTubeVideoJobData +) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(PROCESS_YOUTUBE_VIDEO_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOUTUBE_VIDEO_JOB_NAME), + attempts: 3, + delay: 2000, + }) +} + +export const enqueueProcessYouTubeTranscript = async ( + data: ProcessYouTubeTranscriptJobData +) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME), + attempts: 3, + delay: 2000, + }) +} + export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => { const queue = await getBackendQueue() if (!queue) { diff --git a/packages/api/test/jobs/process-youtube-job.test.ts b/packages/api/test/jobs/process-youtube-job.test.ts new file mode 100644 index 000000000..849aca41c --- /dev/null +++ b/packages/api/test/jobs/process-youtube-job.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai' +import 'mocha' +import { addTranscriptChapters } from '../../src/jobs/process-youtube-video' + +describe('create transcript', () => { + describe('build items', () => { + it('properly adds chapter headers to transcript', async () => { + const chapters = [ + { + title: 'Intro', + start: 0, + }, + { + title: "Joe Biden's re-election effort", + start: 22000, + }, + { + title: 'Ad break', + start: 909000, + }, + { + title: "Trump's crazy speech & Orbán relationship", + start: 1060000, + }, + ] + const transcript = [ + { + text: "welcome to pod save America I'm John", + duration: 3280, + start: 80, + }, + { + text: "favro I'm John L I'm Tommy VOR on", + duration: 3480, + start: 1480, + }, + { + text: "today's show Donald Trump kicks off the", + duration: 3320, + start: 3360, + }, + { + text: 'general election by mocking Joe Biden', + duration: 3400, + start: 4960, + }, + { + text: 'stutter hosting a concert for Victor', + duration: 3680, + start: 6680, + }, + { + text: 'Orban and floating cuts to Medicare and', + duration: 4239, + start: 8360, + }, + { + text: 'Social Security Alabama Senator Katie', + duration: 3840, + start: 10360, + }, + { + text: 'Brit and Republicans are still dealing', + duration: 3401, + start: 12599, + }, + { + text: 'with the Fallout from what may have been', + duration: 3320, + start: 14200, + }, + { + text: 'the worst ever State of the Union', + duration: 4600, + start: 16000, + }, + { + text: 'response and later take appreciator is', + duration: 6640, + start: 17520, + }, + { + text: 'back so is Elijah uh but first the man', + duration: 6519, + start: 20600, + }, + { + text: 'Sean Hannity now calls jacked up Joe has', + duration: 4680, + start: 24160, + }, + ] + + const res = addTranscriptChapters(chapters, transcript) + console.log('res: ', res) + + expect(res.length).to.eq(17) + expect(res[13].text).to.eq("\n\n## Joe Biden's re-election effort\n\n") + }) + }) +}) diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index b7b638d22..e5d05e6ae 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -640,6 +640,7 @@ describe('Article API', () => { context('when the source is rss-feeder and url is from youtube.com', () => { const source = 'rss-feeder' const stub = sinon.stub(createTask, 'enqueueParseRequest') + const stub2 = sinon.stub(createTask, 'enqueueProcessYouTubeVideo') before(() => { url = 'https://www.youtube.com/watch?v=123' diff --git a/packages/api/test/resolvers/integrations.test.ts b/packages/api/test/resolvers/integrations.test.ts index 992cfe9ca..538841a53 100644 --- a/packages/api/test/resolvers/integrations.test.ts +++ b/packages/api/test/resolvers/integrations.test.ts @@ -222,17 +222,6 @@ describe('Integrations resolvers', () => { expect(res.body.data.setIntegration.integration.enabled).to.be .false }) - - it('deletes cloud task', async () => { - const res = await graphqlRequest( - query(integrationId, integrationName, token, enabled), - authToken - ) - const integration = await findIntegration({ - id: res.body.data.setIntegration.integration.id, - }, loginUser.id) - expect(integration?.taskName).to.be.null - }) }) context('when enable is true', () => { diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 2bb3d5831..f62900e18 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -591,7 +591,7 @@ describe('auth router', () => { await deleteUser(user.id) }) - it('adds popular reads to the library', async () => { + it('adds popular reads to the continue reading section', async () => { const pendingUserToken = await createPendingUserToken({ sourceUserId, email, @@ -608,7 +608,7 @@ describe('auth router', () => { ).expect(200) const user = await userRepository.findOneByOrFail({ name }) const { count } = await searchLibraryItems( - { query: 'in:all' }, + { query: 'in:inbox sort:read-desc is:reading' }, user.id ) diff --git a/packages/content-handler/src/websites/youtube-handler.ts b/packages/content-handler/src/websites/youtube-handler.ts index e86eda113..cd7f87c95 100644 --- a/packages/content-handler/src/websites/youtube-handler.ts +++ b/packages/content-handler/src/websites/youtube-handler.ts @@ -86,9 +86,14 @@ export class YoutubeHandler extends ContentHandler { - -

${escapedTitle}

- +
+ +
` diff --git a/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql b/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql new file mode 100755 index 000000000..d8c2521b8 --- /dev/null +++ b/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql @@ -0,0 +1,9 @@ +-- Type: DO +-- Name: add_settings_column_to_integrations +-- Description: Add settings column to integrations table + +BEGIN; + +ALTER TABLE omnivore.integrations ADD COLUMN settings jsonb; + +COMMIT; diff --git a/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql b/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql new file mode 100755 index 000000000..34a03ae7e --- /dev/null +++ b/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: add_settings_column_to_integrations +-- Description: Add settings column to integrations table + +BEGIN; + +ALTER TABLE omnivore.integrations DROP COLUMN settings; + +COMMIT; diff --git a/packages/web/components/patterns/CardMenu.tsx b/packages/web/components/patterns/CardMenu.tsx index 6a3c27dc0..23fb6e7bc 100644 --- a/packages/web/components/patterns/CardMenu.tsx +++ b/packages/web/components/patterns/CardMenu.tsx @@ -81,14 +81,14 @@ export function CardMenu(props: CardMenuProps): JSX.Element { }} title="Remove" /> - {!!props.item.subscription && ( + {/* {!!props.item.subscription && ( { props.actionHandler('unsubscribe') }} title="Unsubscribe" /> - )} + )} */} ) } diff --git a/packages/web/components/patterns/ConfirmationModal.tsx b/packages/web/components/patterns/ConfirmationModal.tsx index 25618fdbe..6e78b9566 100644 --- a/packages/web/components/patterns/ConfirmationModal.tsx +++ b/packages/web/components/patterns/ConfirmationModal.tsx @@ -21,10 +21,11 @@ type ConfirmationModalProps = { export function ConfirmationModal(props: ConfirmationModalProps): JSX.Element { const safeOnOpenChange = useCallback( (open: boolean) => { - props.onOpenChange(open) setTimeout(() => { + console.log('body style: ', document.body.style) document.body.style.removeProperty('pointer-events') }, 200) + props.onOpenChange(open) }, [props] ) diff --git a/packages/web/components/templates/Beta.tsx b/packages/web/components/templates/Beta.tsx new file mode 100644 index 000000000..00b8b473d --- /dev/null +++ b/packages/web/components/templates/Beta.tsx @@ -0,0 +1,5 @@ +import { Alert } from 'antd' + +export function Beta(): JSX.Element { + return +} diff --git a/packages/web/components/templates/EmptyLayout.tsx b/packages/web/components/templates/EmptyLayout.tsx new file mode 100644 index 000000000..53c655a1d --- /dev/null +++ b/packages/web/components/templates/EmptyLayout.tsx @@ -0,0 +1,47 @@ +import { Box, HStack, VStack } from '../elements/LayoutPrimitives' +import { PageMetaData } from '../patterns/PageMetaData' +import { DEFAULT_HEADER_HEIGHT } from './homeFeed/HeaderSpacer' +import { SettingsDropdown } from './navMenu/SettingsDropdown' + +type EmptyLayoutProps = { + title: string + children: React.ReactNode +} + +export function EmptyLayout(props: EmptyLayoutProps): JSX.Element { + return ( + + + + + + + + + {props.children} + + + + + ) +} diff --git a/packages/web/components/templates/SettingsLayout.tsx b/packages/web/components/templates/SettingsLayout.tsx index 05c91ea8b..dd9be633a 100644 --- a/packages/web/components/templates/SettingsLayout.tsx +++ b/packages/web/components/templates/SettingsLayout.tsx @@ -1,5 +1,4 @@ import { Box, HStack, VStack } from '../elements/LayoutPrimitives' -import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' import { navigationCommands } from '../../lib/keyboardShortcuts/navigationShortcuts' import { useKeyboardShortcuts } from '../../lib/keyboardShortcuts/useKeyboardShortcuts' import { useRouter } from 'next/router' diff --git a/packages/web/components/templates/article/Article.tsx b/packages/web/components/templates/article/Article.tsx index 5f7d48ba4..91ef82161 100644 --- a/packages/web/components/templates/article/Article.tsx +++ b/packages/web/components/templates/article/Article.tsx @@ -115,6 +115,28 @@ export function Article(props: ArticleProps): JSX.Element { } }, 2500) + useEffect(() => { + const youtubePlayer = document.getElementById('_omnivore_youtube_video') + + const updateScroll = () => { + console.log('scroll y: ', window.scrollY, youtubePlayer) + + if (youtubePlayer) { + if (window.scrollY > 200) { + youtubePlayer.classList.add('is-sticky') + } else { + youtubePlayer.classList.remove('is-sticky') + } + } + } + if (youtubePlayer) { + window.addEventListener('scroll', updateScroll) + } + return () => { + window.removeEventListener('scroll', updateScroll) // clean up + } + }, [props]) + // Scroll to initial anchor position useEffect(() => { if (typeof window === 'undefined') { diff --git a/packages/web/components/templates/integrations/Readwise.tsx b/packages/web/components/templates/integrations/Readwise.tsx index 4e0469896..353687e1b 100644 --- a/packages/web/components/templates/integrations/Readwise.tsx +++ b/packages/web/components/templates/integrations/Readwise.tsx @@ -1,27 +1,18 @@ -import { useCallback, useMemo, useState } from 'react' -import { styled } from '@stitches/react' import Image from 'next/image' - -import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' -import { Button } from '../../elements/Button' -import { StyledText } from '../../elements/StyledText' -import { FormInput } from '../../elements/FormElements' - +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' import { Integration, useGetIntegrationsQuery, } from '../../../lib/networking/queries/useGetIntegrationsQuery' -import { useRouter } from 'next/router' import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' -import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' - -// Styles -const Header = styled(Box, { - color: '$utilityTextDefault', - fontSize: 'x-large', - margin: '20px', -}) +import { Button } from '../../elements/Button' +import { FormInput } from '../../elements/FormElements' +import { HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' +import { StyledText } from '../../elements/StyledText' +import { Header } from '../settings/SettingsTable' export function Readwise(): JSX.Element { const { integrations, revalidate } = useGetIntegrationsQuery() diff --git a/packages/web/components/templates/settings/SettingsTable.tsx b/packages/web/components/templates/settings/SettingsTable.tsx index 7e87c9a7a..1db5872a6 100644 --- a/packages/web/components/templates/settings/SettingsTable.tsx +++ b/packages/web/components/templates/settings/SettingsTable.tsx @@ -6,11 +6,18 @@ import { MoreOptionsIcon } from '../../elements/images/MoreOptionsIcon' import { InfoLink } from '../../elements/InfoLink' import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives' import { StyledText } from '../../elements/StyledText' -import { theme } from '../../tokens/stitches.config' +import { styled, theme } from '../../tokens/stitches.config' import { SettingsLayout } from '../SettingsLayout' import { usePersistedState } from '../../../lib/hooks/usePersistedState' import { FeatureHelpBox } from '../../elements/FeatureHelpBox' +// Styles +export const Header = styled(Box, { + color: '$utilityTextDefault', + fontSize: 'x-large', + margin: '20px', +}) + type SettingsTableProps = { pageId: string pageInfoLink?: string | undefined diff --git a/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts b/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts new file mode 100644 index 000000000..26ac49d47 --- /dev/null +++ b/packages/web/lib/networking/mutations/optIntoFeatureMutation.ts @@ -0,0 +1,49 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +export interface OptInFeatureInput { + name: string +} + +export interface OptInFeatureSuccess { + feature: { id: string } +} + +interface Response { + optInFeature: OptInFeatureSuccess +} + +export async function optInFeature( + input: OptInFeatureInput +): Promise { + const mutation = gql` + mutation OptInFeature($input: OptInFeatureInput!) { + optInFeature(input: $input) { + ... on OptInFeatureSuccess { + feature { + id + } + } + ... on OptInFeatureError { + errorCodes + } + } + } + ` + try { + const data = await gqlFetcher(mutation, { + input, + }) + const output = data as Response | undefined + if ( + !output || + !output.optInFeature || + 'errorCodes' in output?.optInFeature + ) { + return false + } + return true + } catch (err) { + return undefined + } +} diff --git a/packages/web/lib/networking/mutations/setIntegrationMutation.ts b/packages/web/lib/networking/mutations/setIntegrationMutation.ts index 66434ae7c..0c0bc4b6d 100644 --- a/packages/web/lib/networking/mutations/setIntegrationMutation.ts +++ b/packages/web/lib/networking/mutations/setIntegrationMutation.ts @@ -16,6 +16,7 @@ export type SetIntegrationInput = { token: string enabled: boolean importItemState?: ImportItemState + settings?: any } type SetIntegrationResult = { @@ -52,6 +53,7 @@ export async function setIntegrationMutation( enabled createdAt updatedAt + settings } } ... on SetIntegrationError { diff --git a/packages/web/lib/networking/mutations/unsubscribeMutation.ts b/packages/web/lib/networking/mutations/unsubscribeMutation.ts index 24e2505e1..c1bcbd49f 100644 --- a/packages/web/lib/networking/mutations/unsubscribeMutation.ts +++ b/packages/web/lib/networking/mutations/unsubscribeMutation.ts @@ -12,12 +12,12 @@ type Unsubscribe = { } export async function unsubscribeMutation( - subscribeName: string, - id = '' + subscribtionName: string, + id: string ): Promise { const mutation = gql` - mutation { - unsubscribe(name: "${subscribeName}", subscriptionId: "${id}") { + mutation Unsubscribe($subscribtionName: String!, $subscriptionId: ID!) { + unsubscribe(name: $subscribtionName, subscriptionId: $subscriptionId) { ... on UnsubscribeSuccess { subscription { id @@ -31,7 +31,11 @@ export async function unsubscribeMutation( ` try { - const data = (await gqlFetcher(mutation)) as UnsubscribeResult + const data = (await gqlFetcher(mutation, { + subscriptionId: id, + subscribtionName: subscribtionName, + })) as UnsubscribeResult + return data.unsubscribe.errorCodes ? undefined : data.unsubscribe.subscription.id diff --git a/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx b/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx index 9c28e2505..abd3e3bab 100644 --- a/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetIntegrationsQuery.tsx @@ -11,6 +11,7 @@ export interface Integration { createdAt: Date updatedAt: Date taskName?: string + settings?: any } export type IntegrationType = 'EXPORT' | 'IMPORT' @@ -43,6 +44,7 @@ export function useGetIntegrationsQuery(): IntegrationsQueryResponse { createdAt updatedAt taskName + settings } } ... on IntegrationsError { diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index f7fcad0d5..3434df3dc 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -413,27 +413,27 @@ export function useGetLibraryItemsQuery({ readingProgressAnchorIndex: 0, }) break - case 'unsubscribe': - if (!!item.node.subscription) { - updateData({ - cursor: item.cursor, - node: { - ...item.node, - subscription: undefined, - }, - }) - unsubscribeMutation(item.node.subscription).then((res) => { - if (res) { - showSuccessToast('Unsubscribed successfully', { - position: 'bottom-right', - }) - } else { - showErrorToast('Error unsubscribing', { - position: 'bottom-right', - }) - } - }) - } + // case 'unsubscribe': + // if (!!item.node.subscription) { + // updateData({ + // cursor: item.cursor, + // node: { + // ...item.node, + // subscription: undefined, + // }, + // }) + // unsubscribeMutation(item.node.subscription).then((res) => { + // if (res) { + // showSuccessToast('Unsubscribed successfully', { + // position: 'bottom-right', + // }) + // } else { + // showErrorToast('Error unsubscribing', { + // position: 'bottom-right', + // }) + // } + // }) + // } case 'update-item': updateData(item) break diff --git a/packages/web/lib/networking/queries/useGetViewerQuery.tsx b/packages/web/lib/networking/queries/useGetViewerQuery.tsx index 88beccd8b..abf88c1ff 100644 --- a/packages/web/lib/networking/queries/useGetViewerQuery.tsx +++ b/packages/web/lib/networking/queries/useGetViewerQuery.tsx @@ -3,6 +3,7 @@ import useSWR from 'swr' import { publicGqlFetcher } from '../networkHelpers' type ViewerQueryResponse = { + mutate: () => void viewerData?: ViewerQueryResponseData viewerDataError?: unknown isLoading: boolean @@ -51,9 +52,10 @@ export function useGetViewerQuery(): ViewerQueryResponse { } ` - const { data, error } = useSWR(query, publicGqlFetcher) + const { data, error, mutate } = useSWR(query, publicGqlFetcher) return { + mutate, viewerData: data as ViewerQueryResponseData, viewerDataError: error, // TODO: figure out error possibilities isLoading: !error && !data, diff --git a/packages/web/next.config.js b/packages/web/next.config.js index e4d6e85dc..07228c7f9 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -3,7 +3,7 @@ const ContentSecurityPolicy = ` base-uri 'self'; connect-src 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://proxy-prod.omnivore-image-cache.app https://accounts.google.com https://proxy-demo.omnivore-image-cache.app https://storage.googleapis.com https://api.segment.io https://cdn.segment.com https://widget.intercom.io https://api-iam.intercom.io https://static.intercomassets.com https://downloads.intercomcdn.com https://platform.twitter.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io wss://nexus-europe-websocket.intercom.io wss://nexus-australia-websocket.intercom.io https://uploads.intercomcdn.com https://tools.applemediaservices.com; font-src 'self' data: https://cdn.jsdelivr.net https://js.intercomcdn.com https://fonts.intercomcdn.com; - form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io; + form-action 'self' ${process.env.NEXT_PUBLIC_SERVER_BASE_URL} https://getpocket.com/auth/authorize https://intercom.help https://api-iam.intercom.io https://api-iam.eu.intercom.io https://api-iam.au.intercom.io https://www.notion.so https://api.notion.com; frame-ancestors 'none'; frame-src 'self' https://accounts.google.com https://platform.twitter.com https://www.youtube.com https://www.youtube-nocookie.com; manifest-src 'self'; diff --git a/packages/web/pages/404.tsx b/packages/web/pages/404.tsx index 80e44ab9d..bdf8e40e4 100644 --- a/packages/web/pages/404.tsx +++ b/packages/web/pages/404.tsx @@ -1,6 +1,6 @@ import Head from 'next/head' import { ErrorLayout } from '../components/templates/ErrorLayout' -import { SettingsLayout } from '../components/templates/SettingsLayout' +import { EmptyLayout } from '../components/templates/EmptyLayout' export default function Custom404(): JSX.Element { return ( @@ -8,9 +8,9 @@ export default function Custom404(): JSX.Element { Page Not Found - + - + ) } diff --git a/packages/web/pages/500.tsx b/packages/web/pages/500.tsx index 75d24835c..e4976faba 100644 --- a/packages/web/pages/500.tsx +++ b/packages/web/pages/500.tsx @@ -1,6 +1,6 @@ import { ErrorLayout } from '../components/templates/ErrorLayout' import Head from 'next/head' -import { SettingsLayout } from '../components/templates/SettingsLayout' +import { EmptyLayout } from '../components/templates/EmptyLayout' export default function Custom500(): JSX.Element { return ( @@ -8,9 +8,9 @@ export default function Custom500(): JSX.Element { An unknown error occurred. - - - + + + ) } diff --git a/packages/web/pages/settings/features/beta.tsx b/packages/web/pages/settings/features/beta.tsx new file mode 100644 index 000000000..c48d274de --- /dev/null +++ b/packages/web/pages/settings/features/beta.tsx @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Toaster } from 'react-hot-toast' +import { Button } from '../../../components/elements/Button' +import { + Box, + HStack, + SpanBox, + VStack, +} from '../../../components/elements/LayoutPrimitives' +import { StyledText } from '../../../components/elements/StyledText' +import { SettingsLayout } from '../../../components/templates/SettingsLayout' +import { styled, theme } from '../../../components/tokens/stitches.config' +import { updateEmailMutation } from '../../../lib/networking/mutations/updateEmailMutation' +import { updateUserMutation } from '../../../lib/networking/mutations/updateUserMutation' +import { updateUserProfileMutation } from '../../../lib/networking/mutations/updateUserProfileMutation' +import { useGetLibraryItemsQuery } from '../../../lib/networking/queries/useGetLibraryItemsQuery' +import { useGetViewerQuery } from '../../../lib/networking/queries/useGetViewerQuery' +import { useValidateUsernameQuery } from '../../../lib/networking/queries/useValidateUsernameQuery' +import { applyStoredTheme } from '../../../lib/themeUpdater' +import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' +import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal' +import { ProgressBar } from '../../../components/elements/ProgressBar' +import { emptyTrashMutation } from '../../../lib/networking/mutations/emptyTrashMutation' +import { ProgressIndicator } from '@radix-ui/react-progress' +import { Spinner } from 'phosphor-react' +import { optInFeature } from '../../../lib/networking/mutations/optIntoFeatureMutation' + +const ACCOUNT_LIMIT = 50_000 + +const StyledLabel = styled('label', { + fontWeight: 600, + fontSize: '16px', + marginBottom: '5px', +}) + +export default function Account(): JSX.Element { + const { viewerData, isLoading, mutate } = useGetViewerQuery() + const [pageLoading, setPageLoading] = useState(false) + + const showSpinner = useMemo(() => { + return isLoading || pageLoading + }, [isLoading, pageLoading]) + + const requestFeatureAccess = useCallback( + async (featureName: string) => { + setPageLoading(true) + const result = await optInFeature({ name: featureName }) + if (!result) { + showErrorToast('Error opting into feature.') + } else { + showSuccessToast('Feature added') + } + mutate() + setPageLoading(false) + }, + [setPageLoading, mutate] + ) + + const hasYouTube = useMemo(() => { + return ( + (viewerData?.me?.features.indexOf('youtube-transcripts') ?? -1) !== -1 + ) + }, [viewerData]) + + applyStoredTheme() + + return ( + + + + + + + Enabled beta features + {!showSpinner ? ( + <> + {viewerData?.me?.features.map((feature) => { + return ( + + + {feature} + + ) + })} + + {!hasYouTube /* || !hasAISummaries || !hasDigest */ && ( + + Available beta features + + )} + + + {!hasYouTube && ( + + + - YouTube transcripts: nicely formatted documents + generated from YouTube transcript data. Currently + limited to videos under 30 minutes. + + + + )} + + {/* + + - AI Summaries: Short summaries of your newly saved + articles + + + + + + + - Daily digest: Every day we pick some of the items we + think You will enjoy reading the most and create a daily + digest of them. + + + */} + + + ) : ( + + + + )} + + + + + ) +} diff --git a/packages/web/pages/settings/integrations.tsx b/packages/web/pages/settings/integrations.tsx index 4d549b217..39af9ec23 100644 --- a/packages/web/pages/settings/integrations.tsx +++ b/packages/web/pages/settings/integrations.tsx @@ -89,6 +89,9 @@ export default function Integrations(): JSX.Element { const pocketConnected = useMemo(() => { return integrations.find((i) => i.name == 'POCKET' && i.type == 'IMPORT') }, [integrations]) + const isConnected = (name: string) => { + return integrations.find((i) => i.name == name)?.enabled + } const deleteIntegration = async (id: string) => { try { @@ -110,17 +113,23 @@ export default function Integrations(): JSX.Element { } } - const redirectToPocket = (importItemState: ImportItemState) => { + const redirectToIntegration = ( + name: string, + importItemState?: ImportItemState + ) => { // create a form and submit it to the backend const form = document.createElement('form') form.method = 'POST' - form.action = `${fetchEndpoint}/integration/pocket/auth` - const input = document.createElement('input') - input.type = 'hidden' - input.name = 'state' - input.value = importItemState - form.appendChild(input) + form.action = `${fetchEndpoint}/integration/${name.toLowerCase()}/auth` + if (importItemState) { + const input = document.createElement('input') + input.type = 'hidden' + input.name = 'state' + input.value = importItemState + form.appendChild(input) + } document.body.appendChild(form) + form.submit() } @@ -155,13 +164,41 @@ export default function Integrations(): JSX.Element { { duration: 5000 } ) } finally { - router.replace('/settings/integrations') + router.push('/settings/integrations') } } + + const connectWithNotion = async () => { + try { + // get the token from query string + const token = router.query.code as string + await setIntegrationMutation({ + token, + name: 'NOTION', + type: 'EXPORT', + enabled: false, + }) + + showSuccessToast('Connected with Notion.') + + router.push('/settings/integrations/notion') + } catch (err) { + showErrorToast( + 'There was an error connecting to Notion. Please try again.', + { duration: 5000 } + ) + + router.push('/settings/integrations') + } + } + if (!router.isReady) return if (router.query.pocketToken && router.query.state && !pocketConnected) { connectToPocket() } + if (router.query.code) { + connectWithNotion() + } }, [router]) useEffect(() => { @@ -210,7 +247,7 @@ export default function Integrations(): JSX.Element { action: () => { pocketConnected ? deleteIntegration(pocketConnected.id) - : redirectToPocket(ImportItemState.Unarchived) + : redirectToIntegration('pocket', ImportItemState.Unarchived) }, disabled: isImporting(pocketConnected), isDropdown: !pocketConnected, @@ -218,18 +255,34 @@ export default function Integrations(): JSX.Element { { text: 'Import All', action: () => { - redirectToPocket(ImportItemState.All) + redirectToIntegration('pocket', ImportItemState.All) }, }, { text: 'Import Unarchived', action: () => { - redirectToPocket(ImportItemState.Unarchived) + redirectToIntegration('pocket', ImportItemState.Unarchived) }, }, ], }, }, + { + icon: '/static/icons/notion.png', + title: 'Notion', + subText: + 'Notion is an all-in-one workspace. Use our Notion integration to sync your Omnivore items to Notion.', + button: { + text: isConnected('NOTION') ? 'Settings' : 'Connect', + icon: , + style: isConnected('NOTION') ? 'ctaWhite' : 'ctaDarkYellow', + action: () => { + isConnected('NOTION') + ? router.push('/settings/integrations/notion') + : redirectToIntegration('NOTION') + }, + }, + }, { icon: '/static/icons/webhooks.svg', title: 'Webhooks', @@ -258,7 +311,7 @@ export default function Integrations(): JSX.Element { }, }, ]) - }, [pocketConnected, readwiseConnected, webhooks]) + }, [pocketConnected, readwiseConnected, webhooks, integrations]) return ( diff --git a/packages/web/pages/settings/integrations/notion.tsx b/packages/web/pages/settings/integrations/notion.tsx new file mode 100644 index 000000000..fd92082e4 --- /dev/null +++ b/packages/web/pages/settings/integrations/notion.tsx @@ -0,0 +1,197 @@ +import { + Button, + Checkbox, + Form, + FormProps, + Input, + message, + Space, + Switch, +} from 'antd' +import 'antd/dist/antd.compact.css' +import { CheckboxValueType } from 'antd/lib/checkbox/Group' +import Image from 'next/image' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' +import { HStack, VStack } from '../../../components/elements/LayoutPrimitives' +import { PageMetaData } from '../../../components/patterns/PageMetaData' +import { Beta } from '../../../components/templates/Beta' +import { Header } from '../../../components/templates/settings/SettingsTable' +import { SettingsLayout } from '../../../components/templates/SettingsLayout' +import { deleteIntegrationMutation } from '../../../lib/networking/mutations/deleteIntegrationMutation' +import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' +import { useGetIntegrationsQuery } from '../../../lib/networking/queries/useGetIntegrationsQuery' +import { applyStoredTheme } from '../../../lib/themeUpdater' +import { showSuccessToast } from '../../../lib/toastHelpers' + +type FieldType = { + parentPageId?: string + parentDatabaseId?: string + enabled: boolean + properties?: string[] +} + +export default function Notion(): JSX.Element { + applyStoredTheme() + + const router = useRouter() + const { integrations, revalidate } = useGetIntegrationsQuery() + const notion = useMemo(() => { + return integrations.find((i) => i.name == 'NOTION' && i.type == 'EXPORT') + }, [integrations]) + + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + + useEffect(() => { + form.setFieldsValue({ + parentPageId: notion?.settings?.parentPageId, + parentDatabaseId: notion?.settings?.parentDatabaseId, + enabled: notion?.enabled, + properties: notion?.settings?.properties, + }) + }, [form, notion]) + + const deleteNotion = async () => { + if (!notion) { + throw new Error('Notion integration not found') + } + + await deleteIntegrationMutation(notion.id) + showSuccessToast('Notion integration disconnected successfully.') + + revalidate() + router.push('/settings/integrations') + } + + const updateNotion = async (values: FieldType) => { + if (!notion) { + throw new Error('Notion integration not found') + } + + await setIntegrationMutation({ + id: notion.id, + name: notion.name, + type: notion.type, + token: notion.token, + enabled: values.enabled, + settings: values, + }) + } + + const onFinish: FormProps['onFinish'] = async (values) => { + try { + await updateNotion(values) + + revalidate() + messageApi.success('Notion settings updated successfully.') + } catch (error) { + messageApi.error('There was an error updating Notion settings.') + } + } + + const onFinishFailed: FormProps['onFinishFailed'] = ( + errorInfo + ) => { + console.log('Failed:', errorInfo) + } + + const onDataChange = (value: Array) => { + form.setFieldsValue({ properties: value.map((v) => v.toString()) }) + } + + return ( + <> + {contextHolder} + + + + + Integration Image +
Notion integration settings
+ +
+ + {notion && ( +
+
+ + label="Notion Page Id" + name="parentPageId" + rules={[ + { + required: true, + message: 'Please input your Notion Page Id!', + }, + ]} + > + + + + + label="Notion Database Id" + name="parentDatabaseId" + hidden + > + + + + + label="Automatic Sync" + name="enabled" + valuePropName="checked" + > + + + + + label="Properties to Export" + name="properties" + > + + Highlights + + + + + + + + + + +
+ )} +
+
+ + ) +} diff --git a/packages/web/pages/support.tsx b/packages/web/pages/support.tsx index 98e25f366..edf0d7e09 100644 --- a/packages/web/pages/support.tsx +++ b/packages/web/pages/support.tsx @@ -1,7 +1,7 @@ import { useEffect, useCallback } from 'react' import { Button } from '../components/elements/Button' import { HStack } from '../components/elements/LayoutPrimitives' -import { SettingsLayout } from '../components/templates/SettingsLayout' +import { EmptyLayout } from '../components/templates/EmptyLayout' import { setupAnalytics } from '../lib/analytics' export default function Support(): JSX.Element { @@ -18,7 +18,7 @@ export default function Support(): JSX.Element { }, [initAnalytics]) return ( - + - + ) } diff --git a/packages/web/pages/terms.tsx b/packages/web/pages/terms.tsx index fd267cb75..bed50fe80 100644 --- a/packages/web/pages/terms.tsx +++ b/packages/web/pages/terms.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import { TermsAndConditions } from '../components/templates/TermsAndConditions' -import { SettingsLayout } from '../components/templates/SettingsLayout' +import { EmptyLayout } from '../components/templates/EmptyLayout' export default function Terms(): JSX.Element { const router = useRouter() @@ -11,9 +11,9 @@ export default function Terms(): JSX.Element { return } else { return ( - + - + ) } } diff --git a/packages/web/public/static/icons/notion.png b/packages/web/public/static/icons/notion.png new file mode 100644 index 000000000..391051679 Binary files /dev/null and b/packages/web/public/static/icons/notion.png differ diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index c38f290a7..251da4bb7 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -610,3 +610,58 @@ white-space: pre-wrap; overflow-wrap: break-word; } + +.is-sticky { + position: fixed; + right: 5px; + bottom: 5px; + top: auto; + left: auto; + z-index: 10; + max-width: 400px; + max-height: 222px; + width: 400px; + height: 222px; + animation-name: fadeInUp; + animation-duration: 0.5s; + animation-fill-mode: both; + -webkit-animation-name: fadeInUp; + -webkit-animation-duration: 0.5s; + -webkit-animation-fill-mode: both; + overflow: hidden; + box-shadow: 0px 4px 4px rgba(33, 33, 33, 0.1) !important; +} + +@media (max-width: 600px) { + .is-sticky { + max-width: 200px; + max-height: 110px; + } +} + + +@keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} + +@-webkit-keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} \ No newline at end of file diff --git a/pkg/admin/yarn.lock b/pkg/admin/yarn.lock index 40fe46870..11d22d07e 100644 --- a/pkg/admin/yarn.lock +++ b/pkg/admin/yarn.lock @@ -2830,9 +2830,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.14.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== formidable@^1.0.17: version "1.2.2" diff --git a/yarn.lock b/yarn.lock index 3070136bc..a4b70d6b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2428,6 +2428,11 @@ dependencies: text-decoding "^1.0.0" +"@fastify/busboy@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" + integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== + "@ffmpeg-installer/darwin-arm64@4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz#b7b5c262dd96d1aea4807514e1cdcf6e11f82743" @@ -2768,12 +2773,7 @@ resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-4.0.0.tgz#d600e0433daf51b88c1fa95ac7f02e38e80a07be" integrity sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA== -"@google-cloud/promisify@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-3.0.0.tgz#5cd6941fc30c4acac18051706aa5af96069bd3e3" - integrity sha512-91ArYvRgXWb73YvEOBMmOcJc0bDRs5yiVHnqkwoG0f3nm7nZuipllz6e7BvFESBvjkDTBC0zMD8QxedUwNLc1A== - -"@google-cloud/promisify@^3.0.1": +"@google-cloud/promisify@^3.0.0", "@google-cloud/promisify@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-3.0.1.tgz#8d724fb280f47d1ff99953aee0c1669b25238c2e" integrity sha512-z1CjRjtQyBOYL+5Qr9DdYIfrdLBe746jRTYfaYU6MeXkqp7UfYs/jX16lFFVzZ7PGEJvqZNqYUEtb1mvDww4pA== @@ -2830,25 +2830,25 @@ uuid "^8.0.0" "@google-cloud/storage@^7.0.1": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-7.0.1.tgz#38c267bb8377d442066d4eccb4f942f58d119476" - integrity sha512-YBJ8HaDZvbeVDgEGWuC6sCsfZNCooVfKg1J+CJ4iXwRejIWbKFjl8laWz8w+/+ucJHM9qOdGkB95Q/mhh2CX/A== + version "7.8.0" + resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-7.8.0.tgz#78b41d575c05b35a6dae2dea0e5c2b09bc5ec532" + integrity sha512-4q8rKdLp35z8msAtrhr0pbos7BeD8T0tr6rMbBINewp9cfrwj7ROIElVwBluU8fZ596OvwQcjb6QCyBzTmkMRQ== dependencies: - "@google-cloud/paginator" "^3.0.7" - "@google-cloud/projectify" "^3.0.0" - "@google-cloud/promisify" "^3.0.0" + "@google-cloud/paginator" "^5.0.0" + "@google-cloud/projectify" "^4.0.0" + "@google-cloud/promisify" "^4.0.0" abort-controller "^3.0.0" async-retry "^1.3.3" compressible "^2.0.12" - duplexify "^4.0.0" + duplexify "^4.1.3" ent "^2.2.0" - fast-xml-parser "^4.2.2" + fast-xml-parser "^4.3.0" gaxios "^6.0.2" - google-auth-library "^9.0.0" + google-auth-library "^9.6.3" mime "^3.0.0" mime-types "^2.0.8" p-limit "^3.0.1" - retry-request "^6.0.0" + retry-request "^7.0.0" teeny-request "^9.0.0" uuid "^8.0.0" @@ -3978,6 +3978,14 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@notionhq/client@^2.2.14": + version "2.2.14" + resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-2.2.14.tgz#6807ec27ee89584529abfd28d058b2661f828b74" + integrity sha512-oqUefZtCiJPCX+74A1Os9OVTef3fSnVWe2eVQtU1HJSD+nsfxfhwvDKnzJTh2Tw1ZHKLxpieHB/nzGdY+Uo12A== + dependencies: + "@types/node-fetch" "^2.5.10" + node-fetch "^2.6.1" + "@npmcli/arborist@^5.6.3": version "5.6.3" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-5.6.3.tgz#40810080272e097b4a7a4f56108f4a31638a9874" @@ -6084,11 +6092,6 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== -"@sqltools/formatter@^1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.5.tgz#3abc203c79b8c3e90fd6c156a0c62d5403520e12" - integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw== - "@stitches/react@^1.2.5": version "1.2.8" resolved "https://registry.yarnpkg.com/@stitches/react/-/react-1.2.8.tgz#954f8008be8d9c65c4e58efa0937f32388ce3a38" @@ -7486,10 +7489,10 @@ dependencies: "@types/express" "*" -"@types/express@*", "@types/express@^4.17.13", "@types/express@^4.17.14", "@types/express@^4.17.7": - version "4.17.17" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" - integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== +"@types/express@*", "@types/express@^4.17.13", "@types/express@^4.17.14", "@types/express@^4.17.21", "@types/express@^4.17.7": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33" @@ -7506,16 +7509,6 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/express@^4.17.21": - version "4.17.21" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" - integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.33" - "@types/qs" "*" - "@types/serve-static" "*" - "@types/filesystem@*": version "0.0.32" resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.32.tgz#307df7cc084a2293c3c1a31151b178063e0a8edf" @@ -7881,6 +7874,14 @@ dependencies: "@types/node" "*" +"@types/node-fetch@^2.5.10", "@types/node-fetch@^2.6.4": + version "2.6.11" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" + integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-fetch@^2.5.7": version "2.6.1" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" @@ -7889,14 +7890,6 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node-fetch@^2.6.4": - version "2.6.11" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" - integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - "@types/node-fetch@^2.6.6": version "2.6.7" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.7.tgz#a1abe2ce24228b58ad97f99480fdcf9bbc6ab16d" @@ -7932,13 +7925,6 @@ dependencies: undici-types "~5.26.4" -"@types/node@^20.11.0": - version "20.11.24" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.24.tgz#cc207511104694e84e9fb17f9a0c4c42d4517792" - integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long== - dependencies: - undici-types "~5.26.4" - "@types/nodemailer@^6.4.4": version "6.4.4" resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" @@ -8105,6 +8091,16 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/request@^2.48.8": + version "2.48.12" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.12.tgz#0f590f615a10f87da18e9790ac94c29ec4c5ef30" + integrity sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.0" + "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -8172,6 +8168,11 @@ resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.1.tgz#24134738ba3107237d6a783e054a54773e739f81" integrity sha512-xdnAw2nFqomkaL0QdtEk0t7yz26UkaVPl4v1pYJvtE1T0fmfQEH3JaxErEhGByEAl3zUZrkNBlneuJp0WJGqEA== +"@types/showdown@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.6.tgz#3d7affd5f971b4a17783ec2b23b4ad3b97477b7e" + integrity sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ== + "@types/sinon-chai@^3.2.8": version "3.2.8" resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc" @@ -9466,11 +9467,6 @@ app-root-path@^3.0.0: resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad" integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw== -app-root-path@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.1.0.tgz#5971a2fc12ba170369a7a1ef018c71e6e47c2e86" - integrity sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA== - apparatus@^0.0.10: version "0.0.10" resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.10.tgz#81ea756772ada77863db54ceee8202c109bdca3e" @@ -10564,13 +10560,13 @@ bn.js@^5.2.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.1, body-parser@^1.18.3, body-parser@^1.19.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== +body-parser@1.20.2, body-parser@^1.18.3, body-parser@^1.19.0: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== dependencies: bytes "3.1.2" - content-type "~1.0.4" + content-type "~1.0.5" debug "2.6.9" depd "2.0.0" destroy "1.2.0" @@ -10578,7 +10574,7 @@ body-parser@1.20.1, body-parser@^1.18.3, body-parser@^1.19.0: iconv-lite "0.4.24" on-finished "2.4.1" qs "6.11.0" - raw-body "2.5.1" + raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -12174,10 +12170,10 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +content-type@~1.0.4, content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== conventional-changelog-angular@6.0.0: version "6.0.0" @@ -12929,7 +12925,7 @@ dateformat@^3.0.0, dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dayjs@1.x, dayjs@^1.10.4, dayjs@^1.11.7, dayjs@^1.11.9: +dayjs@1.x, dayjs@^1.10.4, dayjs@^1.11.7: version "1.11.10" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== @@ -13660,11 +13656,6 @@ dotenv@^16.0.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d" integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ== -dotenv@^16.0.3: - version "16.4.5" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" - integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== - dotenv@^8.0.0, dotenv@^8.2.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" @@ -13718,15 +13709,15 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" -duplexify@^4.0.0, duplexify@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.2.tgz#18b4f8d28289132fa0b9573c898d9f903f81c7b0" - integrity sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw== +duplexify@^4.0.0, duplexify@^4.1.1, duplexify@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.3.tgz#a07e1c0d0a2c001158563d32592ba58bddb0236f" + integrity sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA== dependencies: end-of-stream "^1.4.1" inherits "^2.0.3" readable-stream "^3.1.1" - stream-shift "^1.0.0" + stream-shift "^1.0.2" dynamic-dedupe@^0.3.0: version "0.3.0" @@ -14214,13 +14205,14 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@~0.10.14: - version "0.10.62" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" - integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== +es5-ext@^0.10.35, es5-ext@^0.10.50, es5-ext@^0.10.62, es5-ext@~0.10.14: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== dependencies: es6-iterator "^2.0.3" es6-symbol "^3.1.3" + esniff "^2.0.1" next-tick "^1.1.0" es5-shim@^4.5.13: @@ -14661,6 +14653,16 @@ eslint@^8.6.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + espree@^9.0.0: version "9.4.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.1.tgz#51d6092615567a2c2cff7833445e37c28c0065bd" @@ -14961,13 +14963,13 @@ express-rate-limit@^6.3.0: integrity sha512-8+UpWtQY25lJaa4+3WxDBGDcAu4atcTruSs3QSL5VPEplYy6kmk84wutG9rUkkK5LmMQQ7TFHWLZYITwVNbbEg== express@^4.16.4, express@^4.17.1, express@^4.18.2: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.18.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.3.tgz#6870746f3ff904dee1819b82e4b51509afffb0d4" + integrity sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" cookie "0.5.0" @@ -15208,10 +15210,10 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== -fast-xml-parser@^4.2.2: - version "4.2.7" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.2.7.tgz#871f2ca299dc4334b29f8da3658c164e68395167" - integrity sha512-J8r6BriSLO1uj2miOk1NW0YVm8AGOOu3Si2HQp/cSmo6EA4m3fcwu2WKjJ4RK9wMLBtg69y1kS8baDiQBR41Ig== +fast-xml-parser@^4.2.2, fast-xml-parser@^4.3.0: + version "4.3.5" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.3.5.tgz#e2f2a2ae8377e9c3dc321b151e58f420ca7e5ccc" + integrity sha512-sWvP1Pl8H03B8oFJpFR3HE31HUfwtX7Rlf9BNsvdpujD4n7WMhfmu8h9wOV2u+c1k0ZilTADhPqypzx2J690ZQ== dependencies: strnum "^1.0.5" @@ -15549,15 +15551,10 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.14.4, follow-redirects@^1.14.8, follow-redirects@^1.14.9, follow-redirects@^1.15.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== - -follow-redirects@^1.15.4: - version "1.15.5" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" - integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== +follow-redirects@^1.0.0, follow-redirects@^1.14.4, follow-redirects@^1.14.8, follow-redirects@^1.14.9, follow-redirects@^1.15.0, follow-redirects@^1.15.4: + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== for-each@^0.3.3: version "0.3.3" @@ -15973,20 +15970,10 @@ gaxios@^5.0.0, gaxios@^5.0.1: is-stream "^2.0.0" node-fetch "^2.6.7" -gaxios@^6.0.0, gaxios@^6.0.2: - version "6.0.4" - resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.0.4.tgz#e8a2145653b5bad7e3cf358e2a9819160e8e6fa7" - integrity sha512-mwKfHJn7f3pLRfahdEPNyvygXRwjwgsgDPaIIoBRIDkgP4SFyezkYGWQ2aLCfrAnzimSrP+mAg1aSUj5gidXvw== - dependencies: - extend "^3.0.2" - https-proxy-agent "^7.0.1" - is-stream "^2.0.0" - node-fetch "^2.6.9" - -gaxios@^6.0.3: - version "6.1.0" - resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.1.0.tgz#8ab08adbf9cc600368a57545f58e004ccf831ccb" - integrity sha512-EIHuesZxNyIkUGcTQKQPMICyOpDD/bi+LJIJx+NLsSGmnS7N+xCLRX5bi4e9yAu9AlSZdVq+qlyWWVuTh/483w== +gaxios@^6.0.0, gaxios@^6.0.2, gaxios@^6.0.3, gaxios@^6.1.1: + version "6.3.0" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-6.3.0.tgz#5cd858de47c6560caaf0f99bb5d89c5bdfbe9034" + integrity sha512-p+ggrQw3fBwH2F5N/PAI4k/G/y1art5OxKpb2J2chwNNHM4hHuAOtivjPuirMF4KNKwTTUal/lPfL2+7h2mEcg== dependencies: extend "^3.0.2" https-proxy-agent "^7.0.1" @@ -16025,6 +16012,14 @@ gcp-metadata@^6.0.0: gaxios "^6.0.0" json-bigint "^1.0.0" +gcp-metadata@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-6.1.0.tgz#9b0dd2b2445258e7597f2024332d20611cbd6b8c" + integrity sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg== + dependencies: + gaxios "^6.0.0" + json-bigint "^1.0.0" + gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -16329,7 +16324,7 @@ glob@7.2.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glo once "^1.3.0" path-is-absolute "^1.0.0" -glob@^10.2.2, glob@^10.3.10: +glob@^10.2.2: version "10.3.10" resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== @@ -16531,18 +16526,17 @@ google-auth-library@^8.0.1, google-auth-library@^8.0.2: jws "^4.0.0" lru-cache "^6.0.0" -google-auth-library@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.0.0.tgz#b159d22464c679a6a25cb46d48a4ac97f9f426a2" - integrity sha512-IQGjgQoVUAfOk6khqTVMLvWx26R+yPw9uLyb1MNyMQpdKiKt0Fd9sp4NWoINjyGHR8S3iw12hMTYK7O8J07c6Q== +google-auth-library@^9.0.0, google-auth-library@^9.6.3: + version "9.6.3" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-9.6.3.tgz#add8935bc5b842a8e80f84fef2b5ed9febb41d48" + integrity sha512-4CacM29MLC2eT9Cey5GDVK4Q8t+MMp8+OEdOaqD9MG6b0dOyLORaaeJMPQ7EESVgm/+z5EKYyFLxgzBJlJgyHQ== dependencies: base64-js "^1.3.0" ecdsa-sig-formatter "^1.0.11" - gaxios "^6.0.0" - gcp-metadata "^6.0.0" + gaxios "^6.1.1" + gcp-metadata "^6.1.0" gtoken "^7.0.0" jws "^4.0.0" - lru-cache "^6.0.0" google-gax@^3.5.7: version "3.5.8" @@ -19298,17 +19292,24 @@ jest@^27.4.5: import-local "^3.0.2" jest-cli "^27.5.1" +jintr@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jintr/-/jintr-1.1.0.tgz#223a3b07f5e03d410cec6e715c537c8ad1e714c3" + integrity sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg== + dependencies: + acorn "^8.8.0" + jose@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.6.tgz#894ba19169af339d3911be933f913dd02fc57c7c" - integrity sha512-FVoPY7SflDodE4lknJmbAHSUjLCzE2H1F6MS0RYKMQ8SR+lNccpMf8R4eqkNYyyUjR5qZReOzZo5C5YiHOCjjg== + version "2.0.7" + resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.7.tgz#3aabbaec70bff313c108b9406498a163737b16ba" + integrity sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg== dependencies: "@panva/asn1.js" "^1.0.0" jose@^4.10.4: - version "4.13.1" - resolved "https://registry.yarnpkg.com/jose/-/jose-4.13.1.tgz#449111bb5ab171db85c03f1bd2cb1647ca06db1c" - integrity sha512-MSJQC5vXco5Br38mzaQKiq9mwt7lwj2eXpgpRyQYNHYt2lq1PjkWa7DLXX0WVcQLE9HhMh3jPiufS7fhJf+CLQ== + version "4.15.5" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706" + integrity sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg== js-beautify@^1.13.0: version "1.14.0" @@ -21726,36 +21727,17 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.44.0: - version "1.44.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== - -mime-db@1.49.0, "mime-db@>= 1.43.0 < 2": - version "1.49.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed" - integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA== - mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.0.8, mime-types@~2.1.24: - version "2.1.32" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" - integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A== - dependencies: - mime-db "1.49.0" +"mime-db@>= 1.43.0 < 2": + version "1.49.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed" + integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA== -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.27" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== - dependencies: - mime-db "1.44.0" - -mime-types@^2.1.27, mime-types@^2.1.30, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.34: +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.30, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -22081,11 +22063,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^2.1.3: - version "2.1.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" - integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== - mkdirp@~0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" @@ -25534,10 +25511,10 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== dependencies: bytes "3.1.2" http-errors "2.0.0" @@ -26390,14 +26367,6 @@ read-pkg@^7.1.0: parse-json "^5.2.0" type-fest "^2.0.0" -read-yaml-file@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/read-yaml-file/-/read-yaml-file-2.1.0.tgz#c5866712db9ef5343b4d02c2413bada53c41c4a9" - integrity sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ== - dependencies: - js-yaml "^4.0.0" - strip-bom "^4.0.0" - read@1, read@^1.0.7, read@~1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" @@ -26540,11 +26509,6 @@ reflect-metadata@^0.1.13: resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== -reflect-metadata@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.1.tgz#8d5513c0f5ef2b4b9c3865287f3c0940c1f67f74" - integrity sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw== - reflect.getprototypeof@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3" @@ -27075,6 +27039,15 @@ retry-request@^6.0.0: debug "^4.1.1" extend "^3.0.2" +retry-request@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-7.0.2.tgz#60bf48cfb424ec01b03fca6665dee91d06dd95f3" + integrity sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w== + dependencies: + "@types/request" "^2.48.8" + extend "^3.0.2" + teeny-request "^9.0.0" + retry@0.13.1, retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -28329,6 +28302,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +stream-shift@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.3.tgz#85b8fab4d71010fc3ba8772e8046cc49b8a3864b" + integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== + streamsearch@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" @@ -29785,27 +29763,6 @@ typeorm-naming-strategies@^4.1.0: resolved "https://registry.yarnpkg.com/typeorm-naming-strategies/-/typeorm-naming-strategies-4.1.0.tgz#1ec6eb296c8d7b69bb06764d5b9083ff80e814a9" integrity sha512-vPekJXzZOTZrdDvTl1YoM+w+sUIfQHG4kZTpbFYoTsufyv9NIBRe4Q+PdzhEAFA2std3D9LZHEb1EjE9zhRpiQ== -typeorm@^0.3.19: - version "0.3.20" - resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.20.tgz#4b61d737c6fed4e9f63006f88d58a5e54816b7ab" - integrity sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q== - dependencies: - "@sqltools/formatter" "^1.2.5" - app-root-path "^3.1.0" - buffer "^6.0.3" - chalk "^4.1.2" - cli-highlight "^2.1.11" - dayjs "^1.11.9" - debug "^4.3.4" - dotenv "^16.0.3" - glob "^10.3.10" - mkdirp "^2.1.3" - reflect-metadata "^0.2.1" - sha.js "^2.4.11" - tslib "^2.5.0" - uuid "^9.0.0" - yargs "^17.6.2" - typeorm@^0.3.4: version "0.3.7" resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.7.tgz#5776ed5058f0acb75d64723b39ff458d21de64c1" @@ -29844,11 +29801,6 @@ typescript@^4.4.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" - integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== - ua-parser-js@^0.7.30: version "0.7.33" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" @@ -29937,6 +29889,13 @@ undici@^4.9.3: resolved "https://registry.yarnpkg.com/undici/-/undici-4.14.1.tgz#7633b143a8a10d6d63335e00511d071e8d52a1d9" integrity sha512-WJ+g+XqiZcATcBaUeluCajqy4pEDcQfK1vy+Fo+bC4/mqXI9IIQD/XWHLS70fkGUT6P52Drm7IFslO651OdLPQ== +undici@^5.19.1: + version "5.28.3" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.3.tgz#a731e0eff2c3fcfd41c1169a869062be222d1e5b" + integrity sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA== + dependencies: + "@fastify/busboy" "^2.0.0" + unfetch@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" @@ -31597,6 +31556,23 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +youtubei.js@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.1.0.tgz#bcf154c9fa21d3c8c1d00a5e10360d0a065c660e" + integrity sha512-C5GBJ4LgnS6vGAUkdIdQNOFFb5EZ1p3xBvUELNXmIG3Idr6vxWrKNBNy8ClZT3SuDVXaAJqDgF9b5jvY8lNKcg== + dependencies: + jintr "^1.1.0" + tslib "^2.5.0" + undici "^5.19.1" + +youtubei@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/youtubei/-/youtubei-1.3.4.tgz#b9761e33dcc6e0a9569e6628ba1fc48c729636f0" + integrity sha512-xN6p2oddcTpreF/ojU2mChwdiUlV+TwwUL6xgP6lXRuxeGS5MokM1tzRdXCgIpxkzYYNNAWpt7xvPuAUQM0PCg== + dependencies: + node-fetch "2.6.7" + protobufjs "7.2.4" + yup@^0.31.0: version "0.31.1" resolved "https://registry.yarnpkg.com/yup/-/yup-0.31.1.tgz#0954cb181161f397b804346037a04f8a4b31599e"