Merge branch 'main' into discover-no-ts-changes

This commit is contained in:
Jackson Harper 2024-03-19 15:50:51 +08:00 committed by GitHub
commit e47e169402
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 2687 additions and 532 deletions

View file

@ -4,7 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application

View file

@ -239,8 +239,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nathantannar4/Transmission",
"state" : {
"revision" : "3dac53ae4bddc7ab99e6374622a9c5eefbe50eed",
"version" : "1.1.4"
"revision" : "9517912f8f528c777f86f7896b5c35d7e43fa916",
"version" : "1.0.1"
}
},
{

View file

@ -71,9 +71,9 @@ var dependencies: [Package.Dependency] {
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"),
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"),
.package(url: "https://github.com/nathantannar4/Engine", exact: "1.5.1"),
.package(url: "https://github.com/nathantannar4/Turbocharger", exact: "1.1.4"),
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.1.4")
// .package(url: "https://github.com/nathantannar4/Engine", exact: "1.0.1"),
// .package(url: "https://github.com/nathantannar4/Turbocharger", exact: "1.1.4"),
.package(url: "https://github.com/nathantannar4/Transmission", exact: "1.0.1")
]
// Comment out following line for macOS build
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0"))

View file

@ -155,12 +155,21 @@ struct ProfileView: View {
label: { Text(LocalText.documentationGeneric) }
)
#if os(iOS)
Button(
action: { DataService.showIntercomMessenger?() },
label: { Text(LocalText.feedbackGeneric) }
)
#endif
#if os(iOS)
Button(
action: { DataService.showIntercomMessenger?() },
label: { Text(LocalText.feedbackGeneric) }
)
#endif
Button(
action: {
if let url = URL(string: "https://apps.apple.com/app/id1564031042?action=write-review") {
openURL(url)
}
},
label: { Text("Review Omnivore") }
)
Button(
action: {
@ -170,7 +179,9 @@ struct ProfileView: View {
},
label: { Text("Join community on Discord") }
)
}
Section {
Button(
action: {
if let url = URL(string: "https://omnivore.app/privacy") {

View file

@ -98,11 +98,11 @@ typealias OperationStatusHandler = (_: OperationStatus) -> 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

View file

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

View file

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

View file

@ -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<String> = .absent()
var representation: OptionalArgument<Enums.RepresentationType> = .absent()
var sharedAt: OptionalArgument<DateTime> = .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<Enums.RepresentationType> = .absent()
var shortId: String
var suffix: OptionalArgument<String> = .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<String> = .absent()
var syncedAt: OptionalArgument<DateTime> = .absent()
var taskName: OptionalArgument<String> = .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<Bool> = .absent()
var fetchContentType: OptionalArgument<Enums.FetchContentType> = .absent()
var folder: OptionalArgument<String> = .absent()
var isPrivate: OptionalArgument<Bool> = .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<Bool> = .absent()
var fetchContentType: OptionalArgument<Enums.FetchContentType> = .absent()
var folder: OptionalArgument<String> = .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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,7 +62,7 @@ export class Highlight {
createdAt!: Date
@UpdateDateColumn()
updatedAt?: Date | null
updatedAt!: Date
@Column('timestamp')
sharedAt?: Date

View file

@ -59,4 +59,7 @@ export class Integration {
@Column('enum', { enum: ImportItemState, nullable: true })
importItemState?: ImportItemState | null
@Column('jsonb', { nullable: true })
settings?: any
}

View file

@ -1267,6 +1267,7 @@ export type Integration = {
enabled: Scalars['Boolean'];
id: Scalars['ID'];
name: Scalars['String'];
settings?: Maybe<Scalars['JSON']>;
taskName?: Maybe<Scalars['String']>;
token: Scalars['String'];
type: IntegrationType;
@ -2836,6 +2837,7 @@ export type SetIntegrationInput = {
id?: InputMaybe<Scalars['ID']>;
importItemState?: InputMaybe<ImportItemState>;
name: Scalars['String'];
settings?: InputMaybe<Scalars['JSON']>;
syncedAt?: InputMaybe<Scalars['Date']>;
taskName?: InputMaybe<Scalars['String']>;
token: Scalars['String'];
@ -5767,6 +5769,7 @@ export type IntegrationResolvers<ContextType = ResolverContext, ParentType exten
enabled?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
settings?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
taskName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
type?: Resolver<ResolversTypes['IntegrationType'], ParentType, ContextType>;

View file

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

View file

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

View file

@ -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<string> => {
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<string | undefined> => {
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<string | undefined> => {
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<string> {
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<void> => {
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<string | undefined> => {
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<void> => {
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)
}
}

View file

@ -43,7 +43,7 @@ export const refreshAllFeeds = async (db: DataSource): Promise<boolean> => {
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,
}

View file

@ -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<T extends Record<string, any>> = 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,
})

View file

@ -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}`)
}
},
{

View file

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

View file

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

View file

@ -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<express.Request>(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(

View file

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

View file

@ -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<Feature | undefined> => {
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<Feature> => {
const optInLimitedFeature = async (
featureName: string,
uid: string,
maxUsers: number
): Promise<Feature> => {
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<Feature> => {
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<Feature> => {
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<Feature> => {
// 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, [

View file

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

View file

@ -20,9 +20,11 @@ export interface RetrieveRequest {
export interface IntegrationClient {
name: string
apiUrl: string
token: string
accessToken(token: string): Promise<string | null>
accessToken(): Promise<string | null>
export(token: string, items: LibraryItem[]): Promise<boolean>
auth(state: string): Promise<string>
export(items: LibraryItem[]): Promise<boolean>
}

View file

@ -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<Integration, { settings?: Settings }>
constructor(token: string, integration?: Integration) {
this.token = token
this.client = new Client({
auth: token,
timeoutMs: this.timeout,
})
this.integrationData = integration
}
accessToken = async (): Promise<string | null> => {
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<string> {
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<boolean> => {
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
}
}

View file

@ -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<string | null> => {
const url = `${this.apiUrl}/oauth/authorize`
accessToken = async (): Promise<string | null> => {
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<boolean> => {
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}`
)}`
}
}

View file

@ -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<string | null> => {
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<string | null> => {
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<boolean> => {
export = async (items: LibraryItem[]): Promise<boolean> => {
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<boolean> => {
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

@ -86,9 +86,14 @@ export class YoutubeHandler extends ContentHandler {
<meta property="og:type" content="video" />
</head>
<body>
<iframe width="${width}" height="${height}" src="${src}" title="${escapedTitle}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<p><a href="${url}" target="_blank">${escapedTitle}</a></p>
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
<div>
<article id="_omnivore_youtube">
<iframe id="_omnivore_youtube_video" width="${width}" height="${height}" src="${src}" title="${escapedTitle}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<p><a href="${url}" target="_blank">${escapedTitle}</a></p>
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
<div id="_omnivore_youtube_transcript"></div>
</article>
</div>
</body>
</html>`

View file

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

View file

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

View file

@ -81,14 +81,14 @@ export function CardMenu(props: CardMenuProps): JSX.Element {
}}
title="Remove"
/>
{!!props.item.subscription && (
{/* {!!props.item.subscription && (
<DropdownOption
onSelect={() => {
props.actionHandler('unsubscribe')
}}
title="Unsubscribe"
/>
)}
)} */}
</Dropdown>
)
}

View file

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

View file

@ -0,0 +1,5 @@
import { Alert } from 'antd'
export function Beta(): JSX.Element {
return <Alert message="Beta" type="warning" showIcon />
}

View file

@ -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 (
<VStack
alignment="start"
distribution="start"
css={{ width: '100%', height: '100%', minHeight: '100vh' }}
>
<PageMetaData path="/" title={props.title} />
<VStack css={{ width: '100%', height: '100%' }}>
<Box
css={{
height: DEFAULT_HEADER_HEIGHT,
'@mdDown': {
display: 'none',
},
}}
></Box>
<Box
css={{
p: '15px',
display: 'none',
height: DEFAULT_HEADER_HEIGHT,
'@mdDown': {
display: 'flex',
},
}}
>
<SettingsDropdown />
</Box>
<HStack css={{ width: '100%', height: '100%' }} distribution="start">
{props.children}
</HStack>
<Box css={{ height: '120px', width: '100%' }} />
</VStack>
</VStack>
)
}

View file

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

View file

@ -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') {

View file

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

View file

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

View file

@ -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<boolean | undefined> {
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
}
}

View file

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

View file

@ -12,12 +12,12 @@ type Unsubscribe = {
}
export async function unsubscribeMutation(
subscribeName: string,
id = ''
subscribtionName: string,
id: string
): Promise<any | undefined> {
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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {
<Head>
<title>Page Not Found</title>
</Head>
<SettingsLayout title="Page could not be found">
<EmptyLayout title="Page could not be found">
<ErrorLayout statusCode={404} message="This page could not be found." />
</SettingsLayout>
</EmptyLayout>
</>
)
}

View file

@ -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 {
<Head>
<title>An unknown error occurred.</title>
</Head>
<SettingsLayout title="An unknown error occurred">
<ErrorLayout statusCode={404} message="An unknown error occurred." />
</SettingsLayout>
<EmptyLayout title="An unknown error occurred">
<ErrorLayout statusCode={404} message="An unknown error occurred." />
</EmptyLayout>
</>
)
}

View file

@ -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 (
<SettingsLayout>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
<VStack
css={{ width: '100%', height: '100%' }}
distribution="start"
alignment="center"
>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
minWidth: '300px',
maxWidth: '865px',
}}
>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '10px',
borderRadius: '5px',
}}
>
<StyledLabel>Enabled beta features</StyledLabel>
{!showSpinner ? (
<>
{viewerData?.me?.features.map((feature) => {
return (
<StyledText
key={`feature-${feature}`}
style="footnote"
css={{
display: 'flex',
gap: '5px',
marginBlockStart: '0px',
marginBlockEnd: '0px',
}}
>
<input
type="checkbox"
checked={true}
disabled={true}
></input>
{feature}
</StyledText>
)
})}
{!hasYouTube /* || !hasAISummaries || !hasDigest */ && (
<StyledLabel css={{ mt: '25px' }}>
Available beta features
</StyledLabel>
)}
<VStack css={{ gap: '15px' }}>
{!hasYouTube && (
<VStack
alignment="start"
distribution="start"
css={{ width: '100%' }}
>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px' }}
>
- YouTube transcripts: nicely formatted documents
generated from YouTube transcript data. Currently
limited to videos under 30 minutes.
</StyledText>
<Button
style="ctaDarkYellow"
onClick={(event) => {
requestFeatureAccess('youtube-transcripts')
event.preventDefault()
}}
>
Request feature
</Button>
</VStack>
)}
{/* <VStack
alignment="start"
distribution="start"
css={{ width: '100%' }}
>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px' }}
>
- AI Summaries: Short summaries of your newly saved
articles
</StyledText>
<Button
style="ctaDarkYellow"
onClick={(event) => {
requestFeatureAccess('ai-summaries')
event.preventDefault()
}}
>
Request feature
</Button>
</VStack>
<VStack
alignment="start"
distribution="start"
css={{ width: '100%' }}
>
<StyledText
style="footnote"
css={{ display: 'flex', gap: '5px' }}
>
- 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.
</StyledText>
<Button
style="ctaDarkYellow"
onClick={(event) => {
requestFeatureAccess('daily-digest')
event.preventDefault()
}}
>
Request feature
</Button>
</VStack> */}
</VStack>
</>
) : (
<HStack
distribution="center"
css={{
width: '100%',
svg: {
animation: 'spin 1s infinite linear',
'@keyframes spin': {
from: {
transform: 'scale(1) rotate(0deg)',
},
to: {
transform: 'scale(1) rotate(360deg)',
},
},
},
}}
>
<Spinner size={16} />
</HStack>
)}
</VStack>
</VStack>
</VStack>
</SettingsLayout>
)
}

