mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1078
This commit is contained in:
commit
6df73f6d5d
112 changed files with 3845 additions and 619 deletions
|
|
@ -153,7 +153,9 @@ dependencies {
|
|||
implementation 'com.google.android.gms:play-services-auth:20.2.0'
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
|
||||
|
||||
implementation("io.coil-kt:coil-compose:2.2.0")
|
||||
implementation 'io.coil-kt:coil-compose:2.2.0'
|
||||
|
||||
implementation 'com.google.code.gson:gson:2.8.6'
|
||||
}
|
||||
|
||||
apollo {
|
||||
|
|
|
|||
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/CrimsonText-Regular.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Black-900.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Black-900.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Bold-700.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Bold-700.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-ExtraBold-800.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-ExtraBold-800.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-ExtraLight-200.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-ExtraLight-200.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Light-300.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Light-300.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Medium-500.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Medium-500.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Regular-400.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Regular-400.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-SemiBold-600.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-SemiBold-600.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Inter-Thin.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Inter-Thin.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Lora-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Lora-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Lora-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Lora-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Lora-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Lora-Regular.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Merriweather-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Merriweather-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Merriweather-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Merriweather-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Merriweather-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Merriweather-Regular.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/OpenDyslexicAlta-Bold.otf
Normal file
BIN
android/Omnivore/app/src/main/assets/OpenDyslexicAlta-Bold.otf
Normal file
Binary file not shown.
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/OpenDyslexicAlta-Italic.otf
Normal file
BIN
android/Omnivore/app/src/main/assets/OpenDyslexicAlta-Italic.otf
Normal file
Binary file not shown.
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/OpenSans-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/OpenSans-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/OpenSans-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/OpenSans-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/OpenSans-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Roboto-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Roboto-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Roboto-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/Roboto-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/SFMonoRegular.otf
Normal file
BIN
android/Omnivore/app/src/main/assets/SFMonoRegular.otf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Bold.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Bold.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Italic.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Italic.ttf
Normal file
Binary file not shown.
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Regular.ttf
Normal file
BIN
android/Omnivore/app/src/main/assets/SourceSerifPro-Regular.ttf
Normal file
Binary file not shown.
2
android/Omnivore/app/src/main/assets/bundle.js
Normal file
2
android/Omnivore/app/src/main/assets/bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
android/Omnivore/app/src/main/assets/highlight-dark.css
Normal file
1
android/Omnivore/app/src/main/assets/highlight-dark.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#ddd;background:#303030}.hljs-keyword,.hljs-link,.hljs-literal,.hljs-section,.hljs-selector-tag{color:#fff}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#d88}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#979797}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
1
android/Omnivore/app/src/main/assets/highlight.css
Normal file
1
android/Omnivore/app/src/main/assets/highlight.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
MathJax = {
|
||||
tex: {
|
||||
inlineMath: [
|
||||
['$latex', '$'],
|
||||
['\\(', '\\)'],
|
||||
],
|
||||
},
|
||||
}
|
||||
1
android/Omnivore/app/src/main/assets/mathjax.js
Normal file
1
android/Omnivore/app/src/main/assets/mathjax.js
Normal file
File diff suppressed because one or more lines are too long
64
android/Omnivore/app/src/main/graphql/ArticleContent.graphql
Normal file
64
android/Omnivore/app/src/main/graphql/ArticleContent.graphql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
query GetArticle($slug: String!) {
|
||||
article(username: "me", slug: $slug) {
|
||||
... on ArticleSuccess {
|
||||
article {
|
||||
...ArticleFields
|
||||
content
|
||||
highlights(input: { includeFriends: false }) {
|
||||
...HighlightFields
|
||||
}
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ArticleError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment ArticleFields on Article {
|
||||
id
|
||||
title
|
||||
url
|
||||
author
|
||||
image
|
||||
savedAt
|
||||
createdAt
|
||||
publishedAt
|
||||
contentReader
|
||||
originalArticleUrl
|
||||
readingProgressPercent
|
||||
readingProgressAnchorIndex
|
||||
slug
|
||||
isArchived
|
||||
description
|
||||
linkId
|
||||
siteName
|
||||
state
|
||||
readAt
|
||||
updatedAt
|
||||
content
|
||||
}
|
||||
|
||||
fragment HighlightFields on Highlight {
|
||||
id
|
||||
shortId
|
||||
quote
|
||||
prefix
|
||||
suffix
|
||||
patch
|
||||
annotation
|
||||
createdByMe
|
||||
updatedAt
|
||||
sharedAt
|
||||
}
|
||||
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ query Search($after: String, $first: Int, $query: String) {
|
|||
siteName
|
||||
subscription
|
||||
readAt
|
||||
savedAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import androidx.core.view.WindowCompat
|
|||
import androidx.core.view.WindowInsetsCompat
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.home.HomeViewModel
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
|
||||
import app.omnivore.omnivore.ui.root.RootView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
val loginViewModel: LoginViewModel by viewModels()
|
||||
val homeViewModel: HomeViewModel by viewModels()
|
||||
val webReaderViewModel: WebReaderViewModel by viewModels()
|
||||
|
||||
setContent {
|
||||
OmnivoreTheme {
|
||||
|
|
@ -34,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
|||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
) {
|
||||
RootView(loginViewModel, homeViewModel)
|
||||
RootView(loginViewModel, homeViewModel, webReaderViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ package app.omnivore.omnivore
|
|||
|
||||
sealed class Routes(val route: String) {
|
||||
object Home : Routes("Home")
|
||||
object WebReader : Routes("WebReader")
|
||||
object WebAppReader : Routes("WebAppReader")
|
||||
object Settings: Routes("Settings")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package app.omnivore.omnivore.models
|
||||
|
||||
data class Highlight(
|
||||
val id: String,
|
||||
val shortId: String,
|
||||
val quote: String,
|
||||
val prefix: String?,
|
||||
val suffix: String?,
|
||||
val patch: String,
|
||||
val annotation: String?,
|
||||
val createdAt: Any?,
|
||||
val updatedAt: Any?,
|
||||
val createdByMe : Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package app.omnivore.omnivore.models
|
||||
|
||||
import androidx.core.net.toUri
|
||||
|
||||
data class LinkedItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val createdAt: Any,
|
||||
val savedAt: Any,
|
||||
val readAt: Any?,
|
||||
val updatedAt: Any?,
|
||||
val readingProgress: Double,
|
||||
val readingProgressAnchor: Int,
|
||||
val imageURLString: String?,
|
||||
val pageURLString: String,
|
||||
val descriptionText: String?,
|
||||
val publisherURLString: String?,
|
||||
val siteName: String?,
|
||||
val author: String?,
|
||||
val publishDate: Any?,
|
||||
val slug: String,
|
||||
val isArchived: Boolean,
|
||||
val contentReader: String?,
|
||||
val content: String?
|
||||
) {
|
||||
fun publisherDisplayName(): String? {
|
||||
return publisherURLString?.toUri()?.host
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package app.omnivore.omnivore.models
|
||||
|
||||
data class LinkedItemLabel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val color: String,
|
||||
val createdAt: Any?,
|
||||
val labelDescription: String?,
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.Routes
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package app.omnivore.omnivore.ui.home
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -8,6 +7,7 @@ import app.omnivore.omnivore.Constants
|
|||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.graphql.generated.SearchQuery
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -88,14 +88,22 @@ class HomeViewModel @Inject constructor(
|
|||
id = it.node.id,
|
||||
title = it.node.title,
|
||||
createdAt = it.node.createdAt,
|
||||
savedAt = it.node.savedAt,
|
||||
readAt = it.node.readAt,
|
||||
updatedAt = it.node.updatedAt,
|
||||
readingProgress = it.node.readingProgressPercent,
|
||||
readingProgressAnchor = it.node.readingProgressAnchorIndex,
|
||||
imageURLString = it.node.image,
|
||||
pageURLString = it.node.url,
|
||||
descriptionText = it.node.description,
|
||||
publisherURLString = it.node.originalArticleUrl,
|
||||
siteName = it.node.siteName,
|
||||
author = it.node.author,
|
||||
slug = it.node.slug
|
||||
publishDate = it.node.publishedAt,
|
||||
slug = it.node.slug,
|
||||
isArchived = it.node.isArchived,
|
||||
contentReader = it.node.contentReader.rawValue,
|
||||
content = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -121,30 +129,3 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
public data class LinkedItem(
|
||||
public val id: String,
|
||||
public val title: String,
|
||||
public val createdAt: Any,
|
||||
// public val savedAt: Any,
|
||||
public val readAt: Any?,
|
||||
// public val updatedAt: Any,
|
||||
public val readingProgress: Double,
|
||||
public val readingProgressAnchor: Int,
|
||||
public val imageURLString: String?,
|
||||
// public val onDeviceImageURLString: String?,
|
||||
// public val documentDirectoryPath: String?,
|
||||
// public val pageURLString: String,
|
||||
public val descriptionText: String?,
|
||||
public val publisherURLString: String?,
|
||||
// public val siteName: String?,
|
||||
public val author: String?,
|
||||
// public val publishDate: Any?,
|
||||
public val slug: String,
|
||||
// public val isArchived: Boolean,
|
||||
// public val contentReader: String?,
|
||||
// public val originalHtml: String?,
|
||||
) {
|
||||
fun publisherDisplayName(): String? {
|
||||
return publisherURLString?.toUri()?.host
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
||||
@Composable
|
||||
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
|
||||
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
|
||||
|
||||
if (webReaderParams == null) {
|
||||
webReaderViewModel.loadItem(slug = slug)
|
||||
}
|
||||
|
||||
if (webReaderParams != null) {
|
||||
WebReader(webReaderParams!!)
|
||||
} else {
|
||||
// TODO: add a proper loading view
|
||||
Text("Loading...")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun WebReader(params: WebReaderParams) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
|
||||
val webReaderContent = WebReaderContent(
|
||||
textFontSize = 12,
|
||||
lineHeight = 150,
|
||||
maxWidthPercentage = 100,
|
||||
item = params.item,
|
||||
themeKey = "LightGray",
|
||||
fontFamily = WebFont.SYSTEM ,
|
||||
articleContent = params.articleContent,
|
||||
prefersHighContrastText = false,
|
||||
)
|
||||
|
||||
val styledContent = webReaderContent.styledContent()
|
||||
|
||||
AndroidView(factory = {
|
||||
WebView(it).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
settings.javaScriptEnabled = true
|
||||
settings.allowContentAccess = true
|
||||
settings.allowFileAccess = true
|
||||
settings.domStorageEnabled = true
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
}
|
||||
|
||||
loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
|
||||
|
||||
}
|
||||
}, update = {
|
||||
it.loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import android.util.Log
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
|
||||
enum class WebFont(val displayText: String, val rawValue: String) {
|
||||
INTER("Inter", "Inter"),
|
||||
SYSTEM("System Default", "unset"),
|
||||
OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"),
|
||||
MERRIWEATHER("Merriweather", "Merriweather"),
|
||||
LORA("Lora", "Lora"),
|
||||
OPEN_SANS("Open Sans", "Open Sans"),
|
||||
ROBOTO("Roboto", "Roboto"),
|
||||
CRIMSON_TEXT("Crimson Text", "Crimson Text"),
|
||||
SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"),
|
||||
Inter("Inter", "Inter"),
|
||||
}
|
||||
|
||||
enum class ArticleContentStatus(val rawValue: String) {
|
||||
FAILED("FAILED"),
|
||||
PROCESSING("PROCESSING"),
|
||||
SUCCEEDED("SUCCEEDED"),
|
||||
UNKNOWN("UNKNOWN")
|
||||
}
|
||||
|
||||
data class ArticleContent(
|
||||
val title: String,
|
||||
val htmlContent: String,
|
||||
val highlightsJSONString: String,
|
||||
val contentStatus: String, // ArticleContentStatus,
|
||||
val objectID: String?, // whatever the Room Equivalent of objectID is
|
||||
val labelsJSONString: String
|
||||
)
|
||||
|
||||
data class WebReaderContent(
|
||||
val textFontSize: Int,
|
||||
val lineHeight: Int,
|
||||
val maxWidthPercentage: Int,
|
||||
val item: LinkedItem,
|
||||
val themeKey: String,
|
||||
val fontFamily: WebFont,
|
||||
val articleContent: ArticleContent,
|
||||
val prefersHighContrastText: Boolean
|
||||
) {
|
||||
fun styledContent(): String {
|
||||
// TODO: Kotlinize these three values (pasted from Swift)
|
||||
val savedAt = "new Date(1662571290735.0).toISOString()"
|
||||
val createdAt = "new Date().toISOString()"
|
||||
val publishedAt = "new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined"
|
||||
|
||||
val content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight${if (themeKey == "Gray") "-dark" else ""}.css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-htmlContent'>
|
||||
${articleContent.htmlContent}
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
window.omnivoreEnv = {
|
||||
"NEXT_PUBLIC_APP_ENV": "prod",
|
||||
"NEXT_PUBLIC_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
|
||||
}
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: "${item.id}",
|
||||
linkId: "${item.id}",
|
||||
slug: "${item.slug}",
|
||||
createdAt: new Date(1662571290735.0).toISOString(),
|
||||
savedAt: new Date(1662571290981.0).toISOString(),
|
||||
publishedAt: new Date(1662454816000.0).toISOString(),
|
||||
url: `${item.pageURLString}`,
|
||||
title: `${articleContent.title.replace("`", "\\`")}`,
|
||||
content: document.getElementById('_omnivore-htmlContent').innerHTML,
|
||||
originalArticleUrl: "${item.pageURLString}",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: ${item.readingProgress},
|
||||
readingProgressAnchorIndex: ${item.readingProgressAnchor},
|
||||
labels: ${articleContent.labelsJSONString},
|
||||
highlights: ${articleContent.highlightsJSONString},
|
||||
}
|
||||
|
||||
window.fontSize = $textFontSize
|
||||
window.fontFamily = "${fontFamily.rawValue}"
|
||||
window.maxWidthPercentage = $maxWidthPercentage
|
||||
window.lineHeight = $lineHeight
|
||||
window.localStorage.setItem("theme", "$themeKey")
|
||||
window.prefersHighContrastFont = $prefersHighContrastText
|
||||
window.enableHighlightBar = false
|
||||
</script>
|
||||
<script src="bundle.js"></script>
|
||||
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
|
||||
<script src="mathjax.js" id="MathJax-script"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Log.d("Loggo", content)
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.Constants
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
|
||||
import app.omnivore.omnivore.models.Highlight
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import app.omnivore.omnivore.models.LinkedItemLabel
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.google.gson.Gson
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
data class WebReaderParams(
|
||||
val item: LinkedItem,
|
||||
val articleContent: ArticleContent
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class WebReaderViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
): ViewModel() {
|
||||
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
|
||||
|
||||
private fun getAuthToken(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
|
||||
}
|
||||
|
||||
fun loadItem(slug: String) {
|
||||
viewModelScope.launch {
|
||||
val authToken = getAuthToken()
|
||||
|
||||
val apolloClient = ApolloClient.Builder()
|
||||
.serverUrl("${Constants.apiURL}/api/graphql")
|
||||
.addHttpHeader("Authorization", value = authToken ?: "")
|
||||
.build()
|
||||
|
||||
val response = apolloClient.query(
|
||||
GetArticleQuery(slug = slug)
|
||||
).execute()
|
||||
|
||||
val article = response.data?.article?.onArticleSuccess?.article ?: return@launch
|
||||
|
||||
val labels = article.labels ?: listOf()
|
||||
|
||||
val linkedItemLabels = labels.map {
|
||||
LinkedItemLabel(
|
||||
id = it.labelFields.id,
|
||||
name = it.labelFields.name,
|
||||
color = it.labelFields.color,
|
||||
createdAt = it.labelFields.createdAt,
|
||||
labelDescription = it.labelFields.description
|
||||
)
|
||||
}
|
||||
|
||||
val highlights = article.highlights.map {
|
||||
Highlight(
|
||||
id = it.highlightFields.id,
|
||||
shortId = it.highlightFields.shortId,
|
||||
quote = it.highlightFields.quote,
|
||||
prefix = it.highlightFields.prefix,
|
||||
suffix = it.highlightFields.suffix,
|
||||
patch = it.highlightFields.patch,
|
||||
annotation = it.highlightFields.annotation,
|
||||
createdAt = null,
|
||||
updatedAt = it.highlightFields.updatedAt,
|
||||
createdByMe = it.highlightFields.createdByMe,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: handle errors
|
||||
|
||||
val linkedItem = LinkedItem(
|
||||
id = article.articleFields.id,
|
||||
title = article.articleFields.title,
|
||||
createdAt = article.articleFields.createdAt,
|
||||
savedAt = article.articleFields.savedAt,
|
||||
readAt = article.articleFields.readAt,
|
||||
updatedAt = article.articleFields.updatedAt,
|
||||
readingProgress = article.articleFields.readingProgressPercent,
|
||||
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
|
||||
imageURLString = article.articleFields.image,
|
||||
pageURLString = article.articleFields.url,
|
||||
descriptionText = article.articleFields.description,
|
||||
publisherURLString = article.articleFields.originalArticleUrl,
|
||||
siteName = article.articleFields.siteName,
|
||||
author = article.articleFields.author,
|
||||
publishDate = article.articleFields.publishedAt,
|
||||
slug = article.articleFields.slug,
|
||||
isArchived = article.articleFields.isArchived,
|
||||
contentReader = article.articleFields.contentReader.rawValue,
|
||||
content = article.articleFields.content
|
||||
)
|
||||
|
||||
val articleContent = ArticleContent(
|
||||
title = article.articleFields.title,
|
||||
htmlContent = article.articleFields.content ?: "",
|
||||
highlightsJSONString = Gson().toJson(highlights),
|
||||
contentStatus = "SUCCEEDED",
|
||||
objectID = "",
|
||||
labelsJSONString = Gson().toJson(linkedItemLabels)
|
||||
)
|
||||
|
||||
webReaderParamsLiveData.value = WebReaderParams(linkedItem, articleContent)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
webReaderParamsLiveData.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -19,12 +19,16 @@ import app.omnivore.omnivore.ui.auth.WelcomeScreen
|
|||
import app.omnivore.omnivore.ui.home.HomeView
|
||||
import app.omnivore.omnivore.ui.home.HomeViewModel
|
||||
import app.omnivore.omnivore.ui.reader.ArticleWebView
|
||||
import app.omnivore.omnivore.ui.reader.WebReader
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainer
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun RootView(
|
||||
loginViewModel: LoginViewModel,
|
||||
homeViewModel: HomeViewModel
|
||||
homeViewModel: HomeViewModel,
|
||||
webReaderViewModel: WebReaderViewModel
|
||||
) {
|
||||
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
val systemUiController = rememberSystemUiController()
|
||||
|
|
@ -46,7 +50,8 @@ fun RootView(
|
|||
if (hasAuthToken) {
|
||||
PrimaryNavigator(
|
||||
loginViewModel = loginViewModel,
|
||||
homeViewModel = homeViewModel
|
||||
homeViewModel = homeViewModel,
|
||||
webReaderViewModel = webReaderViewModel
|
||||
)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = loginViewModel)
|
||||
|
|
@ -57,7 +62,8 @@ fun RootView(
|
|||
@Composable
|
||||
fun PrimaryNavigator(
|
||||
loginViewModel: LoginViewModel,
|
||||
homeViewModel: HomeViewModel
|
||||
homeViewModel: HomeViewModel,
|
||||
webReaderViewModel: WebReaderViewModel
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
|
|
@ -69,13 +75,23 @@ fun PrimaryNavigator(
|
|||
)
|
||||
}
|
||||
|
||||
composable("WebReader/{slug}") {
|
||||
// TODO: delete this route and views
|
||||
composable("WebAppReader/{slug}") {
|
||||
ArticleWebView(
|
||||
it.arguments?.getString("slug") ?: "",
|
||||
authCookieString = loginViewModel.getAuthCookieString() ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
composable("WebReader/{slug}") {
|
||||
webReaderViewModel.reset() // clear previously loaded item
|
||||
|
||||
WebReaderLoadingContainer(
|
||||
it.arguments?.getString("slug") ?: "",
|
||||
webReaderViewModel = webReaderViewModel
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsView(loginViewModel = loginViewModel, navController = navController)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ public struct ShareExtensionView: View {
|
|||
VStack(alignment: .leading) {
|
||||
Text(viewModel.title ?? "")
|
||||
.lineLimit(1)
|
||||
.foregroundColor(.appGrayText)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(Font.system(size: 15, weight: .semibold))
|
||||
Text(viewModel.url ?? "")
|
||||
.lineLimit(1)
|
||||
|
|
@ -137,7 +137,7 @@ public struct ShareExtensionView: View {
|
|||
public var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(titleText)
|
||||
.foregroundColor(.appGrayText)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 23)
|
||||
|
|
@ -171,7 +171,7 @@ public struct ShareExtensionView: View {
|
|||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Text("Dismiss")
|
||||
Text("Read Later")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public struct MiniPlayer: View {
|
|||
@State var expanded = false
|
||||
@State var offset: CGFloat = 0
|
||||
@State var showVoiceSheet = false
|
||||
@State var showLanguageSheet = false
|
||||
@Namespace private var animation
|
||||
|
||||
let minExpandedHeight = UIScreen.main.bounds.height / 3
|
||||
|
|
@ -128,6 +129,19 @@ public struct MiniPlayer: View {
|
|||
}
|
||||
}
|
||||
|
||||
func defaultArtwork(forDimensions dim: Double) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Color.appButtonBackground
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
|
||||
Image(systemName: "headphones")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: dim / 2, height: dim / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
|
||||
GeometryReader { geom in
|
||||
|
|
@ -156,16 +170,24 @@ public struct MiniPlayer: View {
|
|||
let maxSize = 2 * (min(geom.size.width, geom.size.height) / 3)
|
||||
let dim = expanded ? maxSize : 64
|
||||
|
||||
AsyncImage(url: itemAudioProperties.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 let imageURL = itemAudioProperties.imageURL {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
} else if phase.error != nil {
|
||||
defaultArtwork(forDimensions: dim)
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
defaultArtwork(forDimensions: dim)
|
||||
}
|
||||
|
||||
if !expanded {
|
||||
|
|
@ -201,28 +223,14 @@ public struct MiniPlayer: View {
|
|||
|
||||
HStack {
|
||||
Spacer()
|
||||
if let author = itemAudioProperties.author {
|
||||
Text(author)
|
||||
if let byline = itemAudioProperties.byline {
|
||||
Text(byline)
|
||||
.lineLimit(1)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
if itemAudioProperties.author != nil, itemAudioProperties.siteName != nil {
|
||||
Text(" • ")
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
if let siteName = itemAudioProperties.siteName {
|
||||
Text(siteName)
|
||||
.lineLimit(1)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +332,23 @@ public struct MiniPlayer: View {
|
|||
.onTapGesture {
|
||||
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
|
||||
}.sheet(isPresented: $showVoiceSheet) {
|
||||
changeVoiceView
|
||||
NavigationView {
|
||||
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage)
|
||||
.navigationBarTitle("Voice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) {
|
||||
Image(systemName: "chevron.backward")
|
||||
})
|
||||
}
|
||||
}.sheet(isPresented: $showLanguageSheet) {
|
||||
NavigationView {
|
||||
TextToSpeechLanguageView()
|
||||
.navigationBarTitle("Language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) {
|
||||
Image(systemName: "chevron.backward")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ import Views
|
|||
// because it will kick off the user's future items being automatically transcribed.
|
||||
// This happens because when an article is saved, we check if the user has a recent
|
||||
// listen. If they do, we will automatically transcribe their message.
|
||||
if let first = newItems.first?.id {
|
||||
if let first = newItems.filter({ !$0.isPDF }).first?.id {
|
||||
_ = await audioController.preload(itemIDs: [first])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink(destination: TextToSpeechView()) {
|
||||
Text("Text to Speech")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct TextToSpeechLanguageView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
ForEach(VOICELANGUAGES, id: \.key.self) { language in
|
||||
Button(action: {
|
||||
audioController.defaultLanguage = language.key
|
||||
}) {
|
||||
HStack {
|
||||
Text(language.name)
|
||||
|
||||
Spacer()
|
||||
|
||||
if audioController.defaultLanguage == language.key {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct TextToSpeechView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
Section("Audio Settings") {
|
||||
Toggle("Enable audio prefetch", isOn: $audioController.preloadEnabled)
|
||||
}
|
||||
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Default Language")) {
|
||||
Text("Default Language")
|
||||
}
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Section("Voices") {
|
||||
ForEach(VOICELANGUAGES, id: \.key) { language in
|
||||
NavigationLink(destination: TextToSpeechVoiceSelectionView(forLanguage: language)) {
|
||||
Text(language.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -82,8 +82,7 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
},
|
||||
label: {
|
||||
Image(systemName: audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle")
|
||||
.font(.appTitleTwo)
|
||||
textToSpeechButtonImage
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
|
@ -91,6 +90,14 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
var textToSpeechButtonImage: some View {
|
||||
if audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id {
|
||||
return Image(systemName: "headphones").font(Font.system(size: 19))
|
||||
}
|
||||
let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle"
|
||||
return Image(systemName: name).font(.appTitleTwo)
|
||||
}
|
||||
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ struct WebReaderContent {
|
|||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div>HIIIIII</div>
|
||||
<div id='_omnivore-htmlContent' style="display: none;">
|
||||
\(articleContent.htmlContent)
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ import Views
|
|||
@Published var item: LinkedItem?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
func loadItem(dataService: DataService, requestID: String) async {
|
||||
guard let objectID = try? await dataService.loadItemContentUsingRequestID(requestID: requestID) else { return }
|
||||
func loadItem(dataService: DataService, username: String, requestID: String) async {
|
||||
guard let objectID = try? await dataService.loadItemContentUsingRequestID(username: username,
|
||||
requestID: requestID)
|
||||
else {
|
||||
return
|
||||
}
|
||||
item = dataService.viewContext.object(with: objectID) as? LinkedItem
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +64,13 @@ public struct WebReaderLoadingContainer: View {
|
|||
Text(errorMessage)
|
||||
} else {
|
||||
ProgressView()
|
||||
.task { await viewModel.loadItem(dataService: dataService, requestID: requestID) }
|
||||
.task {
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await viewModel.loadItem(dataService: dataService, username: username, requestID: requestID)
|
||||
} else {
|
||||
viewModel.errorMessage = "You are not logged in."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21A559" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21279" systemVersion="21G115" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="annotation" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="imageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="isArchived" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="language" optional="YES" attributeType="String"/>
|
||||
<attribute name="localPDF" optional="YES" attributeType="String"/>
|
||||
<attribute name="onDeviceImageURLString" optional="YES" attributeType="String"/>
|
||||
<attribute name="originalHtml" optional="YES" attributeType="String"/>
|
||||
|
|
@ -91,11 +92,4 @@
|
|||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Highlight" positionX="27" positionY="225" width="128" height="224"/>
|
||||
<element name="LinkedItem" positionX="-18" positionY="63" width="128" height="464"/>
|
||||
<element name="LinkedItemLabel" positionX="-36" positionY="18" width="128" height="134"/>
|
||||
<element name="NewsletterEmail" positionX="0" positionY="180" width="128" height="74"/>
|
||||
<element name="Viewer" positionX="45" positionY="234" width="128" height="89"/>
|
||||
</elements>
|
||||
</model>
|
||||
|
|
@ -16,9 +16,9 @@ public struct LinkedItemAudioProperties {
|
|||
public let itemID: String
|
||||
public let objectID: NSManagedObjectID
|
||||
public let title: String
|
||||
public let author: String?
|
||||
public let siteName: String?
|
||||
public let byline: String?
|
||||
public let imageURL: URL?
|
||||
public let language: String?
|
||||
}
|
||||
|
||||
// Internal model used for parsing a push notification object only
|
||||
|
|
@ -36,6 +36,7 @@ public struct JSONArticle: Decodable {
|
|||
public let contentReader: String
|
||||
public let url: String
|
||||
public let isArchived: Bool
|
||||
public let language: String?
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
|
|
@ -93,14 +94,29 @@ public extension LinkedItem {
|
|||
return String(data: JSON, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
var formattedByline: String {
|
||||
var byline = ""
|
||||
|
||||
if let author = author {
|
||||
byline += author
|
||||
}
|
||||
if author != nil, publisherDisplayName != nil {
|
||||
byline += " • "
|
||||
}
|
||||
if let publisherDisplayName = publisherDisplayName {
|
||||
byline += publisherDisplayName
|
||||
}
|
||||
return byline
|
||||
}
|
||||
|
||||
var audioProperties: LinkedItemAudioProperties {
|
||||
LinkedItemAudioProperties(
|
||||
itemID: unwrappedID,
|
||||
objectID: objectID,
|
||||
title: unwrappedTitle,
|
||||
author: author,
|
||||
siteName: siteName,
|
||||
imageURL: imageURL
|
||||
byline: formattedByline,
|
||||
imageURL: imageURL,
|
||||
language: language
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,50 +32,232 @@ enum DownloadPriority: String {
|
|||
case high
|
||||
}
|
||||
|
||||
struct VoicePair {
|
||||
public struct VoiceLanguage {
|
||||
public let key: String
|
||||
public let name: String
|
||||
public let defaultVoice: String
|
||||
public let categories: [VoiceCategory]
|
||||
}
|
||||
|
||||
public enum VoiceCategory: String, CaseIterable {
|
||||
case enUS = "English (US)"
|
||||
case enAU = "English (Australia)"
|
||||
case enCA = "English (Canada)"
|
||||
case enIE = "English (Ireland)"
|
||||
case enIN = "English (India)"
|
||||
case enSG = "English (Singapore)"
|
||||
case enUK = "English (UK)"
|
||||
case deDE = "German (Germany)"
|
||||
case esES = "Spanish (Spain)"
|
||||
case jaJP = "Japanese (Japan)"
|
||||
case zhCN = "Chinese (China Mainland)"
|
||||
}
|
||||
|
||||
public struct VoicePair {
|
||||
let firstKey: String
|
||||
let secondKey: String
|
||||
|
||||
let firstName: String
|
||||
let secondName: String
|
||||
|
||||
let language: String
|
||||
let category: VoiceCategory
|
||||
}
|
||||
|
||||
// swiftlint:disable all
|
||||
let VOICES = [
|
||||
VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny (USA)", secondName: "Brandon (USA)"),
|
||||
VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora (USA)", secondName: "Christopher (USA)"),
|
||||
VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth (USA)", secondName: "Eric (USA)"),
|
||||
VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara (Canada)", secondName: "Liam (Canada)"),
|
||||
VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby (UK)", secondName: "Ethan (UK)"),
|
||||
VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha (Australia)", secondName: "William (Australia)"),
|
||||
VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja (India)", secondName: "Prabhat (India)"),
|
||||
VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna (Singapore)", secondName: "Wayne (Singapore)")
|
||||
private let ENGLISH = VoiceLanguage(key: "en",
|
||||
name: "English",
|
||||
defaultVoice: "en-US-ChristopherNeural",
|
||||
categories: [.enUS, .enAU, .enCA, .enIE, .enIN, .enSG, .enUK])
|
||||
|
||||
public let VOICELANGUAGES = [
|
||||
ENGLISH,
|
||||
VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
|
||||
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
|
||||
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
|
||||
VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]),
|
||||
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES])
|
||||
]
|
||||
|
||||
// swiftlint:disable all
|
||||
public let VOICES = [
|
||||
// en
|
||||
VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny", secondName: "Brandon", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora", secondName: "Christopher", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "en-US-ElizabethNeural", secondKey: "en-US-EricNeural", firstName: "Elizabeth", secondName: "Eric", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "en-CA-ClaraNeural", secondKey: "en-CA-LiamNeural", firstName: "Clara", secondName: "Liam", language: "en-CA", category: .enCA),
|
||||
VoicePair(firstKey: "en-GB-LibbyNeural", secondKey: "en-GB-EthanNeural", firstName: "Libby", secondName: "Ethan", language: "en-GB", category: .enUK),
|
||||
VoicePair(firstKey: "en-AU-NatashaNeural", secondKey: "en-AU-WilliamNeural", firstName: "Natasha", secondName: "William", language: "en-AU", category: .enAU),
|
||||
VoicePair(firstKey: "en-IE-ConnorNeural", secondKey: "en-IE-EmilyNeural", firstName: "Connor", secondName: "Emily", language: "en-IE", category: .enIE),
|
||||
VoicePair(firstKey: "en-IN-NeerjaNeural", secondKey: "en-IN-PrabhatNeural", firstName: "Neerja", secondName: "Prabhat", language: "en-IN", category: .enIN),
|
||||
VoicePair(firstKey: "en-SG-LunaNeural", secondKey: "en-SG-WayneNeural", firstName: "Luna", secondName: "Wayne", language: "en-SG", category: .enSG),
|
||||
|
||||
VoicePair(firstKey: "es-ES-AlvaroNeural", secondKey: "es-ES-ElviraNeural", firstName: "Alvaro", secondName: "Elvira", language: "es-ES", category: .esES),
|
||||
VoicePair(firstKey: "de-CH-LeniNeural", secondKey: "de-DE-KatjaNeural", firstName: "Leni", secondName: "Katja", language: "de-DE", category: .deDE),
|
||||
VoicePair(firstKey: "de-DE-AmalaNeural", secondKey: "de-DE-BerndNeural", firstName: "Amala", secondName: "Bernd", language: "de-DE", category: .deDE),
|
||||
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
|
||||
|
||||
// ja
|
||||
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP),
|
||||
|
||||
// zh
|
||||
VoicePair(firstKey: "zh-CN-XiaochenNeural", secondKey: "zh-CN-XiaohanNeural", firstName: "Xiaochen", secondName: "Xiaohan", language: "zh-CN", category: .zhCN),
|
||||
VoicePair(firstKey: "zh-CN-XiaoxiaoNeural", secondKey: "zh-CN-YunyangNeural", firstName: "Xiaoxiao", secondName: "Yunyang", language: "zh-CN", category: .zhCN)
|
||||
]
|
||||
|
||||
let VOICE_REGIONS = ["English "]
|
||||
|
||||
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
|
||||
class SpeechPlayerItem: AVPlayerItem {
|
||||
let resourceLoaderDelegate = ResourceLoaderDelegate()
|
||||
let session: AudioController
|
||||
let speechItem: SpeechItem
|
||||
let completed: () -> Void
|
||||
|
||||
var observer: Any?
|
||||
|
||||
init(session: AudioController, speechItem: SpeechItem, url: URL, completed: @escaping () -> Void) {
|
||||
self.session = session
|
||||
init(session: AudioController, speechItem: SpeechItem, completed: @escaping () -> Void) {
|
||||
self.speechItem = speechItem
|
||||
self.session = session
|
||||
self.completed = completed
|
||||
|
||||
let asset = AVAsset(url: url)
|
||||
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
|
||||
session.updateDuration(forItem: speechItem, newDuration: CMTimeGetSeconds(asset.duration))
|
||||
|
||||
self.observer = observe(\.status, options: [.new]) { item, _ in
|
||||
item.session.updateDuration(forItem: item.speechItem, newDuration: CMTimeGetSeconds(item.duration))
|
||||
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { _ in
|
||||
let asset = AVURLAsset(url: fakeUrl)
|
||||
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
|
||||
|
||||
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
|
||||
|
||||
resourceLoaderDelegate.owner = self
|
||||
|
||||
self.observer = observe(\.status, options: [.new]) { item, _ in
|
||||
if item.status == .readyToPlay {
|
||||
let duration = CMTimeGetSeconds(item.duration)
|
||||
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.completed()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
observer = nil
|
||||
resourceLoaderDelegate.session?.invalidateAndCancel()
|
||||
}
|
||||
|
||||
open func download() {
|
||||
if resourceLoaderDelegate.session == nil {
|
||||
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func playbackStalledHandler() {
|
||||
print("playback stalled...")
|
||||
}
|
||||
|
||||
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
|
||||
var session: URLSession?
|
||||
var mediaData: Data?
|
||||
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
|
||||
weak var owner: SpeechPlayerItem?
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
|
||||
if owner == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
guard let initialUrl = owner?.speechItem.urlRequest else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
startDataRequest(with: initialUrl)
|
||||
}
|
||||
|
||||
pendingRequests.insert(loadingRequest)
|
||||
processPendingRequests()
|
||||
return true
|
||||
}
|
||||
|
||||
func startDataRequest(with _: URLRequest) {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
Task {
|
||||
guard let speechItem = self.owner?.speechItem else {
|
||||
// This probably can't happen, but if it does, just returning should
|
||||
// let AVPlayer try again.
|
||||
print("No speech item found: ", self.owner)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: how do we want to propogate this and handle it in the player
|
||||
let audioData = try? await SpeechSynthesizer.download(speechItem: speechItem, session: self.session)
|
||||
DispatchQueue.main.async {
|
||||
if audioData == nil {
|
||||
self.session = nil
|
||||
}
|
||||
self.mediaData = audioData
|
||||
self.processPendingRequests()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
||||
pendingRequests.remove(loadingRequest)
|
||||
}
|
||||
|
||||
func processPendingRequests() {
|
||||
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
|
||||
self.fillInContentInformationRequest($0.contentInformationRequest)
|
||||
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
|
||||
$0.finishLoading()
|
||||
return $0
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove fulfilled requests from pending requests
|
||||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||||
}
|
||||
|
||||
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
|
||||
contentInformationRequest?.contentType = UTType.mp3.identifier
|
||||
|
||||
if let mediaData = mediaData {
|
||||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||||
contentInformationRequest?.contentLength = Int64(mediaData.count)
|
||||
}
|
||||
}
|
||||
|
||||
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
|
||||
let requestedOffset = Int(dataRequest.requestedOffset)
|
||||
let requestedLength = dataRequest.requestedLength
|
||||
let currentOffset = Int(dataRequest.currentOffset)
|
||||
|
||||
guard let songDataUnwrapped = mediaData,
|
||||
songDataUnwrapped.count > currentOffset
|
||||
else {
|
||||
// Don't have any data at all for this request.
|
||||
return false
|
||||
}
|
||||
|
||||
let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
|
||||
let dataToRespond = songDataUnwrapped.subdata(in: Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond)))
|
||||
dataRequest.respond(with: dataToRespond)
|
||||
|
||||
return songDataUnwrapped.count >= requestedLength + requestedOffset
|
||||
}
|
||||
|
||||
deinit {
|
||||
session?.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
|
|
@ -86,7 +268,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
@Published public var duration: TimeInterval = 0
|
||||
@Published public var timeElapsedString: String?
|
||||
@Published public var durationString: String?
|
||||
@Published public var voiceList: [(name: String, key: String, selected: Bool)]?
|
||||
@Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]?
|
||||
|
||||
let appEnvironment: AppEnvironment
|
||||
let networker: Networker
|
||||
|
|
@ -97,8 +279,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
var synthesizer: SpeechSynthesizer?
|
||||
var durations: [Double]?
|
||||
|
||||
var playbackTask: Task<Void, Error>?
|
||||
|
||||
public init(appEnvironment: AppEnvironment, networker: Networker) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.networker = networker
|
||||
|
|
@ -112,13 +292,19 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
|
||||
self.itemAudioProperties = itemAudioProperties
|
||||
startAudio()
|
||||
|
||||
EventTracker.track(
|
||||
.audioSessionStart(linkID: itemAudioProperties.itemID)
|
||||
)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
let stoppedId = itemAudioProperties?.itemID
|
||||
let stoppedTimeElapsed = timeElapsed
|
||||
|
||||
player?.pause()
|
||||
timer?.invalidate()
|
||||
|
||||
playbackTask?.cancel()
|
||||
clearNowPlayingInfo()
|
||||
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
|
|
@ -133,29 +319,53 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
timeElapsed = 0
|
||||
duration = 1
|
||||
durations = nil
|
||||
|
||||
if let stoppedId = stoppedId {
|
||||
EventTracker.track(
|
||||
.audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func generateVoiceList() -> [(name: String, key: String, selected: Bool)] {
|
||||
public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] {
|
||||
VOICES.flatMap { voicePair in
|
||||
[
|
||||
(name: voicePair.firstName, key: voicePair.firstKey, selected: voicePair.firstKey == currentVoice),
|
||||
(name: voicePair.secondName, key: voicePair.secondKey, selected: voicePair.secondKey == currentVoice)
|
||||
(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
|
||||
(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
|
||||
]
|
||||
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
|
||||
for itemID in itemIDs {
|
||||
print("preloading speech file: ", itemID)
|
||||
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
|
||||
if !preloadEnabled {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
||||
for itemID in itemIDs {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
|
||||
do {
|
||||
try await synthesizer.preload()
|
||||
return true
|
||||
} catch {
|
||||
print("error preloading audio file", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func downloadForOffline(itemID: String) async -> Bool {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document)
|
||||
for await _ in synthesizer.fetch(from: 0) {}
|
||||
for item in synthesizer.createPlayerItems(from: 0) {
|
||||
do {
|
||||
_ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true)
|
||||
} catch {
|
||||
print("error downloading audio segment: ", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
|
@ -181,8 +391,15 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
|
||||
public func seek(to: TimeInterval) {
|
||||
var hasOffset = false
|
||||
let position = max(0, to)
|
||||
|
||||
// If we are in reachedEnd state, and seek back, we need to move to
|
||||
// paused state
|
||||
if to < duration, state == .reachedEnd {
|
||||
state = .paused
|
||||
}
|
||||
|
||||
// First find the item that this interval is within
|
||||
// Not the most effecient, but these lists should be less than 500 items
|
||||
var sum = 0.0
|
||||
|
|
@ -200,6 +417,10 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
let before = durationBefore(playerIndex: foundIdx)
|
||||
let remainder = position - before
|
||||
|
||||
if remainder > 0 {
|
||||
hasOffset = true
|
||||
}
|
||||
|
||||
// if the foundIdx happens to be the current item, we just set the position
|
||||
if let playerItem = player?.currentItem as? SpeechPlayerItem {
|
||||
if playerItem.speechItem.audioIdx == foundIdx {
|
||||
|
|
@ -228,6 +449,12 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
fireTimer()
|
||||
}
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechDefaultLanguage.rawValue) public var defaultLanguage = "en" {
|
||||
didSet {
|
||||
currentLanguage = defaultLanguage
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 {
|
||||
didSet {
|
||||
updateDurations(oldPlayback: oldValue, newPlayback: playbackRate)
|
||||
|
|
@ -236,8 +463,46 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
}
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechCurrentVoice.rawValue) public var currentVoice = "en-US-JennyNeural" {
|
||||
didSet {
|
||||
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = true
|
||||
|
||||
public var currentVoiceLanguage: VoiceLanguage {
|
||||
VOICELANGUAGES.first(where: { $0.key == currentLanguage }) ?? ENGLISH
|
||||
}
|
||||
|
||||
private var _currentLanguage: String?
|
||||
public var currentLanguage: String {
|
||||
get {
|
||||
if let currentLanguage = _currentLanguage {
|
||||
return currentLanguage
|
||||
}
|
||||
if let itemLang = itemAudioProperties?.language, let lang = VOICELANGUAGES.first(where: { $0.name == itemLang || $0.key == itemLang }) {
|
||||
return lang.key
|
||||
}
|
||||
return defaultLanguage
|
||||
}
|
||||
set {
|
||||
_currentLanguage = newValue
|
||||
|
||||
let newVoice = getPreferredVoice(forLanguage: newValue)
|
||||
currentVoice = newVoice
|
||||
}
|
||||
}
|
||||
|
||||
private var _currentVoice: String?
|
||||
public var currentVoice: String {
|
||||
get {
|
||||
if let currentVoice = _currentVoice {
|
||||
return currentVoice
|
||||
}
|
||||
|
||||
if let currentVoice = UserDefaults.standard.string(forKey: "\(currentLanguage)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") {
|
||||
return currentVoice
|
||||
}
|
||||
|
||||
return currentVoiceLanguage.defaultVoice
|
||||
}
|
||||
set {
|
||||
_currentVoice = newValue
|
||||
voiceList = generateVoiceList()
|
||||
|
||||
var currentIdx = 0
|
||||
|
|
@ -252,11 +517,23 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
}
|
||||
|
||||
public var currentVoicePair: VoicePair? {
|
||||
let voice = currentVoice
|
||||
return VOICES.first(where: { $0.firstKey == voice || $0.secondKey == voice })
|
||||
}
|
||||
|
||||
public func getPreferredVoice(forLanguage language: String) -> String {
|
||||
UserDefaults.standard.string(forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") ?? currentVoiceLanguage.defaultVoice
|
||||
}
|
||||
|
||||
public func setPreferredVoice(_ voice: String, forLanguage language: String) {
|
||||
UserDefaults.standard.set(voice, forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)")
|
||||
}
|
||||
|
||||
private func downloadAndPlayFrom(_ currentIdx: Int, _ currentOffset: Double) {
|
||||
let desiredState = state
|
||||
|
||||
pause()
|
||||
playbackTask?.cancel()
|
||||
document = nil
|
||||
synthesizer = nil
|
||||
|
||||
|
|
@ -291,7 +568,21 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
return pair.firstKey
|
||||
}
|
||||
}
|
||||
return "en-US-EricNeural"
|
||||
return "en-US-CoraNeural"
|
||||
}
|
||||
|
||||
public func playVoiceSample(voice: String) {
|
||||
do {
|
||||
if let url = Bundle.main.url(forResource: "tts-voice-sample-\(voice)", withExtension: "mp3") {
|
||||
let player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
|
||||
player.play()
|
||||
} else {
|
||||
NSNotification.operationFailed(message: "Error playing voice sample.")
|
||||
}
|
||||
} catch {
|
||||
print("ERROR", error)
|
||||
NSNotification.operationFailed(message: "Error playing voice sample.")
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDurations(oldPlayback: Double, newPlayback: Double) {
|
||||
|
|
@ -373,33 +664,30 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
|
||||
func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) {
|
||||
playbackTask = Task {
|
||||
if let synthesizer = synthesizer {
|
||||
for await speechItem in synthesizer.fetch(from: start) {
|
||||
DispatchQueue.main.async {
|
||||
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
|
||||
let item = SpeechPlayerItem(session: self, speechItem: speechItem, url: speechItem.audioURL) {
|
||||
// Pause player when we complete the final item.
|
||||
if isLast {
|
||||
self.player?.pause()
|
||||
self.state = .reachedEnd
|
||||
}
|
||||
}
|
||||
self.player?.insert(item, after: nil)
|
||||
|
||||
if playWhenReady, self.player?.items().count == 1 {
|
||||
if atOffset > 0.0 {
|
||||
item.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in
|
||||
print("success seeking to time: ", success)
|
||||
self.fireTimer()
|
||||
}
|
||||
}
|
||||
self.startTimer()
|
||||
self.unpause()
|
||||
self.setupRemoteControl()
|
||||
}
|
||||
if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) {
|
||||
for speechItem in items {
|
||||
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
|
||||
let playerItem = SpeechPlayerItem(session: self, speechItem: speechItem) {
|
||||
if isLast {
|
||||
self.player?.pause()
|
||||
self.state = .reachedEnd
|
||||
}
|
||||
}
|
||||
player?.insert(playerItem, after: nil)
|
||||
if player?.items().count == 1, atOffset > 0.0 {
|
||||
playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in
|
||||
print("success seeking to time: ", success)
|
||||
self.fireTimer()
|
||||
}
|
||||
}
|
||||
if playWhenReady, player?.items().count == 1 {
|
||||
startTimer()
|
||||
unpause()
|
||||
setupRemoteControl()
|
||||
}
|
||||
}
|
||||
if items.count < 1 {
|
||||
state = .reachedEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -444,7 +732,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
@objc func fireTimer() {
|
||||
if let player = player {
|
||||
if player.error != nil || player.currentItem?.error != nil {
|
||||
print("ERROR IN PLAYBACK")
|
||||
stop()
|
||||
}
|
||||
|
||||
|
|
@ -523,7 +810,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
if let itemAudioProperties = itemAudioProperties {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title),
|
||||
MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.author ?? "Omnivore"),
|
||||
MPMediaItemPropertyArtist: NSString(string: itemAudioProperties.byline ?? "Omnivore"),
|
||||
MPMediaItemPropertyPlaybackDuration: NSNumber(value: duration),
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed)
|
||||
]
|
||||
|
|
@ -577,12 +864,19 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
}
|
||||
|
||||
func isoLangForCurrentVoice() -> String {
|
||||
// currentVoicePair should not ever be nil but if it is we return an empty string
|
||||
if let isoLang = currentVoicePair?.language {
|
||||
return "&language=\(isoLang)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
let decoder = JSONDecoder()
|
||||
let speechFileUrl = pathForSpeechFile(itemID: itemID)
|
||||
|
||||
if FileManager.default.fileExists(atPath: speechFileUrl.path) {
|
||||
print("SPEECH FILE ALREADY EXISTS: ", speechFileUrl.path)
|
||||
let data = try Data(contentsOf: speechFileUrl)
|
||||
document = try decoder.decode(SpeechDocument.self, from: data)
|
||||
// If we can't load it from disk we make the API call
|
||||
|
|
@ -591,7 +885,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
}
|
||||
}
|
||||
|
||||
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)"
|
||||
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())"
|
||||
guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else {
|
||||
throw BasicError.message(messageText: "Invalid audio URL")
|
||||
}
|
||||
|
|
@ -604,7 +898,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error", result)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
|
|
@ -669,110 +962,4 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
|||
default: ()
|
||||
}
|
||||
}
|
||||
//
|
||||
// var document: SpeechDocument {
|
||||
// let utterances = [
|
||||
//// Utterance(text: " Published Date: 29 August 2022 "),
|
||||
//// Utterance(text: "Watch the full video of the Green Shoots Seminar here ."),
|
||||
//// Utterance(text: "Good morning. Thank you for joining us today."),
|
||||
//// Utterance(text: " Let me start with the elephant in the room."),
|
||||
//// Utterance(text: " MAS seems to be sending mixed signals when it comes to crypto and digital assets."),
|
||||
//// Utterance(text: " On the one hand, MAS is promoting Singapore as a FinTech hub, partnering industry to explore distributed ledger technology (DLT), and supporting innovation in digital asset use cases. MAS has said it wants to attract leading crypto players to Singapore. On the other hand, MAS has a stringent and lengthy licensing process for those who want to carry out crypto-related services. MAS has also been issuing strong warnings against retail investments in cryptocurrencies and has been taking increasingly stronger measures to restrict retail access to cryptocurrencies. "),
|
||||
//// Utterance(text: "There have been expressions of confusion and concern by some observers."),
|
||||
//// Utterance(text: " They point to apparent contradictions in MAS’ stance – that although MAS has said that it is “excited about the potential to build a crypto or tokenised economy”, it “imposes a stringent regime”. Some others have lamented that MAS has made a “u-turn” in its digital asset policies. They say that MAS was once “making pro-crypto decisions” but was now being “overly cautious and losing its appeal as a global crypto hub”. Yet others see MAS as having struck the right balance, that “the crypto winter is proving MAS’ policies to be right”. "),
|
||||
//// Utterance(text: "What does MAS really want? Well, we know what we want but I think we need to do a better job of explaining it."),
|
||||
//// Utterance(text: " Before I get to that, it is important to be clear what we are talking about."),
|
||||
//// Utterance(text: " Public and media attention has tended to focus on cryptocurrencies. But cryptocurrencies are just one part of the entire digital asset ecosystem. To understand the issues more sharply and what the benefits and risks are, we need to be clear what the different components of this ecosystem are. I can understand why there is confusion about cryptocurrencies, blockchains, and digital assets. The inherent complexity of this ecosystem has made it difficult even for MAS to get its messages across. So, today, we will try to do a better job of explaining the ecosystem and its different components – and what MAS is actively promoting; what MAS is discouraging; and what are the risks MAS is seeking to manage. My apologies if the next couple of minutes sound like a tutorial but it is important that we are clear about the concepts we are dealing with. "),
|
||||
//// Utterance(text: "A good place to start is with digital assets."),
|
||||
//// Utterance(text: " A digital asset is anything of value whose ownership is represented in a digital or computerised form. This is done through a process called tokenisation – which involves using a software programme to convert ownership rights over an asset into a digital token. Many items can potentially be tokenised: financial assets like cash and bonds, real assets like artwork and property, even intangible items like carbon credits and computing resources. In other words, anything that has value, when tokenised, becomes a digital asset. Digital assets are typically deployed on distributed ledgers that record the ownership and transfer of ownership of these assets. A blockchain is a type of distributed ledger that organises transaction records into blocks of data which are cryptographically linked together. When deployed on distributed ledgers, digital assets are referred to as crypto assets. "),
|
||||
//// Utterance(text: "It is this innovative combination of tokenisation and distributed ledgers that offers transformative economic potential."),
|
||||
//// Utterance(text: " It basically allows anything of value to be represented in digital form, and to be stored and exchanged on a ledger that keeps an immutable record of all transactions. It is this crypto or digital asset ecosystem that supports use cases which can potentially facilitate more efficient transactions, enhance financial inclusion, and unlock economic value. "),
|
||||
//// Utterance(text: "This digital asset ecosystem is where MAS sees strong potential and is actively promoting."),
|
||||
//// Utterance(text: "I have not said anything yet about cryptocurrencies. Let me come to that now."),
|
||||
//// Utterance(text: "A cryptocurrency is the digital asset issued directly by the distributed ledger protocol. "),
|
||||
//// Utterance(text: " It is often referred to as the distributed ledger’s native currency, used as a medium of exchange and store of value within the network, for example to pay transaction fees or incentivise users to keep the network secure. "),
|
||||
//// Utterance(text: "But cryptocurrencies have taken a life of their own outside of the distributed ledger – and this is the source of the crypto world’s problems."),
|
||||
//// Utterance(text: " Cryptocurrencies are actively traded and heavily speculated upon, with prices that have nothing to do with any underlying economic value related to their use on the distributed ledger. The extreme price volatility of cryptocurrencies rules them out as a viable form of money or investment asset. "),
|
||||
//// Utterance(text: "This speculation in cryptocurrencies is what MAS strongly discourages and seeks to restrict."),
|
||||
//// Utterance(text: "Let me now elaborate on Singapore’s strategy to develop a digital asset ecosystem as well as our regulatory approach to manage the risks of digital assets. "),
|
||||
//// Utterance(text: "SINGAPORE’S STRATEGY TO DEVELOP A DIGITAL ASSET ECOSYSTEM "),
|
||||
//// Utterance(text: "Our vision is to build an innovative and responsible digital asset ecosystem in Singapore. "),
|
||||
//// Utterance(text: " This is a core part of MAS’ overall FinTech agenda. As with everything else we do in FinTech, innovation through industry collaboration is key to growing the digital asset ecosystem. Crypto technologies are promising and there is great potential to improve financial services – this is a common goal shared by MAS, the financial industry, and the FinTech community. But the only way to find out what works is through experimentation and exploration – “learning by doing”. "),
|
||||
//// Utterance(text: "We are taking a four-pronged approach to building the digital asset ecosystem."),
|
||||
//// Utterance(text: " first, explore the potential of distributed ledger technology in promising use cases; second, support the tokenisation of financial and real economy assets; third, enable digital currency connectivity; and fourth, anchor players with strong value propositions and risk management. "),
|
||||
//// Utterance(text: "EXPLORE POTENTIAL OF DISTRIBUTED LEDGER TECHNOLOGY IN PROMISING USE CASES"),
|
||||
//// Utterance(text: "The most promising use cases of digital assets in financial services are in cross-border payment and settlement, trade finance, and pre- and post-trade capital market activities. There are several promising developments, including in Singapore."),
|
||||
//// Utterance(text: " In cross-border payments and settlements, wholesale settlement networks using distributed ledger technologies such as Partior – a joint venture among DBS, JP Morgan and Temasek – are achieving reductions in settlement time from days to mere minutes. In trade finance, networks like Contour – formed by a group of trade banks – are establishing common ledgers with traceability to automate document verification, enabling faster financing decisions and lower processing cost. In capital markets, Marketnode – a joint venture between SGX and Temasek – is leveraging distributed ledger technology to tokenise assets, which reduces the time needed to clear and settle securities transactions, from days to just minutes. "),
|
||||
//// Utterance(text: "SUPPORT TOKENISATION OF FINANCIAL AND REAL ECONOMY ASSETS "),
|
||||
//// Utterance(text: "The concept of asset tokenisation has transformative potential, not unlike securitisation 50 years ago. "),
|
||||
//// Utterance(text: " Tokenisation enables the monetisation of any tangible or intangible asset. It makes it easier to fractionalise an asset or split up its ownership. Tokenisation allows the assets to be traded securely and seamlessly without the need for intermediaries. "),
|
||||
//// Utterance(text: "There are already interesting applications in Singapore of tokenisation of both financial and real assets."),
|
||||
//// Utterance(text: " UOB Bank has piloted the issuance of a S$600 million digital bond on Marketnode’s servicing platform that facilitates a seamless workflow. OCBC Bank has partnered with MetaVerse Green Exchange to develop green financing products using tokenised carbon credits to help companies offset their carbon emissions. "),
|
||||
//// Utterance(text: "MAS itself has launched an initiative – called Project Guardian – to explore the potential of tokenised real economy and financial assets."),
|
||||
//// Utterance(text: " The first industry pilot, led by DBS Bank, JP Morgan, SBI Group and Marketnode, will explore the institutional trading of tokenised bonds and deposits to improve efficiency and liquidity in wholesale funding markets. "),
|
||||
//// Utterance(text: "ENABLE DIGITAL CURRENCY CONNECTIVITY"),
|
||||
//// Utterance(text: "A digital asset ecosystem needs a medium of exchange to facilitate transactions – three popular candidates are cryptocurrencies, stablecoins, and central bank digital currencies (CBDCs). How does MAS view each of them?"),
|
||||
//// Utterance(text: "MAS regards cryptocurrencies as unsuitable for use as money and as highly hazardous for retail investors."),
|
||||
//// Utterance(text: " Cryptocurrencies lack the three fundamental qualities of money: medium of exchange, store of value, and unit of account. As I mentioned earlier, cryptocurrencies serve a useful function within a blockchain network – to reward the participants who help to validate and maintain the record of transactions on the distributed ledger. But outside a blockchain network, cryptocurrencies serve no useful function except as a vehicle for speculation. Since 2017, MAS has been issuing warnings about the substantial risks of investing in cryptocurrencies. "),
|
||||
//// Utterance(text: "MAS sees good potential in stablecoins provided they are securely backed by high quality reserves and well regulated."),
|
||||
//// Utterance(text: " Stablecoins are tokens whose value is tied to another asset, usually fiat currencies such as the US dollar. They seek to combine the credibility that comes from their supposed stability, with the benefits of tokenisation, that allow them to be used as payment instruments on distributed ledgers. Stablecoins are beginning to find acceptance outside of the crypto ecosystem. Some firms like Mastercard have integrated popular stablecoins into their payment services. This can be a positive development if stablecoins can make payments cheaper, faster, and safer. But to reap the benefits of stablecoins, regulators must ensure that they are indeed stable. I will talk more about this later. "),
|
||||
//// Utterance(text: "MAS sees good potential for wholesale CBDCs, especially for cross-border payments and settlements."),
|
||||
//// Utterance(text: " CBDCs are the direct liability of, and payment instrument, of a central bank. This means that holders of CBDCs will have a direct claim on the central bank that has issued them, similar to how physical currency works today. Wholesale CBDCs are restricted to use by financial institutions. They are akin to the balances which commercial banks place with a central bank today. Wholesale CBDCs on a distributed ledger have the potential to achieve atomic settlement, or the exchange of two linked assets in real-time. They have the potential to radically transform cross-border payments, which today are slow, expensive, and opaque. "),
|
||||
//// Utterance(text: "MAS does not see a compelling case for retail CBDCs in Singapore."),
|
||||
//// Utterance(text: " Retail CBDCs are issued to the general public. They are like the cash we carry with us, except in digital form. The case for a retail CBDC in Singapore is not compelling for now, given well-functioning payment systems and broad financial inclusion. Retail electronic payment systems are fast, efficient, and at zero cost, while a residual amount of cash remains in circulation and is unlikely to disappear. Nevertheless, MAS is building the technology infrastructure that would permit issuance of retail CBDCs should conditions change. "),
|
||||
//// Utterance(text: "MAS has been actively experimenting with digital currency connectivity since 2016. "),
|
||||
//// Utterance(text: " On the international front, MAS is participating in Project Dunbar, which the Bank for International Settlements Innovation Hub is working on in its Singapore Centre. The project is exploring a common multi-CBDC platform to enable cheaper, faster and safer cross-border payments. Domestically, MAS is working with the industry on Project Orchid to develop the infrastructure and technical competencies necessary to issue a digital Singapore dollar should there be a need to do so in future. "),
|
||||
//// Utterance(text: "ANCHOR PLAYERS WITH STRONG VALUE PROPOSITIONS AND RISK MANAGEMENT"),
|
||||
//// Utterance(text: "MAS seeks to anchor in Singapore crypto players who can value add to our digital asset ecosystem and have strong risk management capabilities."),
|
||||
//// Utterance(text: "A vibrant digital asset ecosystem will encompass a wide range of value-adding activities. Let me cite three examples. "),
|
||||
//// Utterance(text: " JP Morgan has established its digital asset capabilities in Singapore via its Onyx division, which has pioneered several DLT-based products and initiatives. Offerings include round-the-clock real-time fund transfers with shorter settlement times and no intermediaries. Contour, a global trade finance network of banks, corporates and trade partners, has established its Future of Finance Lab in Singapore. It will conduct research to develop novel, digitally native trade finance solutions. Nansen is a Singapore-based company that analyses more than 100 million blockchain wallet addresses across the world. It provides insights on blockchain network activities and visibility on transacting parties, thereby helping to improve transparency in the digital asset ecosystem globally. "),
|
||||
//// Utterance(text: "Digital asset activities involving payment services must be licensed under the Payment Services Act. We recognise there is some frustration about MAS’ licensing process. "),
|
||||
//// Utterance(text: " Some industry players have described it as “a slow and tedious ordeal”; others as a “bugbear for the fast-moving space”. "),
|
||||
//// Utterance(text: "Given how new the digital asset industry is, it has not been easy for industry players or for MAS."),
|
||||
//// Utterance(text: " On MAS’ side, we closely scrutinise licence applicants’ business models and technologies, so that we can better understand the risks. On the part of applicants, many are not familiar with managing the risks of facilitating illicit finance. MAS engages the applicants closely to assess their understanding of our rules and their ability to meet our standards. This takes a considerable amount of time – but it is necessary. "),
|
||||
//// Utterance(text: "MAS cannot compromise its due diligence process just to make it easy for digital asset players to get a licence. "),
|
||||
//// Utterance(text: " Given the large number of applicants for licences, we have been prioritising those who demonstrate strong risk management capabilities and the ability to contribute to the growth of Singapore’s FinTech and digital asset ecosystem. "),
|
||||
//// Utterance(text: "SINGAPORE’S REGULATORY APPROACH TO MANAGE DIGITAL ASSET RISKS"),
|
||||
//// Utterance(text: "Like all innovations, digital asset activities pose risks as well as benefits. "),
|
||||
//// Utterance(text: " When digital asset activities took off more than five years ago, regulators around the world, including MAS, assessed money laundering and terrorist financing risks as the key areas of concern. "),
|
||||
//// Utterance(text: "With the rapid growth in scale and complexity of digital asset activities, other risks have surfaced. "),
|
||||
//// Utterance(text: " Regulators around the world including MAS are therefore stepping up their responses to these new risks. "),
|
||||
//// Utterance(text: "There are five areas of risk in digital assets that MAS’ regulatory approach is focused on."),
|
||||
//// Utterance(text: " first, combat money laundering and terrorist financing risks; second, manage technology and cyber related risks; third, safeguard against harm to retail investors; fourth, uphold the promise of stability in stablecoins; and fifth, mitigate potential financial stability risks "),
|
||||
//// Utterance(text: "COMBAT MONEY LAUNDERING AND TERRORIST FINANCING RISKS"),
|
||||
//// Utterance(text: "The key risk that MAS regulation currently addresses is money laundering and terrorist financing. "),
|
||||
//// Utterance(text: " As users of cryptocurrencies operate through wallet addresses and pseudonyms, cryptocurrencies have made it easier to conduct illicit transactions. The online nature of transactions adds to the risk. In 2020, MAS imposed on providers of digital asset services the same anti-money laundering requirements that apply to other financial institutions. Earlier this year, these rules were expanded to Singapore-incorporated entities providing digital asset services overseas. Singapore’s requirements are consistent with international standards, namely those of the Financial Action Task Force (FATF). "),
|
||||
//// Utterance(text: "MANAGE TECHNOLOGY AND CYBER RISKS"),
|
||||
//// Utterance(text: "Another risk that MAS has sought to address early on is technology and cyber related risk."),
|
||||
//// Utterance(text: " MAS is one of the earliest regulators to impose on digital asset players the same cyber hygiene standards and technology risk management principles that is expected of other financial institutions. But technology and cyber risks are continually evolving, for example, coding bugs in smart contracts and compromise of digital token wallets or their encryption keys. MAS is reviewing measures to manage these and other technology and cyber risks, including further requirements to protect customers’ digital assets and uplift system availability. These steps are in line with what other jurisdictions are considering, including in the EU and Japan. "),
|
||||
//// Utterance(text: "SAFEGUARD AGAINST HARM TO RETAIL INVESTORS"),
|
||||
//// Utterance(text: "MAS has since 2017 been reiterating the risks of trading in cryptocurrencies. "),
|
||||
//// Utterance(text: " Prices of cryptocurrencies are highly volatile, driven largely by speculation rather than any underlying economic fundamentals. It is very risky for the public to put their monies in such cryptocurrencies, as the perceived valuation of these cryptocurrencies could plummet rapidly when sentiments shift. We have seen this happen repeatedly. MAS has issued numerous advisories warning consumers that they could potentially lose all the monies they put into cryptocurrencies. Just take for example Luna, the sister token of the so-called stablecoin TerraUSD. Luna was, at one point, worth over US$100 but tumbled to zero. "),
|
||||
//// Utterance(text: "MAS has taken early decisive steps to mitigate consumer harm."),
|
||||
//// Utterance(text: " Since January this year, MAS has restricted digital asset players from promoting cryptocurrency services at public spaces. This has led to the dismantling of Bitcoin ATMs and the removal of advertisements in MRT stations. "),
|
||||
//// Utterance(text: "But despite these warnings and measures, surveys show that consumers are increasingly trading in cryptocurrencies."),
|
||||
//// Utterance(text: " This appears to be a global phenomenon, not just in Singapore. Many consumers are still enticed by the prospect of sharp price increases in cryptocurrencies. They seem to be irrationally oblivious about the risks of cryptocurrency trading. Consumer-related risks have gained the attention of regulators around the world. "),
|
||||
//// Utterance(text: "MAS is therefore considering further measures to reduce consumer harm. "),
|
||||
//// Utterance(text: " Adding frictions on retail access to cryptocurrencies is an area we are contemplating. These may include customer suitability tests and restricting the use of leverage and credit facilities for cryptocurrency trading. But banning retail access to cryptocurrencies is not likely to work. The cryptocurrency world is borderless. With just a mobile phone, Singaporeans have access to any number of crypto exchanges in the world and can buy or sell any number of cryptocurrencies. "),
|
||||
//// Utterance(text: "The cryptocurrency market is also fraught with risks of market manipulation. "),
|
||||
//// Utterance(text: " These risks include cornering and wash trades – actions that mislead and deceive market participants about prices or trading volumes. They compound the inherent volatility and speculative nature of cryptocurrencies and can severely harm consumers. There is greater impetus now among global regulators to enhance regulations in this space. MAS will also do so. "),
|
||||
//// Utterance(text: "Safeguarding consumers from harm requires a multi-pronged approach, not just MAS regulation."),
|
||||
//// Utterance(text: " First, global cooperation is vital to minimise regulatory arbitrage. Cryptocurrency transactions can be conducted from anywhere around the world. MAS is actively involved in international regulatory reviews to enhance market integrity and customer protection in the digital asset space. Second, the industry has an important role in co-creating sensible measures to protect consumer interests. MAS has been sharing its concerns with the industry and inviting views on possible measures to minimise harm to consumers. We will publicly consult on the proposals by October this year. Third, consumers must take responsibility and exercise judgement and caution. No amount of MAS regulation, global co-operation, or industry safeguards will protect consumers from losses if their cryptocurrency holdings lose value. "),
|
||||
//// Utterance(text: "UPHOLD THE PROMISE OF STABILITY IN STABLECOINS"),
|
||||
//// Utterance(text: "Stablecoins can realise their potential only if there is confidence in their ability to maintain a stable value. "),
|
||||
//// Utterance(text: " Many stablecoins lack the ability to uphold the promise of stability in their value. Some of the assets backing these stablecoins – such as commercial papers – are exposed to credit, market, and liquidity risks There are currently no international standards on the quality of reserve assets backing stablecoins. Globally, regulators are looking to impose requirements such as secure reserve backing and timely redemption at par. MAS will propose for consultation a regulatory approach for stablecoins, also by October. "),
|
||||
//// Utterance(text: "MITIGATE POTENTIAL FINANCIAL STABILITY RISKS"),
|
||||
//// Utterance(text: "Financial stability risks from digital asset activities are currently low but bear close monitoring."),
|
||||
//// Utterance(text: " As the digital asset ecosystem grows, it will be natural for linkages between the traditional banking system and digital assets to grow. There is risk of contagion to financial markets through exposures of financial institutions to digital assets. MAS is working closely with other regulators to design a prudential framework for banks’ exposures to digital assets. This framework will provide banks with clarity on how to measure the risks of their digital asset exposures, and maintain adequate capital to address these risks. This will reduce risks of spillovers into the traditional banking system. "),
|
||||
//// Utterance(text: "INNOVATION AND REGULATION HAND-IN-HAND "),
|
||||
//// Utterance(text: "Singapore wants to be a hub for innovative and responsible digital asset activities that enhance efficiency and create economic value. The development strategy and regulatory approach for digital assets that I have described go hand-in-hand towards achieving this. "),
|
||||
//// Utterance(text: "Innovation and regulation are not incapable of co-existing. We do not split the difference by being less stringent in our regulation or being less facilitative of innovation. "),
|
||||
//// Utterance(text: " MAS’ development strategy makes Singapore one of the most conducive and facilitative jurisdictions for digital assets. At the same time, MAS’ evolving regulatory approach makes Singapore one of the most comprehensive in managing the risks of digital assets, and among the strictest in areas like discouraging retail investments in cryptocurrencies. "),
|
||||
//// Utterance(text: "I hope this presentation has made clear that MAS’ facilitative posture on digital asset activities and restrictive stance on cryptocurrency speculation are not contradictory. It is in fact a synergistic and holistic approach to develop Singapore as an innovative and responsible global digital asset hub. ")
|
||||
// ]
|
||||
// // (pageId: item!.unwrappedID, wordCount: 10, utterances: utterances, )
|
||||
//
|
||||
// let result = SpeechDocument(averageWPM: 150.0, wordCount: 100, language: "en-US", defaultVoice: currentVoice, utterances: utterances)
|
||||
// return result
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ struct Utterance: Decodable {
|
|||
public let wordCount: Double
|
||||
|
||||
func toSSML(document: SpeechDocument) throws -> Data? {
|
||||
let request = UtteranceRequest(text: text, voice: voice ?? document.defaultVoice, language: document.language, rate: "1.1")
|
||||
let request = UtteranceRequest(text: text,
|
||||
voice: voice ?? document.defaultVoice,
|
||||
language: document.language,
|
||||
rate: "1.1")
|
||||
return try JSONEncoder().encode(request)
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +58,8 @@ struct SpeechDocument: Decodable {
|
|||
struct SpeechItem {
|
||||
let htmlIdx: String
|
||||
let audioIdx: Int
|
||||
let audioURL: URL
|
||||
let urlRequest: URLRequest
|
||||
let localAudioURL: URL
|
||||
}
|
||||
|
||||
struct SpeechSynthesizer {
|
||||
|
|
@ -74,58 +78,105 @@ struct SpeechSynthesizer {
|
|||
document.utterances.map { document.estimatedDuration(utterance: $0, speed: speed) }
|
||||
}
|
||||
|
||||
func fetch(from: Int) -> SpeechSynthesisFetcher {
|
||||
SpeechSynthesisFetcher(synthesizer: self, start: from)
|
||||
}
|
||||
}
|
||||
|
||||
struct SpeechSynthesisFetcher: AsyncSequence {
|
||||
typealias Element = SpeechItem
|
||||
let start: Int
|
||||
let synthesizer: SpeechSynthesizer
|
||||
|
||||
init(synthesizer: SpeechSynthesizer, start: Int) {
|
||||
self.start = start
|
||||
self.synthesizer = synthesizer
|
||||
func preload() async throws {
|
||||
if document.utterances.count > 0 {
|
||||
if let item = speechItemForIdx(idx: 0) {
|
||||
_ = try await Self.download(speechItem: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeAsyncIterator() -> SpeechSynthesizerIterator {
|
||||
SpeechSynthesizerIterator(synthesizer: synthesizer, start: start)
|
||||
}
|
||||
func speechItemForIdx(idx: Int) -> SpeechItem? {
|
||||
let utterance = document.utterances[idx]
|
||||
let voiceStr = utterance.voice ?? document.defaultVoice
|
||||
let segmentStr = String(format: "%04d", arguments: [idx])
|
||||
let localAudioURL = document.audioDirectory.appendingPathComponent("\(segmentStr)-\(voiceStr).mp3")
|
||||
|
||||
struct SpeechSynthesizerIterator: AsyncIteratorProtocol {
|
||||
let synthesizer: SpeechSynthesizer
|
||||
|
||||
init(synthesizer: SpeechSynthesizer, start: Int) {
|
||||
self.synthesizer = synthesizer
|
||||
self.currentIdx = start
|
||||
if let request = urlRequestFor(utterance: utterance) {
|
||||
let item = SpeechItem(htmlIdx: utterance.idx, audioIdx: idx, urlRequest: request, localAudioURL: localAudioURL)
|
||||
return item
|
||||
}
|
||||
|
||||
var currentIdx: Int
|
||||
return nil
|
||||
}
|
||||
|
||||
mutating func next() async -> SpeechItem? {
|
||||
if Task.isCancelled {
|
||||
return nil
|
||||
func createPlayerItems(from: Int) -> [SpeechItem] {
|
||||
var result: [SpeechItem] = []
|
||||
|
||||
for idx in from ..< document.utterances.count {
|
||||
let utterance = document.utterances[idx]
|
||||
let voiceStr = utterance.voice ?? document.defaultVoice
|
||||
let segmentStr = String(format: "%04d", arguments: [idx])
|
||||
let localAudioURL = document.audioDirectory.appendingPathComponent("\(segmentStr)-\(voiceStr).mp3")
|
||||
|
||||
if let request = urlRequestFor(utterance: utterance) {
|
||||
let item = SpeechItem(htmlIdx: utterance.idx, audioIdx: idx, urlRequest: request, localAudioURL: localAudioURL)
|
||||
result.append(item)
|
||||
} else {
|
||||
// TODO: How do we want to handle completely skipped paragraphs?
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func urlRequestFor(utterance: Utterance) -> URLRequest? {
|
||||
var request = URLRequest(url: appEnvironment.ttsBaseURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 600
|
||||
|
||||
if let ssml = try? utterance.toSSML(document: document) {
|
||||
request.httpBody = ssml
|
||||
}
|
||||
|
||||
for (header, value) in networker.defaultHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
static func download(speechItem: SpeechItem,
|
||||
redownloadCached: Bool = false,
|
||||
session: URLSession? = URLSession.shared) async throws -> Data?
|
||||
{
|
||||
if !redownloadCached, FileManager.default.fileExists(atPath: speechItem.localAudioURL.path) {
|
||||
if let localData = try? Data(contentsOf: speechItem.localAudioURL) {
|
||||
return localData
|
||||
}
|
||||
}
|
||||
|
||||
let request = speechItem.urlRequest
|
||||
let result: (Data, URLResponse)? = try? await (session ?? URLSession.shared).data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", result?.1 as Any)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(UUID().uuidString + ".mp3")
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let jsonData = try decoder.decode(SynthesizeResult.self, from: data)
|
||||
let audioData = Data(fromHexEncodedString: jsonData.audioData)!
|
||||
if audioData.count < 1 {
|
||||
throw BasicError.message(messageText: "Audio data is empty")
|
||||
}
|
||||
|
||||
if currentIdx >= synthesizer.document.utterances.count {
|
||||
return nil
|
||||
}
|
||||
try audioData.write(to: tempPath)
|
||||
try? FileManager.default.removeItem(at: speechItem.localAudioURL)
|
||||
try FileManager.default.moveItem(at: tempPath, to: speechItem.localAudioURL)
|
||||
|
||||
let utterance = synthesizer.document.utterances[currentIdx]
|
||||
let fetched = try? await fetchUtterance(appEnvironment: synthesizer.appEnvironment,
|
||||
networker: synthesizer.networker,
|
||||
document: synthesizer.document,
|
||||
segmentIdx: currentIdx,
|
||||
utterance: utterance)
|
||||
|
||||
if let fetchedURL = fetched {
|
||||
let item = SpeechItem(htmlIdx: utterance.idx, audioIdx: currentIdx, audioURL: fetchedURL)
|
||||
currentIdx += 1
|
||||
return item
|
||||
}
|
||||
|
||||
return nil
|
||||
return audioData
|
||||
} catch {
|
||||
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,63 +216,3 @@ extension Data {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchUtterance(appEnvironment: AppEnvironment,
|
||||
networker: Networker,
|
||||
document: SpeechDocument,
|
||||
segmentIdx: Int,
|
||||
utterance: Utterance) async throws -> URL
|
||||
{
|
||||
let voiceStr = utterance.voice ?? document.defaultVoice
|
||||
let segmentStr = String(format: "%04d", arguments: [segmentIdx])
|
||||
let audioPath = document.audioDirectory.appendingPathComponent("\(segmentStr)-\(voiceStr).mp3")
|
||||
|
||||
if FileManager.default.fileExists(atPath: audioPath.path) {
|
||||
print("audio file already downloaded: ", audioPath.path)
|
||||
return audioPath
|
||||
}
|
||||
|
||||
var request = URLRequest(url: appEnvironment.ttsBaseURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 600
|
||||
|
||||
if let ssml = try utterance.toSSML(document: document) {
|
||||
request.httpBody = ssml
|
||||
}
|
||||
|
||||
for (header, value) in networker.defaultHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", result?.1 as Any)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent(UUID().uuidString + ".mp3")
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let jsonData = try decoder.decode(SynthesizeResult.self, from: data)
|
||||
let audioData = Data(fromHexEncodedString: jsonData.audioData)!
|
||||
if audioData.count < 1 {
|
||||
throw BasicError.message(messageText: "Audio data is empty")
|
||||
}
|
||||
|
||||
try audioData.write(to: tempPath)
|
||||
try? FileManager.default.removeItem(at: audioPath)
|
||||
try FileManager.default.moveItem(at: tempPath, to: audioPath)
|
||||
} catch {
|
||||
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
|
||||
return audioPath
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,11 +84,7 @@ public extension DataService {
|
|||
return persistedItemID
|
||||
}
|
||||
|
||||
func loadItemContentUsingRequestID(requestID: String) async throws -> NSManagedObjectID? {
|
||||
let username: String? = await username()
|
||||
guard let username = username else { throw BasicError.message(messageText: "unauthorized user") }
|
||||
|
||||
// If the page was locally created, make sure they are synced before we pull content
|
||||
func loadItemContentUsingRequestID(username: String, requestID: String) async throws -> NSManagedObjectID? {
|
||||
await syncUnsyncedArticleContent(itemID: requestID)
|
||||
|
||||
let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ extension DataService {
|
|||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
htmlContent: try $0.content(),
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
|
@ -281,6 +282,7 @@ private let searchItemSelection = Selection.SearchItem {
|
|||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ struct InternalLinkedItem {
|
|||
let isArchived: Bool
|
||||
let contentReader: String?
|
||||
let originalHtml: String?
|
||||
let language: String?
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
var isPDF: Bool {
|
||||
|
|
@ -60,6 +61,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.isArchived = isArchived
|
||||
linkedItem.contentReader = contentReader
|
||||
linkedItem.originalHtml = originalHtml
|
||||
linkedItem.language = language
|
||||
|
||||
// Remove existing labels in case a label had been deleted
|
||||
if let existingLabels = linkedItem.labels {
|
||||
|
|
@ -130,6 +132,7 @@ extension JSONArticle {
|
|||
isArchived: isArchived,
|
||||
contentReader: contentReader,
|
||||
originalHtml: nil,
|
||||
language: language,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ public enum TrackableEvent {
|
|||
case linkRead(linkID: String, slug: String, originalArticleURL: String)
|
||||
case debugMessage(message: String)
|
||||
case backgroundFetch(jobStatus: BackgroundFetchJobStatus, itemCount: Int, secondsElapsed: Int)
|
||||
case audioSessionStart(linkID: String)
|
||||
case audioSessionEnd(linkID: String, timeElapsed: Double)
|
||||
}
|
||||
|
||||
public enum BackgroundFetchJobStatus: String {
|
||||
|
|
@ -22,6 +24,10 @@ public extension TrackableEvent {
|
|||
return "debug_message"
|
||||
case .backgroundFetch:
|
||||
return "background_fetch"
|
||||
case .audioSessionStart:
|
||||
return "audio_session_start"
|
||||
case .audioSessionEnd:
|
||||
return "audio_session_end"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +47,15 @@ public extension TrackableEvent {
|
|||
"seconds_elapsed": String(secondsElapsed),
|
||||
"fetched_item_count": String(itemCount)
|
||||
]
|
||||
case let .audioSessionStart(linkID: linkID):
|
||||
return [
|
||||
"link": linkID
|
||||
]
|
||||
case let .audioSessionEnd(linkID: linkID, timeElapsed: timeElapsed):
|
||||
return [
|
||||
"link": linkID,
|
||||
"timeElapsed": String(timeElapsed)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,7 @@ public enum UserDefaultKey: String {
|
|||
case lastUsedAppBuildNumber
|
||||
case lastItemSyncTime
|
||||
case textToSpeechPlaybackRate
|
||||
case textToSpeechCurrentVoice
|
||||
case textToSpeechPreferredVoice
|
||||
case textToSpeechDefaultLanguage
|
||||
case textToSpeechPreloadEnabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ public struct RoundedRectButtonStyle: ButtonStyle {
|
|||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
|
||||
public init(color: Color = .appButtonBackground, textColor: Color = .appGrayText) {
|
||||
public init(color: Color = .appButtonBackground, textColor: Color = .appGrayTextContrast) {
|
||||
self.backgroundColor = color
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -150,6 +150,66 @@
|
|||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1@2x-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "512-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "48.png",
|
||||
"idiom" : "watch",
|
||||
|
|
@ -225,6 +285,13 @@
|
|||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "172.png",
|
||||
"idiom" : "watch",
|
||||
|
|
@ -256,71 +323,18 @@
|
|||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1@2x-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "image 1@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "512-1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,17 @@ services:
|
|||
ports:
|
||||
- "5601:5601"
|
||||
|
||||
redis:
|
||||
image: "redis:6.2.7"
|
||||
container_name: "omnivore-redis"
|
||||
healthcheck:
|
||||
test: "exit 0"
|
||||
interval: 2s
|
||||
timeout: 12s
|
||||
retries: 3
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -100,6 +111,8 @@ services:
|
|||
condition: service_completed_successfully
|
||||
elastic:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface SpeechInput {
|
|||
voice?: string
|
||||
secondaryVoice?: string
|
||||
priority?: 'low' | 'high'
|
||||
language?: string
|
||||
}
|
||||
const outputFormats = ['mp3', 'speech-marks', 'speech']
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
|
@ -85,7 +86,8 @@ export function articleRouter() {
|
|||
async (req, res) => {
|
||||
const articleId = req.params.id
|
||||
const outputFormat = req.params.outputFormat
|
||||
const { voice, priority, secondaryVoice } = req.query as SpeechInput
|
||||
const { voice, priority, secondaryVoice, language } =
|
||||
req.query as SpeechInput
|
||||
if (!articleId || outputFormats.indexOf(outputFormat) === -1) {
|
||||
return res.status(400).send('Invalid data')
|
||||
}
|
||||
|
|
@ -94,6 +96,9 @@ export function articleRouter() {
|
|||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
const { uid } = jwt.decode(token) as Claims
|
||||
if (!uid) {
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
logger.info(`Get article speech in ${outputFormat} format`, {
|
||||
params: req.params,
|
||||
labels: {
|
||||
|
|
@ -102,84 +107,89 @@ export function articleRouter() {
|
|||
},
|
||||
})
|
||||
|
||||
if (outputFormat === 'speech') {
|
||||
try {
|
||||
if (outputFormat === 'speech') {
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
const speechFile = htmlToSpeechFile({
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
options: {
|
||||
primaryVoice: voice,
|
||||
secondaryVoice: secondaryVoice,
|
||||
language: language || page.language,
|
||||
},
|
||||
})
|
||||
return res.send({ ...speechFile, pageId: articleId })
|
||||
}
|
||||
|
||||
const existingSpeech = await getRepository(Speech).findOne({
|
||||
where: {
|
||||
elasticPageId: articleId,
|
||||
voice,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
if (existingSpeech) {
|
||||
if (existingSpeech.user.id !== uid) {
|
||||
logger.info('User is not allowed to access speech of the article', {
|
||||
userId: uid,
|
||||
articleId,
|
||||
})
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.COMPLETED) {
|
||||
logger.info('Found existing completed speech', {
|
||||
audioUrl: existingSpeech.audioFileName,
|
||||
speechMarksUrl: existingSpeech.speechMarksFileName,
|
||||
})
|
||||
await updatePage(
|
||||
existingSpeech.elasticPageId,
|
||||
{
|
||||
listenedAt: new Date(),
|
||||
},
|
||||
{ uid, pubsub: createPubSubClient() }
|
||||
)
|
||||
return res.redirect(await redirectUrl(existingSpeech, outputFormat))
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.INITIALIZED) {
|
||||
logger.info('Found existing in progress speech')
|
||||
// retry later
|
||||
return res.status(202).send('Speech is in progress')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Create Text to speech task', { articleId })
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
const speechFile = htmlToSpeechFile({
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
options: {
|
||||
primaryVoice: voice,
|
||||
secondaryVoice: secondaryVoice,
|
||||
language: page.language,
|
||||
},
|
||||
})
|
||||
return res.send({ ...speechFile, pageId: articleId })
|
||||
}
|
||||
|
||||
const existingSpeech = await getRepository(Speech).findOne({
|
||||
where: {
|
||||
// initialize state
|
||||
const speech = await getRepository(Speech).save({
|
||||
user: { id: uid },
|
||||
elasticPageId: articleId,
|
||||
state: SpeechState.INITIALIZED,
|
||||
voice,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
if (existingSpeech) {
|
||||
if (existingSpeech.user.id !== uid) {
|
||||
logger.info('User is not allowed to access speech of the article', {
|
||||
userId: uid,
|
||||
articleId,
|
||||
})
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.COMPLETED) {
|
||||
logger.info('Found existing completed speech', {
|
||||
audioUrl: existingSpeech.audioFileName,
|
||||
speechMarksUrl: existingSpeech.speechMarksFileName,
|
||||
})
|
||||
await updatePage(
|
||||
existingSpeech.elasticPageId,
|
||||
{
|
||||
listenedAt: new Date(),
|
||||
},
|
||||
{ uid, pubsub: createPubSubClient() }
|
||||
)
|
||||
return res.redirect(await redirectUrl(existingSpeech, outputFormat))
|
||||
}
|
||||
if (existingSpeech.state === SpeechState.INITIALIZED) {
|
||||
logger.info('Found existing in progress speech')
|
||||
// retry later
|
||||
return res.status(202).send('Speech is in progress')
|
||||
}
|
||||
})
|
||||
// enqueue a task to convert text to speech
|
||||
const taskName = await enqueueTextToSpeech({
|
||||
userId: uid,
|
||||
speechId: speech.id,
|
||||
text: page.content,
|
||||
voice: speech.voice,
|
||||
priority: priority || 'high',
|
||||
})
|
||||
logger.info('Start Text to speech task', { taskName })
|
||||
res.status(202).send('Text to speech task started')
|
||||
} catch (error) {
|
||||
logger.error('Error getting article speech:', error)
|
||||
res.status(500).send({ errorCode: 'INTERNAL_ERROR' })
|
||||
}
|
||||
|
||||
logger.info('Create Text to speech task', { articleId })
|
||||
const page = await getPageById(articleId)
|
||||
if (!page) {
|
||||
return res.status(404).send('Page not found')
|
||||
}
|
||||
// initialize state
|
||||
const speech = await getRepository(Speech).save({
|
||||
user: { id: uid },
|
||||
elasticPageId: articleId,
|
||||
state: SpeechState.INITIALIZED,
|
||||
voice,
|
||||
})
|
||||
// enqueue a task to convert text to speech
|
||||
const taskName = await enqueueTextToSpeech({
|
||||
userId: uid,
|
||||
speechId: speech.id,
|
||||
text: page.content,
|
||||
voice: speech.voice,
|
||||
priority: priority || 'high',
|
||||
})
|
||||
logger.info('Start Text to speech task', { taskName })
|
||||
res.status(202).send('Text to speech task started')
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ export class AxiosHandler {
|
|||
prehandle = (url: URL, dom: Document): Promise<Document> => {
|
||||
const body = dom.querySelector('table')
|
||||
|
||||
let isFooter = false
|
||||
// this removes ads and replaces table with a div
|
||||
body?.querySelectorAll('table').forEach((el, k) => {
|
||||
if (k > 0) {
|
||||
body?.querySelectorAll('table').forEach((el) => {
|
||||
// remove the footer and the ads
|
||||
if (!el.textContent || el.textContent.length < 20 || isFooter) {
|
||||
el.remove()
|
||||
} else {
|
||||
// removes the first few rows of the table (the header)
|
||||
|
|
@ -28,6 +30,8 @@ export class AxiosHandler {
|
|||
const div = dom.createElement('div')
|
||||
div.innerHTML = el.innerHTML
|
||||
el.parentNode?.replaceChild(div, el)
|
||||
// set the isFooter flag to true because the next table is the footer
|
||||
isFooter = true
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,27 +10,19 @@ export class MorningBrewHandler {
|
|||
|
||||
prehandle = (url: URL, dom: Document): Promise<Document> => {
|
||||
// retain the width of the cells in the table of market info
|
||||
dom
|
||||
.querySelectorAll('.markets-arrow-cell')
|
||||
.forEach((c) => c.setAttribute('width', '20%'))
|
||||
dom
|
||||
.querySelectorAll('.markets-ticker-cell')
|
||||
.forEach((c) => c.setAttribute('width', '34%'))
|
||||
dom
|
||||
.querySelectorAll('.markets-value-cell')
|
||||
.forEach((c) => c.setAttribute('width', '34%'))
|
||||
dom.querySelectorAll('.markets-bubble-cell').forEach((c) => {
|
||||
const table = c.querySelector('.markets-bubble')
|
||||
dom.querySelectorAll('.markets-arrow-cell').forEach((td) => {
|
||||
const table = td.closest('table')
|
||||
if (table) {
|
||||
// replace the nested table with the text
|
||||
const e = table.querySelector('.markets-table-text')
|
||||
e && table.parentNode?.replaceChild(e, table)
|
||||
const bubbleTable = table.querySelector('.markets-bubble')
|
||||
if (bubbleTable) {
|
||||
// replace the nested table with the text
|
||||
const e = bubbleTable.querySelector('.markets-table-text')
|
||||
e && bubbleTable.parentNode?.replaceChild(e, bubbleTable)
|
||||
}
|
||||
// set custom class for the table
|
||||
table.className = 'morning-brew-markets'
|
||||
}
|
||||
c.setAttribute('width', '12%')
|
||||
})
|
||||
dom
|
||||
.querySelectorAll('table [role="presentation"]')
|
||||
.forEach((table) => (table.className = 'morning-brew-markets'))
|
||||
|
||||
return Promise.resolve(dom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,7 +262,6 @@ export const parsePreparedContent = async (
|
|||
const codeBlocks = article.dom.querySelectorAll('code')
|
||||
if (codeBlocks.length > 0) {
|
||||
codeBlocks.forEach((e) => {
|
||||
console.log(e.textContent)
|
||||
if (e.textContent) {
|
||||
const att = hljs.highlightAuto(e.textContent)
|
||||
const code = dom.createElement('code')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"jsonwebtoken": "^8.5.1",
|
||||
"linkedom": "^0.14.9",
|
||||
"luxon": "^2.3.1",
|
||||
"puppeteer-core": "^16.1.0"
|
||||
"puppeteer-core": "^16.1.0",
|
||||
"underscore": "^1.13.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
const { DateTime } = require('luxon');
|
||||
const _ = require('underscore');
|
||||
|
||||
const TWITTER_BEARER_TOKEN = process.env.TWITTER_BEARER_TOKEN;
|
||||
const TWITTER_URL_MATCH = /twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/
|
||||
|
|
@ -116,7 +117,8 @@ exports.twitterHandler = {
|
|||
const tweetData = (await getTweetById(tweetId)).data;
|
||||
const authorId = tweetData.data.author_id;
|
||||
const author = tweetData.includes.users.filter(u => u.id = authorId)[0];
|
||||
const title = titleForAuthor(author)
|
||||
// escape html entities in title
|
||||
const title = _.escape(titleForAuthor(author))
|
||||
const authorImage = author.profile_image_url.replace('_normal', '_400x400')
|
||||
|
||||
let text = tweetData.data.text;
|
||||
|
|
@ -157,7 +159,7 @@ exports.twitterHandler = {
|
|||
<meta property="og:image" content="${authorImage}" />
|
||||
<meta property="og:image:secure_url" content="${authorImage}" />
|
||||
<meta property="og:title" content="${title}" />
|
||||
<meta property="og:description" content="${tweetData.data.text}" />
|
||||
<meta property="og:description" content="${_.escape(tweetData.data.text)}" />
|
||||
</head>
|
||||
<body>
|
||||
${front}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
const _ = require('underscore');
|
||||
|
||||
const YOUTUBE_URL_MATCH =
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/
|
||||
|
|
@ -36,11 +37,13 @@ exports.youtubeHandler = {
|
|||
|
||||
const oembedUrl = `https://www.youtube.com/oembed?format=json&url=` + encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
const oembed = (await axios.get(oembedUrl.toString())).data;
|
||||
const title = oembed.title;
|
||||
// escape html entities in title
|
||||
const title = _.escape(oembed.title);
|
||||
const ratio = oembed.width / oembed.height;
|
||||
const thumbnail = oembed.thumbnail_url;
|
||||
const height = 350;
|
||||
const width = height * ratio;
|
||||
const authorName = _.escape(oembed.author_name);
|
||||
|
||||
const content = `
|
||||
<html>
|
||||
|
|
@ -49,12 +52,12 @@ exports.youtubeHandler = {
|
|||
<meta property="og:image:secure_url" content="${thumbnail}" />
|
||||
<meta property="og:title" content="${title}" />
|
||||
<meta property="og:description" content="" />
|
||||
<meta property="og:article:author" content="${oembed.author_name}" />
|
||||
<meta property="og:article:author" content="${authorName}" />
|
||||
</head>
|
||||
<body>
|
||||
<iframe width="${width}" height="${height}" src="https://www.youtube.com/embed/${videoId}" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
<p><a href="${url}" target="_blank">${title}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${oembed.author_name}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"linkedom": "^0.14.9",
|
||||
"luxon": "^2.3.1",
|
||||
"puppeteer-core": "^16.1.0",
|
||||
"underscore": "^1.13.4",
|
||||
"winston": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
const { DateTime } = require('luxon');
|
||||
const _ = require("underscore");
|
||||
|
||||
const TWITTER_BEARER_TOKEN = process.env.TWITTER_BEARER_TOKEN;
|
||||
const TWITTER_URL_MATCH = /twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/
|
||||
|
|
@ -116,7 +117,7 @@ exports.twitterHandler = {
|
|||
const tweetData = (await getTweetById(tweetId)).data;
|
||||
const authorId = tweetData.data.author_id;
|
||||
const author = tweetData.includes.users.filter(u => u.id = authorId)[0];
|
||||
const title = titleForAuthor(author)
|
||||
const title = _.escape(titleForAuthor(author))
|
||||
const authorImage = author.profile_image_url.replace('_normal', '_400x400')
|
||||
|
||||
let text = tweetData.data.text;
|
||||
|
|
@ -157,7 +158,7 @@ exports.twitterHandler = {
|
|||
<meta property="og:image" content="${authorImage}" />
|
||||
<meta property="og:image:secure_url" content="${authorImage}" />
|
||||
<meta property="og:title" content="${title}" />
|
||||
<meta property="og:description" content="${tweetData.data.text}" />
|
||||
<meta property="og:description" content="${_.escape(tweetData.data.text)}" />
|
||||
</head>
|
||||
<body>
|
||||
${front}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
require('dotenv').config();
|
||||
const axios = require('axios');
|
||||
const _ = require("underscore");
|
||||
|
||||
const YOUTUBE_URL_MATCH =
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/
|
||||
|
|
@ -36,11 +37,12 @@ exports.youtubeHandler = {
|
|||
|
||||
const oembedUrl = `https://www.youtube.com/oembed?format=json&url=` + encodeURIComponent(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
const oembed = (await axios.get(oembedUrl.toString())).data;
|
||||
const title = oembed.title;
|
||||
const title = _.escape(oembed.title);
|
||||
const ratio = oembed.width / oembed.height;
|
||||
const thumbnail = oembed.thumbnail_url;
|
||||
const height = 350;
|
||||
const width = height * ratio;
|
||||
const authorName = _.escape(oembed.author_name);
|
||||
|
||||
const content = `
|
||||
<html>
|
||||
|
|
@ -49,12 +51,12 @@ exports.youtubeHandler = {
|
|||
<meta property="og:image:secure_url" content="${thumbnail}" />
|
||||
<meta property="og:title" content="${title}" />
|
||||
<meta property="og:description" content="" />
|
||||
<meta property="og:article:author" content="${oembed.author_name}" />
|
||||
<meta property="og:article:author" content="${authorName}" />
|
||||
</head>
|
||||
<body>
|
||||
<iframe width="${width}" height="${height}" src="https://www.youtube.com/embed/${videoId}" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
<p><a href="${url}" target="_blank">${title}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${oembed.author_name}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"title": "Axios Finish Line",
|
||||
"byline": null,
|
||||
"dir": null,
|
||||
"excerpt": "Start and end your work day with the stories that matter from Axios by Mike Allen, the world’s most-wired reporter. Then finish the night with what matters -- and lasts -- in life with insights from Mike, Jim VandeHei and Erica Pandey.",
|
||||
"siteName": null,
|
||||
"previewImage": "https://static.axios.com/img-email/socialcard_axiosam3.jpg",
|
||||
"publishedDate": null,
|
||||
"language": "English",
|
||||
"readerable": true
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<DIV>
|
||||
<!--[if mso]>
|
||||
<table style="width:600px;"><tr><td>
|
||||
<![endif]-->
|
||||
<div>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>Presented By Pfizer</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>Axios AM Thought Bubble</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>By Mike Allen <span>·</span> Sep 20, 2022 </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>Good morning</strong>. Here's Axios world reporter Dave Lawler with what you need to know about today's opening of the UN General Assembly gathering in New York.</p>
|
||||
<ul>
|
||||
<li><em>Smart Brevity™ count: 414 words ... a 2-minute read.</em></li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 1 big thing: Ukraine dominates UN General Assembly </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/bHNds2L6EO6Mtv62a5agdJxfd0c=/0x0:1920x1080/1920x1080/2022/09/20/1663677802854.jpg" width="600" id="item00" alt="Illustration of country name placards at the UN with microphones, with Ukraine's microphone bigger than all others. ">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Illustration: Aïda Amer/Axios</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>NEW YORK — </strong>The war in Ukraine is set to dominate this week's UN General Assembly meeting, overshadowing other global dilemmas like food security, climate change and other political and humanitarian crises around the world.</p>
|
||||
<p><strong>Why it matters:</strong> The Biden administration is intent on keeping up the pressure on Moscow, but some developing countries and aid groups have expressed concern that diplomatic skirmishes over the war will undermine a key opportunity to address other crises that deserve attention.</p>
|
||||
<p><strong>What they're saying: </strong>Stéphane Dujarric, spokesperson for Secretary-General António Guterres, said the war "does take up a lot of the space" and can make it harder to build momentum and consensus on other issues.</p>
|
||||
<ul>
|
||||
<li>Ecuadorian President Guillermo Lasso <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3MuY29tLzIwMjIvMDkvMjAvZ3VpbGxlcm1vLWxhc3NvLWludGVydmlldy11cy1jaGluYS1yZWxhdGlvbnM_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Bf0481178" target="_blank">told Axios in an interview</a> that the focus on Ukraine is understandable — as long as the big powers recognize that the knock-on effects of soaring food and fuel prices are hitting smaller, poorer countries hardest.</li>
|
||||
<li>However, Linda Thomas-Greenfield, U.S. ambassador to the UN, argued ahead of the summit that those concerns were misplaced, as the war "will not be the only thing that we're dealing with." The U.S. and African and European Unions will co-host a summit on food security, for example.</li>
|
||||
</ul>
|
||||
<p><strong>Driving the news:</strong> Kicking off the six-day procession of speeches just now, Guterres called attention to the array of crises unfolding "far from the spotlight" — from Ethiopia, to Haiti, to Myanmar and beyond.</p>
|
||||
<ul>
|
||||
<li>He also acknowledged that rather than taking collective action, the international community — and the UN itself — had been "paralyzed" by "geopolitical divides." </li>
|
||||
<li>That paralysis will be on display Thursday, when Secretary of State Tony Blinken, Russian Foreign Minister Sergey Lavrov and their counterparts are expected to discuss Ukraine at the UN Security Council. Russia is outnumbered on the council, but wields a veto.</li>
|
||||
</ul>
|
||||
<p><strong>The big picture: </strong>The UN General Assembly is back at full force for the first time in three years.</p>
|
||||
<ul>
|
||||
<li>The past two annual gatherings were derailed by COVID-19, but the pandemic has slipped down the agenda and most delegates are wandering UN HQ maskless. </li>
|
||||
</ul>
|
||||
<p><strong>What to watch: </strong>President Biden forfeited the prime U.S. speaking slot this morning (always second after Brazil) to travel back from Queen Elizabeth II's funeral, and will instead speak on Wednesday.</p>
|
||||
<ul>
|
||||
<li>China and Russia won't address the forum until Saturday, because both Xi Jinping and Vladimir Putin are staying home, and ministerial-level officials get the later speaking slots.</li>
|
||||
<li>Ukrainian President Volodymyr Zelensky, however, is slated to address the forum remotely on Wednesday.</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtdGhvdWdodC1idWJibGUtYmI3NWY4MGUtNjJjZC00Zjk5LTlkYWItZmRlNzIzNzBhMjk2Lmh0bWw_Y2h1bmslM0QwJTI2dXRtX3Rlcm0lM0RmYnNvY2lhbHNoYXJlJTIzc3RvcnkwJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208B72ea463b"></a>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0xJTIwYmlnJTIwdGhpbmc6JTIwVWtyYWluZSUyMGRvbWluYXRlcyUyMFVOJTIwR2VuZXJhbCUyMEFzc2VtYmx5Jmhhc2h0YWdzPWF4aW9zYW10aG91Z2h0YnViYmxlJnVybD1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtdGhvdWdodC1idWJibGUtYmI3NWY4MGUtNjJjZC00Zjk5LTlkYWItZmRlNzIzNzBhMjk2Lmh0bWw_Y2h1bmslM0QwJTI2dXRtX3Rlcm0lM0R0d3NvY2lhbHNoYXJlJTIzc3RvcnkwJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208Bfe1c2959"></a>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLXRob3VnaHQtYnViYmxlLWJiNzVmODBlLTYyY2QtNGY5OS05ZGFiLWZkZTcyMzcwYTI5Ni5odG1sP2NodW5rJTNEMCUyNTI2dXRtX3Rlcm0lMjUzRGxpc29jaWFsc2hhcmUlMjUyM3N0b3J5MCZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208Bb342c543"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%201%20big%20thing:%20Ukraine%20dominates%20UN%20General%20Assembly&body=hongbo-jcdrcby5e%40inbox-demo.omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A1%20big%20thing:%20Ukraine%20dominates%20UN%20General%20Assembly%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-thought-bubble-bb75f80e-62cd-4f99-9dab-fde72370a296.html%3Fchunk%3D0%26utm_term%3Demshare#story0"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p> A message from Pfizer </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>An Accord for a Healthier World</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCEk4jcNBABGAEoATIIOBBtC45s_bI" alt="" width="600">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>People living in lower income countries are disproportionately impacted by disease, causing lasting social and economic consequences.</p>
|
||||
<p>Through an Accord for a Healthier World, we’re working to close the health equity gap.</p>
|
||||
<p>We believe <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cucGZpemVyLmNvbS9hY2NvcmQ_Y2lkPWJuX2NvcnBfcmVwdXRfYXhpb3MtNDI0OHlfMDkyMiZheGlvc19hZGxpbms9MSZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208B7fd12ebf" target="_blank">better health is possible for everyone, everywhere.</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://static.axios.com/img-email/axioshq-new-image-footer.gif" alt="HQ" width="87">
|
||||
</td>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<p>Are you a fan of this email format?</p>
|
||||
<p> It's called Smart Brevity®. Over 300 orgs use it — in a tool called <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3NocS5jb20vc2lnbnVwP3V0bV9zb3VyY2U9YXhpb3MtbmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1heGlvc2hxLW5sLWZvb3RlciZzdHJlYW09dG9w/624d3447776363102d0b2208B876bd7d6">Axios HQ</a> — to drive productivity with clearer workplace communications.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- End email content -->
|
||||
<!-- Start footer -->
|
||||
<div>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Axios thanks our partners for supporting our newsletters. If you’re interested in advertising, learn more <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208B06c15577">here</a>. <br> Sponsorship has no influence on editorial content.</p>
|
||||
</span>
|
||||
<span> Axios, 3100 Clarendon Blvd, Arlington VA 22201 </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> You received this email because you signed up for newsletters from Axios.<br>
|
||||
<a href="https://link.axios.com/oc/624d3447776363102d0b2208hbv6u.bdsb/bfdde539">Change your preferences or unsubscribe here.</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> Was this email forwarded to you?<br>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cDovL2xpbmsuYXhpb3MuY29tL2pvaW4vNWZ4L3NpZ251cC1hbGw_dXRtX3NvdXJjZT1mb3J3YXJkZWRfZW1haWwmdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208Bbb028aae">Sign up now</a> to get Axios in your inbox. </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Follow Axios on social media:</p>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL2F4aW9zbmV3cy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Beada5dfb"><img src="https://static.axios.com/img-email/facebook@2x.png" height="16" alt="Axios on Facebook"></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cudHdpdHRlci5jb20vYXhpb3MvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208B3521bd9a"><img src="https://static.axios.com/img-email/twitter@2x.png" height="16" alt="Axios on Twitter"></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuaW5zdGFncmFtLmNvbS9heGlvcy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Bcd0c3da7"><img src="https://static.axios.com/img-email/instagram@2x.png" height="16" alt="Axios on Instagram"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<!--<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.75;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.75px; color: #222222;">
|
||||
View in browser at <a href="https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c" target="_blank" style="color: #222222; letter-spacing: 1px;">https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>-->
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- End footer -->
|
||||
<!--[if mso]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</DIV>
|
||||
</DIV>
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
|
||||
<meta property="og:title" content="Axios Finish Line" />
|
||||
<meta property="og:image" content="https://static.axios.com/img-email/socialcard_axiosam3.jpg" />
|
||||
<meta property="og:description" content="Start and end your work day with the stories that matter from Axios by Mike Allen, the world’s most-wired reporter. Then finish the night with what matters -- and lasts -- in life with insights from Mike, Jim VandeHei and Erica Pandey." />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Axios Finish Line" />
|
||||
<meta name="twitter:description" content="Start and end your work day with the stories that matter from Axios by Mike Allen, the world’s most-wired reporter. Then finish the night with what matters -- and lasts -- in life with insights from Mike, Jim VandeHei and Erica Pandey." />
|
||||
<meta property="twitter:image" content="https://static.axios.com/img-email/socialcard_axiosam3.jpg" />
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="format-detection" content="date=no">
|
||||
<meta name="format-detection" content="address=no">
|
||||
<meta name="format-detection" content="email=no">
|
||||
<style type="text/css">
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'atizatext';
|
||||
src: url('https://static.axios.com/fonts/atizatext-regular-webfont.eot');
|
||||
src: url('https://static.axios.com/fonts/atizatext-regular-webfont.eot?#iefix') format('embedded-opentype'),
|
||||
url('https://static.axios.com/fonts/atizatext-regular-webfont.woff2') format('woff2'),
|
||||
url('https://static.axios.com/fonts/atizatext-regular-webfont.woff') format('woff'),
|
||||
url('https://static.axios.com/fonts/atizatext-regular-webfont.ttf') format('truetype'),
|
||||
url('https://static.axios.com/fonts/atizatext-regular-webfont.svg#atizatext') format('svg');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'atizabold';
|
||||
src: url('https://static.axios.com/fonts/atizatext-bold-webfont.eot');
|
||||
src: url('https://static.axios.com/fonts/atizatext-bold-webfont.eot?#iefix') format('embedded-opentype'),
|
||||
url('https://static.axios.com/fonts/atizatext-bold-webfont.woff2') format('woff2'),
|
||||
url('https://static.axios.com/fonts/atizatext-bold-webfont.woff') format('woff'),
|
||||
url('https://static.axios.com/fonts/atizatext-bold-webfont.ttf') format('truetype'),
|
||||
url('https://static.axios.com/fonts/atizatext-bold-webfont.svg#atizatext') format('svg');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'gordita';
|
||||
src: url('https://static.axios.com/fonts/gorditaregular-webfont.eot');
|
||||
src: url('https://static.axios.com/fonts/gorditaregular-webfont.eot?#iefix') format('embedded-opentype'),
|
||||
url('https://static.axios.com/fonts/gorditaregular-webfont.woff2') format('woff2'),
|
||||
url('https://static.axios.com/fonts/gorditaregular-webfont.woff') format('woff'),
|
||||
url('https://static.axios.com/fonts/gorditaregular-webfont.ttf') format('truetype'),
|
||||
url('https://static.axios.com/fonts/gorditaregular-webfont.svg#gorditaregular') format('svg');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'gorditamedium';
|
||||
src: url('https://static.axios.com/fonts/gorditamedium-webfont.eot');
|
||||
src: url('https://static.axios.com/fonts/gorditamedium-webfont.eot?#iefix') format('embedded-opentype'),
|
||||
url('https://static.axios.com/fonts/gorditamedium-webfont.woff2') format('woff2'),
|
||||
url('https://static.axios.com/fonts/gorditamedium-webfont.woff') format('woff'),
|
||||
url('https://static.axios.com/fonts/gorditamedium-webfont.ttf') format('truetype'),
|
||||
url('https://static.axios.com/fonts/gorditamedium-webfont.svg#gorditamedium') format('svg');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'gorditabold';
|
||||
src: url('https://static.axios.com/fonts/gorditabold-webfont.eot');
|
||||
src: url('https://static.axios.com/fonts/gorditabold-webfont.eot?#iefix') format('embedded-opentype'),
|
||||
url('https://static.axios.com/fonts/gorditabold-webfont.woff2') format('woff2'),
|
||||
url('https://static.axios.com/fonts/gorditabold-webfont.woff') format('woff'),
|
||||
url('https://static.axios.com/fonts/gorditabold-webfont.ttf') format('truetype'),
|
||||
url('https://static.axios.com/fonts/gorditabold-webfont.svg#gorditabold') format('svg');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
p {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
p strong {
|
||||
font-family: Georgia, serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
.main-header,
|
||||
.bodytext {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.post-text a {
|
||||
border-bottom: 1px solid #008dc8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.post-text a:hover {
|
||||
color: #008dc8;
|
||||
}
|
||||
.post-text ul li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
a {
|
||||
color: #222222;
|
||||
}
|
||||
.hq-cta a {
|
||||
border-bottom: 1px solid #008dc8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.hq-cta a:hover {
|
||||
color: #008dc8;
|
||||
}
|
||||
.gmailfix {
|
||||
display:none;
|
||||
display:none!important;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.hed {
|
||||
font-size: 26px !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
.main-header {
|
||||
font-size: 34px !important;
|
||||
}
|
||||
.post-text {
|
||||
font-size: 17px !important;
|
||||
-webkit-text-size-adjust: none !important;
|
||||
}
|
||||
.hq-cta{
|
||||
padding: 16px !important;
|
||||
}
|
||||
.hq-cta img{
|
||||
width:64px !important;
|
||||
}
|
||||
.hq-cta-text-header{
|
||||
display:block;
|
||||
font-size: 14px !important;
|
||||
line-height:17.5px !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
.hq-cta-text-body{
|
||||
display:block;
|
||||
margin-top:8px !important;
|
||||
line-height:13.75px !important;
|
||||
font-size: 11px !important;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css?family=PT+Mono&display=swap" rel="stylesheet">
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.bodytext, .main-header {font-family: Arial, Helvetica, Helvetica, sans-serif !important;}
|
||||
.post-text {font-family: Georgia, Palatino Linotype, serif !important;}
|
||||
.ol-adpad {padding-bottom: 10px !important;}
|
||||
a {color: #222222; text-decoration:underline !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if gte mso 7]><xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
</head>
|
||||
<body style="background-color: #f5f5f5; margin: 0 auto; padding: 0; text-align: center;">
|
||||
<div style="display:none;font-size:1px;color:#222222;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||
Crowding out other crises | Tuesday, September 20, 2022
|
||||
</div>
|
||||
<table cellpadding="0" cellspacing="0" width="100%" style="background-color: #f6f6f6;text-align: center">
|
||||
<tr>
|
||||
<td style="max-width: 600px;">
|
||||
<!--[if mso]>
|
||||
<table style="width:600px;"><tr><td>
|
||||
<![endif]-->
|
||||
<table style="max-width: 600px; margin: 0 auto; text-align: left; width: 100%;" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td height="25" style="height: 25px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-left: 35px;"><a href="#" title="AXIOS"><img src="https://static.axios.com/img-email/axios-retina.png" border="0" style="margin-bottom: -1px;" alt="Axios Logo" width="83" /></a> <span class="bodytext" style="font-family: 'gordita', sans-serif; font-size: 25px; color: #4a4a4a;">Alerts</span><a href="https://link.axios.com/click/29107110.531083/aHR0cDovL3d3dy5heGlvcy5jb20_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208B1993966b"><img src="https://link.axios.com/img/624d3447776363102d0b2208hbv6u.bdsb/39c4b23e.gif" alt="" border="0" /></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="max-width: 600px; width: 100%; margin: 0 auto; text-align: left; background: #fff;" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: top;">
|
||||
<table cellspacing="0" cellpadding="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding-right: 3.5%;" align="right">
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLXRob3VnaHQtYnViYmxlLWJiNzVmODBlLTYyY2QtNGY5OS05ZGFiLWZkZTcyMzcwYTI5Ni5odG1sP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208B7d40361f" target="_blank" class="bodytext" style="color: #222222; font-size: 13px; font-family: 'gordita', Arial, Helvetica, sans-serif;text-decoration:underline;">View in browser</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="30" style="height: 30px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; line-height: 1.3;">
|
||||
<span class="bodytext" style="font-size: 11px; letter-spacing:1.4px; color: #222222; font-family: 'gorditamedium', Arial, Helvetica, sans-serif; text-transform: uppercase;">Presented By Pfizer</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="16" style="height: 16px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; line-height: 1;">
|
||||
<span class="main-header" style="font-size: 44px; font-family: 'gorditamedium', Arial, Helvetica, sans-serif; color: #008dc8; letter-spacing: -1px; line-height: 1;">Axios AM Thought Bubble</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align: center; line-height: 1.5; padding-bottom:35px;padding-top:10px;">
|
||||
<span class="bodytext" style="font-size: 13px; color: #222222; font-family: 'gorditamedium', Arial, Helvetica, sans-serif; ">By
|
||||
Mike Allen
|
||||
<span style="margin: 0 6px;">·</span>
|
||||
Sep 20, 2022
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="post-text" style="font-size: 17px; font-family: Georgia, serif; line-height: 1.75; color: #222222; letter-spacing: -0.1px; padding: 0 3.5%;">
|
||||
<p><strong>Good morning</strong>. Here's Axios world reporter Dave Lawler with what you need to know about today's opening of the UN General Assembly gathering in New York.</p><ul><li><em>Smart Brevity™ count: 414 words ... a 2-minute read.</em></li></ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0; border-bottom: 10px solid #f5f5f5;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 3.5%; line-height: 1.5;">
|
||||
<span class="bodytext hed " style="font-size: 27px; font-family: 'gorditamedium', Arial, Helvetica, sans-serif; color: #222222; letter-spacing: -0.1px; line-height: 1.5;">
|
||||
1 big thing: Ukraine dominates UN General Assembly
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/bHNds2L6EO6Mtv62a5agdJxfd0c=/0x0:1920x1080/1920x1080/2022/09/20/1663677802854.jpg" width="600" style="width:100%" id="item00" alt="Illustration of country name placards at the UN with microphones, with Ukraine's microphone bigger than all others. ">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 3.5%; line-height: 1.3;">
|
||||
<span class="bodytext" style="font-size: 13px; color: #848484;">
|
||||
<p>Illustration: Aïda Amer/Axios</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="15" style="height: 15px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="post-text" style="padding: 0 3.5%; line-height: 1.75; font-size: 17px; font-family: Georgia, serif; line-height: 1.75; color: #222222; letter-spacing: -0.1px;">
|
||||
<p><strong>NEW YORK — </strong>The war in Ukraine is set to dominate this week's UN General Assembly meeting, overshadowing other global dilemmas like food security, climate change and other political and humanitarian crises around the world.</p><p><strong>Why it matters:</strong> The Biden administration is intent on keeping up the pressure on Moscow, but some developing countries and aid groups have expressed concern that diplomatic skirmishes over the war will undermine a key opportunity to address other crises that deserve attention.</p><p><strong>What they're saying: </strong>Stéphane Dujarric, spokesperson for Secretary-General António Guterres, said the war "does take up a lot of the space" and can make it harder to build momentum and consensus on other issues.</p><ul><li>Ecuadorian President Guillermo Lasso <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3MuY29tLzIwMjIvMDkvMjAvZ3VpbGxlcm1vLWxhc3NvLWludGVydmlldy11cy1jaGluYS1yZWxhdGlvbnM_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Bf0481178" target="_blank">told Axios in an interview</a> that the focus on Ukraine is understandable — as long as the big powers recognize that the knock-on effects of soaring food and fuel prices are hitting smaller, poorer countries hardest.</li><li>However, Linda Thomas-Greenfield, U.S. ambassador to the UN, argued ahead of the summit that those concerns were misplaced, as the war "will not be the only thing that we're dealing with." The U.S. and African and European Unions will co-host a summit on food security, for example.</li></ul><p><strong>Driving the news:</strong> Kicking off the six-day procession of speeches just now, Guterres called attention to the array of crises unfolding "far from the spotlight" — from Ethiopia, to Haiti, to Myanmar and beyond.</p><ul><li>He also acknowledged that rather than taking collective action, the international community — and the UN itself — had been "paralyzed" by "geopolitical divides." </li><li>That paralysis will be on display Thursday, when Secretary of State Tony Blinken, Russian Foreign Minister Sergey Lavrov and their counterparts are expected to discuss Ukraine at the UN Security Council. Russia is outnumbered on the council, but wields a veto.</li></ul><p><strong>The big picture: </strong>The UN General Assembly is back at full force for the first time in three years.</p><ul><li>The past two annual gatherings were derailed by COVID-19, but the pandemic has slipped down the agenda and most delegates are wandering UN HQ maskless. </li></ul><p><strong>What to watch: </strong>President Biden forfeited the prime U.S. speaking slot this morning (always second after Brazil) to travel back from Queen Elizabeth II's funeral, and will instead speak on Wednesday.</p><ul><li>China and Russia won't address the forum until Saturday, because both Xi Jinping and Vladimir Putin are staying home, and ministerial-level officials get the later speaking slots.</li><li>Ukrainian President Volodymyr Zelensky, however, is slated to address the forum remotely on Wednesday.</li></ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 3.5% 25px 3.5%;">
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtdGhvdWdodC1idWJibGUtYmI3NWY4MGUtNjJjZC00Zjk5LTlkYWItZmRlNzIzNzBhMjk2Lmh0bWw_Y2h1bmslM0QwJTI2dXRtX3Rlcm0lM0RmYnNvY2lhbHNoYXJlJTIzc3RvcnkwJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208B72ea463b" style="display: inline-block;"><img width="32" height="32" style="border: none" target="_blank" src="https://static.axios.com/img-email/social/facebook@2x.png" alt="Share on Facebook"></a>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0xJTIwYmlnJTIwdGhpbmc6JTIwVWtyYWluZSUyMGRvbWluYXRlcyUyMFVOJTIwR2VuZXJhbCUyMEFzc2VtYmx5Jmhhc2h0YWdzPWF4aW9zYW10aG91Z2h0YnViYmxlJnVybD1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtdGhvdWdodC1idWJibGUtYmI3NWY4MGUtNjJjZC00Zjk5LTlkYWItZmRlNzIzNzBhMjk2Lmh0bWw_Y2h1bmslM0QwJTI2dXRtX3Rlcm0lM0R0d3NvY2lhbHNoYXJlJTIzc3RvcnkwJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208Bfe1c2959" style="display: inline-block; margin-left: 7px"><img width="32" height="32" style="border: none" target="_blank" src="https://static.axios.com/img-email/social/twitter@2x.png" alt="Tweet this Story"></a>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLXRob3VnaHQtYnViYmxlLWJiNzVmODBlLTYyY2QtNGY5OS05ZGFiLWZkZTcyMzcwYTI5Ni5odG1sP2NodW5rJTNEMCUyNTI2dXRtX3Rlcm0lMjUzRGxpc29jaWFsc2hhcmUlMjUyM3N0b3J5MCZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208Bb342c543" style="display: inline-block; margin-left: 7px"><img width="32" height="32" style="border: none" target="_blank" src="https://static.axios.com/img-email/social/linkedin@2x.png" alt="Post to LinkedIn"></a>
|
||||
<a href="mailto:?subject=From Axios: 1 big thing: Ukraine dominates UN General Assembly&body=hongbo-jcdrcby5e%40inbox-demo.omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A1 big thing: Ukraine dominates UN General Assembly%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-thought-bubble-bb75f80e-62cd-4f99-9dab-fde72370a296.html%3Fchunk%3D0%26utm_term%3Demshare#story0" style="display: inline-block; margin-left: 7px"><img width="32" height="32" style="border: none" target="_blank" src="https://static.axios.com/img-email/social/email@2x.png" alt="Email this Story"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0; border-bottom: 10px solid #f5f5f5;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 10px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ol-adpad" style="padding: 20px 3.5% 0 3.5%;">
|
||||
<p class="bodytext" style="-webkit-text-size-adjust: 100%; margin-bottom: 0 !important; font-size: 11px !important; letter-spacing: 1.4px; color: #008dc8; text-transform: uppercase; font-weight:700;">
|
||||
A message from Pfizer
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 3.5%; line-height: 1.5;">
|
||||
<span class="bodytext hed" style="font-size: 26px; font-family: 'gorditamedium', Arial, Helvetica, sans-serif; color: #222222; letter-spacing: -0.1px; line-height: 1.5;">An Accord for a Healthier World</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCEk4jcNBABGAEoATIIOBBtC45s_bI" alt="" width="600" style="width:100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="15" style="height: 15px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="post-text" style="padding: 0 3.5%; line-height: 1.75; font-size: 17px; font-family: Georgia, serif; line-height: 1.75; color: #222222; letter-spacing: -0.1px;">
|
||||
<p>People living in lower income countries are disproportionately impacted by disease, causing lasting social and economic consequences.</p>
|
||||
<p>Through an Accord for a Healthier World, we’re working to close the health equity gap.</p>
|
||||
<p>We believe <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cucGZpemVyLmNvbS9hY2NvcmQ_Y2lkPWJuX2NvcnBfcmVwdXRfYXhpb3MtNDI0OHlfMDkyMiZheGlvc19hZGxpbms9MSZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208B7fd12ebf" target="_blank">better health is possible for everyone, everywhere.</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="10" style="height: 10px; font-size: 0; border-bottom: 10px solid #f5f5f5;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="hq-cta"
|
||||
style="
|
||||
padding: 24px 4% 24px 4%;
|
||||
margin: 0;
|
||||
line-height: 1.75;
|
||||
font-size: 16px;
|
||||
color: #222222;
|
||||
letter-spacing: -0.1px;
|
||||
font-family: Arial, sans-serif;
|
||||
"
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
style="margin: 0 auto; width: 100%"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
>
|
||||
<tr>
|
||||
<td style="font-size: 0">
|
||||
<img
|
||||
src=https://static.axios.com/img-email/axioshq-new-image-footer.gif
|
||||
style="width: 100%; max-width: 87px; min-width: 64px"
|
||||
border="0"
|
||||
alt=HQ
|
||||
width="87"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<table
|
||||
width="100%"
|
||||
style="max-width: 600px; margin: 0 auto; width: 100%"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
>
|
||||
<tr>
|
||||
<td style="padding-left: 20px; font-family: 'NB International Pro', 'Helvetica', 'Helvetica Neue'" class="hq-cta-text">
|
||||
<div style="font-size: 17px; color: #333335" class"hq-cta-text-header">Are you a fan of this email format?</div><div style="padding-top: 12px; font-size: 14px; color: #737376; line-height: 1.4" class="hq-cta-text-body">
|
||||
It's called Smart Brevity®. Over 300 orgs use it — in a tool called <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3NocS5jb20vc2lnbnVwP3V0bV9zb3VyY2U9YXhpb3MtbmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1heGlvc2hxLW5sLWZvb3RlciZzdHJlYW09dG9w/624d3447776363102d0b2208B876bd7d6">Axios HQ</a> — to drive productivity with clearer workplace communications.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- End email content -->
|
||||
<!-- Start footer -->
|
||||
<table style="max-width:600px; margin: 0 auto; width: 100%;" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.75;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.75; color: #222222;">
|
||||
<p>Axios thanks our partners for supporting our newsletters. If you’re interested in advertising, learn more <a href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208B06c15577" style="color: #222222;">here</a>.
|
||||
<br> Sponsorship has no influence on editorial content.</p>
|
||||
</span>
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.75; color: #222222;">
|
||||
Axios, 3100 Clarendon B‌lvd, Arlington VA 22201
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.2;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.2; color: #222222;">
|
||||
You received this email because you signed up for newsletters from Axios.<br>
|
||||
<a href="https://link.axios.com/oc/624d3447776363102d0b2208hbv6u.bdsb/bfdde539" style="color: #222222;">Change your preferences or unsubscribe here.</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.2;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.2; color: #222222;">
|
||||
Was this email forwarded to you?<br>
|
||||
<a href="https://link.axios.com/click/29107110.531083/aHR0cDovL2xpbmsuYXhpb3MuY29tL2pvaW4vNWZ4L3NpZ251cC1hbGw_dXRtX3NvdXJjZT1mb3J3YXJkZWRfZW1haWwmdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249dGhvdWdodC1idWJibGUtYW1wbSZzdHJlYW09dG9w/624d3447776363102d0b2208Bbb028aae" style="color: #222222;">Sign up now</a> to get Axios in your inbox.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="30" align="center">
|
||||
<p class="bodytext" style="font-size: 12px; line-height: 1.75; color: #222222;">Follow Axios on social media:</p>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL2F4aW9zbmV3cy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Beada5dfb"><img src="https://static.axios.com/img-email/facebook@2x.png" height="16" alt="Axios on Facebook" style="border: 0; padding-right: 28px;" /></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cudHdpdHRlci5jb20vYXhpb3MvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj10aG91Z2h0LWJ1YmJsZS1hbXBtJnN0cmVhbT10b3A/624d3447776363102d0b2208B3521bd9a"><img src="https://static.axios.com/img-email/twitter@2x.png" height="16" alt="Axios on Twitter" style="border: 0; padding-right: 20px;" /></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/29107110.531083/aHR0cHM6Ly93d3cuaW5zdGFncmFtLmNvbS9heGlvcy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPXRob3VnaHQtYnViYmxlLWFtcG0mc3RyZWFtPXRvcA/624d3447776363102d0b2208Bcd0c3da7"><img src="https://static.axios.com/img-email/instagram@2x.png" height="16" alt="Axios on Instagram" style="border: 0" /></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="20" style="height: 20px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
<!--<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.75;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.75px; color: #222222;">
|
||||
View in browser at <a href="https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c" target="_blank" style="color: #222222; letter-spacing: 1px;">https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>-->
|
||||
<tr>
|
||||
<td height="70" style="height: 70px; font-size: 0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- End footer -->
|
||||
<!--[if mso]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="gmailfix" style="white-space:nowrap; font:15px courier; line-height:0;">
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1 @@
|
|||
https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"title": "Axios Chicago",
|
||||
"byline": null,
|
||||
"dir": null,
|
||||
"excerpt": "A daily look at the most significant and interesting stories affecting Chicago. Written by Monica Eng and Justin Kauffman.",
|
||||
"siteName": null,
|
||||
"previewImage": "https://static.axios.com/img/axios-site/axios-local-chicago.png",
|
||||
"publishedDate": null,
|
||||
"language": "English",
|
||||
"readerable": true
|
||||
}
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<DIV width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso]>
|
||||
<table style="width:600px;"><tr><td>
|
||||
<![endif]-->
|
||||
<div>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>Presented By Facebook </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>Axios Chicago</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>By Justin Kaufmann and Monica Eng <span>·</span>Feb 14, 2022</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>💘 Happy Monday! It's Valentine's Day. </strong>The last-minute rush is on to buy something for your loved ones. Or not. We don't judge. </p>
|
||||
<ul>
|
||||
<li><strong><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9mb3JlY2FzdC53ZWF0aGVyLmdvdi9NYXBDbGljay5waHA_bGF0PTQxLjk3OTgxMDAwMDAwMDA0Jmxvbj0tODcuODgyMDM5OTk5OTk5OTYmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3AjLllna2RNZTdNSmhG/620a4aa717e6565ad1452bd8Ba40a324e" target="_blank">Today's weather</a>:</strong> Warming up! Partly sunny with a high of 27 and a slight chance of snow. </li>
|
||||
</ul>
|
||||
<p><em>Today's newsletter is 873 words — a 3-minute read.</em></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 1 big thing: The (former) candy capital of the world </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/Xg0VMeccPWvRTFNXdMJFlG1yDFg=/0x220:3858x2390/1920x1080/2022/02/13/1644769985537.jpg" width="600" id="item00" alt="A little boy watches candy bars being made at the Curtiss plant in Chicago, 1961. ">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>A boy watches candy bars being made at the Curtiss Candy plant in Chicago, 1961. Photo: Bettmann/Getty Images</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>Chicago was once known</strong> as the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g_dj1hRW1sZEd4cTRwZyZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8B5fe147df" target="_blank">candy capital of the world</a>. But the remaining industry is now a (chocolate) shell of what it was. </p>
|
||||
<p><strong>Why it matters:</strong> At one time, more than 1,000 local candy companies supplied Chicagoans with relatively high-paying jobs. The industry, driven by immigrants and the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY2FuZHlpbmR1c3RyeS5jb20vYXJ0aWNsZXMvODI4NDEtdGhlLWNvbmZlY3Rpb24tY29ubmVjdGlvbj91dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8B846dceb0" target="_blank">river and railroads </a>bringing in affordable corn syrup and sugar, stayed strong for more than <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY29uZmVjdGlvbmVyeW5ld3MuY29tL0FydGljbGUvMjAyMC8wNS8xMi9DaGljYWdvLUNhbmR5LUNhcGl0YWwtb2YtdGhlLXdvcmxkP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9wIzp-OnRleHQ9Q2hpY2FnbydzJTIwdGhyaXZpbmclMjBzd2VldHMlMjBpbmR1c3RyeSUyMHJlc3VsdGVkLHNoYXBlJTIwdGhlJTIwYnVzaW5lc3MlMjBvZiUyMGNhbmR5Lg/620a4aa717e6565ad1452bd8Bcf116256" target="_blank">a century</a>. </p>
|
||||
<ul>
|
||||
<li><strong>But in recent years,</strong> the city has lost factories that made <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLmN1cmJlZC5jb20vMjAxNC83LzkvMTAwNzc3OTAvd2l0bmVzcy10aGUtZGVtb2xpdGlvbi1vZi10aGUtb2xkLWJyYWNocy1jYW5keS1mYWN0b3J5P3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B259e6495" target="_blank">Brach's candies</a>, <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cubnl0aW1lcy5jb20vMjAwNC8wMS8yMC9idXNpbmVzcy9jaGljYWdvLXRvLWxvc2UtMi1oaXN0b3JpYy1jYW5keS1icmFuZHMuaHRtbD91dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8B79e3fbe1" target="_blank">Fannie May</a> and now <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9hYmM3Y2hpY2Fnby5jb20vbWFycy13cmlnbGV5LWNvbmZlY3Rpb25hcnktY2hpY2Fnby1wbGFudC1jbG9zaW5nLW9hay1wYXJrLWF2ZS8xMTUwOTA4My8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B55e27893" target="_blank">Mars</a> products.</li>
|
||||
</ul>
|
||||
<p>💝 <strong>This Valentine's Day,</strong> we want to take current stock of the industry by breaking down which companies are still operating in town. </p>
|
||||
<p><strong>Mars/Wrigley: </strong>The company <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY2hpY2Fnb3RyaWJ1bmUuY29tL2J1c2luZXNzL2N0LWJpei1tYXJzLXdyaWdsZXktY2xvc2luZy1jaGljYWdvLWNob2NvbGF0ZS1wbGFudC0yMDIyMDEyNi03eG9xemY3ZmZuZmtwZmFudHpmY3Vsa2Z4ZS1zdG9yeS5odG1sP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B47d5f6dc" target="_blank">just announced plans</a> to shutter its West Side candy plant, reassigning 280 jobs. </p>
|
||||
<ul>
|
||||
<li><strong>The building was </strong><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLnN1bnRpbWVzLmNvbS9idXNpbmVzcy8yMDIyLzEvMjcvMjI5MDU1MDMvbWFycy13cmlnbGV5LWNhbmR5LWNsb3NpbmctaGlzdG9yaWMtZmFjdG9yeS1nYWxld29vZD91dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8B9f7cbc5d" target="_blank"><strong>built</strong> in 1928, has its own Metra stop</a> and makes M&Ms, Snickers, and Skittles.</li>
|
||||
<li><strong>Mars and Wrigley</strong> merged in 2008. Its global headquarters are now on Goose Island.</li>
|
||||
</ul>
|
||||
<p><strong>Tootsie Roll Industries</strong>: The famed chewy chocolate candy is made on the South Side. They moved headquarters here in 1966. </p>
|
||||
<ul>
|
||||
<li><strong>This facility also makes</strong> Tootsie Pops and Dots.</li>
|
||||
<li><strong>Tours</strong> <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90b290c2llLmNvbS9wbGFudC10b3VyLz91dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8B8fa72c46" target="_blank">are available</a>. </li>
|
||||
</ul>
|
||||
<p><strong>Ferrara Pan (now Ferrero):</strong> Chicago's Ferrara Pan was bought by European giant Ferrero, which owns Nutella. </p>
|
||||
<ul>
|
||||
<li><strong>Its headquarters are</strong> in the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cub2ZmaWNlbG92aW4uY29tLzIwMjAvMDkvYS1sb29rLWluc2lkZS1mZXJyYXJhLWNhbmR5cy1uZXctY2hpY2Fnby1oZWFkcXVhcnRlcnMvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8Bfd0827dc" target="_blank">old Post Office building</a>, but the main attraction is the outlet store in Forest Park. </li>
|
||||
<li><strong>They make millions</strong> of Lemonheads per day as well as Red Hots and Now and Laters. </li>
|
||||
</ul>
|
||||
<p><strong>Blommer Chocolate Company: </strong>One of the last remaining chocolate factories in the downtown area, Blommer is known less for its candy than for the intoxicating smell its plant produces.</p>
|
||||
<ul>
|
||||
<li><strong>When the wind hits</strong> the right way, the whole Loop can <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cud2Jlei5vcmcvc3Rvcmllcy9ibG9tbWVyLXdoZXJlLXRoZS1icmlkZ2VzLXNtZWxsLWxpa2UtY2hvY29sYXRlL2Q5NDU5OGJmLTdlY2MtNDFiNy04NWQ4LTY1NjljNzExZGM3Mj91dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Ba7f97d25" target="_blank">smell like Blommer chocolate</a>, as the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cud2FzaGluZ3RvbnBvc3QuY29tL2FyY2hpdmUvcG9saXRpY3MvMjAwNS8xMi8wNC9lcGEtdHVybnMtdXAtbm9zZS1hdC1jaG9jb2xhdGUtZmFjdG9yeS1hcm9tYS8wMGMxN2VmNS1jMDBjLTRhYmUtOWM1Yi04ZTIzOWY3NTU4MjQvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B8f60d1a2" target="_blank">EPA found out</a> in 2005.</li>
|
||||
</ul>
|
||||
<p><strong>Cupid Candies: </strong>The South Side factory started in 1936, but the Western Avenue location closed in 2020 after the death of its owner. </p>
|
||||
<ul>
|
||||
<li><strong>It was bought </strong>by Brown Sugar Bakery owner Stephanie Hart, who later <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9ibG9ja2NsdWJjaGljYWdvLm9yZy8yMDIyLzAyLzA5L2Jyb3duLXN1Z2FyLWJha2VyeS1vd25lci10by1leHBhbmQtaGVyLWxpZmUtaXMtc3dlZXQtY2FuZHktYnVzaW5lc3MtYXQtZm9ybWVyLWN1cGlkLWNhbmRpZXMtZmFjdG9yeS10aGFua3MtdG8tMS01LW1pbGxpb24tZ3JhbnQvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B25a48f8f" target="_blank">secured a $200,000 grant to start production</a>. </li>
|
||||
</ul>
|
||||
<p><strong>What's next:</strong> Even with these losses, the candy industry, represented by the Sweets & Snacks Expo, is <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY29uZmVjdGlvbmVyeW5ld3MuY29tL0FydGljbGUvMjAyMS8wNS8yMC9FeGNsdXNpdmUtU3dlZXRzLVNuYWNrcy1FeHBvLXRvLXJldHVybi10by1pdHMtaG9tZXRvd24tQ2hpY2Fnby1pbi0yMDIyLWZvci0yNXRoLWFubml2ZXJzYXJ5LWFmdGVyLXRoaXMteWVhci1zLXN3ZXJ2ZS10by1JbmRpYW5hcG9saXM_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B5b0b7136" target="_blank">returning in May</a>.</p>
|
||||
<p><strong>💭 Monica's thought bubble:</strong> My step-grandmother Carmela came to Chicago from Peru in the 1960s and found work at the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cubWFkZWluY2hpY2Fnb211c2V1bS5jb20vc2luZ2xlLXBvc3QvY3VydGlzcy1jYW5keS1jby8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Bea573bde" target="_blank">Curtiss Candy factory</a>. It kept her grandkids plentifully supplied for years with Baby Ruths and Butterfingers. </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDAlMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTAmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B53c419a4"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0xJTIwYmlnJTIwdGhpbmc6JTIwVGhlJTIwJTI4Zm9ybWVyJTI5JTIwY2FuZHklMjBjYXBpdGFsJTIwb2YlMjB0aGUlMjB3b3JsZCZoYXNodGFncz1heGlvc2NoaWNhZ28mdXJsPWh0dHBzOi8vd3d3LmF4aW9zLmNvbS9uZXdzbGV0dGVycy9heGlvcy1jaGljYWdvLTc0Yzg4OGUxLThhYzYtNDFmNS1iNjAxLThlYWFiMzVlMTIyNS5odG1sP2NodW5rJTNEMCUyNnV0bV90ZXJtJTNEdHdzb2NpYWxzaGFyZSUyM3N0b3J5MCZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Bacee6d2a"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0QwJTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3RvcnkwJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B662fb541"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%201%20big%20thing:%20The%20(former)%20candy%20capital%20of%20the%20world%20&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A1%20big%20thing:%20The%20(former)%20candy%20capital%20of%20the%20world%20%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D0%26utm_term%3Demshare#story0"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 2. LaVine sees specialist </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/Bi2PvzGIu8MHJcS9PFn4YZXol7Q=/0x154:2426x1519/1920x1080/2022/02/13/1644776242230.jpg" width="600" id="item01" alt="Zach LaVine flies through the air during a win against the Timberwolves on Friday night. ">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Zach LaVine flies through the air during a win against the Timberwolves on Friday night. Photo: David Banks/Getty Images</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>The Bulls</strong> got bad news yesterday.</p>
|
||||
<ul>
|
||||
<li><strong>Zach LaVine</strong> is seeing a specialist in Los Angeles about his ailing knee, according to <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZXNwbi5jb20vbmJhL3N0b3J5P2lkPTMzMjgxNDgzJl9zbHVnXz1zb3VyY2VzLWNoaWNhZ28tYnVsbHMtemFjaC1sYXZpbmUtc2VlLXNwZWNpYWxpc3QtYWlsaW5nLWxlZnQta25lZSZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Bb41705aa" target="_blank">ESPN</a>. </li>
|
||||
</ul>
|
||||
<p><strong>Why it matters:</strong> LaVine is a two-time All-Star averaging almost 25 points a game, and the Bulls didn't make any trades at last week's deadline. </p>
|
||||
<p><strong>Driving the news:</strong> LaVine has played on his sore left knee for a few games but sat out Saturday night during a win over Oklahoma City. He already missed games this season due to the injury, though a recent MRI revealed no structural damage. </p>
|
||||
<ul>
|
||||
<li><strong>He still wants to play</strong> in the All-Star game this weekend, per <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZXNwbi5jb20vbmJhL3N0b3J5P2lkPTMzMjgxNDgzJl9zbHVnXz1zb3VyY2VzLWNoaWNhZ28tYnVsbHMtemFjaC1sYXZpbmUtc2VlLXNwZWNpYWxpc3QtYWlsaW5nLWxlZnQta25lZSZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Cb41705aa" target="_blank">ESPN sources</a>. </li>
|
||||
</ul>
|
||||
<p><strong>Context:</strong> The Bulls have played short-handed this season without injured contributors Lonzo Ball, Alex Caruso and Patrick Williams. </p>
|
||||
<ul>
|
||||
<li><strong>They also lead the league</strong> in players who have missed time due to <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL25iYS1wbGF5ZXJzLW9taWNyb24tY292aWQtcHJvdG9jb2xzLW91dGJyZWFrLTZkNTA1OWQ0LWM5NjktNGNhMy1hNTI3LTVjYzAyYmYyNmNhZS5odG1sP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B0a97913e" target="_blank">COVID protocols</a>. </li>
|
||||
</ul>
|
||||
<p><strong>What's next: </strong>The Bulls still hold the No. 2 seed in the Eastern Conference, just a game behind the Miami Heat. They play the Spurs tonight at the United Center. </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDElMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTEmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8C5f76cb1e"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0yLiUyMExhVmluZSUyMHNlZXMlMjBzcGVjaWFsaXN0Jmhhc2h0YWdzPWF4aW9zY2hpY2FnbyZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0QxJTI2dXRtX3Rlcm0lM0R0d3NvY2lhbHNoYXJlJTIzc3RvcnkxJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8C8c67a2a9"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0QxJTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3RvcnkxJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8C4add8da0"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%202.%20LaVine%20sees%20specialist%20&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A2.%20LaVine%20sees%20specialist%20%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D1%26utm_term%3Demshare#story1"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 3. Tips and hot links </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/jbcylkuItUNftBwhTQP1UyKQJyw=/0x0:1280x720/1920x1080/2022/02/13/1644768052966.gif" width="600" id="item02" alt="Illustration of sign that says tips and hot links. ">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Illustration: Brendan Lynch/Axios</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>🏫 CPS wants you</strong> to choose when your kids go back next year. Spoiler alert: both options are before Labor Day. (<em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLmNoYWxrYmVhdC5vcmcvMjAyMi8yLzEwLzIyOTI3NTE4L2NoaWNhZ28tcHVibGljLXNjaG9vbHMtYWNhZGVtaWMtY2FsZW5kYXIteWVhci1zdXJ2ZXktYm9hcmQtb2YtZWR1Y2F0aW9uP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B8441c528" target="_blank">Chalkbeat Chicago</a></em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLmNoYWxrYmVhdC5vcmcvMjAyMi8yLzEwLzIyOTI3NTE4L2NoaWNhZ28tcHVibGljLXNjaG9vbHMtYWNhZGVtaWMtY2FsZW5kYXIteWVhci1zdXJ2ZXktYm9hcmQtb2YtZWR1Y2F0aW9uP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8C8441c528" target="_blank"></a>) </p>
|
||||
<p><strong>👩⚖️ Closing arguments</strong> in the Ald. Patrick Daley Thompson fraud trial are expected today. (<em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY2hpY2Fnb3RyaWJ1bmUuY29tL25ld3MvY3JpbWluYWwtanVzdGljZS9jdC1hbGQtcGF0cmljay1kYWxleS10aG9tcHNvbi1mZWRlcmFsLXRyaWFsLWRheS1maXZlLTIwMjIwMjExLTdxZnMydnFuZXpheGhtZW9hbWJ3Y2N6M2dlLXN0b3J5Lmh0bWw_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B35ee708a" target="_blank">Chicago Tribune</a></em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuY2hpY2Fnb3RyaWJ1bmUuY29tL25ld3MvY3JpbWluYWwtanVzdGljZS9jdC1hbGQtcGF0cmljay1kYWxleS10aG9tcHNvbi1mZWRlcmFsLXRyaWFsLWRheS1maXZlLTIwMjIwMjExLTdxZnMydnFuZXpheGhtZW9hbWJ3Y2N6M2dlLXN0b3J5Lmh0bWw_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8C35ee708a" target="_blank"></a>) </p>
|
||||
<p>💉 <strong>The White Sox</strong> are the first MLB team to require minor league players to get the COVID-19 booster. (<em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2NoaWNhZ28td2hpdGUtc294LXJlcXVpcmUtY292aWQtdmFjY2luZXMtbWlub3ItbGVhZ3VlLXBsYXllcnMtYmM5YWE4ZGEtNDdiNy00ODIyLWE0NGMtYzNmY2Q2OGRkODI5Lmh0bWw_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B1fcb2e7f" target="_blank">Axios</a></em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2NoaWNhZ28td2hpdGUtc294LXJlcXVpcmUtY292aWQtdmFjY2luZXMtbWlub3ItbGVhZ3VlLXBsYXllcnMtYmM5YWE4ZGEtNDdiNy00ODIyLWE0NGMtYzNmY2Q2OGRkODI5Lmh0bWw_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8C1fcb2e7f" target="_blank"></a>) </p>
|
||||
<p><strong>🏀 Congratulations to Whitney Young</strong> for winning the boys basketball city championship! (<em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLnN1bnRpbWVzLmNvbS8yMDIyLzIvMTIvMjI5MzEwMjUvd2hpdG5leS15b3VuZy1jdXJpZS1jaXR5LXRpdGxlLXB1YmxpYy1sZWFndWUtaGlnaC1zY2hvb2wtYmFza2V0YmFsbC1jcHM_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B91aab0b3" target="_blank">Chicago</a></em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9jaGljYWdvLnN1bnRpbWVzLmNvbS8yMDIyLzIvMTIvMjI5MzEwMjUvd2hpdG5leS15b3VuZy1jdXJpZS1jaXR5LXRpdGxlLXB1YmxpYy1sZWFndWUtaGlnaC1zY2hvb2wtYmFza2V0YmFsbC1jcHM_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8C91aab0b3" target="_blank"> <em>Sun-Times</em></a>) </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDIlMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTImdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Dd0fed0d9"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0zLiUyMFRpcHMlMjBhbmQlMjBob3QlMjBsaW5rcyZoYXNodGFncz1heGlvc2NoaWNhZ28mdXJsPWh0dHBzOi8vd3d3LmF4aW9zLmNvbS9uZXdzbGV0dGVycy9heGlvcy1jaGljYWdvLTc0Yzg4OGUxLThhYzYtNDFmNS1iNjAxLThlYWFiMzVlMTIyNS5odG1sP2NodW5rJTNEMiUyNnV0bV90ZXJtJTNEdHdzb2NpYWxzaGFyZSUyM3N0b3J5MiZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8D693f0998"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0QyJTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3RvcnkyJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8D9703e1b3"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%203.%20Tips%20and%20hot%20links%20&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A3.%20Tips%20and%20hot%20links%20%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D2%26utm_term%3Demshare#story2"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<!-- checking if the header is populated, if so, render, since the header will only be in position 0 -->
|
||||
<p> A message from Facebook </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>We’re making investments in safety and security — and seeing results</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCQ2qTDvAEQARgBKAEyCGHqPStbqtE4" alt="" width="600">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Facebook has invested $13 billion over the last 5 years to help keep you safe. Since July, we’ve taken action on:</p>
|
||||
<ul>
|
||||
<li>34.7M pieces of explicit adult content.</li>
|
||||
<li>26.6M pieces of violent and graphic content.</li>
|
||||
<li>9.8M pieces of terrorism-related content.</li>
|
||||
</ul>
|
||||
<p><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL3RyYWNrY2xrL04xNDI0MTMxLjI2MDIzMDJBWElPUy9CMjY5NjcwMDEuMzI0MDg3NDA2O2RjX3Rya19haWQ9NTE2MzQxNDQwO2RjX3Rya19jaWQ9MTYzNDgzNzc4O2RjX2xhdD07ZGNfcmRpZD07dGFnX2Zvcl9jaGlsZF9kaXJlY3RlZF90cmVhdG1lbnQ9O3RmdWE9O2x0ZD0_YXhpb3NfYWRsaW5rPTE/620a4aa717e6565ad1452bd8Ba169b9d1" target="_blank">See how we're working to help you connect safely.</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>Job board</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 💗 Find the perfect match for your career path </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>See what’s new on our job board. </p>
|
||||
<ol>
|
||||
<li><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2pvYnMvZjllODlhYzQtMTM4Ni00YmYzLWFhZWQtYmU0NTk4M2ViM2RhP3V0bV9zb3VyY2U9YXhpb3MtY2hpY2FnbyZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1qb2JzLW5sLWNoaWNhZ28mdXRtX2NvbnRlbnQ9am9icy1ubC1jaGljYWdvLWVuZnVzaW9uLWhyYnVzaW5lc3NwYXJ0bmVyLTIwMjIwMjE0JnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Bd3d190bc" target="_blank"><strong>Human Resources Business Partner</strong></a> at Enfusion. </li>
|
||||
<li><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2pvYnMvNTBlOWFiYTQtMGI5NC00OWE1LTgzMzEtMWQ5ZjRjMzE2ZDNjP3V0bV9zb3VyY2U9YXhpb3MtY2hpY2FnbyZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1qb2JzLW5sLWNoaWNhZ28mdXRtX2NvbnRlbnQ9am9icy1ubC1jaGljYWdvLWFsdG1lYXQtbW5nZWRpdG9yLTIwMjIwMjE0JnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Ba0e1e582" target="_blank"><strong>Managing Editor, Alt-Meat / Senior Editor, Meatingplace</strong></a> at Alt-Meat. </li>
|
||||
<li><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2pvYnMvMGQ5OWU3NzEtMGZlMC00NmE0LTkxZmItYmY2ZWUyNmRlODZiP3V0bV9zb3VyY2U9YXhpb3MtY2hpY2FnbyZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1qb2JzLW5sLWNoaWNhZ28mdXRtX2NvbnRlbnQ9am9icy1ubC1jaGljYWdvLWRpc2NvdmVyLWFzc29jaWF0ZS0yMDIyMDIxNCZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B8ef50f09" target="_blank"><strong>Associate Multimedia and Center Communications Specialist</strong></a> at Discover. </li>
|
||||
</ol>
|
||||
<p><strong>Want more opportunities?</strong> <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2xvY2FsL2NoaWNhZ28vam9icz91dG1fc291cmNlPWF4aW9zLWNoaWNhZ28mdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249am9icy1ubC1jaGljYWdvJnV0bV9jb250ZW50PWpvYnMtbmwtY2hpY2Fnby1jaGVja291dCZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B27dcb76d" target="_blank">Check out our Job Board.</a></p>
|
||||
<p><strong>Hiring?</strong> <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2xvY2FsL2NoaWNhZ28vam9icy9uZXc_dXRtX3NvdXJjZT1heGlvcy1jaGljYWdvJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPWpvYnMtbmwtY2hpY2FnbyZ1dG1fY29udGVudD1qb2JzLW5sLWNoaWNhZ28tcG9zdGFpbCZ1dG1fY2FtcGFpZ249am9icy1ubC1jaGljYWdvJnV0bV9jb250ZW50PWpvYnMtbmwtY2hpY2Fnby1jaGVja291dCZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B6c501d79" target="_blank">Post a Job. </a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDMlMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTMmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Ec4c05768"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD0lRjAlOUYlOTIlOTclMjBGaW5kJTIwdGhlJTIwcGVyZmVjdCUyMG1hdGNoJTIwZm9yJTIweW91ciUyMGNhcmVlciUyMHBhdGgmaGFzaHRhZ3M9YXhpb3NjaGljYWdvJnVybD1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDMlMjZ1dG1fdGVybSUzRHR3c29jaWFsc2hhcmUlMjNzdG9yeTMmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8E4f7a925d"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0QzJTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3RvcnkzJnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8Ece640066"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%20%F0%9F%92%97%20Find%20the%20perfect%20match%20for%20your%20career%20path&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A%F0%9F%92%97%20Find%20the%20perfect%20match%20for%20your%20career%20path%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D3%26utm_term%3Demshare#story3"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS9sb2NhbD91dG1fc291cmNlPWF4aW9zJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPWxlYWRzLW5sd2lkZ2V0LW5sLWxvY2FsJnV0bV9jb250ZW50PWFkdmVydGlzZS15b3VyYWRoZXJlJnV0bV9saXN0PW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B81fc7d86"><img src="https://static.axios.com/img/axios-site/your-ad-here.jpg" alt="HQ" width="87"></> </a>
|
||||
</td>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS9sb2NhbD91dG1fc291cmNlPWF4aW9zJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPWxlYWRzLW5sd2lkZ2V0LW5sLWxvY2FsJnV0bV9jb250ZW50PWFkdmVydGlzZS15b3VyYWRoZXJlJnV0bV9saXN0PW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8C81fc7d86">Advertise with Axios Local</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Over 500,000 readers now wake up to Axios Local in their inbox. You can reach these smart professionals in their hometown.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><a id="img-container" href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS9sb2NhbD91dG1fc291cmNlPWF4aW9zJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPWxlYWRzLW5sd2lkZ2V0LW5sLWxvY2FsJnV0bV9jb250ZW50PWFkdmVydGlzZS15b3VyYWRoZXJlJnV0bV9saXN0PW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8E81fc7d86"><img id="link-arrow-img" src="https://static.axios.com/img/axios-site/newsletter-arrow-desktop.png"></a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 4. Your Chicago marketing slogans </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/gCALDTFkTBGGHA0oIYp0g3uxFjc=/0x0:1280x720/1920x1080/2022/02/13/1644768184938.jpg" width="600" id="item04" alt="Illustration of the Chicago skyline with word balloons filled with exclamation points popping up over it.">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Illustration: Brendan Lynch/Axios</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>We recently reported</strong> on the <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2xvY2FsL2NoaWNhZ28vMjAyMi8wMi8xMS9jaGljYWdvcy1uZXctbWFya2V0aW5nLWNhbXBhaWduLWJsYXN0ZWQ_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Bf3089d93" target="_blank">criticism surrounding the city's</a> new marketing slogan: "Chicago not in Chicago," which some find confusing and way too focused on other cities. </p>
|
||||
<ul>
|
||||
<li><strong>We asked you</strong> for better, or just more amusing, alternatives. As always, you did not disappoint. </li>
|
||||
</ul>
|
||||
<p><strong>😜 Sunil G:</strong> <em>Chicago: We don't want nobody that nobody sent</em></p>
|
||||
<p><strong>🏛 Mike N:</strong> <em>Bureaucracy Is Beautiful</em></p>
|
||||
<p><strong>🗯 Patrick D: </strong><em>Please f*ck off</em></p>
|
||||
<p><strong>🧀 Peter B:</strong> <em>At least it’s warmer than Green Bay</em></p>
|
||||
<p><strong>🤔 Jerry C:</strong> <em>You don't understand us, and that's ok</em></p>
|
||||
<p><strong>☃️ Matt T:</strong> <em>Come for the political corruption and stay for the snow in April</em></p>
|
||||
<p><strong>🌊 Nathan G:</strong> <em>Giving St. Louis all of our sh*t since 1900</em></p>
|
||||
<p><strong>❤️ Kelly M:</strong> <em>Calling Dibs on Your Heart (in time for Valentine's Day)</em></p>
|
||||
<p><strong>🗞 Tom N:</strong> <em>Where people fold their newspaper, not their pizza</em></p>
|
||||
<p><strong>👃 Miranda S:</strong> <em>Where Each Block Has a Unique Smell</em></p>
|
||||
<ul>
|
||||
<li><strong>🍁</strong><em> Culture, Cuisine, and Cannabis</em></li>
|
||||
</ul>
|
||||
<p><em><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2xvY2FsL2NoaWNhZ28vMjAyMi8wMi8xNC9yZWFkZXJzLWlkZWFzLWZvci1jaGljYWdvcy1uZXctc2xvZ2FuP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B62a3e6ec" target="_blank">Go deeper for more funny slogans.</a></em></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDQlMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTQmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Fb8d4824d"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD00LiUyMFlvdXIlMjBDaGljYWdvJTIwbWFya2V0aW5nJTIwc2xvZ2FucyZoYXNodGFncz1heGlvc2NoaWNhZ28mdXJsPWh0dHBzOi8vd3d3LmF4aW9zLmNvbS9uZXdzbGV0dGVycy9heGlvcy1jaGljYWdvLTc0Yzg4OGUxLThhYzYtNDFmNS1iNjAxLThlYWFiMzVlMTIyNS5odG1sP2NodW5rJTNENCUyNnV0bV90ZXJtJTNEdHdzb2NpYWxzaGFyZSUyM3N0b3J5NCZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Ff472307f"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0Q0JTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3Rvcnk0JnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8F5a429f3f"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%204.%20Your%20Chicago%20marketing%20slogans&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A4.%20Your%20Chicago%20marketing%20slogans%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D4%26utm_term%3Demshare#story4"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> 5. Photos of the day: St. Valentine's Day Massacre </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://images.axios.com/pkkeQD_NVCMbBmIkuVcK77MWh_M=/0x580:4129x2903/1920x1080/2022/02/13/1644768348421.jpg" width="600" id="item05" alt="Photo of a block in 1929.">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>The 2100 block of North Clark Street in February, 1929. Photo: Bettmann Archive/Getty Images</p>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>Today marks the 93rd anniversary</strong> of the infamous <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuaGlzdG9yeS5jb20vdG9waWNzL2NyaW1lL3NhaW50LXZhbGVudGluZXMtZGF5LW1hc3NhY3JlP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B303c11fb" target="_blank">St. Valentine's Day Massacre</a>. </p>
|
||||
<ul>
|
||||
<li><strong>This photo shows the block</strong> where the shootings happened. The building on the right was a rooming house where Al Capone's gangsters rented a room to spy on the garage where Bugs Moran's gangsters hung out. </li>
|
||||
<li><strong>The garage where the shootings took place</strong> is on the left, between the laundry and tailor shop signs. </li>
|
||||
</ul>
|
||||
<p><strong>Zoom in: </strong>Today, this block is in the heart of Lincoln Park. That rooming house is now the restaurant Riccardo Trattoria.</p>
|
||||
<p><img alt="" src="https://images.axios.com/4GsZNi6LA15Unse5XKs5f_veVuI=/fit-in/1116x1300/2022/02/13/1644768464044.jpg" width="100%"></p>
|
||||
<p> The 2100 block of North Clark Street in February 2022. Photo: Justin Kaufmann/Axios </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL3NoYXJlci5waHA_dT1odHRwczovL3d3dy5heGlvcy5jb20vbmV3c2xldHRlcnMvYXhpb3MtY2hpY2Fnby03NGM4ODhlMS04YWM2LTQxZjUtYjYwMS04ZWFhYjM1ZTEyMjUuaHRtbD9jaHVuayUzRDUlMjZ1dG1fdGVybSUzRGZic29jaWFsc2hhcmUlMjNzdG9yeTUmdXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Gafcbb6b5"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly90d2l0dGVyLmNvbS9pbnRlbnQvdHdlZXQ_dGV4dD01LiUyMFBob3RvcyUyMG9mJTIwdGhlJTIwZGF5OiUyMFN0LiUyMFZhbGVudGluZSUyN3MlMjBEYXklMjBNYXNzYWNyZSZoYXNodGFncz1heGlvc2NoaWNhZ28mdXJsPWh0dHBzOi8vd3d3LmF4aW9zLmNvbS9uZXdzbGV0dGVycy9heGlvcy1jaGljYWdvLTc0Yzg4OGUxLThhYzYtNDFmNS1iNjAxLThlYWFiMzVlMTIyNS5odG1sP2NodW5rJTNENSUyNnV0bV90ZXJtJTNEdHdzb2NpYWxzaGFyZSUyM3N0b3J5NSZ1dG1fc291cmNlPW5ld3NsZXR0ZXImdXRtX21lZGl1bT1lbWFpbCZ1dG1fY2FtcGFpZ249bmV3c2xldHRlcl9heGlvc2xvY2FsX2NoaWNhZ28mc3RyZWFtPXRvcA/620a4aa717e6565ad1452bd8Gac25c9da"></a>
|
||||
<a href="https://link.axios.com/click/26703154.43913/aHR0cDovL3d3dy5saW5rZWRpbi5jb20vc2hhcmVBcnRpY2xlP21pbmk9dHJ1ZSZ1cmw9aHR0cHM6Ly93d3cuYXhpb3MuY29tL25ld3NsZXR0ZXJzL2F4aW9zLWNoaWNhZ28tNzRjODg4ZTEtOGFjNi00MWY1LWI2MDEtOGVhYWIzNWUxMjI1Lmh0bWw_Y2h1bmslM0Q1JTI1MjZ1dG1fdGVybSUyNTNEbGlzb2NpYWxzaGFyZSUyNTIzc3Rvcnk1JnV0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8G1413d65c"></a>
|
||||
<a href="mailto:?subject=From%20Axios:%205.%20Photos%20of%20the%20day:%20St.%20Valentine's%20Day%20Massacre&body=hongbo%40omnivore.app%20has%20shared%20an%20Axios%20story%20with%20you%3A%0A%0A5.%20Photos%20of%20the%20day:%20St.%20Valentine's%20Day%20Massacre%0Ahttps%3A%2F%2Fwww.axios.com%2Fnewsletters%2Faxios-chicago-74c888e1-8ac6-41f5-b601-8eaab35e1225.html%3Fchunk%3D5%26utm_term%3Demshare#story5"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>A message from Facebook </p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>We’re making investments in safety and security — and seeing results</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCQ2qTDvAEQARgBKAEyCGHqPStbqtE4" alt="" width="600">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Facebook has invested $13 billion over the last 5 years to help keep you safe. Since July, we’ve taken action on:</p>
|
||||
<ul>
|
||||
<li>34.7M pieces of explicit adult content.</li>
|
||||
<li>26.6M pieces of violent and graphic content.</li>
|
||||
<li>9.8M pieces of terrorism-related content.</li>
|
||||
</ul>
|
||||
<p><a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL3RyYWNrY2xrL04xNDI0MTMxLjI2MDIzMDJBWElPUy9CMjY5NjcwMDEuMzI0MDg3NDA2O2RjX3Rya19haWQ9NTE2MzQxNDQwO2RjX3Rya19jaWQ9MTYzNDgzNzc4O2RjX2xhdD07ZGNfcmRpZD07dGFnX2Zvcl9jaGlsZF9kaXJlY3RlZF90cmVhdG1lbnQ9O3RmdWE9O2x0ZD0_YXhpb3NfYWRsaW5rPTE/620a4aa717e6565ad1452bd8Ca169b9d1" target="_blank">See how we're working to help you connect safely.</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>Our picks: </strong></p>
|
||||
<p><strong>🎤 Monica recently </strong>heard about Chicago's <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly9iZXZyYWdlYW5kdGhlZHJpbmtzLmNvbS8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Bd266294e" target="_blank">Bev Rage and the Drinks</a> from a reader. Now she can't get enough of this drag queen garage pop. Expect a new album out this year. </p>
|
||||
<p><strong>🌶 Justin </strong>skipped the chocolate this year and went with spicy, cinnamon-flavored gummy bears for his 14-year-old son. Why not live a little? </p>
|
||||
<p><strong><em>Want free Axios swag?</em></strong><em></em> <em><a href="https://link.axios.com/click/26703154.43913/aHR0cDovL2VtYWlsLmF4aW9zaHEuYXhpb3MuY29tL2MvZUp5TmtNRnF4Q0FRaHA4bTNndzZhdFJERG1HWFBmY055dWhvWTV2RzFnUlMtdlJORjVaZUM4TV9fd19EOE0zVTlvSnItY2E5MVBXNTBPaWNCdlJPY2tzb3VRNFJlRUFGZkZDUmpEZENnaFdNUmtFRW5sZ1pRWUFVd3lsR2E3Qzk3S2ZwZXBVMzYtM0ZETzdpcGs0TF9DcDFtel83ZS05amZXZnppTjRuMUlPMk1DU1RCNVcxREZJYms0endHREt4Wlp6M19XUHIxTlRCN2F6ak9QNFduTG1sbkZyRDViUkxqYml3TGJXU3R0OGJORUFVSW1qdWpZMWNleE41QUllY0pOSUo3Z0psejlyNFd1ZTF6dzNYdHdmam5TMVJlVHdqaWdUQnBjUXR1TUIxVnNpZG84d2hLaWR0Y2tJcXdfYl9qajNWYllfWWFQc0IxMXR1MGc_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8Bb47f2889" target="_blank">Refer your friends</a> to Axios Chicago and get cool merch like stickers, totes, hats, T-shirts and more!</em></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="https://static.axios.com/img-email/axioshq-new-image-footer.png" alt="HQ" width="87">
|
||||
</td>
|
||||
<td>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<span>Like this email style and format? </span><span></span>
|
||||
<p>Bring the strength of Smart Brevity® to your team — more effective communications, powered by <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3NocS5jb20vc2lnbnVwP3V0bV9zb3VyY2U9YXhpb3MtbmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1heGlvc2hxLW5sLWZvb3RlciZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B01989ce3">Axios HQ</a>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- End email content -->
|
||||
<!-- Start footer -->
|
||||
<div>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span>
|
||||
<p>Axios thanks our partners for supporting our newsletters. If you’re interested in advertising, learn more <a href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuYXhpb3MuY29tL2FkdmVydGlzZS8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B5df0b5e4">here</a>. <br> Sponsorship has no influence on editorial content.</p>
|
||||
</span>
|
||||
<span> Axios, 3100 Clarendon Blvd, Suite 1300, Arlington VA 22201 </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> You received this email because you signed up for newsletters from Axios.<br>
|
||||
<a href="https://link.axios.com/oc/620a4aa717e6565ad1452bd8fwcaa.xvt/d645d220">Change your preferences or unsubscribe here.</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<span> Was this email forwarded to you?<br>
|
||||
<a href="http://fakehost/test/link.axios.com/join/5fx/chicago-signup?utm_source=forwarded_email">Sign up now</a> to get Axios in your inbox. </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Follow Axios on social media:</p>
|
||||
<a target="_blank" href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuZmFjZWJvb2suY29tL2F4aW9zbmV3cy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B48885ef6"><img src="https://static.axios.com/img-email/facebook@2x.png" height="16" alt="Axios on Facebook"></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cudHdpdHRlci5jb20vYXhpb3MvP3V0bV9zb3VyY2U9bmV3c2xldHRlciZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jYW1wYWlnbj1uZXdzbGV0dGVyX2F4aW9zbG9jYWxfY2hpY2FnbyZzdHJlYW09dG9w/620a4aa717e6565ad1452bd8B40da146c"><img src="https://static.axios.com/img-email/twitter@2x.png" height="16" alt="Axios on Twitter"></a>
|
||||
<a target="_blank" href="https://link.axios.com/click/26703154.43913/aHR0cHM6Ly93d3cuaW5zdGFncmFtLmNvbS9heGlvcy8_dXRtX3NvdXJjZT1uZXdzbGV0dGVyJnV0bV9tZWRpdW09ZW1haWwmdXRtX2NhbXBhaWduPW5ld3NsZXR0ZXJfYXhpb3Nsb2NhbF9jaGljYWdvJnN0cmVhbT10b3A/620a4aa717e6565ad1452bd8B5cbe1948"><img src="https://static.axios.com/img-email/instagram@2x.png" height="16" alt="Axios on Instagram"></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<!--<tr>
|
||||
<td align="center" style="padding: 0 5%; line-height: 1.75;">
|
||||
<span class="bodytext" style="font-size: 12px; line-height: 1.75px; color: #222222;">
|
||||
View in browser at <a href="https://link.axios.com/view/620a4aa717e6565ad1452bd8fwcaa.xvt/23d9abe3" target="_blank" style="color: #222222; letter-spacing: 1px;">https://link.axios.com/view/620a4aa717e6565ad1452bd8fwcaa.xvt/23d9abe3</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>-->
|
||||
<tr>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- End footer -->
|
||||
<!--[if mso]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</DIV>
|
||||
</DIV>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
https://link.axios.com/view/620a4aa717e6565ad1452bd8fwcaa.xvt/23d9abe3
|
||||
|
|
@ -21,11 +21,11 @@
|
|||
"deploy": "yarn build && yarn gcloud-deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/html-to-text": "^8.1.1",
|
||||
"@types/natural": "^5.1.1",
|
||||
"@types/node": "^14.11.2",
|
||||
"@types/underscore": "^1.11.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"@types/html-to-text": "^8.1.1",
|
||||
"@types/natural": "^5.1.1"
|
||||
"eslint-plugin-prettier": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/functions-framework": "3.1.2",
|
||||
|
|
@ -33,11 +33,12 @@
|
|||
"@sentry/serverless": "^6.16.1",
|
||||
"axios": "^0.27.2",
|
||||
"dotenv": "^16.0.1",
|
||||
"html-to-text": "^8.2.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"linkedom": "^0.14.12",
|
||||
"microsoft-cognitiveservices-speech-sdk": "^1.22.0",
|
||||
"underscore": "^1.13.4",
|
||||
"natural": "^5.2.3",
|
||||
"html-to-text": "^8.2.1"
|
||||
"redis": "^4.3.1",
|
||||
"underscore": "^1.13.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export type SSMLOptions = {
|
|||
|
||||
const DEFAULT_LANGUAGE = 'en-US'
|
||||
const DEFAULT_VOICE = 'en-US-JennyNeural'
|
||||
const DEFAULT_SECONDARY_VOICE = 'en-US-GuyNeural'
|
||||
const DEFAULT_RATE = '1.0'
|
||||
|
||||
const ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES = [
|
||||
|
|
@ -54,8 +55,7 @@ const ANCHOR_ELEMENTS_BLOCKED_ATTRIBUTES = [
|
|||
|
||||
function ssmlTagsForTopLevelElement() {
|
||||
return {
|
||||
opening: `<p>`,
|
||||
closing: `</p>`,
|
||||
opening: `<break />`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +68,7 @@ const TOP_LEVEL_TAGS = [
|
|||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'UL',
|
||||
'OL',
|
||||
'LI',
|
||||
'CODE',
|
||||
]
|
||||
|
||||
|
|
@ -105,9 +104,9 @@ function parseDomTree(pageNode: Element) {
|
|||
|
||||
visitedNodeList.shift()
|
||||
visitedNodeList.forEach((node, index) => {
|
||||
// We start at index 2, because the frontend starts one node above us
|
||||
// on the #readability-content element that wraps the entire content.
|
||||
node.setAttribute('data-omnivore-anchor-idx', (index + 2).toString())
|
||||
// We start at index 3, because the frontend starts two nodes above us
|
||||
// on the #readability-page-1 element that wraps the entire content.
|
||||
node.setAttribute('data-omnivore-anchor-idx', (index + 3).toString())
|
||||
})
|
||||
return visitedNodeList
|
||||
}
|
||||
|
|
@ -116,8 +115,12 @@ function emit(textItems: string[], text: string) {
|
|||
textItems.push(text)
|
||||
}
|
||||
|
||||
const cleanText = (text: string): string => {
|
||||
return stripEmojis(_.escape(text.replace(/\s+/g, ' ')))
|
||||
}
|
||||
|
||||
function cleanTextNode(textNode: ChildNode): string {
|
||||
return stripEmojis(_.escape(textNode.textContent ?? ''.replace(/\s+/g, ' ')))
|
||||
return cleanText(textNode.textContent ?? '')
|
||||
}
|
||||
|
||||
function emitTextNode(
|
||||
|
|
@ -176,23 +179,17 @@ function emitElement(
|
|||
}
|
||||
}
|
||||
|
||||
if (isTopLevel) {
|
||||
emit(textItems, topLevelTags.closing)
|
||||
}
|
||||
|
||||
return Number(maxVisitedIdx)
|
||||
}
|
||||
|
||||
export const startSsml = (options: SSMLOptions, element?: Element): string => {
|
||||
const voice =
|
||||
element?.nodeName === 'BLOCKQUOTE'
|
||||
? options.secondaryVoice
|
||||
: options.primaryVoice
|
||||
? options.secondaryVoice ?? DEFAULT_SECONDARY_VOICE
|
||||
: options.primaryVoice ?? DEFAULT_VOICE
|
||||
return `<speak xmlns="http://www.w3.org/2001/10/synthesis" version="1.0" xml:lang="${
|
||||
options.language || DEFAULT_LANGUAGE
|
||||
}"><voice name="${voice || DEFAULT_VOICE}"><prosody rate="${
|
||||
options.rate || DEFAULT_RATE
|
||||
}">`
|
||||
}"><voice name="${voice}"><prosody rate="${options.rate || DEFAULT_RATE}">`
|
||||
}
|
||||
|
||||
export const endSsml = (): string => {
|
||||
|
|
@ -231,9 +228,9 @@ export const htmlToSsmlItems = (
|
|||
}
|
||||
|
||||
const items: SSMLItem[] = []
|
||||
for (let i = 2; i < parsedNodes.length + 2; i++) {
|
||||
for (let i = 3; i < parsedNodes.length + 3; i++) {
|
||||
const textItems: string[] = []
|
||||
const node = parsedNodes[i - 2]
|
||||
const node = parsedNodes[i - 3]
|
||||
|
||||
if (TOP_LEVEL_TAGS.includes(node.nodeName) || hasSignificantText(node)) {
|
||||
const idx = i
|
||||
|
|
@ -273,7 +270,7 @@ const textToUtterance = ({
|
|||
voice?: string
|
||||
isHtml?: boolean
|
||||
}): Utterance => {
|
||||
const text = stripEmojis(textItems.join(''))
|
||||
const text = textItems.join('')
|
||||
let textWithWordOffset = text
|
||||
if (isHtml) {
|
||||
try {
|
||||
|
|
@ -303,16 +300,30 @@ const textToUtterance = ({
|
|||
export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => {
|
||||
const { title, content, options } = htmlInput
|
||||
console.log('creating speech file with options:', options)
|
||||
const language = options.language || DEFAULT_LANGUAGE
|
||||
const defaultVoice = options.primaryVoice || DEFAULT_VOICE
|
||||
|
||||
const dom = parseHTML(content)
|
||||
const body = dom.document.querySelector('#readability-page-1')
|
||||
if (!body) {
|
||||
throw new Error('Unable to parse HTML document')
|
||||
console.log('No HTML body found')
|
||||
return {
|
||||
wordCount: 0,
|
||||
language,
|
||||
defaultVoice,
|
||||
utterances: [],
|
||||
}
|
||||
}
|
||||
|
||||
const parsedNodes = parseDomTree(body)
|
||||
if (parsedNodes.length < 1) {
|
||||
throw new Error('No HTML nodes found')
|
||||
console.log('No HTML nodes found')
|
||||
return {
|
||||
wordCount: 0,
|
||||
language,
|
||||
defaultVoice,
|
||||
utterances: [],
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizer = new WordPunctTokenizer()
|
||||
|
|
@ -323,7 +334,7 @@ export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => {
|
|||
const titleUtterance = textToUtterance({
|
||||
tokenizer,
|
||||
idx: '',
|
||||
textItems: [title],
|
||||
textItems: [cleanText(title)], // title could have HTML entity names like & or emoji
|
||||
wordOffset,
|
||||
isHtml: false,
|
||||
})
|
||||
|
|
@ -331,9 +342,10 @@ export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => {
|
|||
wordOffset += titleUtterance.wordCount
|
||||
}
|
||||
|
||||
for (let i = 2; i < parsedNodes.length + 2; i++) {
|
||||
// start at 3 to skip the #readability-content and #readability-page-1 elements
|
||||
for (let i = 3; i < parsedNodes.length + 3; i++) {
|
||||
const textItems: string[] = []
|
||||
const node = parsedNodes[i - 2]
|
||||
const node = parsedNodes[i - 3]
|
||||
|
||||
if (TOP_LEVEL_TAGS.includes(node.nodeName) || hasSignificantText(node)) {
|
||||
// use paragraph as anchor
|
||||
|
|
@ -354,8 +366,8 @@ export const htmlToSpeechFile = (htmlInput: HtmlInput): SpeechFile => {
|
|||
|
||||
return {
|
||||
wordCount: wordOffset,
|
||||
language: options.language || DEFAULT_LANGUAGE,
|
||||
defaultVoice: options.primaryVoice || DEFAULT_VOICE,
|
||||
language,
|
||||
defaultVoice,
|
||||
utterances,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ import * as Sentry from '@sentry/serverless'
|
|||
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 { synthesizeTextToSpeech, TextToSpeechInput } from './textToSpeech'
|
||||
import {
|
||||
SpeechMark,
|
||||
synthesizeTextToSpeech,
|
||||
TextToSpeechInput,
|
||||
} from './textToSpeech'
|
||||
import { File, Storage } from '@google-cloud/storage'
|
||||
import { htmlToSpeechFile } from './htmlToSsml'
|
||||
import { endSsml, htmlToSpeechFile, startSsml } from './htmlToSsml'
|
||||
import crypto from 'crypto'
|
||||
import { createRedisClient } from './redis'
|
||||
|
||||
interface UtteranceInput {
|
||||
voice?: string
|
||||
|
|
@ -29,6 +35,11 @@ interface HTMLInput {
|
|||
bucket: string
|
||||
}
|
||||
|
||||
interface CacheResult {
|
||||
audioDataString: string
|
||||
speechMarks: SpeechMark[]
|
||||
}
|
||||
|
||||
dotenv.config()
|
||||
Sentry.GCPFunction.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
|
|
@ -158,24 +169,70 @@ export const textToSpeechStreamingHandler = Sentry.GCPFunction.wrapHttpFunction(
|
|||
return res.status(401).send({ errorCode: 'UNAUTHENTICATED' })
|
||||
}
|
||||
|
||||
// create redis client
|
||||
const redisClient = await createRedisClient(
|
||||
process.env.REDIS_URL,
|
||||
process.env.REDIS_CERT
|
||||
)
|
||||
|
||||
try {
|
||||
const utteranceInput = req.body as UtteranceInput
|
||||
const ssmlOptions = {
|
||||
primaryVoice: utteranceInput.voice,
|
||||
secondaryVoice: utteranceInput.voice,
|
||||
language: utteranceInput.language,
|
||||
rate: utteranceInput.rate,
|
||||
}
|
||||
// for utterance, assemble the ssml and pass it through
|
||||
const ssml = `${startSsml(ssmlOptions)}${utteranceInput.text}${endSsml()}`
|
||||
// hash ssml to get the cache key
|
||||
const cacheKey = crypto.createHash('md5').update(ssml).digest('hex')
|
||||
// find audio data in cache
|
||||
const cacheResult = await redisClient.get(cacheKey)
|
||||
if (cacheResult) {
|
||||
console.log('Cache hit')
|
||||
const { audioDataString, speechMarks }: CacheResult =
|
||||
JSON.parse(cacheResult)
|
||||
res.send({
|
||||
idx: utteranceInput.idx,
|
||||
audioData: audioDataString,
|
||||
speechMarks,
|
||||
})
|
||||
return
|
||||
}
|
||||
console.log('Cache miss')
|
||||
// synthesize text to speech if cache miss
|
||||
const input: TextToSpeechInput = {
|
||||
...utteranceInput,
|
||||
textType: 'utterance',
|
||||
textType: 'ssml',
|
||||
}
|
||||
const { audioData, speechMarks } = await synthesizeTextToSpeech(input)
|
||||
if (!audioData) {
|
||||
return res.status(500).send({ errorCode: 'SYNTHESIZER_ERROR' })
|
||||
}
|
||||
const audioDataString = audioData.toString('hex')
|
||||
// save audio data to cache for 24 hours for mainly the newsletters
|
||||
await redisClient.set(
|
||||
cacheKey,
|
||||
JSON.stringify({ audioDataString, speechMarks }),
|
||||
{
|
||||
EX: 3600 * 24, // in seconds
|
||||
NX: true,
|
||||
}
|
||||
)
|
||||
console.log('Cache saved')
|
||||
|
||||
res.send({
|
||||
idx: utteranceInput.idx,
|
||||
audioData: audioData.toString('hex'),
|
||||
audioData: audioDataString,
|
||||
speechMarks,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Text to speech streaming error:', e)
|
||||
return res.status(500).send({ errorCodes: 'SYNTHESIZER_ERROR' })
|
||||
} finally {
|
||||
await redisClient.quit()
|
||||
console.log('Redis Client Disconnected')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
26
packages/text-to-speech/src/redis.ts
Normal file
26
packages/text-to-speech/src/redis.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createClient } from 'redis'
|
||||
|
||||
export const createRedisClient = async (url?: string, cert?: string) => {
|
||||
const redisClient = createClient({
|
||||
url,
|
||||
socket: {
|
||||
tls: url?.startsWith('rediss://'), // rediss:// is the protocol for TLS
|
||||
cert: cert?.replace(/\\n/g, '\n'), // replace \n with new line
|
||||
rejectUnauthorized: false, // for self-signed certs
|
||||
connectTimeout: 10000, // 10 seconds
|
||||
reconnectStrategy(retries: number): number | Error {
|
||||
if (retries > 10) {
|
||||
return new Error('Retries exhausted')
|
||||
}
|
||||
return 1000
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err))
|
||||
|
||||
await redisClient.connect()
|
||||
console.log('Redis Client Connected:', url)
|
||||
|
||||
return redisClient
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export interface TextToSpeechInput {
|
|||
text: string
|
||||
voice?: string
|
||||
language?: string
|
||||
textType?: 'html' | 'utterance'
|
||||
textType?: 'html' | 'ssml'
|
||||
rate?: string
|
||||
secondaryVoice?: string
|
||||
audioStream?: NodeJS.ReadWriteStream
|
||||
|
|
@ -137,16 +137,29 @@ export const synthesizeTextToSpeech = async (
|
|||
speechMarks,
|
||||
}
|
||||
}
|
||||
// for utterance, just assemble the ssml and pass it through
|
||||
const start = startSsml(ssmlOptions)
|
||||
wordOffset = -start.length
|
||||
const ssml = `${start}${input.text}${endSsml()}`
|
||||
const result = await speakSsmlAsyncPromise(ssml)
|
||||
if (result.reason === ResultReason.Canceled) {
|
||||
throw new Error(result.errorDetails)
|
||||
// for ssml
|
||||
let audioData: Buffer = Buffer.from([])
|
||||
// split ssml into chunks of 2000 characters to stream faster
|
||||
// both within limit & without breaking on words and bookmarks <bookmark mark="1"/>
|
||||
const ssmlChunks = input.text.match(/.{1,2000}(?= |$)(?! mark=)/g)
|
||||
if (ssmlChunks) {
|
||||
for (const ssmlChunk of ssmlChunks) {
|
||||
const startSsmlChunk = startSsml(ssmlOptions)
|
||||
const ssml = `${startSsmlChunk}${ssmlChunk}${endSsml()}`
|
||||
// set the text offset to be the end of SSML start tag
|
||||
wordOffset -= startSsmlChunk.length
|
||||
const result = await speakSsmlAsyncPromise(ssml)
|
||||
if (result.reason === ResultReason.Canceled) {
|
||||
throw new Error(result.errorDetails)
|
||||
}
|
||||
timeOffset = timeOffset + result.audioDuration
|
||||
wordOffset = wordOffset + ssmlChunk.length
|
||||
audioData = Buffer.concat([audioData, Buffer.from(result.audioData)])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
audioData: Buffer.from(result.audioData),
|
||||
audioData,
|
||||
speechMarks,
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
181
packages/text-to-speech/test/fixtures/large.html
vendored
Normal file
181
packages/text-to-speech/test/fixtures/large.html
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<div id="readability-content">
|
||||
<div class="page" id="readability-page-1">
|
||||
<div data-omnivore-anchor-idx="1">
|
||||
<div data-omnivore-anchor-idx="2" dir="auto">
|
||||
<p data-omnivore-anchor-idx="3">Summary of today’s Essential Eight:</p>
|
||||
<ol data-omnivore-anchor-idx="4">
|
||||
<li data-omnivore-anchor-idx="5">
|
||||
<p data-omnivore-anchor-idx="6">
|
||||
<strong data-omnivore-anchor-idx="7"
|
||||
><a
|
||||
data-omnivore-anchor-idx="8"
|
||||
href="https://sinocism.com/i/74410518/wang-yi-at-the-un"
|
||||
rel=""
|
||||
>Wang Yi at the UN</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="9">
|
||||
- Among Wang YI’s meetings was one with Russian Foreign Minister
|
||||
Lavrov. There was nothing in the readout from the Lavrov meeting
|
||||
that would indicate a shift in the PRC position in the Russian
|
||||
invasion of Ukraine. Wang will meet US Secretary of State
|
||||
Blinken Friday.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="10">
|
||||
<p data-omnivore-anchor-idx="11">
|
||||
<strong data-omnivore-anchor-idx="12"
|
||||
><a
|
||||
data-omnivore-anchor-idx="13"
|
||||
href="https://sinocism.com/i/74410518/two-more-sentences-in-sun-lijun-clique-case"
|
||||
rel=""
|
||||
>Two more sentences in “Sun Lijun clique” case</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="14">
|
||||
- Authorities are wrapping up the Sun Lijun "clique" case before
|
||||
the 20th. Today both Fu Zhenghua and Wang Like were sentenced
|
||||
death with a two year reprieve, and both releases said they had
|
||||
no possibility of parole or reduction in sentence. Sun has yet
|
||||
to be sentenced but it feels like it will happen imminently.
|
||||
Given his leadership role he should at least get life in jail,
|
||||
if not the actual death penalty, though he was promoted for
|
||||
years by people above him in the system so perhaps he performed
|
||||
“meritorious service” and ratted out other senior officials.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="15">
|
||||
<p data-omnivore-anchor-idx="16">
|
||||
<strong data-omnivore-anchor-idx="17"
|
||||
><a
|
||||
data-omnivore-anchor-idx="18"
|
||||
href="https://sinocism.com/i/74410518/weekly-state-council-executive-meeting"
|
||||
rel=""
|
||||
>Weekly State Council Executive Meeting</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="19">
|
||||
- This meeting did not offer any significant economic boosts,
|
||||
among other things it reviewed reports of the inspection teams
|
||||
sent to several provinces to check on implementation of economic
|
||||
stabilization measures, promised more administrative reforms,
|
||||
and cut toll fees for freight trucks by 10% and
|
||||
government-designated cargo port charges by 20% in Q4.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="20">
|
||||
<p data-omnivore-anchor-idx="21">
|
||||
<strong data-omnivore-anchor-idx="22"
|
||||
><a
|
||||
data-omnivore-anchor-idx="23"
|
||||
href="https://sinocism.com/i/74410518/why-this-economic-downturn-may-be-different"
|
||||
rel=""
|
||||
>Why this economic downturn may be different</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="24">
|
||||
- Two good pieces, one from Logan Wright and another from </span
|
||||
><a
|
||||
data-omnivore-anchor-idx="25"
|
||||
href="https://www.realchinacharts.com/p/long-view-its-coming-pt22?isFreemail=false"
|
||||
rel=""
|
||||
>“China Charts”</a
|
||||
><span data-omnivore-anchor-idx="26"
|
||||
>. The real estate boom is over and it is not coming back any
|
||||
time soon, if ever. That is the outcome the policymakers have
|
||||
been targeting for years, though they may have been
|
||||
overconfident in their ability to rein in real estate without
|
||||
creating dangerous domino effects throughout the economy. We are
|
||||
all waiting for the 20th Party Congress outcomes, but I see no
|
||||
reason to think there will be outcomes from that meeting that
|
||||
reverse the trajectory of the real estate sector.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="27">
|
||||
<p data-omnivore-anchor-idx="28">
|
||||
<strong data-omnivore-anchor-idx="29"
|
||||
><a
|
||||
data-omnivore-anchor-idx="30"
|
||||
href="https://sinocism.com/i/74410518/pcaob-audit-inspections-in-hong-kong"
|
||||
rel=""
|
||||
>PCAOB Audit inspections in Hong Kong</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="31">
|
||||
- The trial audits of PRC firms are underway, so far the signs
|
||||
are positive that the PRC side understands the concessions
|
||||
needed to keep the PRC firms listed in the US, but as the PCAOB
|
||||
chair said today “The Holding Foreign Companies Accountable Act
|
||||
demands complete access. The agreement we signed with our
|
||||
Chinese counterparts guarantees complete access. And the PCAOB
|
||||
will accept nothing less than complete access when we make our
|
||||
determinations by the end of this year. When I say no loopholes
|
||||
and no exceptions, I mean none.” Having a law that allows little
|
||||
room for concessions has been very helpful to US negotiators.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="32">
|
||||
<p data-omnivore-anchor-idx="33">
|
||||
<strong data-omnivore-anchor-idx="34"
|
||||
><a
|
||||
data-omnivore-anchor-idx="35"
|
||||
href="https://sinocism.com/i/74410518/nvidia-ceo-does-not-sound-too-worried-about-china-sales"
|
||||
rel=""
|
||||
>Nvidia CEO does not sound too worried about China sales</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="36">
|
||||
- The CEO told Caixin that ““There will be versions that are
|
||||
going to be not restricted and serve the needs of the vast
|
||||
majority of our market very comfortably” and he told </span
|
||||
><a
|
||||
data-omnivore-anchor-idx="37"
|
||||
href="https://stratechery.com/2022/an-interview-with-nvidia-ceo-jensen-huang-about-building-the-omniverse-cloud/#glut"
|
||||
rel=""
|
||||
>Stratechery</a
|
||||
><span data-omnivore-anchor-idx="38">
|
||||
that “The limitations and the restrictions are very specific to
|
||||
a combination of computation level and multi-chip
|
||||
interconnection level. That restriction gives us plenty of
|
||||
envelope to go and run our business and for the vast majority of
|
||||
our customers in China”.</span
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="39">
|
||||
<p data-omnivore-anchor-idx="40">
|
||||
<strong data-omnivore-anchor-idx="41"
|
||||
><a
|
||||
data-omnivore-anchor-idx="42"
|
||||
href="https://sinocism.com/i/74410518/us-prc-scientific-relations"
|
||||
rel=""
|
||||
>US-PRC scientific relations</a
|
||||
></strong
|
||||
><span data-omnivore-anchor-idx="43">
|
||||
- There are two new reports of note, one on scientists who
|
||||
worked at Los Alamos labs and then returned to the PRC and
|
||||
contributed to PRC weapons development, and another on the
|
||||
outflow of Chinese scientists from the US.
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
<li data-omnivore-anchor-idx="44">
|
||||
<p data-omnivore-anchor-idx="45">
|
||||
<strong data-omnivore-anchor-idx="46"
|
||||
><a
|
||||
data-omnivore-anchor-idx="47"
|
||||
href="https://sinocism.com/i/74410518/another-scandal-in-the-film-and-tv-sector"
|
||||
rel=""
|
||||
>Another scandal in the film and TV sector</a
|
||||
></strong
|
||||
>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p data-omnivore-anchor-idx="48">Thanks for reading.</p>
|
||||
</div>
|
||||
<div data-omnivore-anchor-idx="49" data-testid="paywall">
|
||||
<h2 data-omnivore-anchor-idx="50">This post is for paid subscribers</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,19 @@
|
|||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { htmlToSsmlItems, stripEmojis } from '../src/htmlToSsml'
|
||||
import {
|
||||
htmlToSpeechFile,
|
||||
htmlToSsmlItems,
|
||||
stripEmojis,
|
||||
} from '../src/htmlToSsml'
|
||||
import * as fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const TEST_OPTIONS = {
|
||||
primaryVoice: 'test-primary',
|
||||
secondaryVoice: 'test-secondary',
|
||||
language: 'en-US',
|
||||
rate: '1.0',
|
||||
}
|
||||
|
||||
describe('stripEmojis', () => {
|
||||
it('strips emojis from text and removes the extra space', () => {
|
||||
|
|
@ -20,14 +33,7 @@ describe('stripEmojis', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('htmlToSsmlItems', () => {
|
||||
const TEST_OPTIONS = {
|
||||
primaryVoice: 'test-primary',
|
||||
secondaryVoice: 'test-secondary',
|
||||
language: 'en-US',
|
||||
rate: '1.0',
|
||||
}
|
||||
|
||||
describe('htmlToSpeechFile', () => {
|
||||
describe('a simple html file', () => {
|
||||
xit('should convert Html to SSML', () => {
|
||||
const ssml = htmlToSsmlItems(
|
||||
|
|
@ -217,3 +223,18 @@ describe('htmlToSsmlItems', () => {
|
|||
// })
|
||||
// })
|
||||
})
|
||||
|
||||
describe('convert HTML to Speech file', () => {
|
||||
it('converts each <li> to an utterance', () => {
|
||||
const html = fs.readFileSync(
|
||||
path.resolve(__dirname, './fixtures/large.html'),
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
const speechFile = htmlToSpeechFile({
|
||||
content: html,
|
||||
title: 'Wang Yi at the UN; Fu Zhenghua sentenced; Nvidia China sales',
|
||||
options: TEST_OPTIONS,
|
||||
})
|
||||
expect(speechFile.utterances).to.have.lengthOf(11)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue