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

This commit is contained in:
Rupin Khandelwal 2022-09-26 07:25:37 -05:00
commit 6df73f6d5d
112 changed files with 3845 additions and 619 deletions

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

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

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

View file

@ -0,0 +1,8 @@
MathJax = {
tex: {
inlineMath: [
['$latex', '$'],
['\\(', '\\)'],
],
},
}

File diff suppressed because one or more lines are too long

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

View file

@ -34,6 +34,8 @@ query Search($after: String, $first: Int, $query: String) {
siteName
subscription
readAt
savedAt
updatedAt
}
}
pageInfo {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -99,6 +99,12 @@ struct ProfileView: View {
}
}
Section {
NavigationLink(destination: TextToSpeechView()) {
Text("Text to Speech")
}
}
Section {
NavigationLink(
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)

View file

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

View file

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

View file

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

View file

@ -51,7 +51,6 @@ struct WebReaderContent {
</head>
<body>
<div id="root" />
<div>HIIIIII</div>
<div id='_omnivore-htmlContent' style="display: none;">
\(articleContent.htmlContent)
</div>

View file

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

View file

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

View file

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

View file

@ -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 ledgers 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 worlds 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 Singapores strategy to develop a digital asset ecosystem as well as our regulatory approach to manage the risks of digital assets. "),
//// Utterance(text: "SINGAPORES 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 Marketnodes 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 Singapores FinTech and digital asset ecosystem. "),
//// Utterance(text: "SINGAPORES 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. Singapores 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
// }
}

View file

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

View file

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

View file

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

View file

@ -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) ?? []
)
}

View file

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

View file

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

View file

@ -14,5 +14,7 @@ public enum UserDefaultKey: String {
case lastUsedAppBuildNumber
case lastItemSyncTime
case textToSpeechPlaybackRate
case textToSpeechCurrentVoice
case textToSpeechPreferredVoice
case textToSpeechDefaultLanguage
case textToSpeechPreloadEnabled
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,227 @@
<DIV class="page" id="readability-page-1">
<DIV>
<!--[if mso]>
<table style="width:600px;"><tr><td>
<![endif]-->
<div>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<span>Presented By Pfizer</span>
</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCEk4jcNBABGAEoATIIOBBtC45s_bI" alt="" width="600">
</td>
</tr>
<tr>
<td>&nbsp;</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, were 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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>
<span>
<p>Axios thanks our partners for supporting our newsletters. If youre 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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</td>
</tr>
</div>
<!-- End footer -->
<!--[if mso]>
</td></tr></table>
<![endif]-->
</DIV>
</DIV>

View file

@ -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 worlds 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 worlds 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;">&nbsp;</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>&nbsp;&nbsp;<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;">&nbsp;</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;">&nbsp;</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;">&nbsp;</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;">&middot;</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;">&nbsp;</td>
</tr>
<tr>
<td height="10" style="height: 10px; font-size: 0;">&nbsp;</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;">&nbsp;</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;">&nbsp;</td>
</tr>
<tr>
<td height="20" style="height: 10px; font-size: 0;">&nbsp;</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;">&nbsp;</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;">&nbsp;</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, were 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;">&nbsp;</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;">&nbsp;</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 youre 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&zwnj;lvd, Arlington VA 22201
</span>
</td>
</tr>
<tr>
<td height="20" style="height: 20px; font-size: 0;">&nbsp;</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;">&nbsp;</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;">&nbsp;</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;">&nbsp;</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;">&nbsp;</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;">
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</div>
</body>
</html>

View file

@ -0,0 +1 @@
https://link.axios.com/view/624d3447776363102d0b2208hbv6u.bdsb/95d21d5c

View file

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

View file

@ -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>&nbsp;</td>
</tr>
<tr>
<td>
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<span>Presented By Facebook </span>
</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</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&amp;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 &amp; 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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>Were making investments in safety and security — and seeing results</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCQ2qTDvAEQARgBKAEyCGHqPStbqtE4" alt="" width="600">
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<p>Facebook has invested $13 billion over the last 5 years to help keep you safe. Since July, weve 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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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 whats 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>&nbsp;</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">&lt;/&gt; </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>&nbsp;</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>&nbsp;</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 its 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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<p>A message from Facebook </p>
</td>
</tr>
<tr>
<td>
<span>Were making investments in safety and security — and seeing results</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<img src="https://tpc.googlesyndication.com/pageadimg/imgad?id=CICAgOCQ2qTDvAEQARgBKAEyCGHqPStbqtE4" alt="" width="600">
</td>
</tr>
<tr>
<td>&nbsp;</td>
</tr>
<tr>
<td>
<p>Facebook has invested $13 billion over the last 5 years to help keep you safe. Since July, weve 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>&nbsp;</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>&nbsp;</td>
</tr>
<tr>
<td>
<span>
<p>Axios thanks our partners for supporting our newsletters. If youre 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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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

View file

@ -0,0 +1 @@
https://link.axios.com/view/620a4aa717e6565ad1452bd8fwcaa.xvt/23d9abe3

View file

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

View file

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

View file

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

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

View file

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

View 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 todays 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 YIs 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>

View file

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