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}
- By ${authorName}
+