Merge pull request #3812 from omnivore-app/feature/digest-api
feature/digest api
|
|
@ -0,0 +1,303 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
import Services
|
||||
|
||||
public class FullScreenDigestViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var digest: DigestResult?
|
||||
|
||||
func load(dataService: DataService) async {
|
||||
isLoading = true
|
||||
if digest == nil {
|
||||
do {
|
||||
digest = try await dataService.getLatestDigest(timeoutInterval: 10)
|
||||
} catch {
|
||||
print("ERROR WITH DIGEST: ", error)
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct DigestAudioItem: AudioItemProperties {
|
||||
let audioItemType = Models.AudioItemType.digest
|
||||
|
||||
var itemID = ""
|
||||
|
||||
var title = "TITLE"
|
||||
|
||||
var byline: String? = "byline"
|
||||
|
||||
var imageURL: URL? = nil
|
||||
|
||||
var language: String?
|
||||
|
||||
var startIndex: Int = 0
|
||||
var startOffset: Double = 0.0
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@MainActor
|
||||
struct FullScreenDigestView: View {
|
||||
let viewModel: DigestViewModel = DigestViewModel()
|
||||
let dataService: DataService
|
||||
let audioController: AudioController
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let textBody = "In a significant political turn, the SOTU response faces unexpected collapse, " +
|
||||
"marking a stark contrast to Trump's latest downturn, alongside an unprecedented " +
|
||||
"surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " +
|
||||
"The analysis provides insights into the shifting dynamics of political support and " +
|
||||
"the potential implications for future electoral strategies. Based on the information " +
|
||||
"you provided, the video seems to discuss a recent event where former President " +
|
||||
"Donald Trump made a controversial statement that shocked even his own audience. " +
|
||||
"The video likely covers Trump's response to the State of the Union (SOTU) address " +
|
||||
"and how it received negative feedback, possibly leading to a decline in his support " +
|
||||
"or approval ratings. Additionally, it appears that the video touches upon a surge " +
|
||||
"in fundraising for President Joe Biden's administration around March 11, 2024."
|
||||
|
||||
public init(dataService: DataService, audioController: AudioController) {
|
||||
self.dataService = dataService
|
||||
self.audioController = audioController
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
itemBody
|
||||
.task {
|
||||
await viewModel.load(dataService: dataService)
|
||||
}.onAppear {
|
||||
self.audioController.play(itemAudioProperties: DigestAudioItem())
|
||||
}
|
||||
}
|
||||
} .navigationTitle("Omnivore digest")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
// HStack(alignment: .top) {
|
||||
// Spacer()
|
||||
// closeButton
|
||||
// }
|
||||
// .padding(20)
|
||||
// }
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(0.1)
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.font(.appCallout)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
var itemBody: some View {
|
||||
VStack {
|
||||
ScrollView(.vertical) {
|
||||
VStack(spacing: 20) {
|
||||
Text("SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast")
|
||||
.font(.title)
|
||||
Text(textBody)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
// .scrollTargetBehavior(.paging)
|
||||
// .ignoresSafeArea()
|
||||
MiniPlayerViewer()
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 40)
|
||||
.background(Color.themeTabBarColor)
|
||||
.onTapGesture {
|
||||
// showExpandedAudioPlayer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class PreviewItemViewModel: ObservableObject {
|
||||
let dataService: DataService
|
||||
@Published var item: DigestItem
|
||||
let showSwipeHint: Bool
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var resultText: String?
|
||||
@Published var promptDisplayText: String?
|
||||
|
||||
init(dataService: DataService, item: DigestItem, showSwipeHint: Bool) {
|
||||
self.dataService = dataService
|
||||
self.item = item
|
||||
self.showSwipeHint = showSwipeHint
|
||||
}
|
||||
|
||||
func loadResult() async {
|
||||
// isLoading = true
|
||||
// let taskId = try? await dataService.createAITask(
|
||||
// extraText: extraText,
|
||||
// libraryItemId: item?.id ?? "",
|
||||
// promptName: "summarize-001"
|
||||
// )
|
||||
//
|
||||
// if let taskId = taskId {
|
||||
// do {
|
||||
// let fetchedText = try await dataService.pollAITask(jobId: taskId, timeoutInterval: 30)
|
||||
// resultText = fetchedText
|
||||
// } catch {
|
||||
// print("ERROR WITH RESULT TEXT: ", error)
|
||||
// }
|
||||
// } else {
|
||||
// print("NO TASK ID: ", taskId)
|
||||
// }
|
||||
// isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PreviewItemView: View {
|
||||
@StateObject var viewModel: PreviewItemViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
HStack {
|
||||
AsyncImage(url: viewModel.item.siteIcon) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 20, height: 20, alignment: .center)
|
||||
}
|
||||
}
|
||||
Text(viewModel.item.site)
|
||||
.font(Font.system(size: 14))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
Text(viewModel.item.title)
|
||||
// .font(.body)
|
||||
// .fontWeight(.semibold)
|
||||
.font(Font.system(size: 18, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Text(viewModel.item.author)
|
||||
.font(Font.system(size: 14))
|
||||
.foregroundColor(Color(hex: "898989"))
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
|
||||
Color(hex: "2A2A2A")
|
||||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.task {
|
||||
await viewModel.loadResult()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
Text(viewModel.item.summaryText)
|
||||
.font(Font.system(size: 16))
|
||||
// .font(.body)
|
||||
.lineSpacing(12.0)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
HStack {
|
||||
Button(action: {}, label: {
|
||||
HStack(alignment: .center) {
|
||||
Text("Start listening")
|
||||
.font(Font.system(size: 14))
|
||||
.frame(height: 42, alignment: .center)
|
||||
Image(systemName: "play.fill")
|
||||
.resizable()
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.background(Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(18)
|
||||
})
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.showSwipeHint {
|
||||
VStack {
|
||||
Image.doubleChevronUp
|
||||
Text("Swipe up for next article")
|
||||
.foregroundColor(Color(hex: "898989"))
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 100)
|
||||
.padding(.horizontal, 15)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct RatingView: View {
|
||||
@State private var rating: Int = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 30) {
|
||||
Text("Rate today's digest")
|
||||
.font(.title)
|
||||
.padding(.vertical, 40)
|
||||
Text("I liked the stories picked for today's digest")
|
||||
RatingWidget()
|
||||
|
||||
Text("The stories were interesting")
|
||||
RatingWidget()
|
||||
|
||||
Text("The voices sounded good")
|
||||
RatingWidget()
|
||||
|
||||
Text("I liked the music")
|
||||
RatingWidget()
|
||||
Spacer()
|
||||
}.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
|
||||
struct StarView: View {
|
||||
var isFilled: Bool
|
||||
var body: some View {
|
||||
Image(systemName: isFilled ? "star.fill" : "star")
|
||||
.foregroundColor(isFilled ? Color.yellow : Color.gray)
|
||||
}
|
||||
}
|
||||
|
||||
struct RatingWidget: View {
|
||||
@State private var rating: Int = 0
|
||||
var body: some View {
|
||||
HStack {
|
||||
ForEach(1...5, id: \.self) { index in
|
||||
StarView(isFilled: index <= rating)
|
||||
.onTapGesture {
|
||||
rating = index
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(hex: "313131"))
|
||||
.cornerRadius(8)
|
||||
// .shadow(radius: 3)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
if audioController.playbackError {
|
||||
return AnyView(Color.clear)
|
||||
}
|
||||
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
|
||||
if audioController.isLoadingItem(audioController.itemAudioProperties) {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(
|
||||
|
|
@ -98,11 +98,11 @@
|
|||
}
|
||||
).padding(.trailing, 5)
|
||||
|
||||
if !(audioController.itemAudioProperties?.isArchived ?? false) {
|
||||
if !((audioController.itemAudioProperties as? LinkedItemAudioProperties)?.isArchived ?? false) {
|
||||
Button(
|
||||
action: { performArchive() },
|
||||
label: {
|
||||
if audioController.itemAudioProperties?.isArchived ?? false {
|
||||
if (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.isArchived ?? false {
|
||||
Image
|
||||
.toolbarUnarchive
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
|
|
@ -130,20 +130,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
func performViewArticle() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
viewArticle(objectID)
|
||||
}
|
||||
}
|
||||
// func performViewArticle() {
|
||||
// if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
// viewArticle(objectID)
|
||||
// }
|
||||
// }
|
||||
|
||||
func performDelete() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
if let objectID = (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.objectID {
|
||||
delete(objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func performArchive() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
if let objectID = (audioController.itemAudioProperties as? LinkedItemAudioProperties)?.objectID {
|
||||
archive(objectID)
|
||||
}
|
||||
}
|
||||
|
|
@ -467,7 +467,7 @@
|
|||
}
|
||||
|
||||
public var innerBody: some View {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties as? LinkedItemAudioProperties {
|
||||
return AnyView(
|
||||
playerContent(itemAudioProperties)
|
||||
.tint(.appGrayTextContrast)
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@
|
|||
public var body: some View {
|
||||
ZStack(alignment: .center) {
|
||||
presentingView
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
if self.audioController.itemAudioProperties != nil {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
|
||||
.frame(height: expanded ? 0 : 110, alignment: .bottom)
|
||||
|
||||
VStack {
|
||||
Spacer(minLength: 0)
|
||||
MiniPlayerViewer(itemAudioProperties: itemAudioProperties)
|
||||
MiniPlayerViewer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@
|
|||
|
||||
@State var expanded = true
|
||||
|
||||
let itemAudioProperties: LinkedItemAudioProperties
|
||||
|
||||
var playPauseButtonImage: String {
|
||||
switch audioController.state {
|
||||
case .playing:
|
||||
|
|
@ -32,7 +30,7 @@
|
|||
if audioController.playbackError {
|
||||
return AnyView(Color.clear)
|
||||
}
|
||||
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
|
||||
if audioController.isLoadingItem(audioController.itemAudioProperties) {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(
|
||||
|
|
@ -84,8 +82,8 @@
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
|
||||
if let imageURL = itemAudioProperties.imageURL {
|
||||
func artwork(_ itemAudioProperties: AudioItemProperties?, forDimensions dim: Double) -> some View {
|
||||
if let imageURL = itemAudioProperties?.imageURL {
|
||||
return AnyView(AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
|
|
@ -125,9 +123,9 @@
|
|||
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
artwork(itemAudioProperties, forDimensions: 50)
|
||||
artwork(audioController.itemAudioProperties, forDimensions: 50)
|
||||
|
||||
Text(itemAudioProperties.title)
|
||||
Text(audioController.itemAudioProperties?.title ?? "")
|
||||
.font(Font.system(size: 17, weight: .medium))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,31 @@ import Foundation
|
|||
import SwiftUI
|
||||
|
||||
struct CustomTabBar: View {
|
||||
let displayTabs: [String]
|
||||
@Binding var selectedTab: String
|
||||
let hideFollowingTab: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
if !hideFollowingTab {
|
||||
TabBarButton(key: "following", image: Image.tabFollowing, selectedTab: $selectedTab)
|
||||
if displayTabs.contains("following") {
|
||||
TabBarButton(key: "following",
|
||||
image: Image.tabFollowing,
|
||||
selectedTab: $selectedTab,
|
||||
selectionColor: Color(hex: "EE8232"))
|
||||
}
|
||||
if displayTabs.contains("digest") {
|
||||
TabBarButton(key: "digest",
|
||||
image: Image.tabDigest,
|
||||
selectedTab: $selectedTab,
|
||||
selectedImage: Image.tabDigestSelected)
|
||||
}
|
||||
if displayTabs.contains("inbox") {
|
||||
TabBarButton(key: "inbox",
|
||||
image: Image.tabLibrary,
|
||||
selectedTab: $selectedTab)
|
||||
}
|
||||
if displayTabs.contains("profile") {
|
||||
TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab)
|
||||
}
|
||||
TabBarButton(key: "inbox", image: Image.tabLibrary, selectedTab: $selectedTab)
|
||||
TabBarButton(key: "profile", image: Image.tabProfile, selectedTab: $selectedTab)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 10)
|
||||
|
|
@ -23,6 +38,8 @@ struct TabBarButton: View {
|
|||
let key: String
|
||||
let image: Image
|
||||
@Binding var selectedTab: String
|
||||
var selectedImage: Image?
|
||||
var selectionColor: Color?
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
|
|
@ -31,13 +48,20 @@ struct TabBarButton: View {
|
|||
}
|
||||
selectedTab = key
|
||||
}, label: {
|
||||
image
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
tabImage
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
}).buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var tabImage: some View {
|
||||
if let selectedImage = selectedImage, selectedTab == key {
|
||||
return AnyView(selectedImage
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit))
|
||||
} else {
|
||||
return AnyView(image
|
||||
.foregroundColor(selectedTab == key ? selectionColor ?? Color.blue : Color.themeTabButtonColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var isListScrolled = false
|
||||
@State var listTitle = ""
|
||||
@State var showExpandedAudioPlayer = false
|
||||
@State var showLibraryDigest = false
|
||||
|
||||
@Binding var isEditMode: EditMode
|
||||
|
||||
|
|
@ -203,6 +204,9 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@State private var selection = Set<String>()
|
||||
|
||||
@AppStorage("LibraryList::digestEnabled") var digestEnabled = false
|
||||
@AppStorage("LibraryList::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false
|
||||
|
||||
init(viewModel: HomeFeedViewModel, isEditMode: Binding<EditMode>) {
|
||||
_viewModel = ObservedObject(wrappedValue: viewModel)
|
||||
_isEditMode = isEditMode
|
||||
|
|
@ -224,7 +228,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack {
|
||||
HomeFeedView(
|
||||
listTitle: $listTitle,
|
||||
isListScrolled: $isListScrolled,
|
||||
|
|
@ -264,8 +268,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
if let audioProperties = audioController.itemAudioProperties {
|
||||
MiniPlayerViewer(itemAudioProperties: audioProperties)
|
||||
if audioController.itemAudioProperties != nil {
|
||||
MiniPlayerViewer()
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 20)
|
||||
.background(Color.themeTabBarColor)
|
||||
|
|
@ -316,6 +320,15 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showLibraryDigest) {
|
||||
if #available(iOS 17.0, *) {
|
||||
NavigationView {
|
||||
FullScreenDigestView(dataService: dataService, audioController: audioController)
|
||||
}
|
||||
} else {
|
||||
Text("Sorry digest is only available on iOS 17 and above")
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
}
|
||||
|
|
@ -338,6 +351,20 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
viewModel.stopUsingFollowingPrimer = true
|
||||
}
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
if let viewer = try await dataService.fetchViewer() {
|
||||
digestEnabled = viewer.digestEnabled ?? false
|
||||
if !hasCheckedForDigestFeature {
|
||||
hasCheckedForDigestFeature = true
|
||||
// selectedTab = "digest"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("ERROR FETCHING VIEWER: ", error)
|
||||
print("")
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, self.$isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
|
@ -382,6 +409,14 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
if isEditMode == .active {
|
||||
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
|
||||
} else {
|
||||
if #available(iOS 17.0, *) {
|
||||
Button(
|
||||
action: { showLibraryDigest = true },
|
||||
label: { Image.tabDigestSelected }
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
if prefersListLayout {
|
||||
Button(
|
||||
action: { isEditMode = isEditMode == .active ? .inactive : .active },
|
||||
|
|
@ -471,12 +506,12 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
let showFeatureCards: Bool
|
||||
var slideTransition: PresentationLinkTransition {
|
||||
PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
options:
|
||||
PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true
|
||||
)
|
||||
))
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(
|
||||
edge: .trailing,
|
||||
options: PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -484,12 +519,12 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false {
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
options:
|
||||
PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true,
|
||||
preferredPresentationBackgroundColor: ThemeManager.currentBgColor
|
||||
))),
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(
|
||||
edge: .trailing,
|
||||
options: PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true,
|
||||
preferredPresentationBackgroundColor: ThemeManager.currentBgColor
|
||||
))),
|
||||
isPresented: $viewModel.presentWebContainer,
|
||||
destination: {
|
||||
WebReaderLoadingContainer(requestID: linkRequest.serverID)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/29/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
|
|
@ -76,6 +69,30 @@ struct LibraryTabView: View {
|
|||
@State var operationStatus: OperationStatus = .none
|
||||
@State var operationMessage: String?
|
||||
|
||||
@State var digestEnabled = false
|
||||
|
||||
var showDigest: Bool {
|
||||
if digestEnabled, #available(iOS 17.0, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var displayTabs: [String] {
|
||||
var res = [String]()
|
||||
if !hideFollowingTab {
|
||||
res.append("following")
|
||||
}
|
||||
if showDigest {
|
||||
res.append("digest")
|
||||
}
|
||||
res.append("inbox")
|
||||
if !showDigest {
|
||||
res.append("profile")
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
|
||||
|
|
@ -116,19 +133,32 @@ struct LibraryTabView: View {
|
|||
}.tag("following")
|
||||
}
|
||||
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("inbox")
|
||||
if showDigest, #available(iOS 17.0, *) {
|
||||
NavigationView {
|
||||
DigestView(dataService: dataService)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("digest")
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("inbox")
|
||||
} else {
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("inbox")
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("profile")
|
||||
}
|
||||
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("profile")
|
||||
}
|
||||
if let audioProperties = audioController.itemAudioProperties {
|
||||
MiniPlayerViewer(itemAudioProperties: audioProperties)
|
||||
if audioController.itemAudioProperties != nil {
|
||||
MiniPlayerViewer()
|
||||
.onTapGesture {
|
||||
showExpandedAudioPlayer = true
|
||||
}
|
||||
|
|
@ -138,7 +168,9 @@ struct LibraryTabView: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
if isEditMode != .active {
|
||||
CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab)
|
||||
CustomTabBar(
|
||||
displayTabs: displayTabs,
|
||||
selectedTab: $selectedTab)
|
||||
.padding(0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import Transmission
|
|||
return AnyView(splitView)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
func startTimer(amount: Int) {
|
||||
self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
let articleContent: ArticleContent
|
||||
let openLinkAction: (URL) -> Void
|
||||
let tapHandler: () -> Void
|
||||
let explainHandler: ((String) -> Void)?
|
||||
let scrollPercentHandler: (Int) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityUpdater: (Bool) -> Void
|
||||
|
|
@ -51,6 +52,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
let contentController = WKUserContentController()
|
||||
|
||||
webView.tapHandler = tapHandler
|
||||
webView.explainHandler = explainHandler
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.configuration.userContentController = contentController
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ struct WebReaderContainerView: View {
|
|||
@State var displayLinkSheet = false
|
||||
@State var linkToOpen: URL?
|
||||
|
||||
@State var showExplainSheet = false
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.openURL) var openURL
|
||||
|
|
@ -92,6 +94,11 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func explainHandler(text: String) {
|
||||
viewModel.explainText = String(text)
|
||||
showExplainSheet = true
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
guard let messageBody = message.body as? [String: String] else { return }
|
||||
guard let actionID = messageBody["actionID"] else { return }
|
||||
|
|
@ -126,7 +133,7 @@ struct WebReaderContainerView: View {
|
|||
|
||||
#if os(iOS)
|
||||
var audioNavbarItem: some View {
|
||||
if !audioController.playbackError, audioController.isLoadingItem(itemID: item.unwrappedID) {
|
||||
if audioController.isLoadingItem(audioController.itemAudioProperties) {
|
||||
return AnyView(ProgressView()
|
||||
.padding(.horizontal))
|
||||
} else {
|
||||
|
|
@ -271,6 +278,13 @@ struct WebReaderContainerView: View {
|
|||
Spacer()
|
||||
#endif
|
||||
|
||||
// Button(
|
||||
// action: { showExplainSheet = true },
|
||||
// label: { Image(systemName: "sparkles") }
|
||||
// )
|
||||
// .buttonStyle(.plain)
|
||||
// .padding(.trailing, 4)
|
||||
|
||||
Button(
|
||||
action: { showLabelsModal = true },
|
||||
label: {
|
||||
|
|
@ -378,6 +392,7 @@ struct WebReaderContainerView: View {
|
|||
#endif
|
||||
},
|
||||
tapHandler: tapHandler,
|
||||
explainHandler: explainHandler,
|
||||
scrollPercentHandler: scrollPercentHandler,
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
navBarVisibilityUpdater: { visible in
|
||||
|
|
@ -600,8 +615,8 @@ struct WebReaderContainerView: View {
|
|||
.offset(y: navBarVisible ? 0 : -150)
|
||||
|
||||
Spacer()
|
||||
if let audioProperties = audioController.itemAudioProperties {
|
||||
MiniPlayerViewer(itemAudioProperties: audioProperties)
|
||||
if audioController.itemAudioProperties != nil {
|
||||
MiniPlayerViewer()
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, showBottomBar ? 10 : 40)
|
||||
.background(Color.themeTabBarColor)
|
||||
|
|
@ -633,7 +648,6 @@ struct WebReaderContainerView: View {
|
|||
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
|
||||
}
|
||||
.onDisappear {
|
||||
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in
|
||||
|
|
@ -674,7 +688,7 @@ struct WebReaderContainerView: View {
|
|||
shareActionID = UUID()
|
||||
}
|
||||
|
||||
func print() {
|
||||
func printReader() {
|
||||
shareActionID = UUID()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ struct SafariWebLink: Identifiable {
|
|||
@Published var showOperationToast: Bool = false
|
||||
@Published var operationStatus: OperationStatus = .none
|
||||
|
||||
@Published var explainText: String?
|
||||
|
||||
func hasOriginalUrl(_ item: Models.LibraryItem) -> Bool {
|
||||
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
|
||||
if host == "omnivore.app" {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Filter" representedClassName="Filter" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="defaultFilter" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="filter" optional="YES" attributeType="String"/>
|
||||
|
|
@ -137,6 +137,7 @@
|
|||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
<entity name="Viewer" representedClassName="Viewer" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="digestEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="profileImageURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
|
|
|
|||
10
apple/OmnivoreKit/Sources/Models/DataModels/Feature.swift
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
|
||||
public struct FeatureInternal {
|
||||
public let name: String
|
||||
public let enabled: Bool
|
||||
|
||||
public init(name: String, enabled: Bool) {
|
||||
self.name = name
|
||||
self.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,7 @@ public struct LinkedItemSyncResult {
|
|||
hasMore: Bool,
|
||||
mostRecentUpdatedAt: Date?,
|
||||
oldestUpdatedAt: Date?,
|
||||
isEmpty: Bool)
|
||||
{
|
||||
isEmpty: Bool) {
|
||||
self.updatedItemIDs = updatedItemIDs
|
||||
self.cursor = cursor
|
||||
self.hasMore = hasMore
|
||||
|
|
@ -38,7 +37,41 @@ public struct LinkedItemSyncResult {
|
|||
}
|
||||
}
|
||||
|
||||
public struct LinkedItemAudioProperties {
|
||||
public enum AudioItemType {
|
||||
case digest
|
||||
case libraryItem
|
||||
}
|
||||
|
||||
public protocol AudioItemProperties {
|
||||
var audioItemType: AudioItemType {
|
||||
get
|
||||
}
|
||||
var itemID: String {
|
||||
get
|
||||
}
|
||||
var title: String {
|
||||
get
|
||||
}
|
||||
var byline: String? {
|
||||
get
|
||||
}
|
||||
var imageURL: URL? {
|
||||
get
|
||||
}
|
||||
var language: String? {
|
||||
get
|
||||
}
|
||||
var startIndex: Int {
|
||||
get
|
||||
}
|
||||
var startOffset: Double {
|
||||
get
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinkedItemAudioProperties: AudioItemProperties {
|
||||
public let audioItemType = AudioItemType.libraryItem
|
||||
|
||||
public let itemID: String
|
||||
public let objectID: NSManagedObjectID
|
||||
public let title: String
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
@Published public var currentAudioIndex: Int = 0
|
||||
@Published public var readText: String = ""
|
||||
@Published public var unreadText: String = ""
|
||||
@Published public var itemAudioProperties: LinkedItemAudioProperties?
|
||||
@Published public var itemAudioProperties: AudioItemProperties?
|
||||
|
||||
@Published public var timeElapsed: TimeInterval = 0
|
||||
@Published public var duration: TimeInterval = 0
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
observer = nil
|
||||
}
|
||||
|
||||
public func play(itemAudioProperties: LinkedItemAudioProperties) {
|
||||
public func play(itemAudioProperties: AudioItemProperties) {
|
||||
stop()
|
||||
|
||||
playbackError = false
|
||||
|
|
@ -539,11 +539,14 @@
|
|||
state == .playing
|
||||
}
|
||||
|
||||
public func isLoadingItem(itemID: String) -> Bool {
|
||||
public func isLoadingItem(_ audioItem: AudioItemProperties?) -> Bool {
|
||||
if state == .reachedEnd {
|
||||
return false
|
||||
}
|
||||
return itemAudioProperties?.itemID == itemID && isLoading
|
||||
if audioItem?.itemID == nil {
|
||||
return false
|
||||
}
|
||||
return itemAudioProperties?.itemID == audioItem?.itemID && isLoading
|
||||
}
|
||||
|
||||
public func isPlayingItem(itemID: String) -> Bool {
|
||||
|
|
@ -860,8 +863,19 @@
|
|||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
switch(self.itemAudioProperties?.audioItemType) {
|
||||
case .digest:
|
||||
return try await downloadDigestItemSpeechFile(itemID: itemID, priority: priority)
|
||||
case .libraryItem:
|
||||
return try await downloadLibraryItemSpeechFile(itemID: itemID, priority: priority)
|
||||
case .none:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func downloadLibraryItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
let decoder = JSONDecoder()
|
||||
let speechFileUrl = pathForSpeechFile(itemID: itemID)
|
||||
|
||||
|
|
@ -910,6 +924,60 @@
|
|||
return nil
|
||||
}
|
||||
|
||||
func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
let decoder = JSONDecoder()
|
||||
let speechFileUrl = URL.om_documentsDirectory.appendingPathComponent("digest").appendingPathComponent("speech-\(currentVoice).json")
|
||||
|
||||
if FileManager.default.fileExists(atPath: speechFileUrl.path) {
|
||||
let data = try Data(contentsOf: speechFileUrl)
|
||||
document = try decoder.decode(SpeechDocument.self, from: data)
|
||||
// If we can't load it from disk we make the API call
|
||||
if let document = document {
|
||||
return document
|
||||
}
|
||||
}
|
||||
|
||||
let path = "/api/digest/v1/"
|
||||
guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else {
|
||||
throw BasicError.message(messageText: "Invalid audio URL")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
for (header, value) in dataService.networker.defaultHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
let str = String(decoding: data, as: UTF8.self)
|
||||
print("result digest file: ", str)
|
||||
|
||||
do {
|
||||
let digest = try JSONDecoder().decode(DigestResult.self, from: data)
|
||||
let directory = URL.om_documentsDirectory.appendingPathComponent("digest")
|
||||
// do {
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: speechFileUrl)
|
||||
return digest.speechFile
|
||||
// } catch {
|
||||
// print("error writing file", error)
|
||||
// }
|
||||
// }
|
||||
} catch {
|
||||
print("error with digest file", error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
document = try await downloadSpeechFile(itemID: itemID, priority: priority)
|
||||
return document
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ struct UtteranceRequest: Codable {
|
|||
let isOpenAIVoice: Bool
|
||||
}
|
||||
|
||||
struct Utterance: Decodable {
|
||||
public struct Utterance: Decodable {
|
||||
public let idx: String
|
||||
public let text: String
|
||||
public let voice: String?
|
||||
|
|
@ -39,7 +39,7 @@ struct Utterance: Decodable {
|
|||
}
|
||||
}
|
||||
|
||||
struct SpeechDocument: Decodable {
|
||||
public struct SpeechDocument: Decodable {
|
||||
static let averageWPM: Double = 195
|
||||
|
||||
public let pageId: String
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ extension LoginError {
|
|||
return .unauthorized
|
||||
case .unknown:
|
||||
return .unknown
|
||||
case .pendingEmailVerification:
|
||||
case .pendingEmailVerification, .stillProcessing:
|
||||
return .pendingEmailVerification
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
struct AITaskRequest: Decodable {
|
||||
public let requestId: String
|
||||
}
|
||||
|
||||
public struct DigestResult: Decodable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let content: String
|
||||
public let urlsToAudio: [String]
|
||||
public let speechFile: SpeechDocument
|
||||
|
||||
public let jobState: String
|
||||
}
|
||||
|
||||
public struct DigestItem: Decodable {
|
||||
public let id: String
|
||||
public let site: String
|
||||
public let siteIcon: URL?
|
||||
public let author: String
|
||||
public let title: String
|
||||
public let summaryText: String
|
||||
public let keyPointsText: String
|
||||
public let highlightsText: String
|
||||
public init(id: String, site: String, siteIcon: URL?,
|
||||
author: String, title: String, summaryText: String,
|
||||
keyPointsText: String, highlightsText: String) {
|
||||
self.id = id
|
||||
self.site = site
|
||||
self.siteIcon = siteIcon
|
||||
self.author = author
|
||||
self.title = title
|
||||
self.summaryText = summaryText
|
||||
self.keyPointsText = keyPointsText
|
||||
self.highlightsText = highlightsText
|
||||
}
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
// public func createAITask(extraText: String?, libraryItemId: String, promptName: String) async throws -> String? {
|
||||
// let jsonData = try JSONSerialization.data(withJSONObject: [
|
||||
// "libraryItemId": libraryItemId,
|
||||
// "promptName": promptName,
|
||||
// "extraText": extraText
|
||||
// ])
|
||||
//
|
||||
// let urlRequest = URLRequest.create(
|
||||
// baseURL: appEnvironment.serverBaseURL,
|
||||
// urlPath: "/api/ai-task",
|
||||
// requestMethod: .post(params: jsonData),
|
||||
// includeAuthToken: true
|
||||
// )
|
||||
// let resource = ServerResource<AITaskRequest>(
|
||||
// urlRequest: urlRequest,
|
||||
// decode: AITaskRequest.decode
|
||||
// )
|
||||
//
|
||||
// do {
|
||||
// let taskRequest = try await networker.urlSession.performRequest(resource: resource)
|
||||
// return taskRequest.requestId
|
||||
// } catch {
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
|
||||
// Function to poll the status of the AI task with timeout
|
||||
public func getLatestDigest(timeoutInterval: TimeInterval) async throws -> DigestResult? {
|
||||
var count = 0
|
||||
let startTime = Date()
|
||||
while true {
|
||||
count += 1
|
||||
if count > 3 {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
// Check if timeout has occurred
|
||||
if -startTime.timeIntervalSinceNow >= timeoutInterval {
|
||||
throw NSError(domain: "Timeout Error", code: -1, userInfo: nil)
|
||||
}
|
||||
|
||||
let urlRequest = URLRequest.create(
|
||||
baseURL: appEnvironment.serverBaseURL,
|
||||
urlPath: "/api/digest/v1/",
|
||||
requestMethod: .get,
|
||||
includeAuthToken: true
|
||||
)
|
||||
|
||||
let resource = ServerResource<DigestResult>(
|
||||
urlRequest: urlRequest,
|
||||
decode: DigestResult.decode
|
||||
)
|
||||
|
||||
do {
|
||||
let digest = try await networker.urlSession.performRequest(resource: resource)
|
||||
print("GOT RESPONSE: ", digest)
|
||||
return digest
|
||||
} catch {
|
||||
print("ERROR FETCHING TASK: ", error)
|
||||
// if let response = error as? ServerError {
|
||||
// if response != .stillProcessing {
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// Wait for some time before polling again
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
} catch let error {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ public enum ServerError: String, Error {
|
|||
case unauthenticated
|
||||
case timeout
|
||||
case unknown
|
||||
case stillProcessing
|
||||
case pendingEmailVerification
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ extension ServerError {
|
|||
case 401?, 403?:
|
||||
self = .unauthenticated
|
||||
return
|
||||
case 202:
|
||||
self = .stillProcessing
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public enum RuleActionType {
|
|||
case delete
|
||||
case markAsRead
|
||||
case sendNotification
|
||||
case export
|
||||
case webhook
|
||||
|
||||
static func from(_ other: Enums.RuleActionType) -> RuleActionType {
|
||||
switch other {
|
||||
|
|
@ -28,6 +30,10 @@ public enum RuleActionType {
|
|||
return .sendNotification
|
||||
case .delete:
|
||||
return .delete
|
||||
case Enums.RuleActionType.export:
|
||||
return .export
|
||||
case Enums.RuleActionType.webhook:
|
||||
return .webhook
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ public extension DataService {
|
|||
profileImageURL: try $0.profile(
|
||||
selection: .init { try $0.pictureUrl() }
|
||||
),
|
||||
intercomHash: try $0.intercomHash()
|
||||
intercomHash: try $0.intercomHash(),
|
||||
digestEnabled: true // (try $0.featureList(selection: featureSelection.list.nullable)?
|
||||
// .filter { $0.enabled && $0.name == "digest" } ?? []).count > 0
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ public struct ViewerInternal {
|
|||
public let name: String
|
||||
public let profileImageURL: String?
|
||||
public let intercomHash: String?
|
||||
public let digestEnabled: Bool?
|
||||
|
||||
func persist(context: NSManagedObjectContext) throws {
|
||||
try context.performAndWait {
|
||||
|
|
@ -73,6 +76,7 @@ public struct ViewerInternal {
|
|||
viewer.username = username
|
||||
viewer.name = name
|
||||
viewer.profileImageURL = profileImageURL
|
||||
viewer.digestEnabled = digestEnabled ?? false
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let featureSelection = Selection.Feature {
|
||||
FeatureInternal(name: try $0.name(), enabled: try $0.grantedAt() != nil)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ public final class OmnivoreWebView: WKWebView {
|
|||
#endif
|
||||
|
||||
public var tapHandler: (() -> Void)?
|
||||
public var explainHandler: ((String) -> Void)?
|
||||
|
||||
private var currentMenu: ContextMenu = .defaultMenu
|
||||
|
||||
|
|
@ -298,6 +299,7 @@ public final class OmnivoreWebView: WKWebView {
|
|||
case #selector(removeSelection): return true
|
||||
case #selector(copy(_:)): return true
|
||||
case #selector(setLabels(_:)): return true
|
||||
case #selector(explainSelection): return true
|
||||
|
||||
case Selector(("_lookup:")): return (currentMenu == .defaultMenu)
|
||||
case Selector(("_define:")): return (currentMenu == .defaultMenu)
|
||||
|
|
@ -334,6 +336,18 @@ public final class OmnivoreWebView: WKWebView {
|
|||
hideMenu()
|
||||
}
|
||||
|
||||
@objc private func explainSelection() {
|
||||
Task {
|
||||
let selection = try? await self.evaluateJavaScript("window.getSelection().toString()")
|
||||
if let selection = selection as? String, let explainHandler = explainHandler {
|
||||
print("Explaining \(selection)")
|
||||
explainHandler(selection)
|
||||
} else {
|
||||
showInReaderSnackbar("Error getting text to explain")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func shareSelection() {
|
||||
do {
|
||||
try dispatchEvent(.share)
|
||||
|
|
@ -386,7 +400,8 @@ public final class OmnivoreWebView: WKWebView {
|
|||
return
|
||||
}
|
||||
let highlight = UICommand(title: LocalText.genericHighlight, action: #selector(highlightSelection))
|
||||
items = [highlight, annotate]
|
||||
// let explain = UICommand(title: "Explain", action: #selector(explainSelection))
|
||||
items = [highlight, /* explain, */ annotate]
|
||||
} else {
|
||||
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
|
||||
let setLabels = UICommand(title: LocalText.labelsGeneric, action: #selector(setLabels))
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ public extension Image {
|
|||
|
||||
static var tabFollowing: Image { Image("_tab_following", bundle: .module).renderingMode(.template) }
|
||||
static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) }
|
||||
static var tabDigest: Image { Image("_tab_digest", bundle: .module).renderingMode(.template) }
|
||||
static var tabDigestSelected: Image { Image("_tab_digest_selected", bundle: .module) }
|
||||
|
||||
static var tabSearch: Image { Image("_tab_search", bundle: .module).renderingMode(.template) }
|
||||
static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) }
|
||||
static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) }
|
||||
|
|
@ -49,4 +52,7 @@ public extension Image {
|
|||
static var flairNewsletter: Image { Image("flair-newsletter", bundle: .module) }
|
||||
static var flairPinned: Image { Image("flair-pinned", bundle: .module) }
|
||||
static var flairRecommended: Image { Image("flair-recommended", bundle: .module) }
|
||||
|
||||
static var doubleChevronUp: Image { Image("double_chevron_up", bundle: .module) }
|
||||
|
||||
}
|
||||
|
|
|
|||
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "_tab_digest.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
18
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_digest.imageset/_tab_digest.svg
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10231_7167)">
|
||||
<path d="M12.5 3.5V5.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 19.5V21.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 8.5V16.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 16.5V19.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 10.5V15.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.5 10.5V16.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 6.5V13.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 9.5V6.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 19.5V12.5" stroke="#8E8E93" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10231_7167">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.5 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "_tab_digest_selected.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10210_6950)">
|
||||
<path d="M12.5 3.5V5.5" stroke="#767AF8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 19.5V21.5" stroke="#DE76F8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 8.5V16.5" stroke="#767AF8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 16.5V19.5" stroke="#76AAF8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.5 10.5V15.5" stroke="#76F8D1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.5 10.5V16.5" stroke="#76F8D1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 6.5V13.5" stroke="#CF76F8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 9.5V6.5" stroke="#CF76F8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.5 19.5V12.5" stroke="#76AAF8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10210_6950">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.5 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "digest-archive-button.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="34" height="34" rx="17" stroke="#3D3D3D"/>
|
||||
<g clip-path="url(#clip0_9916_1773)">
|
||||
<path d="M10 12.5002C10 12.0581 10.1756 11.6342 10.4882 11.3217C10.8007 11.0091 11.2246 10.8335 11.6667 10.8335H23.3333C23.7754 10.8335 24.1993 11.0091 24.5118 11.3217C24.8244 11.6342 25 12.0581 25 12.5002C25 12.9422 24.8244 13.3661 24.5118 13.6787C24.1993 13.9912 23.7754 14.1668 23.3333 14.1668H11.6667C11.2246 14.1668 10.8007 13.9912 10.4882 13.6787C10.1756 13.3661 10 12.9422 10 12.5002Z" stroke="#EDEDED" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 14.1665V22.4998C11.6667 22.9419 11.8423 23.3658 12.1549 23.6783C12.4675 23.9909 12.8914 24.1665 13.3334 24.1665H21.6667C22.1088 24.1665 22.5327 23.9909 22.8453 23.6783C23.1578 23.3658 23.3334 22.9419 23.3334 22.4998V14.1665" stroke="#EDEDED" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.8333 17.5H19.1666" stroke="#EDEDED" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9916_1773">
|
||||
<rect width="20" height="20" fill="white" transform="translate(7.5 7.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-dots-button.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "digest-dots-button.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="34" height="34" rx="17" stroke="#3D3D3D"/>
|
||||
<g clip-path="url(#clip0_9916_1805)">
|
||||
<path d="M10.8333 17.4998C10.8333 17.7209 10.921 17.9328 11.0773 18.0891C11.2336 18.2454 11.4456 18.3332 11.6666 18.3332C11.8876 18.3332 12.0996 18.2454 12.2558 18.0891C12.4121 17.9328 12.4999 17.7209 12.4999 17.4998C12.4999 17.2788 12.4121 17.0669 12.2558 16.9106C12.0996 16.7543 11.8876 16.6665 11.6666 16.6665C11.4456 16.6665 11.2336 16.7543 11.0773 16.9106C10.921 17.0669 10.8333 17.2788 10.8333 17.4998Z" fill="#D9D9D9" stroke="#D9D9D9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.6667 17.4998C16.6667 17.7209 16.7545 17.9328 16.9108 18.0891C17.0671 18.2454 17.2791 18.3332 17.5001 18.3332C17.7211 18.3332 17.9331 18.2454 18.0893 18.0891C18.2456 17.9328 18.3334 17.7209 18.3334 17.4998C18.3334 17.2788 18.2456 17.0669 18.0893 16.9106C17.9331 16.7543 17.7211 16.6665 17.5001 16.6665C17.2791 16.6665 17.0671 16.7543 16.9108 16.9106C16.7545 17.0669 16.6667 17.2788 16.6667 17.4998Z" fill="#D9D9D9" stroke="#D9D9D9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22.5 17.4998C22.5 17.7209 22.5878 17.9328 22.7441 18.0891C22.9004 18.2454 23.1123 18.3332 23.3333 18.3332C23.5543 18.3332 23.7663 18.2454 23.9226 18.0891C24.0789 17.9328 24.1667 17.7209 24.1667 17.4998C24.1667 17.2788 24.0789 17.0669 23.9226 16.9106C23.7663 16.7543 23.5543 16.6665 23.3333 16.6665C23.1123 16.6665 22.9004 16.7543 22.7441 16.9106C22.5878 17.0669 22.5 17.2788 22.5 17.4998Z" fill="#D9D9D9" stroke="#D9D9D9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9916_1805">
|
||||
<rect width="20" height="20" fill="white" transform="translate(7.5 7.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/digest-play-button.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "digest-play-button.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="34" height="34" rx="17" stroke="#3D3D3D"/>
|
||||
<g clip-path="url(#clip0_9916_1819)">
|
||||
<path d="M13.75 12.5002V22.5002C13.75 22.6114 13.7796 22.7206 13.8359 22.8165C13.8921 22.9124 13.9729 22.9916 14.07 23.0459C14.1671 23.1002 14.2769 23.1275 14.3881 23.1252C14.4992 23.1229 14.6078 23.0909 14.7025 23.0327L22.8275 18.0327C22.9185 17.9768 22.9937 17.8985 23.0458 17.8052C23.0979 17.712 23.1253 17.607 23.1253 17.5002C23.1253 17.3934 23.0979 17.2883 23.0458 17.1951C22.9937 17.1019 22.9185 17.0236 22.8275 16.9677L14.7025 11.9677C14.6078 11.9094 14.4992 11.8775 14.3881 11.8751C14.2769 11.8728 14.1671 11.9002 14.07 11.9545C13.9729 12.0087 13.8921 12.0879 13.8359 12.1838C13.7796 12.2798 13.75 12.389 13.75 12.5002Z" fill="#EDEDED"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9916_1819">
|
||||
<rect width="15" height="15" fill="white" transform="translate(10 10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "digest-trash-button.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<svg width="35" height="35" viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="34" height="34" rx="17" stroke="#3D3D3D"/>
|
||||
<g clip-path="url(#clip0_9916_1792)">
|
||||
<path d="M10.8333 13.3335H24.1666" stroke="#D9D9D9" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.8333 16.6665V21.6665" stroke="#D9D9D9" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19.1667 16.6665V21.6665" stroke="#D9D9D9" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 13.3335L12.5001 23.3335C12.5001 23.7755 12.6757 24.1994 12.9882 24.512C13.3008 24.8246 13.7247 25.0002 14.1667 25.0002H20.8334C21.2754 25.0002 21.6994 24.8246 22.0119 24.512C22.3245 24.1994 22.5001 23.7755 22.5001 23.3335L23.3334 13.3335" stroke="#D9D9D9" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 13.3333V10.8333C15 10.6123 15.0878 10.4004 15.2441 10.2441C15.4004 10.0878 15.6123 10 15.8333 10H19.1667C19.3877 10 19.5996 10.0878 19.7559 10.2441C19.9122 10.4004 20 10.6123 20 10.8333V13.3333" stroke="#D9D9D9" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_9916_1792">
|
||||
<rect width="20" height="20" fill="white" transform="translate(7.5 7.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
21
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/double_chevron_up.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "double_chevron_up.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.4614 17L12.4614 12L7.46143 17" stroke="#898989" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.4614 11L12.4614 6L7.46143 11" stroke="#898989" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 360 B |
|
|
@ -31,6 +31,22 @@ public enum Theme: String, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
public var fgColor: Color {
|
||||
let prefersHighContrastText = UserDefaults.standard.bool(forKey: UserDefaultKey.prefersHighContrastWebFont.rawValue)
|
||||
switch self {
|
||||
case .system:
|
||||
return Color.isDarkMode ? .white : .black
|
||||
case .light:
|
||||
return .black
|
||||
case .dark:
|
||||
return Color.white
|
||||
case .sepia:
|
||||
return prefersHighContrastText ? Color.black : (Color(hex: "#5F4B32") ?? Color.black)
|
||||
case .apollo:
|
||||
return prefersHighContrastText ? Color.white : (Color(hex: "#F3F3F3") ?? Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
public var toolbarColor: Color {
|
||||
ThemeManager.currentTheme.isDark ? Color.themeDarkWhiteGray : Color.themeMiddleGray
|
||||
}
|
||||
|
|
@ -98,6 +114,10 @@ public enum ThemeManager {
|
|||
currentTheme.bgColor
|
||||
}
|
||||
|
||||
public static var currentFgColor: Color {
|
||||
currentTheme.fgColor
|
||||
}
|
||||
|
||||
public static var currentHighlightColor: Color {
|
||||
currentTheme.highlightColor
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"voca": "^1.4.0",
|
||||
"winston": "^3.3.3",
|
||||
"word-counting": "^1.1.4",
|
||||
"yaml": "^2.4.1",
|
||||
"youtubei": "1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -124,7 +125,7 @@
|
|||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/chai-string": "^1.4.2",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/csv-stringify": "^3.1.0",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/dompurify": "^2.0.4",
|
||||
|
|
|
|||
439
packages/api/src/jobs/ai/create_digest.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { logger } from '../../utils/logger'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { OpenAI } from '@langchain/openai'
|
||||
import { PromptTemplate } from '@langchain/core/prompts'
|
||||
import { LibraryItem } from '../../entity/library_item'
|
||||
import {
|
||||
htmlToSpeechFile,
|
||||
SpeechFile,
|
||||
SSMLOptions,
|
||||
} from '@omnivore/text-to-speech-handler'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
findLibraryItemsByIds,
|
||||
searchLibraryItems,
|
||||
} from '../../services/library_item'
|
||||
import { redisDataSource } from '../../redis_data_source'
|
||||
import { htmlToMarkdown } from '../../utils/parser'
|
||||
import yaml from 'yaml'
|
||||
import { JsonOutputParser } from '@langchain/core/output_parsers'
|
||||
import showdown from 'showdown'
|
||||
import { Digest, writeDigest } from '../../services/digest'
|
||||
import { TaskState } from '../../generated/graphql'
|
||||
|
||||
export type CreateDigestJobSchedule = 'daily' | 'weekly'
|
||||
|
||||
export interface CreateDigestJobData {
|
||||
id: string
|
||||
userId: string
|
||||
voices?: string[]
|
||||
language?: string
|
||||
rate?: string
|
||||
libraryItemIds?: string[]
|
||||
}
|
||||
|
||||
export interface CreateDigestJobResponse {
|
||||
jobId: string
|
||||
jobState: TaskState
|
||||
}
|
||||
interface Selector {
|
||||
query: string
|
||||
count: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface ZeroShotDefinition {
|
||||
userPreferencesProfilePrompt: string
|
||||
rankPrompt: string
|
||||
}
|
||||
|
||||
interface DigestDefinition {
|
||||
name: string
|
||||
preferenceSelectors: Selector[]
|
||||
candidateSelectors: Selector[]
|
||||
contentFeaturesPrompt: string
|
||||
contentRatingPrompt: string
|
||||
summaryPrompt: string
|
||||
assemblePrompt: string
|
||||
|
||||
zeroShot: ZeroShotDefinition
|
||||
}
|
||||
|
||||
interface RankedItem {
|
||||
topic: string
|
||||
summary: string
|
||||
libraryItem: LibraryItem
|
||||
}
|
||||
|
||||
interface RankedTitle {
|
||||
topic: string
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const CREATE_DIGEST_JOB = 'create-digest'
|
||||
|
||||
let digestDefinition: DigestDefinition
|
||||
|
||||
const fetchDigestDefinition = async (): Promise<DigestDefinition> => {
|
||||
const promptFileUrl = process.env.PROMPT_FILE_URL
|
||||
if (!promptFileUrl) {
|
||||
const msg = 'PROMPT_FILE_URL not set'
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
// fetch the yaml file
|
||||
const response = await axios.get<string>(promptFileUrl)
|
||||
|
||||
// parse the yaml file
|
||||
return yaml.parse(response.data) as DigestDefinition
|
||||
}
|
||||
|
||||
// Makes multiple DB queries and combines the results
|
||||
const getPreferencesList = async (userId: string): Promise<LibraryItem[]> => {
|
||||
// use the queries from the digest definitions to lookup preferences
|
||||
// There should be a list of multiple queries we use. For now we can
|
||||
// hardcode these queries:
|
||||
// - query: "in:all is:read OR has:highlights sort:updated-desc wordsCount:>=20"
|
||||
// count: 21
|
||||
// reason: "recently read or highlighted items that are not part of the digest"
|
||||
// - query: "in:all is:read OR has:highlights sort:saved-asc wordsCount:>=20"
|
||||
// count: 4
|
||||
// reason: "some older items that were interacted with"
|
||||
|
||||
const preferences = await Promise.all(
|
||||
digestDefinition.preferenceSelectors.map(async (selector) => {
|
||||
// use the selector to fetch items
|
||||
const results = await searchLibraryItems(
|
||||
{
|
||||
query: selector.query,
|
||||
size: selector.count,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
return results.libraryItems
|
||||
})
|
||||
)
|
||||
|
||||
// deduplicate and flatten the items
|
||||
const dedupedPreferences = preferences
|
||||
.flat()
|
||||
.filter(
|
||||
(item, index, self) => index === self.findIndex((t) => t.id === item.id)
|
||||
)
|
||||
|
||||
return dedupedPreferences
|
||||
}
|
||||
|
||||
// Makes multiple DB queries and combines the results
|
||||
const getCandidatesList = async (
|
||||
userId: string,
|
||||
libraryItemIds?: string[]
|
||||
): Promise<LibraryItem[]> => {
|
||||
// use the queries from the digest definitions to lookup preferences
|
||||
// There should be a list of multiple queries we use. For now we can
|
||||
// hardcode these queries:
|
||||
// - query: "in:all is:unread saved:last24hrs sort:saved-desc wordsCount:>=500"
|
||||
// count: 100
|
||||
// reason: "most recent 100 items saved over 500 words
|
||||
|
||||
if (libraryItemIds) {
|
||||
logger.info('Using libraryItemIds')
|
||||
return findLibraryItemsByIds(libraryItemIds, userId)
|
||||
}
|
||||
|
||||
const candidates = await Promise.all(
|
||||
digestDefinition.candidateSelectors.map(async (selector) => {
|
||||
// use the selector to fetch items
|
||||
const results = await searchLibraryItems(
|
||||
{
|
||||
includeContent: true,
|
||||
query: selector.query,
|
||||
size: selector.count,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
return results.libraryItems
|
||||
})
|
||||
)
|
||||
|
||||
// deduplicate and flatten the items
|
||||
const dedupedCandidates = candidates
|
||||
.flat()
|
||||
.filter(
|
||||
(item, index, self) => index === self.findIndex((t) => t.id === item.id)
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
readableContent: htmlToMarkdown(item.readableContent),
|
||||
})) // convert the html content to markdown
|
||||
|
||||
return dedupedCandidates
|
||||
}
|
||||
|
||||
// Takes a list of library items, and uses a prompt to generate
|
||||
// a text representation of a user profile
|
||||
const createUserProfile = async (
|
||||
preferences: LibraryItem[]
|
||||
): Promise<string> => {
|
||||
const llm = new OpenAI({
|
||||
modelName: 'gpt-4-0125-preview',
|
||||
configuration: {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
const contextualTemplate = PromptTemplate.fromTemplate(
|
||||
digestDefinition.zeroShot.userPreferencesProfilePrompt
|
||||
)
|
||||
|
||||
const chain = contextualTemplate.pipe(llm)
|
||||
const result = await chain.invoke({
|
||||
titles: preferences.map((item) => `* ${item.title}`).join('\n'),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Checks redis for a user profile, if not found creates one and writes
|
||||
// it to redis
|
||||
const findOrCreateUserProfile = async (userId: string): Promise<string> => {
|
||||
// check redis for user profile, return if found
|
||||
const key = `userProfile:${userId}`
|
||||
const existingProfile = await redisDataSource.redisClient?.get(key)
|
||||
if (existingProfile) {
|
||||
return existingProfile
|
||||
}
|
||||
|
||||
// if not found
|
||||
const preferences = await getPreferencesList(userId)
|
||||
const profile = await createUserProfile(preferences)
|
||||
|
||||
// write to redis here and ttl is 1 week
|
||||
await redisDataSource.redisClient?.set(key, profile, 'EX', 60 * 60 * 24 * 7)
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// Uses OpenAI to rank all the titles based on the user profiles
|
||||
const rankCandidates = async (
|
||||
candidates: LibraryItem[],
|
||||
userProfile: string
|
||||
): Promise<RankedItem[]> => {
|
||||
const llm = new OpenAI({
|
||||
modelName: 'gpt-4-0125-preview',
|
||||
configuration: {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
const contextualTemplate = PromptTemplate.fromTemplate(
|
||||
digestDefinition.zeroShot.rankPrompt
|
||||
)
|
||||
|
||||
const outputParser = new JsonOutputParser()
|
||||
const chain = contextualTemplate.pipe(llm).pipe(outputParser)
|
||||
const contextStr = await chain.invoke({
|
||||
userProfile,
|
||||
titles: JSON.stringify(
|
||||
candidates.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
}))
|
||||
),
|
||||
})
|
||||
|
||||
logger.info('contextStr: ', contextStr)
|
||||
// convert the json output to an array of ranked candidates
|
||||
const rankedCandidate = contextStr as RankedTitle[]
|
||||
|
||||
// map the ranked titles to the library items based on id
|
||||
const rankedItems = rankedCandidate
|
||||
.map((item) => {
|
||||
const libraryItem = candidates.find((t) => t.id === item.id)
|
||||
return {
|
||||
topic: item.topic,
|
||||
libraryItem,
|
||||
summary: '',
|
||||
}
|
||||
})
|
||||
.filter((item) => item.libraryItem !== undefined) as RankedItem[]
|
||||
|
||||
return rankedItems
|
||||
}
|
||||
|
||||
// Does some grouping by topic while trying to maintain ranking
|
||||
// adds some basic topic diversity
|
||||
const chooseRankedSelections = (rankedCandidates: RankedItem[]) => {
|
||||
const selected = []
|
||||
const rankedTopics = []
|
||||
const topicCount = {} as Record<string, number>
|
||||
|
||||
for (const item of rankedCandidates) {
|
||||
if (selected.length >= 5) {
|
||||
break
|
||||
}
|
||||
|
||||
topicCount[item.topic] = (topicCount[item.topic] || 0) + 1
|
||||
|
||||
if (topicCount[item.topic] <= 2) {
|
||||
selected.push(item)
|
||||
if (rankedTopics.indexOf(item.topic) === -1) {
|
||||
rankedTopics.push(item.topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('rankedTopics: ', rankedTopics)
|
||||
logger.info('finalSelections: ', selected)
|
||||
|
||||
const finalSelections = []
|
||||
|
||||
for (const topic of rankedTopics) {
|
||||
const matches = selected.filter((item) => item.topic == topic)
|
||||
finalSelections.push(...matches)
|
||||
}
|
||||
|
||||
logger.info('finalSelections: ', finalSelections)
|
||||
|
||||
return { finalSelections, rankedTopics }
|
||||
}
|
||||
|
||||
const summarizeItems = async (
|
||||
rankedCandidates: RankedItem[]
|
||||
): Promise<RankedItem[]> => {
|
||||
const llm = new OpenAI({
|
||||
modelName: 'gpt-4-0125-preview',
|
||||
configuration: {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
const contextualTemplate = PromptTemplate.fromTemplate(
|
||||
digestDefinition.summaryPrompt
|
||||
)
|
||||
const chain = contextualTemplate.pipe(llm)
|
||||
|
||||
// send all the ranked candidates to openAI at once in a batch
|
||||
const summaries = await chain.batch(
|
||||
rankedCandidates.map((item) => ({
|
||||
title: item.libraryItem.title,
|
||||
author: item.libraryItem.author ?? '',
|
||||
content: item.libraryItem.readableContent, // markdown content
|
||||
}))
|
||||
)
|
||||
|
||||
summaries.forEach(
|
||||
(summary, index) => (rankedCandidates[index].summary = summary)
|
||||
)
|
||||
|
||||
return rankedCandidates
|
||||
}
|
||||
|
||||
// generate speech files from the summaries
|
||||
const generateSpeechFiles = (
|
||||
rankedItems: RankedItem[],
|
||||
options: SSMLOptions
|
||||
): SpeechFile[] => {
|
||||
// convert the summaries from markdown to HTML
|
||||
const converter = new showdown.Converter({
|
||||
backslashEscapesHTMLTags: true,
|
||||
})
|
||||
|
||||
const speechFiles = rankedItems.map((item) => {
|
||||
const html = `
|
||||
<div id="readability-content">
|
||||
<div id="readability-page-1">
|
||||
${converter.makeHtml(item.summary)}
|
||||
</div>
|
||||
</div>`
|
||||
return htmlToSpeechFile({
|
||||
content: html,
|
||||
options,
|
||||
})
|
||||
})
|
||||
|
||||
return speechFiles
|
||||
}
|
||||
|
||||
// we should have a QA step here that does some
|
||||
// basic checks to make sure the summaries are good.
|
||||
const filterSummaries = (summaries: RankedItem[]): RankedItem[] => {
|
||||
return summaries.filter((item) => item.summary.length > 100)
|
||||
}
|
||||
|
||||
// we can use something more sophisticated to generate titles
|
||||
const generateTitle = (summaries: RankedItem[]): string =>
|
||||
'Omnivore digest: ' +
|
||||
summaries.map((item) => item.libraryItem.title).join(', ')
|
||||
|
||||
// generate description based on the summaries
|
||||
const generateDescription = (
|
||||
summaries: RankedItem[],
|
||||
rankedTopics: string[]
|
||||
): string =>
|
||||
`We selected ${
|
||||
summaries.length
|
||||
} articles from your last 24 hours of saved items, covering ${rankedTopics.join(
|
||||
', '
|
||||
)}.`
|
||||
|
||||
// generate content based on the summaries
|
||||
const generateContent = (summaries: RankedItem[]): string =>
|
||||
summaries
|
||||
.map((summary) => `## ${summary.libraryItem.title}\n ${summary.summary}`)
|
||||
.join('\n\n')
|
||||
|
||||
const generateByline = (summaries: RankedItem[]): string =>
|
||||
summaries
|
||||
.filter((summary) => !!summary.libraryItem.author)
|
||||
.map((item) => item.libraryItem.author)
|
||||
.join(', ')
|
||||
|
||||
export const createDigestJob = async (jobData: CreateDigestJobData) => {
|
||||
digestDefinition = await fetchDigestDefinition()
|
||||
|
||||
const candidates = await getCandidatesList(
|
||||
jobData.userId,
|
||||
jobData.libraryItemIds
|
||||
)
|
||||
const userProfile = await findOrCreateUserProfile(jobData.userId)
|
||||
const rankedCandidates = await rankCandidates(candidates, userProfile)
|
||||
const { finalSelections, rankedTopics } =
|
||||
chooseRankedSelections(rankedCandidates)
|
||||
|
||||
const summaries = await summarizeItems(finalSelections)
|
||||
|
||||
const filteredSummaries = filterSummaries(summaries)
|
||||
|
||||
const speechFiles = generateSpeechFiles(filteredSummaries, {
|
||||
...jobData,
|
||||
primaryVoice: jobData.voices?.[0],
|
||||
secondaryVoice: jobData.voices?.[1],
|
||||
})
|
||||
const title = generateTitle(summaries)
|
||||
const digest: Digest = {
|
||||
id: jobData.id,
|
||||
title,
|
||||
content: generateContent(summaries),
|
||||
urlsToAudio: [],
|
||||
jobState: TaskState.Succeeded,
|
||||
speechFiles,
|
||||
chapters: filteredSummaries.map((item, index) => ({
|
||||
title: item.libraryItem.title,
|
||||
id: item.libraryItem.id,
|
||||
url: item.libraryItem.originalUrl,
|
||||
thumbnail: item.libraryItem.thumbnail ?? undefined,
|
||||
wordCount: speechFiles[index].wordCount,
|
||||
})),
|
||||
createdAt: new Date(),
|
||||
description: generateDescription(summaries, rankedTopics),
|
||||
byline: generateByline(summaries),
|
||||
}
|
||||
|
||||
await writeDigest(jobData.userId, digest)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { appDataSource } from './data_source'
|
|||
import { env } from './env'
|
||||
import { TaskState } from './generated/graphql'
|
||||
import { aiSummarize, AI_SUMMARIZE_JOB_NAME } from './jobs/ai-summarize'
|
||||
import { createDigestJob, CREATE_DIGEST_JOB } from './jobs/ai/create_digest'
|
||||
import { bulkAction, BULK_ACTION_JOB_NAME } from './jobs/bulk_action'
|
||||
import { callWebhook, CALL_WEBHOOK_JOB_NAME } from './jobs/call_webhook'
|
||||
import { findThumbnail, THUMBNAIL_JOB } from './jobs/find_thumbnail'
|
||||
|
|
@ -67,16 +68,14 @@ import {
|
|||
export const QUEUE_NAME = 'omnivore-backend-queue'
|
||||
export const JOB_VERSION = 'v001'
|
||||
|
||||
let backendQueue: Queue | undefined
|
||||
export const getBackendQueue = async (): Promise<Queue | undefined> => {
|
||||
if (backendQueue) {
|
||||
await backendQueue.waitUntilReady()
|
||||
return backendQueue
|
||||
}
|
||||
export const getBackendQueue = async (
|
||||
name = QUEUE_NAME
|
||||
): Promise<Queue | undefined> => {
|
||||
if (!redisDataSource.workerRedisClient) {
|
||||
throw new Error('Can not create queues, redis is not initialized')
|
||||
}
|
||||
backendQueue = new Queue(QUEUE_NAME, {
|
||||
|
||||
const backendQueue = new Queue(name, {
|
||||
connection: redisDataSource.workerRedisClient,
|
||||
defaultJobOptions: {
|
||||
backoff: {
|
||||
|
|
@ -95,8 +94,11 @@ export const getBackendQueue = async (): Promise<Queue | undefined> => {
|
|||
return backendQueue
|
||||
}
|
||||
|
||||
export const getJob = async (jobId: string) => {
|
||||
const queue = await getBackendQueue()
|
||||
export const createJobId = (jobName: string, userId: string) =>
|
||||
`${jobName}_${userId}_${JOB_VERSION}`
|
||||
|
||||
export const getJob = async (jobId: string, queueName?: string) => {
|
||||
const queue = await getBackendQueue(queueName)
|
||||
if (!queue) {
|
||||
return
|
||||
}
|
||||
|
|
@ -178,6 +180,8 @@ export const createWorker = (connection: ConnectionOptions) =>
|
|||
return saveNewsletterJob(job.data)
|
||||
case FORWARD_EMAIL_JOB:
|
||||
return forwardEmailJob(job.data)
|
||||
case CREATE_DIGEST_JOB:
|
||||
return createDigestJob(job.data)
|
||||
default:
|
||||
logger.warning(`[queue-processor] unhandled job: ${job.name}`)
|
||||
}
|
||||
|
|
|
|||
242
packages/api/src/routers/digest_router.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import { env } from '../env'
|
||||
import { TaskState } from '../generated/graphql'
|
||||
import { CreateDigestJobSchedule } from '../jobs/ai/create_digest'
|
||||
import { getDigest } from '../services/digest'
|
||||
import { FeatureName, findGrantedFeatureByName } from '../services/features'
|
||||
import { findActiveUser } from '../services/user'
|
||||
import { analytics } from '../utils/analytics'
|
||||
import { getClaimsByToken, getTokenByRequest } from '../utils/auth'
|
||||
import { corsConfig } from '../utils/corsConfig'
|
||||
import { enqueueCreateDigest } from '../utils/createTask'
|
||||
import { logger } from '../utils/logger'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
interface Feedback {
|
||||
digestRating: number
|
||||
rankingModels: string[]
|
||||
rankingRating: number
|
||||
summaryRating: number
|
||||
summaryModels: string[]
|
||||
voiceRating: number
|
||||
musicRating: number
|
||||
comment?: string
|
||||
}
|
||||
|
||||
const isFeedback = (data: any): data is Feedback => {
|
||||
return (
|
||||
'digestRating' in data &&
|
||||
'rankingRating' in data &&
|
||||
'summaryRating' in data &&
|
||||
'voiceRating' in data &&
|
||||
'musicRating' in data
|
||||
)
|
||||
}
|
||||
|
||||
interface CreateDigestRequest {
|
||||
voices?: string[]
|
||||
language?: string
|
||||
rate?: string
|
||||
schedule?: CreateDigestJobSchedule
|
||||
libraryItemIds?: string[]
|
||||
}
|
||||
|
||||
export function digestRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
// v1 version of create digest api
|
||||
router.post('/v1', cors<express.Request>(corsConfig), async (req, res) => {
|
||||
const token = getTokenByRequest(req)
|
||||
|
||||
let userId: string
|
||||
try {
|
||||
// get claims from token
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
logger.info('Token not found')
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// get user by uid from claims
|
||||
userId = claims.uid
|
||||
} catch (error) {
|
||||
logger.info('Error while getting claims from token', error)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findActiveUser(userId)
|
||||
if (!user) {
|
||||
logger.info(`User not found: ${userId}`)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const feature = await findGrantedFeatureByName(
|
||||
FeatureName.AIDigest,
|
||||
userId
|
||||
)
|
||||
if (!feature) {
|
||||
logger.info(`${FeatureName.AIDigest} not granted: ${userId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const data = req.body as CreateDigestRequest
|
||||
|
||||
// check if job is running
|
||||
// if yes then return 202 accepted
|
||||
// else enqueue job
|
||||
const digest = await getDigest(userId)
|
||||
if (digest?.jobState === TaskState.Running) {
|
||||
logger.info(`Digest job is running: ${userId}`)
|
||||
return res.sendStatus(202)
|
||||
}
|
||||
|
||||
// enqueue job and return job id
|
||||
const result = await enqueueCreateDigest(
|
||||
{
|
||||
id: uuid(), // generate job id
|
||||
userId,
|
||||
...data,
|
||||
},
|
||||
data.schedule
|
||||
)
|
||||
|
||||
// return job id
|
||||
return res.status(201).send(result)
|
||||
} catch (error) {
|
||||
logger.error('Error while enqueuing create digest task', error)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
})
|
||||
|
||||
// v1 version of get digest api
|
||||
router.get('/v1', cors<express.Request>(corsConfig), async (req, res) => {
|
||||
const token = getTokenByRequest(req)
|
||||
|
||||
let userId: string
|
||||
try {
|
||||
// get claims from token
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
logger.info('Token not found')
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// get user by uid from claims
|
||||
userId = claims.uid
|
||||
} catch (error) {
|
||||
logger.info('Error while getting claims from token', error)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findActiveUser(userId)
|
||||
if (!user) {
|
||||
logger.info(`User not found: ${userId}`)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const feature = await findGrantedFeatureByName(
|
||||
FeatureName.AIDigest,
|
||||
userId
|
||||
)
|
||||
if (!feature) {
|
||||
logger.info(`${FeatureName.AIDigest} not granted: ${userId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// get the digest from redis
|
||||
const digest = await getDigest(userId)
|
||||
if (!digest) {
|
||||
logger.info(`Digest not found: ${userId}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (digest.jobState === TaskState.Running) {
|
||||
// if job is running then return job state
|
||||
return res.send({
|
||||
jobId: digest.id,
|
||||
jobState: digest.jobState,
|
||||
})
|
||||
}
|
||||
|
||||
// if job is done then return the digest
|
||||
return res.send(digest)
|
||||
} catch (error) {
|
||||
logger.error('Error while getting digest', error)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
})
|
||||
|
||||
// v1 version of sending feedback api
|
||||
router.post(
|
||||
'/v1/feedback',
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req, res) => {
|
||||
const token = getTokenByRequest(req)
|
||||
|
||||
let userId: string
|
||||
try {
|
||||
// get claims from token
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
logger.info('Token not found')
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// get user by uid from claims
|
||||
userId = claims.uid
|
||||
} catch (error) {
|
||||
logger.info('Error while getting claims from token', error)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findActiveUser(userId)
|
||||
if (!user) {
|
||||
logger.info(`User not found: ${userId}`)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const feature = await findGrantedFeatureByName(
|
||||
FeatureName.AIDigest,
|
||||
userId
|
||||
)
|
||||
if (!feature) {
|
||||
logger.info(`${FeatureName.AIDigest} not granted: ${userId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
// get feedback from request body
|
||||
if (!isFeedback(req.body)) {
|
||||
logger.info('Invalid feedback format')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const feedback = req.body
|
||||
logger.info(`Sending feedback: ${JSON.stringify(feedback)}`)
|
||||
|
||||
// remove comment from feedback before sending to analytics
|
||||
delete feedback.comment
|
||||
// send feedback to analytics
|
||||
analytics.capture({
|
||||
distinctId: userId,
|
||||
event: 'digest_feedback',
|
||||
properties: {
|
||||
...feedback,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
// return success
|
||||
return res.sendStatus(200)
|
||||
} catch (error) {
|
||||
logger.error('Error while saving feedback', error)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return router
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { aiSummariesRouter } from './routers/ai_summary_router'
|
|||
import { articleRouter } from './routers/article_router'
|
||||
import { authRouter } from './routers/auth/auth_router'
|
||||
import { mobileAuthRouter } from './routers/auth/mobile/mobile_auth_router'
|
||||
import { digestRouter } from './routers/digest_router'
|
||||
import { integrationRouter } from './routers/integration_router'
|
||||
import { localDebugRouter } from './routers/local_debug_router'
|
||||
import { notificationRouter } from './routers/notification_router'
|
||||
|
|
@ -89,6 +90,7 @@ export const createApp = (): Express => {
|
|||
app.use('/api/notification', notificationRouter())
|
||||
app.use('/api/integration', integrationRouter())
|
||||
app.use('/api/tasks', taskRouter())
|
||||
app.use('/api/digest', digestRouter())
|
||||
app.use('/svc/pubsub/content', contentServiceRouter())
|
||||
app.use('/svc/pubsub/links', linkServiceRouter())
|
||||
app.use('/svc/pubsub/newsletters', newsletterServiceRouter())
|
||||
|
|
|
|||
51
packages/api/src/services/digest.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { redisDataSource } from '../redis_data_source'
|
||||
import { SpeechFile } from '@omnivore/text-to-speech-handler'
|
||||
import { logger } from '../utils/logger'
|
||||
import { TaskState } from '../generated/graphql'
|
||||
|
||||
interface Chapter {
|
||||
title: string
|
||||
id: string
|
||||
url: string
|
||||
wordCount: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
id: string
|
||||
jobState: TaskState
|
||||
|
||||
createdAt?: Date
|
||||
description?: string
|
||||
byline?: string
|
||||
url?: string
|
||||
title?: string
|
||||
content?: string
|
||||
chapters?: Chapter[]
|
||||
|
||||
urlsToAudio?: string[]
|
||||
speechFiles?: SpeechFile[]
|
||||
}
|
||||
|
||||
const digestKey = (userId: string) => `digest:${userId}`
|
||||
|
||||
export const getDigest = async (userId: string): Promise<Digest | null> => {
|
||||
const digest = await redisDataSource.redisClient?.get(digestKey(userId))
|
||||
return digest ? (JSON.parse(digest) as Digest) : null
|
||||
}
|
||||
|
||||
export const writeDigest = async (userId: string, digest: Digest) => {
|
||||
// write to redis
|
||||
const result = await redisDataSource.redisClient?.set(
|
||||
digestKey(userId),
|
||||
JSON.stringify(digest),
|
||||
'EX',
|
||||
60 * 60 * 24 * 7 // 1 week
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
const msg = `Error while writing digest to redis: ${userId}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,14 @@ import { logger } from '../utils/logger'
|
|||
const MAX_ULTRA_REALISTIC_USERS = 1500
|
||||
const MAX_YOUTUBE_TRANSCRIPT_USERS = 500
|
||||
const MAX_NOTION_USERS = 1000
|
||||
const MAX_AIDIGEST_USERS = 5
|
||||
|
||||
export enum FeatureName {
|
||||
AISummaries = 'ai-summaries',
|
||||
YouTubeTranscripts = 'youtube-transcripts',
|
||||
UltraRealisticVoice = 'ultra-realistic-voice',
|
||||
Notion = 'notion',
|
||||
AIDigest = 'ai-digest',
|
||||
}
|
||||
|
||||
export const getFeatureName = (name: string): FeatureName | undefined => {
|
||||
|
|
@ -40,6 +42,8 @@ export const optInFeature = async (
|
|||
)
|
||||
case FeatureName.Notion:
|
||||
return optInLimitedFeature(FeatureName.Notion, uid, MAX_NOTION_USERS)
|
||||
case FeatureName.AIDigest:
|
||||
return optInLimitedFeature(FeatureName.AIDigest, uid, MAX_AIDIGEST_USERS)
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -626,12 +626,10 @@ export const buildQuery = (
|
|||
) => {
|
||||
// select all columns except content
|
||||
const selects: Select[] = getColumns(libraryItemRepository)
|
||||
.map((column) => ({ column: `library_item.${column}` }))
|
||||
.filter(
|
||||
(select) =>
|
||||
select.column !== 'library_item.readableContent' &&
|
||||
select.column !== 'library_item.originalContent'
|
||||
(select) => select !== 'readableContent' && select !== 'originalContent'
|
||||
)
|
||||
.map((column) => ({ column: `library_item.${column}` }))
|
||||
|
||||
const parameters: ObjectLiteral[] = []
|
||||
const orders: Sort[] = []
|
||||
|
|
@ -652,10 +650,18 @@ export const buildQuery = (
|
|||
queryBuilder.where('library_item.user_id = :userId', { userId })
|
||||
|
||||
// add select
|
||||
selects.forEach((select) => {
|
||||
selects.forEach((select, index) => {
|
||||
if (index === 0) {
|
||||
queryBuilder.select(select.column, select.alias)
|
||||
}
|
||||
|
||||
queryBuilder.addSelect(select.column, select.alias)
|
||||
})
|
||||
|
||||
if (args.includeContent) {
|
||||
queryBuilder.addSelect('library_item.readableContent')
|
||||
}
|
||||
|
||||
if (!args.includePending) {
|
||||
queryBuilder.andWhere("library_item.state <> 'PROCESSING'")
|
||||
}
|
||||
|
|
@ -755,17 +761,13 @@ export const findRecentLibraryItems = async (
|
|||
|
||||
export const findLibraryItemsByIds = async (ids: string[], userId: string) => {
|
||||
const selectColumns = getColumns(libraryItemRepository)
|
||||
.filter(
|
||||
(column) => column !== 'readableContent' && column !== 'originalContent'
|
||||
)
|
||||
.filter((column) => 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 })
|
||||
.getMany(),
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -124,8 +124,7 @@ export const getTokenByRequest = (req: express.Request): string | undefined => {
|
|||
return (
|
||||
req.header(OmnivoreAuthorizationHeader) ||
|
||||
req.headers.authorization ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
(req.cookies?.auth as string)
|
||||
(req.cookies.auth as string | undefined)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,15 @@ import { env } from '../env'
|
|||
import {
|
||||
ArticleSavingRequestStatus,
|
||||
CreateLabelInput,
|
||||
TaskState,
|
||||
} from '../generated/graphql'
|
||||
import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize'
|
||||
import {
|
||||
CreateDigestJobData,
|
||||
CreateDigestJobResponse,
|
||||
CreateDigestJobSchedule,
|
||||
CREATE_DIGEST_JOB,
|
||||
} from '../jobs/ai/create_digest'
|
||||
import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action'
|
||||
import { CallWebhookJobData, CALL_WEBHOOK_JOB_NAME } from '../jobs/call_webhook'
|
||||
import { THUMBNAIL_JOB } from '../jobs/find_thumbnail'
|
||||
|
|
@ -52,6 +59,7 @@ import { CreateTaskError } from './errors'
|
|||
import { stringToHash } from './helpers'
|
||||
import { logger } from './logger'
|
||||
import View = google.cloud.tasks.v2.Task.View
|
||||
import { writeDigest } from '../services/digest'
|
||||
|
||||
// Instantiates a client.
|
||||
const client = new CloudTasksClient()
|
||||
|
|
@ -81,9 +89,9 @@ export const getJobPriority = (jobName: string): number => {
|
|||
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:
|
||||
case CREATE_DIGEST_JOB:
|
||||
return 50
|
||||
case EXPORT_ALL_ITEMS_JOB_NAME:
|
||||
case REFRESH_ALL_FEEDS_JOB_NAME:
|
||||
|
|
@ -853,4 +861,44 @@ export const enqueueSendEmail = async (jobData: SendEmailJobData) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const enqueueCreateDigest = async (
|
||||
data: CreateDigestJobData,
|
||||
schedule?: CreateDigestJobSchedule
|
||||
): Promise<CreateDigestJobResponse> => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
throw new Error('No queue found')
|
||||
}
|
||||
|
||||
const job = await queue.add(CREATE_DIGEST_JOB, data, {
|
||||
jobId: data.id, // dedupe by job id
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 3,
|
||||
priority: getJobPriority(CREATE_DIGEST_JOB),
|
||||
repeat: schedule
|
||||
? {
|
||||
immediately: true, // run immediately
|
||||
pattern: schedule === 'daily' ? '0 13 * * *' : '0 13 * * 7', // every day or every Sunday at 1PM
|
||||
utc: true,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
logger.info('create digest job enqueued', { jobId: job.id })
|
||||
|
||||
const digest = {
|
||||
id: data.id,
|
||||
jobState: TaskState.Running,
|
||||
}
|
||||
|
||||
// update digest job state in redis
|
||||
await writeDigest(data.userId, digest)
|
||||
|
||||
return {
|
||||
jobId: digest.id,
|
||||
jobState: digest.jobState,
|
||||
}
|
||||
}
|
||||
|
||||
export default createHttpTaskWithToken
|
||||
|
|
|
|||
|
|
@ -703,6 +703,10 @@ export const htmlToMarkdown = (html: string) => {
|
|||
return nhm.translate(/* html */ html)
|
||||
}
|
||||
|
||||
export const markdownToHtml = (markdown: string) => {
|
||||
return nhm.translate(/* markdown */ markdown)
|
||||
}
|
||||
|
||||
export const getDistillerResult = async (
|
||||
uid: string,
|
||||
html: string
|
||||
|
|
|
|||
13
yarn.lock
|
|
@ -7767,10 +7767,10 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/cookie-parser@^1.4.2":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5"
|
||||
integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==
|
||||
"@types/cookie-parser@^1.4.7":
|
||||
version "1.4.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.7.tgz#c874471f888c72423d78d2b3c32d1e8579cf3c8f"
|
||||
integrity sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
|
||||
|
|
@ -32048,6 +32048,11 @@ yaml@^2.2.1:
|
|||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.0.tgz#2376db1083d157f4b3a452995803dbcf43b08140"
|
||||
integrity sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ==
|
||||
|
||||
yaml@^2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed"
|
||||
integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==
|
||||
|
||||
yargs-parser@20.2.4:
|
||||
version "20.2.4"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
|
||||
|
|
|
|||