Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1079
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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")
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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() {
|
||||
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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')
|
||||
}
|
||||
|
||||
@ -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
|
||||
}`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
11
packages/db/migrations/0095.do.add_rls_to_speech.sql
Executable file
11
packages/db/migrations/0095.do.add_rls_to_speech.sql
Executable 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;
|
||||
9
packages/db/migrations/0095.undo.add_rls_to_speech.sql
Executable file
9
packages/db/migrations/0095.undo.add_rls_to_speech.sql
Executable 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;
|
||||
4
packages/text-to-speech/.eslintignore
Normal file
4
packages/text-to-speech/.eslintignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
readabilityjs/
|
||||
src/generated/
|
||||
6
packages/text-to-speech/.eslintrc
Normal file
6
packages/text-to-speech/.eslintrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
16
packages/text-to-speech/.gcloudignore
Normal file
16
packages/text-to-speech/.gcloudignore
Normal 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
|
||||
5
packages/text-to-speech/mocha-config.json
Normal file
5
packages/text-to-speech/mocha-config.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "test/**/*.test.ts",
|
||||
"require": "test/babel-register.js"
|
||||
}
|
||||
36
packages/text-to-speech/package.json
Normal file
36
packages/text-to-speech/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
185
packages/text-to-speech/src/htmlToSsml.ts
Normal file
185
packages/text-to-speech/src/htmlToSsml.ts
Normal 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
|
||||
}
|
||||
411
packages/text-to-speech/src/index.ts
Normal file
411
packages/text-to-speech/src/index.ts
Normal 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(/ |\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')
|
||||
}
|
||||
)
|
||||
3
packages/text-to-speech/test/babel-register.js
Normal file
3
packages/text-to-speech/test/babel-register.js
Normal file
@ -0,0 +1,3 @@
|
||||
const register = require('@babel/register').default
|
||||
|
||||
register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] })
|
||||
99
packages/text-to-speech/test/htmlToSsml.test.ts
Normal file
99
packages/text-to-speech/test/htmlToSsml.test.ts
Normal 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)
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
})
|
||||
13
packages/text-to-speech/test/stub.test.ts
Normal file
13
packages/text-to-speech/test/stub.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
9
packages/text-to-speech/tsconfig.json
Normal file
9
packages/text-to-speech/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@tsconfig/node14/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build",
|
||||
"rootDir": ".",
|
||||
"lib": ["dom"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
68
yarn.lock
68
yarn.lock
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user