Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1079

This commit is contained in:
Rupin Khandelwal
2022-08-29 21:40:25 -05:00
43 changed files with 1275 additions and 629 deletions

View File

@ -29,7 +29,7 @@
</activity>
<activity
android:name=".ui.save.NewFlowActivity"
android:name=".ui.save.SaveSheetActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Translucent">
<intent-filter>

View File

@ -12,8 +12,7 @@ object DatastoreKeys {
object AppleConstants {
const val clientId = "app.omnivore"
const val redirectURI = "https://api-demo.omnivore.app/api/auth/vercel/apple-redirect"
const val redirectURI = "https%3A%2F%2Fapi-demo.omnivore.app%2Fapi%2Fmobile-auth%2Fandroid-apple-redirect"
const val scope = "name%20email"
const val authUrl = "https://appleid.apple.com/auth/authorize"
const val tokenUrl = "https://appleid.apple.com/auth/token"
}

View File

@ -1,11 +1,11 @@
package app.omnivore.omnivore.ui.auth
import android.annotation.SuppressLint
import android.content.ContentValues
import android.net.Uri
import android.util.Log
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.TopAppBar
@ -37,9 +37,11 @@ fun AppleAuthButton(viewModel: LoginViewModel) {
)
if (showDialog.value) {
AppleAuthDialog(onDismiss = {
AppleAuthDialog(onDismiss = { token ->
if (token != null ) {
viewModel.handleAppleToken(token)
}
showDialog.value = false
Log.i("Apple payload: ", it ?: "null")
})
}
}
@ -51,41 +53,21 @@ fun AppleAuthDialog(onDismiss: (String?) -> Unit) {
shape = RoundedCornerShape(16.dp),
color = Color.White
) {
AppleAuthWebContainerView(onDismiss)
AppleAuthWebView(onDismiss)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun AppleAuthWebContainerView(onDismiss: (String?) -> Unit) {
Scaffold(
topBar = { TopAppBar(title = { Text("WebView", color = Color.White) }, backgroundColor = Color(0xff0f9d58)) },
content = { AppleAuthWebView(onDismiss) }
)
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
val url = AppleConstants.authUrl +
"?client_id=" +
AppleConstants.clientId +
"&redirect_uri=" +
AppleConstants.redirectURI +
"&response_type=code%20id_token&scope=" +
AppleConstants.scope +
"&response_mode=form_post&state=android:login"
// clientId="app.omnivore"
// scope="name email"
// state="web:login"
// redirectURI={appleAuthRedirectURI}
// responseMode="form_post"
// responseType="code id_token"
// designProp={{
// color: 'black',
"?client_id=" + AppleConstants.clientId +
"&redirect_uri=" + AppleConstants.redirectURI +
"&response_type=code%20id_token" +
"&scope=" + AppleConstants.scope +
"&response_mode=form_post" +
"&state=android:login"
// Adding a WebView inside AndroidView
// with layout as full screen
@ -95,18 +77,13 @@ fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// webViewClient = WebViewClient()
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
Log.i("Apple payload one: ", request?.url.toString() ?: "null")
if (request?.url.toString().startsWith(AppleConstants.redirectURI)) {
// handleUrl(request?.url.toString())
onDismiss(request?.url.toString())
// Close the dialog after getting the authorization code
if (request?.url.toString().contains("success=")) {
onDismiss(null)
}
return true
if (request?.url.toString().contains("android-apple-token")) {
val uri = Uri.parse(request!!.url.toString())
val token = uri.getQueryParameter("token")
onDismiss(token)
}
return true
}
@ -118,80 +95,3 @@ fun AppleAuthWebView(onDismiss: (String?) -> Unit) {
it.loadUrl(url)
})
}
//// A client to know about WebView navigation
//// For API 21 and above
//class AppleWebViewClient : WebViewClient() {
// @TargetApi(Build.VERSION_CODES.LOLLIPOP)
// override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
// if (request?.url.toString().startsWith(AppleConstants.redirectURI)) {
// handleUrl(request?.url.toString())
// // Close the dialog after getting the authorization code
// if (request.url.toString().contains("success=")) {
//// appledialog.dismiss()
// }
// return true
// }
// return true
// }
// // For API 19 and below
// override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
// if (url.startsWith(AppleConstants.redirectURI)) {
// handleUrl(url)
// // Close the dialog after getting the authorization code
// if (url.contains("success=")) {
//// appledialog.dismiss()
// }
// return true
// }
// return false
// }
// @SuppressLint("ClickableViewAccessibility")
// override fun onPageFinished(view: WebView?, url: String?) {
// super.onPageFinished(view, url)
// // retrieve display dimensions
// val displayRectangle = Rect()
// val window = this@AppleWebViewClient.w
// window.decorView.getWindowVisibleDisplayFrame(displayRectangle)
// // Set height of the Dialog to 90% of the screen
// val layoutParams = view?.layoutParams
// layoutParams?.height = (displayRectangle.height() * 0.9f).toInt()
// view?.layoutParams = layoutParams
// }
// // Check WebView url for access token code or error
// @SuppressLint("LongLogTag")
// private fun handleUrl(url: String) {
// val uri = Uri.parse(url)
// val success = uri.getQueryParameter("success")
// if (success == "true") {
// // Get the Authorization Code from the URL
//// appleAuthCode = uri.getQueryParameter("code") ?: ""
//// Log.i("Apple Code: ", appleAuthCode)
// // Get the Client Secret from the URL
//// appleClientSecret = uri.getQueryParameter("client_secret") ?: ""
//// Log.i("Apple Client Secret: ", appleClientSecret)
// //Check if user gave access to the app for the first time by checking if the url contains their email
// if (url.contains("email")) {
// //Get user's First Name
// val firstName = uri.getQueryParameter("first_name")
// Log.i("Apple User First Name: ", firstName ?: "")
// //Get user's Middle Name
// val middleName = uri.getQueryParameter("middle_name")
// Log.i("Apple User Middle Name: ", middleName ?: "")
// //Get user's Last Name
// val lastName = uri.getQueryParameter("last_name")
// Log.i("Apple User Last Name: ", lastName ?: "")
// //Get user's email
// val email = uri.getQueryParameter("email")
// Log.i("Apple User Email: ", email ?: "Not exists")
// }
// // Exchange the Auth Code for Access Token
//// requestForAccessToken(appleAuthCode, appleClientSecret)
// } else if (success == "false") {
// Log.e("ERROR", "We couldn't get the Auth Code")
// }
// }
//}

View File

@ -68,6 +68,12 @@ class LoginViewModel @Inject constructor(
}
}
fun handleAppleToken(authToken: String) {
submitAuthProviderPayload(
params = SignInParams(token = authToken, provider = "APPLE")
)
}
fun logout() {
viewModelScope.launch {
datastoreRepo.clear()
@ -84,11 +90,7 @@ class LoginViewModel @Inject constructor(
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
val result = task?.getResult(ApiException::class.java)
Log.d(ContentValues.TAG, "server auth code?: ${result.serverAuthCode}")
Log.d(ContentValues.TAG, "is Expired?: ${result.isExpired}")
Log.d(ContentValues.TAG, "granted Scopes?: ${result.grantedScopes}")
val googleIdToken = result.idToken
Log.d(ContentValues.TAG, "Google id token?: $googleIdToken")
// If token is missing then set the error message
if (googleIdToken == null) {
@ -96,15 +98,19 @@ class LoginViewModel @Inject constructor(
return
}
submitAuthProviderPayload(
params = SignInParams(token = googleIdToken, provider = "GOOGLE")
)
}
private fun submitAuthProviderPayload(params: SignInParams) {
val login = RetrofitHelper.getInstance().create(AuthProviderLoginSubmit::class.java)
viewModelScope.launch {
isLoading = true
errorMessage = null
val result = login.submitAuthProviderLogin(
SignInParams(token = googleIdToken, provider = "GOOGLE")
)
val result = login.submitAuthProviderLogin(params)
isLoading = false

View File

@ -128,16 +128,14 @@ fun AuthProviderView(
) {
Spacer(modifier = Modifier.weight(1.0F))
Column(
// verticalArrangement = Arrangement.Center,
// horizontalAlignment = Alignment.CenterHorizontally
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (isGoogleAuthAvailable) {
GoogleAuthButton(viewModel)
}
// AppleAuthButton(viewModel)
AppleAuthButton(viewModel)
ClickableText(
text = AnnotatedString("Continue with Email"),

View File

@ -1,12 +0,0 @@
package app.omnivore.omnivore.ui.save
import androidx.compose.material.ExperimentalMaterialApi
import app.omnivore.omnivore.ui.save.SaveSheetActivity
// Not sure why we need this class, but directly opening SaveSheetActivity
// causes the app to crash.
@OptIn(ExperimentalMaterialApi::class)
class NewFlowActivity : SaveSheetActivity() {
}

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.ui.save
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -12,37 +12,36 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.ui.save.SaveViewModel
import kotlinx.coroutines.launch
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomSheetState, modifier: Modifier) {
val coroutineScope = rememberCoroutineScope()
val coroutineScope = rememberCoroutineScope()
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
.padding(top = 48.dp, bottom = 32.dp)
) {
Text(text = viewModel.message ?: "Saving")
Button(onClick = {
coroutineScope.launch {
modalBottomSheetState.hide()
}
},
colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
backgroundColor = Color(0xffffd234)
)
) {
Text(text = "Dismiss")
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
.padding(top = 48.dp, bottom = 32.dp)
) {
Text(text = viewModel.message ?: "Saving")
Button(onClick = {
coroutineScope.launch {
modalBottomSheetState.hide()
}
},
colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
backgroundColor = Color(0xffffd234)
)
) {
Text(text = "Dismiss")
}
}
}
}

View File

@ -18,153 +18,154 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.SaveContent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
// Not sure why we need this class, but directly opening SaveSheetActivity
// causes the app to crash.
class SaveSheetActivity : SaveSheetActivityBase() {}
@AndroidEntryPoint
@OptIn(ExperimentalMaterialApi::class)
abstract class SaveSheetActivity: AppCompatActivity() {
abstract class SaveSheetActivityBase: AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel: SaveViewModel by viewModels()
var extractedText: String? = null
val viewModel: SaveViewModel by viewModels()
var extractedText: String? = null
when (intent?.action) {
Intent.ACTION_SEND -> {
if (intent.type?.startsWith("text/plain") == true) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
Log.d(ContentValues.TAG, "Extracted text: $extractedText")
extractedText = it
viewModel.saveURL(it)
}
}
if (intent.type?.startsWith("text/html") == true) {
intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let {
extractedText = it
}
}
}
else -> {
// Handle other intents, such as being started from the home screen
}
when (intent?.action) {
Intent.ACTION_SEND -> {
if (intent.type?.startsWith("text/plain") == true) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
Log.d(ContentValues.TAG, "Extracted text: $extractedText")
extractedText = it
viewModel.saveURL(it)
}
}
setContent {
val coroutineScope = rememberCoroutineScope()
val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val isSheetOpened = remember { mutableStateOf(false) }
ModalBottomSheetLayout(
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
BottomSheetUI {
ScreenContent(viewModel, modalBottomSheetState)
}
}
) {}
BackHandler {
onFinish(coroutineScope, modalBottomSheetState)
}
// Take action based on hidden state
LaunchedEffect(modalBottomSheetState.currentValue) {
when (modalBottomSheetState.currentValue) {
ModalBottomSheetValue.Hidden -> {
handleBottomSheetAtHiddenState(
isSheetOpened,
modalBottomSheetState
)
}
else -> {
Log.i(TAG, "Bottom sheet ${modalBottomSheetState.currentValue} state")
}
}
}
if (intent.type?.startsWith("text/html") == true) {
intent.getStringExtra(Intent.EXTRA_HTML_TEXT)?.let {
extractedText = it
}
}
}
else -> {
// Handle other intents, such as being started from the home screen
}
}
@Composable
private fun BottomSheetUI(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
) {
content()
setContent {
val coroutineScope = rememberCoroutineScope()
val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val isSheetOpened = remember { mutableStateOf(false) }
Divider(
color = Color.Gray,
thickness = 5.dp,
modifier = Modifier
.padding(top = 15.dp)
.align(TopCenter)
.width(80.dp)
.clip(RoundedCornerShape(50.dp))
ModalBottomSheetLayout(
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
BottomSheetUI {
ScreenContent(viewModel, modalBottomSheetState)
}
}
) {}
BackHandler {
onFinish(coroutineScope, modalBottomSheetState)
}
// Take action based on hidden state
LaunchedEffect(modalBottomSheetState.currentValue) {
when (modalBottomSheetState.currentValue) {
ModalBottomSheetValue.Hidden -> {
handleBottomSheetAtHiddenState(
isSheetOpened,
modalBottomSheetState
)
}
else -> {
Log.i(TAG, "Bottom sheet ${modalBottomSheetState.currentValue} state")
}
}
}
}
}
// Helper methods
private suspend fun handleBottomSheetAtHiddenState(
isSheetOpened: MutableState<Boolean>,
modalBottomSheetState: ModalBottomSheetState
@Composable
private fun BottomSheetUI(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
) {
when {
!isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
else -> exit()
}
}
content()
private suspend fun initializeModalLayout(
isSheetOpened: MutableState<Boolean>,
modalBottomSheetState: ModalBottomSheetState
) {
isSheetOpened.value = true
modalBottomSheetState.show()
Divider(
color = Color.Gray,
thickness = 5.dp,
modifier = Modifier
.padding(top = 15.dp)
.align(TopCenter)
.width(80.dp)
.clip(RoundedCornerShape(50.dp))
)
}
}
open fun exit() = finish()
private fun onFinish(
coroutineScope: CoroutineScope,
modalBottomSheetState: ModalBottomSheetState,
withResults: Boolean = false,
result: Intent? = null
) {
coroutineScope.launch {
if (withResults) setResult(RESULT_OK)
result?.let { intent = it}
modalBottomSheetState.hide() // will trigger the LaunchedEffect
}
// Helper methods
private suspend fun handleBottomSheetAtHiddenState(
isSheetOpened: MutableState<Boolean>,
modalBottomSheetState: ModalBottomSheetState
) {
when {
!isSheetOpened.value -> initializeModalLayout(isSheetOpened, modalBottomSheetState)
else -> exit()
}
}
@Composable
fun ScreenContent(
viewModel: SaveViewModel,
modalBottomSheetState: ModalBottomSheetState
) {
Box(modifier = Modifier.height(300.dp).background(Color.White)) {
SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize())
}
}
private suspend fun initializeModalLayout(
isSheetOpened: MutableState<Boolean>,
modalBottomSheetState: ModalBottomSheetState
) {
isSheetOpened.value = true
modalBottomSheetState.show()
}
override fun onPause() {
super.onPause()
overridePendingTransition(0, 0)
}
open fun exit() = finish()
companion object {
private val TAG = SaveSheetActivity::class.java.simpleName
private fun onFinish(
coroutineScope: CoroutineScope,
modalBottomSheetState: ModalBottomSheetState,
withResults: Boolean = false,
result: Intent? = null
) {
coroutineScope.launch {
if (withResults) setResult(RESULT_OK)
result?.let { intent = it}
modalBottomSheetState.hide() // will trigger the LaunchedEffect
}
}
@Composable
fun ScreenContent(
viewModel: SaveViewModel,
modalBottomSheetState: ModalBottomSheetState
) {
Box(modifier = Modifier.height(300.dp).background(Color.White)) {
SaveContent(viewModel, modalBottomSheetState, modifier = Modifier.fillMaxSize())
}
}
override fun onPause() {
super.onPause()
overridePendingTransition(0, 0)
}
companion object {
private val TAG = SaveSheetActivity::class.java.simpleName
}
}

View File

@ -38,9 +38,11 @@ class SaveViewModel @Inject constructor(
isLoading = true
message = "Saving to Omnivore..."
val apiKey = getAuthToken()
val authToken = getAuthToken()
if (apiKey == null) {
Log.d(ContentValues.TAG, "AuthToken: $authToken")
if (authToken == null) {
message = "You are not logged in. Please login before saving."
isLoading = false
return@launch
@ -48,7 +50,7 @@ class SaveViewModel @Inject constructor(
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.addHttpHeader("Authorization", value = apiKey)
.addHttpHeader("Authorization", value = authToken)
.build()
val response = apolloClient.mutation(
@ -70,7 +72,7 @@ class SaveViewModel @Inject constructor(
"There was an error saving your page"
}
Log.d(ContentValues.TAG, "Saved URL?: ${success.toString()}")
Log.d(ContentValues.TAG, "Saved URL?: $success")
}
}
}

View File

@ -73,19 +73,21 @@ public struct ShareExtensionView: View {
HStack {
if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) {
if !iconURL.isFileURL {
AsyncLoadingImage(url: iconURL) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
AsyncImage(
url: iconURL,
content: { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
.clipped()
} else {
},
placeholder: {
Color.appButtonBackground
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
}
}
)
} else {
if let localImage = localImage(from: iconURL) {
localImage

View File

@ -67,46 +67,54 @@ public struct MiniPlayer: View {
)
}
var shareButton: some View {
Button(
action: {
let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
if let vc = UIApplication.shared.windows.first?.rootViewController {
shareActivity.popoverPresentationController?.sourceView = vc.view
// Setup share activity position on screen on bottom center
shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
vc.present(shareActivity, animated: true, completion: nil)
}
},
label: {
Image(systemName: "square.and.arrow.up")
.font(.appCallout)
.tint(.appGrayText)
}
)
}
var closeButton: some View {
Button(
action: {
withAnimation(.interactiveSpring()) {
self.expanded = false
}
},
label: {
Image(systemName: "chevron.down")
.font(.appCallout)
.tint(.appGrayText)
}
)
}
// swiftlint:disable:next function_body_length
func playerContent(_ item: LinkedItem) -> some View {
GeometryReader { geom in
VStack {
if expanded {
ZStack {
Button(
action: {
withAnimation(.interactiveSpring()) {
self.expanded = false
}
},
label: {
Image(systemName: "chevron.down")
.font(.appCallout)
.tint(.appGrayText)
}
)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
closeButton
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
Button(
action: {
let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
if let vc = UIApplication.shared.windows.first?.rootViewController {
shareActivity.popoverPresentationController?.sourceView = vc.view
// Setup share activity position on screen on bottom center
shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
vc.present(shareActivity, animated: true, completion: nil)
}
},
label: {
Image(systemName: "square.and.arrow.up")
.font(.appCallout)
.tint(.appGrayText)
}
)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
shareButton
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
Capsule()
.fill(.gray)
@ -119,31 +127,19 @@ public struct MiniPlayer: View {
Spacer(minLength: 0)
HStack {
Group {
if let imageURL = item.imageURL {
let maxSize = 2 * (min(geom.size.width, geom.size.height) / 3)
let scale = (geom.size.height - offset) / geom.size.height
let dim2 = maxSize * scale
let dim = expanded ? dim2 : 64
// print("offset", offset, "maxSize", maxSize)
let maxSize = 2 * (min(geom.size.width, geom.size.height) / 3)
let dim = expanded ? maxSize : 64
AsyncLoadingImage(url: imageURL) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dim, height: dim)
.cornerRadius(6)
.matchedGeometryEffect(id: "ArticleArt", in: animation)
} else if case AsyncImageStatus.loading = imageStatus {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
} else {
EmptyView().frame(width: dim, height: dim, alignment: .top)
}
}
}
AsyncImage(url: item.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dim, height: dim)
.cornerRadius(6)
} placeholder: {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
}
if !expanded {
@ -226,7 +222,6 @@ public struct MiniPlayer: View {
slider.setThumbImage(image, for: .normal)
slider.minimumTrackTintColor = tintColor
slider.value = 10
}
HStack {

View File

@ -25,6 +25,11 @@ public enum PlayerScrubState {
case scrubEnded(TimeInterval)
}
enum DownloadPriority: String {
case low = "low"
case high = "high"
}
// Our observable object class
public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published public var state: AudioSessionState = .stopped
@ -77,7 +82,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
}
// Attempt to fetch the file if not downloaded already
let result = try? await downloadAudioFile(pageId: pageId)
let result = try? await downloadAudioFile(pageId: pageId, priority: .low)
if result == nil {
print("audio file had error downloading: ", pageId)
pendingList.append(pageId)
@ -168,7 +173,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
let pageId = item!.unwrappedID
downloadTask = Task {
let result = try? await downloadAudioFile(pageId: pageId)
let result = try? await downloadAudioFile(pageId: pageId, priority: .high)
if Task.isCancelled { return }
if result == nil {
@ -303,19 +308,6 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
]
}
// if let imageURL = item?.imageURL, let cachedImage = ImageCache.shared[imageURL] {
//// #if os(iOS)
//// status = .loaded(image: Image(uiImage: cachedImage))
//// #else
//// status = .loaded(image: Image(nsImage: cachedImage))
//// #endif
// MPNowPlayingInfoCenter.default().nowPlayingInfo = [
// // MPMediaItemPropertyArtwork: cachedImage,
// MPMediaItemPropertyArtist: item?.author ?? "Omnivore",
// MPMediaItemPropertyTitle: item?.title ?? "Your Omnivore Article"
// ]
// }
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
@ -360,14 +352,14 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
}
}
func downloadAudioFile(pageId: String) async throws -> (pending: Bool, url: URL?) {
func downloadAudioFile(pageId: String, priority: DownloadPriority) async throws -> (pending: Bool, url: URL?) {
let audioUrl = pathForAudioFile(pageId: pageId)
if FileManager.default.fileExists(atPath: audioUrl.path) {
return (pending: false, url: audioUrl)
}
guard let url = URL(string: "/api/article/\(pageId)/mp3/\(currentVoice)", relativeTo: appEnvironment.serverBaseURL) else {
guard let url = URL(string: "/api/article/\(pageId)/mp3/\(priority)/\(currentVoice)", relativeTo: appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
}

View File

@ -1,63 +0,0 @@
#if os(iOS)
import UIKit
public typealias PlatformImage = UIImage
#elseif os(macOS)
import AppKit
public typealias PlatformImage = NSImage
#endif
/// Reference: https://www.onswiftwings.com/posts/reusable-image-cache/
public final class ImageCache {
public static let shared = ImageCache()
public subscript(_ key: URL) -> PlatformImage? {
get {
image(key)
}
set {
insertImage(newValue, url: key)
}
}
public func removeAllObjects() {
cache.removeAllObjects()
}
private let queue = DispatchQueue(label: "app.omnivore.image.cache.queue", attributes: .concurrent)
private let cache = NSCache<NSString, PlatformImage>()
private init() {
cache.totalCostLimit = 1024 * 1024 * 1024 * 50 // 50 MB
}
private func image(_ url: URL) -> PlatformImage? {
var cachedImage: PlatformImage?
queue.sync {
cachedImage = cache.object(forKey: NSString(string: url.absoluteString))
}
return cachedImage
}
private func insertImage(_ image: PlatformImage?, url: URL) {
guard let image = image else { return }
queue.async(flags: .barrier) {
self.cache.setObject(image, forKey: NSString(string: url.absoluteString), cost: 1)
}
}
}
private extension PlatformImage {
var diskSize: Int {
#if os(iOS)
guard let cgImage = cgImage else { return 0 }
return cgImage.bytesPerRow * cgImage.height
#elseif os(macOS)
// Instead of calculating the nsimage size just assume 250k
// which will allow for up to 200 images in the cache
(1024 * 1024) / 4
#endif
}
}

View File

@ -300,7 +300,7 @@ public enum WebViewDispatchEvent {
case let .updateFontSize(size: size):
return "event.fontSize = '\(size)';"
case let .updateColorMode(isDark: isDark):
return "event.isDarkMode = '\(isDark)';"
return "event.isDark = '\(isDark)';"
case let .updateFontFamily(family: family):
return "event.fontFamily = '\(family)';"
case let .saveAnnotation(annotation: annotation):

View File

@ -1,81 +0,0 @@
import Foundation
import Models
import SwiftUI
import Utils
public enum AsyncImageStatus {
case loading
case loaded(image: Image)
case error
}
public struct AsyncLoadingImage<Content: View>: View {
let viewBuilder: (AsyncImageStatus) -> Content
let url: URL
@StateObject private var imageLoader = ImageLoader()
public init(url: URL, @ViewBuilder viewBuilder: @escaping (AsyncImageStatus) -> Content) {
self.url = url
self.viewBuilder = viewBuilder
}
public var body: some View {
viewBuilder(imageLoader.status)
.task { await imageLoader.load(fromUrl: url) }
}
}
@MainActor private final class ImageLoader: ObservableObject {
@Published var status: AsyncImageStatus = .loading
var loadStarted = false
func load(fromUrl url: URL) async {
guard !loadStarted else { return }
loadStarted = true
if let cachedImage = ImageCache.shared[url] {
#if os(iOS)
status = .loaded(image: Image(uiImage: cachedImage))
#else
status = .loaded(image: Image(nsImage: cachedImage))
#endif
return
}
if let imageData = try? await fetchImageData(url: url) {
#if os(iOS)
let fetchedImage = UIImage(data: imageData)
#else
let fetchedImage = NSImage(data: imageData)
#endif
guard let fetchedImage = fetchedImage else {
status = .error
return
}
ImageCache.shared[url] = fetchedImage
#if os(iOS)
status = .loaded(image: Image(uiImage: fetchedImage))
#else
status = .loaded(image: Image(nsImage: fetchedImage))
#endif
} else {
status = .error
}
}
}
private func fetchImageData(url: URL) async throws -> Data {
do {
let (data, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode {
return data
} else {
throw BasicError.message(messageText: "failed")
}
} catch {
throw BasicError.message(messageText: "failed")
}
}

View File

@ -133,19 +133,19 @@ public struct GridCard: View {
Spacer()
if let imageURL = item.imageURL {
AsyncLoadingImage(url: imageURL) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
AsyncImage(url: imageURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9)
.cornerRadius(3)
} else if case AsyncImageStatus.loading = imageStatus {
} else if phase.error != nil {
EmptyView()
} else {
Color.appButtonBackground
.frame(width: geo.size.width / 3, height: (geo.size.width * 2) / 9)
.cornerRadius(3)
} else {
EmptyView()
}
}
}

View File

@ -47,19 +47,19 @@ public struct FeedCard: View {
Group {
if let imageURL = item.imageURL {
AsyncLoadingImage(url: imageURL) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
AsyncImage(url: imageURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.cornerRadius(6)
} else if case AsyncImageStatus.loading = imageStatus {
} else if phase.error != nil {
EmptyView().frame(width: 80, height: 80, alignment: .top)
} else {
Color.appButtonBackground
.frame(width: 80, height: 80)
.cornerRadius(6)
} else {
EmptyView().frame(width: 80, height: 80, alignment: .top)
}
}
}

View File

@ -22,18 +22,11 @@ public struct ProfileCard: View {
public var body: some View {
HStack(alignment: .center) {
Group {
if let url = data.imageURL {
AsyncLoadingImage(url: url) { imageStatus in
if case let AsyncImageStatus.loaded(image) = imageStatus {
image.resizable()
} else {
Image(systemName: "person.crop.circle").resizable()
}
}
} else {
Image(systemName: "person.crop.circle")
.resizable()
}
AsyncImage(
url: data.imageURL,
content: { $0.resizable() },
placeholder: { Image(systemName: "person.crop.circle").resizable() }
)
}
.aspectRatio(contentMode: .fill)
.frame(width: 70, height: 70, alignment: .center)

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,6 @@ import { Speech, SpeechState } from '../entity/speech'
import { getPageById, updatePage } from '../elastic/pages'
import { generateDownloadSignedUrl } from '../utils/uploads'
import { enqueueTextToSpeech } from '../utils/createTask'
import { UserPersonalization } from '../entity/user_personalization'
import { createPubSubClient } from '../datalayer/pubsub'
const logger = buildLogger('app.dispatch')
@ -73,13 +72,18 @@ export function articleRouter() {
})
router.get(
'/:id/:outputFormat/:voice?',
'/:id/:outputFormat/:priority/:voice?',
cors<express.Request>(corsConfig),
async (req, res) => {
const articleId = req.params.id
const outputFormat = req.params.outputFormat
const voice = req.params.voice
if (!articleId || !['mp3', 'speech-marks'].includes(outputFormat)) {
const voice = req.params.voice || 'en-US-JennyNeural'
const priority = req.params.priority
if (
!articleId ||
!['mp3', 'speech-marks'].includes(outputFormat) ||
!['low', 'high'].includes(priority)
) {
return res.status(400).send('Invalid data')
}
const token = req.cookies?.auth || req.headers?.authorization
@ -140,20 +144,21 @@ export function articleRouter() {
if (!page) {
return res.status(404).send('Page not found')
}
const userPersonalization = await getRepository(
UserPersonalization
).findOneBy({
user: { id: uid },
})
// initialize state
const speech = await getRepository(Speech).save({
user: { id: uid },
elasticPageId: articleId,
state: SpeechState.INITIALIZED,
voice: voice || userPersonalization?.speechVoice || 'en-US-JennyNeural',
voice,
})
// enqueue a task to convert text to speech
const taskName = await enqueueTextToSpeech(uid, speech.id)
const taskName = await enqueueTextToSpeech({
userId: uid,
speechId: speech.id,
text: page.content,
voice: speech.voice,
priority: priority as 'low' | 'high',
})
logger.info('Start Text to speech task', { taskName })
res.status(202).send('Text to speech task started')
}

View File

@ -41,8 +41,7 @@ async function fetchApplePublicKey(kid: string): Promise<string | null> {
}
export async function decodeAppleToken(
token: string,
isWeb?: boolean
token: string
): Promise<DecodeTokenResult> {
const decodedToken = jwt.decode(token, { complete: true })
const { kid, alg } = (decodedToken as any).header
@ -54,8 +53,8 @@ export async function decodeAppleToken(
}
const jwtClaims: any = jwt.verify(token, publicKey, { algorithms: [alg] })
const issVerified = (jwtClaims.iss ?? '') === appleBaseURL
const audVerified =
(jwtClaims.aud ?? '') === isWeb ? webAudienceName : audienceName
const audience = jwtClaims.aud ?? ''
const audVerified = audience == webAudienceName || audience === audienceName
if (issVerified && audVerified && jwtClaims.email) {
return {
email: jwtClaims.email,
@ -106,7 +105,7 @@ export async function handleAppleWebAuth(
return env.client.url
}
const decodedTokenResult = await decodeAppleToken(idToken, true)
const decodedTokenResult = await decodeAppleToken(idToken)
const authFailedRedirect = `${baseURL()}/login?errorCodes=${
LoginErrorCode.AuthFailed
}`

View File

@ -11,6 +11,9 @@ import {
createMobileEmailSignUpResponse,
} from './sign_up'
import { createMobileAccountCreationResponse } from './account_creation'
import { env } from '../../../env'
import { corsConfig } from '../../../utils/corsConfig'
import cors from 'cors'
export function mobileAuthRouter() {
const router = express.Router()
@ -60,5 +63,18 @@ export function mobileAuthRouter() {
res.status(payload.statusCode).json(payload.json)
})
// Required since this will be called from Android WebView
router.options(
'/android-apple-redirect',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post('/android-apple-redirect', (req, res) => {
const { id_token } = req.body
return res.redirect(
`${env.client.url}/android-apple-token?token=${id_token as string}`
)
})
return router
}

View File

@ -3,22 +3,19 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import express from 'express'
import cors from 'cors'
import { corsConfig } from '../../utils/corsConfig'
import { getRepository } from '../../entity/utils'
import { getPageById } from '../../elastic/pages'
import { Speech, SpeechState } from '../../entity/speech'
import { buildLogger } from '../../utils/logger'
import { getClaimsByToken } from '../../utils/auth'
import {
setSpeechFailure,
shouldSynthesize,
synthesize,
} from '../../services/speech'
import { readPushSubscription } from '../../datalayer/pubsub'
import { corsConfig } from '../utils/corsConfig'
import { getRepository, setClaims } from '../entity/utils'
import { getPageById } from '../elastic/pages'
import { Speech, SpeechState } from '../entity/speech'
import { buildLogger } from '../utils/logger'
import { getClaimsByToken } from '../utils/auth'
import { shouldSynthesize, synthesize } from '../services/speech'
import { readPushSubscription } from '../datalayer/pubsub'
import { AppDataSource } from '../server'
const logger = buildLogger('app.dispatch')
export function speechServiceRouter() {
export function textToSpeechRouter() {
const router = express.Router()
// eslint-disable-next-line @typescript-eslint/no-misused-promises
@ -79,58 +76,46 @@ export function speechServiceRouter() {
router.options('/', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
// eslint-disable-next-line @typescript-eslint/no-misused-promises
router.post('/', async (req, res) => {
logger.info('Synthesize svc request', {
logger.info('Updating speech', {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
body: req.body,
})
let userId: string
const token = req.query.token as string
try {
if (!(await getClaimsByToken(token))) {
const claims = await getClaimsByToken(token)
if (!claims) {
logger.info('Unauthorized request', { token })
return res.status(200).send('UNAUTHORIZED')
return res.status(401).send('UNAUTHORIZED')
}
userId = claims.uid
} catch (error) {
logger.error('Unauthorized request', { token, error })
return res.status(200).send('UNAUTHORIZED')
return res.status(401).send('UNAUTHORIZED')
}
const { userId, speechId } = req.body as {
userId: string
speechId: string
}
if (!userId || !speechId) {
return res.status(200).send('Invalid data')
const { speechId, audioFileName, speechMarksFileName, state } =
req.body as {
speechId: string
audioFileName: string
speechMarksFileName: string
state: SpeechState
}
if (!speechId) {
return res.status(400).send('Invalid data')
}
logger.info(`Create article speech`, {
body: {
userId,
speechId,
},
labels: {
source: 'CreateArticleSpeech',
},
// set state to completed
await AppDataSource.transaction(async (t) => {
await setClaims(t, userId)
await t.getRepository(Speech).update(speechId, {
audioFileName: audioFileName,
speechMarksFileName: speechMarksFileName,
state,
})
})
const speech = await getRepository(Speech).findOneBy({
id: speechId,
user: { id: userId },
})
if (!speech) {
return res.status(200).send('Speech not found')
}
const page = await getPageById(speech.elasticPageId)
if (!page) {
await setSpeechFailure(speech.id)
return res.status(200).send('Page not found')
}
try {
await synthesize(page, speech)
} catch (error) {
logger.error(`Error synthesizing article`, { error })
res.status(500).send('Error synthesizing article')
}
res.send('OK')
})
return router

View File

@ -45,7 +45,7 @@ import { uploadServiceRouter } from './routers/svc/upload'
import rateLimit from 'express-rate-limit'
import { webhooksServiceRouter } from './routers/svc/webhooks'
import { integrationsServiceRouter } from './routers/svc/integrations'
import { speechServiceRouter } from './routers/svc/text_to_speech'
import { textToSpeechRouter } from './routers/text_to_speech'
const PORT = process.env.PORT || 4000
@ -111,6 +111,7 @@ export const createApp = (): {
app.use('/api/page', pageRouter())
app.use('/api/article', articleRouter())
app.use('/api/mobile-auth', mobileAuthRouter())
app.use('/api/text-to-speech', textToSpeechRouter())
app.use('/svc/pubsub/content', contentServiceRouter())
app.use('/svc/pubsub/links', linkServiceRouter())
app.use('/svc/pubsub/newsletters', newsletterServiceRouter())
@ -120,7 +121,6 @@ export const createApp = (): {
app.use('/svc/pubsub/integrations', integrationsServiceRouter())
app.use('/svc/reminders', remindersServiceRouter())
app.use('/svc/pdf-attachments', pdfAttachmentsRouter())
app.use('/svc/text-to-speech', speechServiceRouter())
if (env.dev.isLocal) {
app.use('/local/debug', localDebugRouter())

View File

@ -94,6 +94,9 @@ interface BackendEnv {
speechKey: string
speechRegion: string
}
gcp: {
location: string
}
}
/***
@ -148,6 +151,7 @@ const nullableEnvVars = [
'TEXT_TO_SPEECH_TASK_HANDLER_URL',
'AZURE_SPEECH_KEY',
'AZURE_SPEECH_REGION',
'GCP_LOCATION',
] // Allow some vars to be null/empty
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
@ -273,6 +277,10 @@ export function getEnv(): BackendEnv {
speechRegion: parse('AZURE_SPEECH_REGION'),
}
const gcp = {
location: parse('GCP_LOCATION'),
}
return {
pg,
client,
@ -292,6 +300,7 @@ export function getEnv(): BackendEnv {
sendgrid,
readwise,
azure,
gcp,
}
}

View File

@ -45,11 +45,7 @@ const createHttpTaskWithToken = async ({
]
> => {
// Construct the fully qualified queue name.
if (priority === 'low') {
queue = `${queue}-low`
// use GCF url for low priority tasks
taskHandlerUrl = env.queue.contentFetchGCFUrl
}
priority === 'low' && (queue = `${queue}-low`)
const parent = client.queuePath(project, location, queue)
console.log(`Task creation options: `, {
@ -211,13 +207,15 @@ export const deleteTask = async (
* @param userId - Id of the user authorized
* @param saveRequestId - Id of the article_saving_request table record
* @param priority - Priority of the task
* @param queue - Queue name
* @returns Name of the task created
*/
export const enqueueParseRequest = async (
url: string,
userId: string,
saveRequestId: string,
priority: 'low' | 'high' = 'high'
priority: 'low' | 'high' = 'high',
queue = env.queue.name
): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT } = process.env
const payload = {
@ -240,10 +238,18 @@ export const enqueueParseRequest = async (
return ''
}
// use GCF url for low priority tasks
const taskHandlerUrl =
priority === 'low'
? env.queue.contentFetchGCFUrl
: env.queue.contentFetchUrl
const createdTasks = await createHttpTaskWithToken({
project: GOOGLE_CLOUD_PROJECT,
payload,
priority,
taskHandlerUrl,
queue,
})
if (!createdTasks || !createdTasks[0].name) {
logger.error(`Unable to get the name of the task`, {
@ -328,14 +334,34 @@ export const enqueueSyncWithIntegration = async (
return createdTasks[0].name
}
export const enqueueTextToSpeech = async (
userId: string,
export const enqueueTextToSpeech = async ({
userId,
text,
speechId,
voice,
priority,
textType = 'ssml',
bucket = env.fileUpload.gcsUploadBucket,
queue = 'omnivore-demo-text-to-speech-queue',
location = env.gcp.location,
}: {
userId: string
speechId: string
): Promise<string> => {
text: string
voice: string
priority: 'low' | 'high'
bucket?: string
textType?: 'text' | 'ssml'
queue?: string
location?: string
}): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT } = process.env
const payload = {
userId,
speechId,
id: speechId,
text,
voice,
bucket,
textType,
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -357,6 +383,9 @@ export const enqueueTextToSpeech = async (
project: GOOGLE_CLOUD_PROJECT,
payload,
taskHandlerUrl,
queue,
location,
priority,
})
if (!createdTasks || !createdTasks[0].name) {

View File

@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack --mode production"
"build": "webpack --mode production",
"build-and-update": "yarn build && cp build/bundle.js ../../apple/OmnivoreKit/Sources/Views/Resources/bundle.js"
},
"dependencies": {
"@omnivore/web": "1.0.0"

View File

@ -110,7 +110,7 @@ const getBrowserPromise = (async () => {
defaultViewport: { height: 1080, width: 1920 },
executablePath: process.env.CHROMIUM_PATH ,
headless: !!process.env.LAUNCH_HEADLESS,
timeout: 0,
timeout: 120000, // 2 minutes
});
})();

View File

@ -0,0 +1,11 @@
-- Type: DO
-- Name: add_rls_to_speech
-- Description: Add Row level security to speech table
BEGIN;
CREATE POLICY update_speech on omnivore.speech
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
COMMIT;

View File

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: add_rls_to_speech
-- Description: Add Row level security to speech table
BEGIN;
DROP POLICY IF EXISTS update_speech ON omnivore.speech;
COMMIT;

View File

@ -0,0 +1,4 @@
node_modules/
dist/
readabilityjs/
src/generated/

View File

@ -0,0 +1,6 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
}
}

View File

@ -0,0 +1,16 @@
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
node_modules

View File

@ -0,0 +1,5 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"require": "test/babel-register.js"
}

View File

@ -0,0 +1,36 @@
{
"name": "@omnivore/text-to-speech-handler",
"version": "1.0.0",
"description": "",
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"files": [
"build/src"
],
"license": "Apache-2.0",
"keywords": [],
"scripts": {
"test": "yarn mocha -r ts-node/register --config mocha-config.json",
"lint": "eslint src --ext ts,js,tsx,jsx",
"compile": "tsc",
"build": "tsc",
"start": "functions-framework --source=build/src/ --target=textToSpeechHandler",
"dev": "concurrently \"tsc -w\" \"nodemon --watch ./build/ --exec npm run start\"",
"gcloud-deploy": "gcloud functions deploy text-to-speech --gen2 --trigger-http --allow-unauthenticated --region=us-west2 --runtime nodejs14",
"deploy": "yarn build && yarn gcloud-deploy"
},
"devDependencies": {
"@types/node": "^14.11.2",
"eslint-plugin-prettier": "^4.0.0"
},
"dependencies": {
"@google-cloud/functions-framework": "3.1.2",
"@google-cloud/storage": "^6.4.1",
"@sentry/serverless": "^6.16.1",
"axios": "^0.27.2",
"dotenv": "^16.0.1",
"jsonwebtoken": "^8.5.1",
"linkedom": "^0.14.12",
"microsoft-cognitiveservices-speech-sdk": "^1.22.0"
}
}

View File

@ -0,0 +1,185 @@
import { parseHTML } from 'linkedom'
// this code needs to be kept in sync with the
// frontend code in: useReadingProgressAnchor
const ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES = [
'omnivore-highlight-id',
'data-twitter-tweet-id',
'data-instagram-id',
]
function ssmlTagsForTopLevelElement() {
return {
opening: `<p>`,
closing: `</p>`,
}
}
function parseDomTree(pageNode: Element) {
if (!pageNode || pageNode.childNodes.length == 0) {
console.log(' no child nodes found')
return []
}
const nodesToVisitStack = [pageNode]
const visitedNodeList = []
while (nodesToVisitStack.length > 0) {
const currentNode = nodesToVisitStack.pop()
if (
currentNode?.nodeType !== 1 /* Node.ELEMENT_NODE */ ||
// Avoiding dynamic elements from being counted as anchor-allowed elements
ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES.some((attrib) =>
currentNode.hasAttribute(attrib)
)
) {
continue
}
visitedNodeList.push(currentNode)
;[].slice
.call(currentNode.childNodes)
.reverse()
.forEach(function (node) {
nodesToVisitStack.push(node)
})
}
visitedNodeList.shift()
visitedNodeList.forEach((node, index) => {
// start from index 1, index 0 reserved for anchor unknown.
node.setAttribute('data-omnivore-anchor-idx', (index + 1).toString())
})
return visitedNodeList
}
function emit(textItems: string[], text: string) {
textItems.push(text)
}
function cleanTextNode(textNode: ChildNode): string {
return (textNode.textContent ?? '').replace(/\s+/g, ' ')
}
function emitTextNode(
textItems: string[],
cleanedText: string,
textNode: ChildNode
) {
const ssmlElement =
textNode.parentNode?.nodeName === 'B' ? 'emphasis' : undefined
if (!cleanedText) {
return
}
if (ssmlElement) {
emit(textItems, `<${ssmlElement}>`)
}
emit(textItems, `${cleanedText}`)
if (ssmlElement) {
emit(textItems, `</${ssmlElement}>`)
}
}
function emitElement(
textItems: string[],
element: Element,
isTopLevel: boolean
) {
const SKIP_TAGS = ['SCRIPT', 'STYLE', 'IMG', 'FIGURE', 'FIGCAPTION', 'IFRAME']
const topLevelTags = ssmlTagsForTopLevelElement()
const idx = element.getAttribute('data-omnivore-anchor-idx')
let maxVisitedIdx = Number(idx)
if (isTopLevel) {
emit(textItems, topLevelTags.opening)
}
for (const child of Array.from(element.childNodes)) {
if (SKIP_TAGS.indexOf(child.nodeName) >= 0) {
continue
}
if (
child.nodeType == 3 /* Node.TEXT_NODE */ &&
(child.textContent?.length ?? 0) > 0
) {
const cleanedText = cleanTextNode(child)
if (idx && cleanedText.length > 1) {
// Make sure its more than just a space
emit(textItems, `<bookmark mark="${idx}" />`)
}
emitTextNode(textItems, cleanedText, child)
}
if (child.nodeType == 1 /* Node.ELEMENT_NODE */) {
maxVisitedIdx = emitElement(textItems, child as HTMLElement, false)
}
}
if (isTopLevel) {
emit(textItems, topLevelTags.closing)
}
return Number(maxVisitedIdx)
}
export type SSMLItem = {
open: string
close: string
textItems: string[]
}
export type SSMLOptions = {
primaryVoice: string
secondaryVoice: string
rate: string
language: string
}
const startSsml = (element: Element, options: SSMLOptions): string => {
const voice =
element.nodeName === 'BLOCKQUOTE'
? options.secondaryVoice
: options.primaryVoice
return `
<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="${options.language}"><voice name="${voice}"><prosody rate="${options.rate}" pitch="default">
`
}
const endSsml = (): string => {
return `</prosody></voice></speak>`
}
export const ssmlItemText = (item: SSMLItem): string => {
return [item.open, ...item.textItems, item.close].join('')
}
export const htmlToSsml = (html: string, options: SSMLOptions): SSMLItem[] => {
const dom = parseHTML(html)
const body = dom.document.querySelector('#readability-page-1')
if (!body) {
throw new Error('Unable to parse HTML document')
}
const parsedNodes = parseDomTree(body)
if (parsedNodes.length < 1) {
throw new Error('No HTML nodes found')
}
const items: SSMLItem[] = []
for (let i = 1; i < parsedNodes.length + 1; i++) {
const textItems: string[] = []
const node = parsedNodes[i - 1]
i = emitElement(textItems, node, true)
items.push({
open: startSsml(node, options),
close: endSsml(),
textItems: textItems,
})
}
return items
}

View File

@ -0,0 +1,411 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as Sentry from '@sentry/serverless'
import { parseHTML } from 'linkedom'
import { File, Storage } from '@google-cloud/storage'
import {
CancellationDetails,
CancellationReason,
ResultReason,
SpeechConfig,
SpeechSynthesisOutputFormat,
SpeechSynthesisResult,
SpeechSynthesizer,
} from 'microsoft-cognitiveservices-speech-sdk'
import axios from 'axios'
import * as jwt from 'jsonwebtoken'
import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import
import { htmlToSsml, ssmlItemText } from './htmlToSsml'
dotenv.config()
interface TextToSpeechInput {
id: string
text: string
voice?: string
languageCode?: string
textType?: 'text' | 'ssml'
rate?: number
volume?: number
complimentaryVoice?: string
bucket: string
}
interface TextToSpeechOutput {
audioFileName: string
speechMarksFileName: string
}
interface SpeechMark {
time: number
start?: number
length?: number
word: string
type: 'word' | 'bookmark'
}
const storage = new Storage()
const uploadToBucket = async (
filePath: string,
data: Buffer,
bucket: string,
options?: { contentType?: string; public?: boolean }
): Promise<void> => {
await storage.bucket(bucket).file(filePath).save(data, options)
}
const createGCSFile = (bucket: string, filename: string): File => {
return storage.bucket(bucket).file(filename)
}
const updateSpeech = async (
speechId: string,
token: string,
state: 'COMPLETED' | 'FAILED',
audioFileName?: string,
speechMarksFileName?: string
): Promise<boolean> => {
if (!process.env.REST_BACKEND_ENDPOINT) {
throw new Error('backend rest api endpoint not exists')
}
const response = await axios.post(
`${process.env.REST_BACKEND_ENDPOINT}/text-to-speech?token=${token}`,
{
speechId,
audioFileName,
speechMarksFileName,
state,
}
)
return response.status === 200
}
const synthesizeTextToSpeech = async (
input: TextToSpeechInput
): Promise<TextToSpeechOutput> => {
if (!process.env.AZURE_SPEECH_KEY || !process.env.AZURE_SPEECH_REGION) {
throw new Error('Azure Speech Key or Region not set')
}
const audioFileName = `speech/${input.id}.mp3`
const audioFile = createGCSFile(input.bucket, audioFileName)
const writeStream = audioFile.createWriteStream({
resumable: true,
})
const speechConfig = SpeechConfig.fromSubscription(
process.env.AZURE_SPEECH_KEY,
process.env.AZURE_SPEECH_REGION
)
const textType = input.textType || 'text'
if (textType === 'text') {
speechConfig.speechSynthesisLanguage = input.languageCode || 'en-US'
speechConfig.speechSynthesisVoiceName = input.voice || 'en-US-JennyNeural'
}
speechConfig.speechSynthesisOutputFormat =
SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3
// Create the speech synthesizer.
const synthesizer = new SpeechSynthesizer(speechConfig)
const speechMarks: SpeechMark[] = []
let timeOffset = 0
let characterOffset = 0
synthesizer.synthesizing = function (s, e) {
// convert arrayBuffer to stream and write to gcs file
writeStream.write(Buffer.from(e.result.audioData))
}
// The event synthesis completed signals that the synthesis is completed.
synthesizer.synthesisCompleted = (s, e) => {
console.info(
`(synthesized) Reason: ${ResultReason[e.result.reason]} Audio length: ${
e.result.audioData.byteLength
}`
)
}
// The synthesis started event signals that the synthesis is started.
synthesizer.synthesisStarted = (s, e) => {
console.info('(synthesis started)')
}
// The event signals that the service has stopped processing speech.
// This can happen when an error is encountered.
synthesizer.SynthesisCanceled = (s, e) => {
const cancellationDetails = CancellationDetails.fromResult(e.result)
let str =
'(cancel) Reason: ' + CancellationReason[cancellationDetails.reason]
if (cancellationDetails.reason === CancellationReason.Error) {
str += ': ' + e.result.errorDetails
}
console.info(str)
}
// The unit of e.audioOffset is tick (1 tick = 100 nanoseconds), divide by 10,000 to convert to milliseconds.
synthesizer.wordBoundary = (s, e) => {
speechMarks.push({
word: e.text,
time: (timeOffset + e.audioOffset) / 10000,
start: characterOffset + e.textOffset,
length: e.wordLength,
type: 'word',
})
}
synthesizer.bookmarkReached = (s, e) => {
console.debug(
`(Bookmark reached), Audio offset: ${
e.audioOffset / 10000
}ms, bookmark text: ${e.text}`
)
speechMarks.push({
word: e.text,
time: (timeOffset + e.audioOffset) / 10000,
type: 'bookmark',
})
}
const speakTextAsyncPromise = (
text: string
): Promise<SpeechSynthesisResult> => {
return new Promise((resolve, reject) => {
synthesizer.speakTextAsync(
text,
(result) => {
resolve(result)
},
(error) => {
reject(error)
}
)
})
}
const speakSsmlAsyncPromise = (
text: string
): Promise<SpeechSynthesisResult> => {
return new Promise((resolve, reject) => {
synthesizer.speakSsmlAsync(
text,
(result) => {
resolve(result)
},
(error) => {
reject(error)
}
)
})
}
if (textType === 'text') {
// slice the text into chunks of 5,000 characters
let currentTextChunk = ''
const textChunks = input.text.split('\n')
for (let i = 0; i < textChunks.length; i++) {
currentTextChunk += textChunks[i] + '\n'
if (currentTextChunk.length < 5000 && i < textChunks.length - 1) {
continue
}
console.debug(`synthesizing ${currentTextChunk}`)
const result = await speakTextAsyncPromise(currentTextChunk)
timeOffset = timeOffset + result.audioDuration
characterOffset = characterOffset + currentTextChunk.length
currentTextChunk = ''
}
} else {
const ssmlItems = htmlToSsml(input.text, {
primaryVoice: speechConfig.speechSynthesisVoiceName,
secondaryVoice: 'en-US-GuyNeural',
language: speechConfig.speechSynthesisLanguage,
rate: '1',
})
for (const ssmlItem of Array.from(ssmlItems)) {
const ssml = ssmlItemText(ssmlItem)
console.debug(`synthesizing ${ssml}`)
const result = await speakSsmlAsyncPromise(ssml)
// if (result.reason === ResultReason.Canceled) {
// synthesizer.close()
// throw new Error(result.errorDetails)
// }
timeOffset = timeOffset + result.audioDuration
// characterOffset = characterOffset + htmlElement.innerText.length
}
}
writeStream.end()
synthesizer.close()
console.debug(`audio file: ${audioFileName}`)
// upload Speech Marks file to GCS
const speechMarksFileName = `speech/${input.id}.json`
await uploadToBucket(
speechMarksFileName,
Buffer.from(JSON.stringify(speechMarks)),
input.bucket
)
return {
audioFileName,
speechMarksFileName,
}
}
const htmlElementToSsml = ({
htmlElement,
language = 'en-US',
voice = 'en-US-JennyNeural',
rate = 1,
volume = 100,
}: {
htmlElement: Element
language?: string
voice?: string
rate?: number
volume?: number
}): string => {
const replaceElement = (newElement: Element, oldElement: Element) => {
const id = oldElement.getAttribute('data-omnivore-anchor-idx')
if (id) {
const e = htmlElement.querySelector(`[data-omnivore-anchor-idx="${id}"]`)
e?.parentNode?.replaceChild(newElement, e)
}
}
const appendBookmarkElement = (parent: Element, element: Element) => {
const id = element.getAttribute('data-omnivore-anchor-idx')
if (id) {
const bookMark = ssml.createElement('bookmark')
bookMark.setAttribute('mark', `data-omnivore-anchor-idx-${id}`)
parent.appendChild(bookMark)
}
}
const replaceWithEmphasis = (element: Element, level: string) => {
const parent = ssml.createDocumentFragment() as unknown as Element
appendBookmarkElement(parent, element)
const emphasisElement = ssml.createElement('emphasis')
emphasisElement.setAttribute('level', level)
emphasisElement.innerHTML = element.innerHTML.trim()
parent.appendChild(emphasisElement)
replaceElement(parent, element)
}
const replaceWithSentence = (element: Element) => {
const parent = ssml.createDocumentFragment() as unknown as Element
appendBookmarkElement(parent, element)
const sentenceElement = ssml.createElement('s')
sentenceElement.innerHTML = element.innerHTML.trim()
parent.appendChild(sentenceElement)
replaceElement(parent, element)
}
// create new ssml document
const ssml = parseHTML('').document
const speakElement = ssml.createElement('speak')
speakElement.setAttribute('version', '1.0')
speakElement.setAttribute('xmlns', 'http://www.w3.org/2001/10/synthesis')
speakElement.setAttribute('xml:lang', language)
const voiceElement = ssml.createElement('voice')
voiceElement.setAttribute('name', voice)
speakElement.appendChild(voiceElement)
const prosodyElement = ssml.createElement('prosody')
prosodyElement.setAttribute('rate', `${rate}`)
prosodyElement.setAttribute('volume', volume.toString())
voiceElement.appendChild(prosodyElement)
// add each paragraph to the ssml document
appendBookmarkElement(prosodyElement, htmlElement)
// replace emphasis elements with ssml
htmlElement.querySelectorAll('*').forEach((e) => {
switch (e.tagName.toLowerCase()) {
case 's':
replaceWithEmphasis(e, 'moderate')
break
case 'sub':
if (e.getAttribute('alias') === null) {
replaceWithEmphasis(e, 'moderate')
}
break
case 'i':
case 'em':
case 'q':
case 'blockquote':
case 'cite':
case 'del':
case 'strike':
case 'sup':
case 'summary':
case 'caption':
case 'figcaption':
replaceWithEmphasis(e, 'moderate')
break
case 'b':
case 'strong':
case 'dt':
case 'dfn':
case 'u':
case 'mark':
case 'th':
case 'title':
case 'var':
replaceWithEmphasis(e, 'moderate')
break
case 'li':
replaceWithSentence(e)
break
default: {
const parent = ssml.createDocumentFragment() as unknown as Element
appendBookmarkElement(parent, e)
const text = (e as HTMLElement).innerText.trim()
const textElement = ssml.createTextNode(text)
parent.appendChild(textElement)
replaceElement(parent, e)
}
}
})
prosodyElement.appendChild(htmlElement)
return speakElement.outerHTML.replace(/&nbsp;|\n/g, '')
}
export const textToSpeechHandler = Sentry.GCPFunction.wrapHttpFunction(
async (req, res) => {
console.debug('New text to speech request', req)
const token = req.query.token as string
if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET not exists')
return res.status(500).send('JWT_SECRET not exists')
}
try {
jwt.verify(token, process.env.JWT_SECRET)
} catch (e) {
console.error(e)
return res.status(200).send('UNAUTHENTICATED')
}
const input = req.body as TextToSpeechInput
try {
const { audioFileName, speechMarksFileName } =
await synthesizeTextToSpeech(input)
const updated = await updateSpeech(
input.id,
token,
'COMPLETED',
audioFileName,
speechMarksFileName
)
if (!updated) {
return res.status(500).send('Failed to update speech')
}
} catch (e) {
console.error(e)
await updateSpeech(input.id, token, 'FAILED')
return res.status(500).send('Failed to synthesize')
}
res.send('OK')
}
)

View File

@ -0,0 +1,3 @@
const register = require('@babel/register').default
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })

View File

@ -0,0 +1,99 @@
import 'mocha'
import { expect } from 'chai'
import fs from 'fs'
import { glob } from 'glob'
import { htmlToSsml } from '../src/htmlToSsml'
describe('htmlToSsml', () => {
const TEST_OPTIONS = {
primaryVoice: 'test-primary',
secondaryVoice: 'test-secondary',
language: 'en-US',
rate: '1'
}
describe('a simple html file', () => {
it('should convert Html to SSML', async () => {
const ssml = htmlToSsml(`
<div class="page" id="readability-page-1">
<p data-omnivore-anchor-idx="1">this is some text</p>
</div>
`, TEST_OPTIONS
)
const text = ssml[0].textItems.join('').trim()
expect(text).to.equal(
`<p><bookmark mark="1" />this is some text</p>`
)
})
})
describe('a file with nested elements', () => {
it('should convert Html to SSML', async () => {
const ssml = htmlToSsml(`
<div class="page" id="readability-page-1">
<p>
this is in the first paragraph
<span>this is in the second span</span>
this is also in the first paragraph
</p>
</div>
`, TEST_OPTIONS
)
const text = ssml[0].textItems.join('').trim()
expect(text).to.equal(
`<p><bookmark mark="1" /> this is in the first paragraph <bookmark mark="2" />this is in the second span<bookmark mark="1" /> this is also in the first paragraph </p>`.trim()
)
})
})
describe('a file with blockquotes', () => {
it('should convert Html to SSML with complimentary voices', async () => {
const ssml = htmlToSsml(`
<div class="page" id="readability-page-1">
<p>first</p>
<blockquote>second</blockquote>
<p>third</p>
</div>
`, TEST_OPTIONS
)
const first = ssml[0].textItems.join('').trim()
const second = ssml[1].textItems.join('').trim()
const third = ssml[2].textItems.join('').trim()
expect(first).to.equal(
`<p><bookmark mark="1" />first</p>`
)
expect(second).to.equal(
`<p><bookmark mark="2" />second</p>`
)
expect(third).to.equal(
`<p><bookmark mark="3" />third</p>`
)
expect(ssml[0].open.trim()).to.equal(
`<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"><voice name="test-primary"><prosody rate="1" pitch="default">`
)
expect(ssml[1].open.trim()).to.equal(
`<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"><voice name="test-secondary"><prosody rate="1" pitch="default">`
)
expect(ssml[2].open.trim()).to.equal(
`<speak xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="http://www.w3.org/2001/mstts" xmlns:emo="http://www.w3.org/2009/10/emotionml" version="1.0" xml:lang="en-US"><voice name="test-primary"><prosody rate="1" pitch="default">`
)
})
})
// For local testing:
// describe('readability test files', () => {
// it('should convert Html to SSML without throwing', async () => {
// const g = new glob.GlobSync('../readabilityjs/test/test-pages/*')
// console.log('glob: ', glob)
// for (const f of g.found) {
// const readablePath = `${f}/expected.html`
// if (!fs.existsSync(readablePath)) {
// continue
// }
// const html = fs.readFileSync(readablePath, { encoding: 'utf-8' })
// const ssmlItems = htmlToSsml(html, TEST_OPTIONS)
// console.log('SSML ITEMS', ssmlItems)
// }
// })
// })
})

View File

@ -0,0 +1,13 @@
import 'mocha'
import * as chai from 'chai'
import { expect } from 'chai'
import 'chai/register-should'
import chaiString from 'chai-string'
chai.use(chaiString)
describe('Stub test', () => {
it('should pass', () => {
expect(true).to.be.true
})
})

View File

@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"compilerOptions": {
"outDir": "build",
"rootDir": ".",
"lib": ["dom"]
},
"include": ["src", "test"]
}

View File

@ -125,12 +125,12 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
}
interface UpdateColorModeEvent extends Event {
isDark?: boolean
isDark?: string
}
const updateColorMode = (event: UpdateColorModeEvent) => {
const isDark = event.isDark ?? false
updateThemeLocally(isDark ? ThemeId.Dark : ThemeId.Light)
const isDark = event.isDark ?? "false"
updateThemeLocally(isDark === "true" ? ThemeId.Dark : ThemeId.Light)
}
const share = () => {

View File

@ -2458,7 +2458,7 @@
resolved "https://registry.yarnpkg.com/@google-cloud/opentelemetry-resource-util/-/opentelemetry-resource-util-1.1.0.tgz#0bd1fe708ba27288f6efc9712fbd3705fd325540"
integrity sha512-AXfQiqIxeespEYcRNaotC05ddiy2Vgk2yqY73b7Hl1UoJ75Gt4kSRcswrVn18eoDI0YQkSTBh7Ye9ugfFLN5HA==
"@google-cloud/paginator@^3.0.0", "@google-cloud/paginator@^3.0.6":
"@google-cloud/paginator@^3.0.0", "@google-cloud/paginator@^3.0.6", "@google-cloud/paginator@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b"
integrity sha512-jJNutk0arIQhmpUUQJPJErsojqo834KcyB6X7a1mxuic8i1tKXxde8E69IZxNZawRIlZdIK2QY4WALvlK5MzYQ==
@ -2548,6 +2548,30 @@
stream-events "^1.0.4"
xdg-basedir "^4.0.0"
"@google-cloud/storage@^6.4.1":
version "6.4.1"
resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-6.4.1.tgz#83334150d4e224cb48691de4d7f9c38e143a0970"
integrity sha512-lAddmRJ8tvxPykUqJfONBQA5XGwGk0vut1POXublc64+nCdB5aQMxwuBMf7J1zubx19QGpYPQwW6wR7YTWrvLw==
dependencies:
"@google-cloud/paginator" "^3.0.7"
"@google-cloud/projectify" "^3.0.0"
"@google-cloud/promisify" "^3.0.0"
abort-controller "^3.0.0"
arrify "^2.0.0"
async-retry "^1.3.3"
compressible "^2.0.12"
duplexify "^4.0.0"
ent "^2.2.0"
extend "^3.0.2"
gaxios "^5.0.0"
google-auth-library "^8.0.1"
mime "^3.0.0"
mime-types "^2.0.8"
p-limit "^3.0.1"
retry-request "^5.0.0"
teeny-request "^8.0.0"
uuid "^8.0.0"
"@google-cloud/tasks@^2.3.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@google-cloud/tasks/-/tasks-2.5.0.tgz#e6c2598038001550c408845e91570d176c18a25a"
@ -12450,6 +12474,11 @@ dotenv@^16.0.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411"
integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q==
dotenv@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==
dotenv@^8.0.0, dotenv@^8.2.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
@ -14122,7 +14151,7 @@ gaxios@^4.0.0:
is-stream "^2.0.0"
node-fetch "^2.3.0"
gaxios@^5.0.0:
gaxios@^5.0.0, gaxios@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-5.0.1.tgz#50fc76a2d04bc1700ed8c3ff1561e52255dfc6e0"
integrity sha512-keK47BGKHyyOVQxgcUaSaFvr3ehZYAlvhvpHXy0YB2itzZef+GqZR8TBsfVRWghdwlKrYsn+8L8i3eblF7Oviw==
@ -14512,6 +14541,21 @@ google-auth-library@^7.0.0, google-auth-library@^7.6.1, google-auth-library@^7.9
jws "^4.0.0"
lru-cache "^6.0.0"
google-auth-library@^8.0.1:
version "8.4.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.4.0.tgz#3a5414344bb313ee64ceeef1f7e5162cc1fdf04b"
integrity sha512-cg/usxyQEmq4PPDBQRt+kGIrfL3k+mOrAoS9Xv1hitQL66AoY7iWvRBcYo3Rb0w4V1t9e/GqW2/D4honlAtMDg==
dependencies:
arrify "^2.0.0"
base64-js "^1.3.0"
ecdsa-sig-formatter "^1.0.11"
fast-text-encoding "^1.0.0"
gaxios "^5.0.0"
gcp-metadata "^5.0.0"
gtoken "^6.1.0"
jws "^4.0.0"
lru-cache "^6.0.0"
google-auth-library@^8.0.2:
version "8.1.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.1.0.tgz#879e8d2e90a9d47e6eab32fd1d5fd9ed52d7d441"
@ -14744,6 +14788,15 @@ gtoken@^6.0.0:
google-p12-pem "^4.0.0"
jws "^4.0.0"
gtoken@^6.1.0:
version "6.1.1"
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-6.1.1.tgz#29ebf3e6893719176d180f5694f1cad525ce3c04"
integrity sha512-HPM4VzzPEGxjQ7T2xLrdSYBs+h1c0yHAUiN+8RHPDoiZbndlpg9Sx3SjWcrTt9+N3FHsSABEpjvdQVan5AAuZQ==
dependencies:
gaxios "^5.0.1"
google-p12-pem "^4.0.0"
jws "^4.0.0"
gzip-size@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"
@ -17490,6 +17543,17 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
linkedom@^0.14.12:
version "0.14.12"
resolved "https://registry.yarnpkg.com/linkedom/-/linkedom-0.14.12.tgz#3b19442e41de33a9ef9b035ccdd97bf5b66c77e1"
integrity sha512-8uw8LZifCwyWeVWr80T79sQTMmNXt4Da7oN5yH5gTXRqQM+TuZWJyBqRMcIp32zx/f8anHNHyil9Avw9y76ziQ==
dependencies:
css-select "^5.1.0"
cssom "^0.5.0"
html-escaper "^3.0.3"
htmlparser2 "^8.0.1"
uhyphen "^0.1.0"
linkedom@^0.14.9:
version "0.14.9"
resolved "https://registry.yarnpkg.com/linkedom/-/linkedom-0.14.9.tgz#34c6f15eddc809406f42d8ee48cd30b0222eccb0"