View file

@ -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: <Link size={16} weight={'bold'} />,
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 (
<SettingsLayout>

View file

@ -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<FieldType>()
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<FieldType>['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<FieldType>['onFinishFailed'] = (
errorInfo
) => {
console.log('Failed:', errorInfo)
}
const onDataChange = (value: Array<CheckboxValueType>) => {
form.setFieldsValue({ properties: value.map((v) => v.toString()) })
}
return (
<>
{contextHolder}
<PageMetaData title="Notion" path="/integrations/notion" />
<SettingsLayout>
<VStack
css={{
margin: '0 auto',
width: '80%',
}}
>
<HStack
css={{
width: '100%',
pb: '$2',
borderBottom: '1px solid $utilityTextDefault',
pr: '$1',
}}
>
<Image
src="/static/icons/notion.png"
alt="Integration Image"
width={75}
height={75}
/>
<Header>Notion integration settings</Header>
<Beta />
</HStack>
{notion && (
<div style={{ width: '100%', marginTop: '40px' }}>
<Form
labelCol={{ span: 6 }}
wrapperCol={{ span: 8 }}
labelAlign="left"
form={form}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
>
<Form.Item<FieldType>
label="Notion Page Id"
name="parentPageId"
rules={[
{
required: true,
message: 'Please input your Notion Page Id!',
},
]}
>
<Input />
</Form.Item>
<Form.Item<FieldType>
label="Notion Database Id"
name="parentDatabaseId"
hidden
>
<Input disabled />
</Form.Item>
<Form.Item<FieldType>
label="Automatic Sync"
name="enabled"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item<FieldType>
label="Properties to Export"
name="properties"
>
<Checkbox.Group onChange={onDataChange}>
<Checkbox value="highlights">Highlights</Checkbox>
</Checkbox.Group>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
Save
</Button>
<Button type="primary" danger onClick={deleteNotion}>
Disconnect
</Button>
</Space>
</Form.Item>
</Form>
</div>
)}
</VStack>
</SettingsLayout>
</>
)
}

View file

@ -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 (
<SettingsLayout title="Support">
<EmptyLayout title="Support">
<HStack
alignment="center"
distribution="end"
@ -46,6 +46,6 @@ export default function Support(): JSX.Element {
{'Open Chat Window'}
</Button>
</HStack>
</SettingsLayout>
</EmptyLayout>
)
}

View file

@ -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 <TermsAndConditions />
} else {
return (
<SettingsLayout title="Terms and Conditions">
<EmptyLayout title="Terms and Conditions">
<TermsAndConditions />
</SettingsLayout>
</EmptyLayout>
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

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

View file

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

370
yarn.lock
View file

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