Merge pull request #3006 from omnivore-app/main
Web production deployment
45
.github/CONTRIBUTING.md
vendored
|
|
@ -1,40 +1,29 @@
|
|||
Guidelines
|
||||
==========
|
||||
📝 **Guidelines for Contribution**
|
||||
|
||||
When contributing to Omnivore, please follow the style of the file you
|
||||
are editing. For most code running `yarn lint` should tell you if
|
||||
your code meets are style.
|
||||
Hello there! When contributing to Omnivore, it's essential to maintain a style consistent with the file you're editing. You can quickly check if your code aligns with our style by running `yarn lint`. Your attention to detail here is appreciated! 😊
|
||||
|
||||
📜 **License**
|
||||
|
||||
License
|
||||
=======
|
||||
Omnivore is proudly licensed under the [AGPL license](https://github.com/omnivore-app/omnivore/blob/main/LICENSE). Let's keep our code open and accessible to all!
|
||||
|
||||
Omnivore is licensed under the [AGPL license](https://github.com/omnivore-app/omnivore/blob/main/LICENSE).
|
||||
🤝 **CLA (Contributor License Agreement)**
|
||||
|
||||
CLA
|
||||
=======
|
||||
We kindly request all contributors to adhere to Omnivore's CLA, which you can review and accept at [Omnivore's CLA](https://cla-assistant.io/omnivore-app/omnivore). Your commitment to this agreement is much appreciated.
|
||||
|
||||
Contributions are accepted under [Omnivore's CLA](https://cla-assistant.io/omnivore-app/omnivore).
|
||||
👩💻 **Code Review**
|
||||
|
||||
Code Review
|
||||
=======
|
||||
We require code review from a [CODEOWNER](https://github.com/omnivore-app/omnivore/blob/main/.github/CODEOWNERS) before merging a pull request.
|
||||
Before merging any pull request, we require a code review from one of our trusted [CODEOWNERS](https://github.com/omnivore-app/omnivore/blob/main/.github/CODEOWNERS). It ensures the quality and integrity of our codebase.
|
||||
|
||||
Testing
|
||||
=======
|
||||
🧪 **Testing**
|
||||
|
||||
Pull requests are automatically tested using GitHub Actions. We
|
||||
usually only merge a pull request if it causes no regressions in
|
||||
our tests.
|
||||
We take quality seriously! Pull requests automatically undergo testing using GitHub Actions. We typically merge a pull request only if it passes all our tests without causing any regressions. 🧪
|
||||
|
||||
When you submit a pull request:
|
||||
📬 **Submitting a Pull Request**
|
||||
|
||||
* If you are a new contributor, GitHub will ask for permissions (on
|
||||
the pull request) to test it. A maintainer will reply to approve
|
||||
the test run if they find the patch appropriate.
|
||||
* If you have previously contributed, GitHub will test your pull
|
||||
request as soon as a test machine is available.
|
||||
- **New Contributors**: If you're a new contributor, GitHub may request permissions on the pull request to run tests. Don't worry, our maintainer will swiftly approve the test run if your patch looks promising.
|
||||
|
||||
Once tests are passing on your pull request it will be reviewed,
|
||||
merged, and deployed by a maintiner. We deploy around ten times
|
||||
a day, so your changes should hit production quickly.
|
||||
- **Previous Contributors**: For those who've contributed before, GitHub will kick off testing as soon as a test machine is available.
|
||||
|
||||
Once your pull request passes the tests, it'll be promptly reviewed, merged, and deployed by one of our diligent maintainers. We usually deploy changes around ten times a day, so your contributions will go live quickly! 🚀
|
||||
|
||||
Thank you for being part of the Omnivore community. Let's keep coding, collaborating, and creating something amazing together! 🌟
|
||||
|
|
@ -18,7 +18,7 @@ We built Omnivore because we love reading and we want it to be more social. Join
|
|||
- Add newsletter articles via email (with substack support!)
|
||||
- PDF support
|
||||
- [Web app](https://omnivore.app/) written in Node.js and TypeScript
|
||||
- [Native iOS app](https://omnivore.app/install/ios)
|
||||
- [Native iOS app](https://omnivore.app/install/ios) ([source](https://github.com/omnivore-app/omnivore/tree/main/apple))
|
||||
- [Android app](https://omnivore.app/install/android) ([source](https://github.com/omnivore-app/omnivore/tree/main/android/Omnivore))
|
||||
- Progressive web app for Android users
|
||||
- Browser extensions for [Chrome](https://omnivore.app/install/chrome), [Safari](https://omnivore.app/install/safari), [Firefox](https://omnivore.app/install/firefox), and [Edge](https://omnivore.app/install/edge)
|
||||
|
|
@ -88,7 +88,7 @@ Open <http://localhost:3000> and confirm Omnivore is running
|
|||
|
||||
### 3. Login with the test account
|
||||
|
||||
During database setup docker compose creates an account `demo@omnivore.app`, password: `demo`.
|
||||
During database setup docker compose creates an account `demo@omnivore.app`, password: `demo_password`.
|
||||
|
||||
Go to <http://localhost:3000/> in your browser and choose `Continue with Email` to login.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ android {
|
|||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 110
|
||||
versionName "0.0.110"
|
||||
versionCode 122
|
||||
versionName "0.0.122"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import app.omnivore.omnivore.graphql.generated.SetLabelsMutation
|
|||
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
|
||||
|
||||
suspend fun Networker.updateLabelsForSavedItem(input: SetLabelsInput): Boolean {
|
||||
suspend fun Networker.updateLabelsForSavedItem(input: SetLabelsInput): List<SetLabelsMutation.Label>? {
|
||||
return try {
|
||||
val result = authenticatedApolloClient().mutation(SetLabelsMutation(input)).execute()
|
||||
return result.data?.setLabels?.onSetLabelsSuccess?.labels != null
|
||||
return result.data?.setLabels?.onSetLabelsSuccess?.labels
|
||||
} catch (e: java.lang.Exception) {
|
||||
false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import android.util.Log
|
||||
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
|
||||
import app.omnivore.omnivore.graphql.generated.type.ContentReader
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.persistence.entities.Highlight
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
data class SavedItemQueryResponse(
|
||||
val item: SavedItem?,
|
||||
|
|
@ -60,7 +66,18 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO: handle errors
|
||||
var localPDFPath: String? = null
|
||||
if (article.articleFields.contentReader == ContentReader.PDF) {
|
||||
// download the PDF and save it locally
|
||||
// article.articleFields.url
|
||||
|
||||
val localFile = File.createTempFile("pdf-" + article.articleFields.id, ".pdf", )
|
||||
val url = URL(article.articleFields.url)
|
||||
Log.d("pdf", "creating local file: $localFile")
|
||||
|
||||
url.openStream().use { Files.copy(it, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING) }
|
||||
localPDFPath = localFile.toPath().toString()
|
||||
}
|
||||
|
||||
val savedItem = SavedItem(
|
||||
savedItemId = article.articleFields.id,
|
||||
|
|
@ -82,7 +99,8 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
|
|||
isArchived = article.articleFields.isArchived,
|
||||
contentReader = article.articleFields.contentReader.rawValue,
|
||||
content = article.articleFields.content,
|
||||
wordsCount = article.articleFields.wordsCount
|
||||
wordsCount = article.articleFields.wordsCount,
|
||||
localPDFPath = localPDFPath
|
||||
)
|
||||
|
||||
return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels, state = article.articleFields.state?.rawValue ?: "")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import app.omnivore.omnivore.persistence.entities.*
|
|||
SavedItemAndSavedItemLabelCrossRef::class,
|
||||
SavedItemAndHighlightCrossRef::class
|
||||
],
|
||||
version = 11
|
||||
version = 12
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun viewerDao(): ViewerDao
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ data class SavedItem(
|
|||
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) val pdfData: ByteArray? = null,
|
||||
var serverSyncStatus: Int = 0,
|
||||
val tempPDFURL: String? = null,
|
||||
val wordsCount: Int? = null
|
||||
val wordsCount: Int? = null,
|
||||
val localPDFPath: String? = null
|
||||
|
||||
// hasMany highlights
|
||||
// hasMany labels
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import LabelChip
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -219,12 +220,12 @@ class LabelChipView(label: SavedItemLabel) : Chip(label.name) {
|
|||
val label = label
|
||||
}
|
||||
|
||||
fun findOrCreateLabel(labelsViewModel: LabelsViewModel, labels: List<SavedItemLabel>, name: TextFieldValue): SavedItemLabel {
|
||||
val found = labels.find { it.name == name.text }
|
||||
fun findOrCreateLabel(labelsViewModel: LabelsViewModel, labels: List<SavedItemLabel>, name: String): SavedItemLabel {
|
||||
val found = labels.find { it.name == name }
|
||||
if (found != null) {
|
||||
return found
|
||||
}
|
||||
return labelsViewModel.createNewSavedItemLabelWithTemp(name.text, LabelSwatchHelper.random())
|
||||
return labelsViewModel.createNewSavedItemLabelWithTemp(name, LabelSwatchHelper.random())
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -308,7 +309,7 @@ fun LabelsSelectionSheetContent(
|
|||
LabelChipView(it)
|
||||
} ?: null
|
||||
} else {
|
||||
LabelChipView(findOrCreateLabel(labelsViewModel = labelsViewModel, labels = labels, name = it))
|
||||
LabelChipView(findOrCreateLabel(labelsViewModel = labelsViewModel, labels = labels, name = it.text))
|
||||
}
|
||||
},
|
||||
chipLeadingIcon = { chip -> CircleIcon(colorHex = chip.label.color) },
|
||||
|
|
@ -341,19 +342,34 @@ fun LabelsSelectionSheetContent(
|
|||
)
|
||||
|
||||
if (!isLibraryMode && filterTextValue.text.isNotEmpty() && currentLabel == null) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val label = findOrCreateLabel(
|
||||
labelsViewModel = labelsViewModel,
|
||||
labels = labels,
|
||||
name = filterTextValue
|
||||
)
|
||||
state.addChip(LabelChipView(label))
|
||||
filterTextValue = TextFieldValue()
|
||||
val labelName = filterTextValue.text.trim()
|
||||
when(labelsViewModel.validateLabelName(labelName)) {
|
||||
LabelsViewModel.Error.LabelNameTooLong -> {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.label_selection_sheet_label_too_long_error_msg,
|
||||
labelsViewModel.labelNameMaxLength),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
null -> {
|
||||
val label = findOrCreateLabel(
|
||||
labelsViewModel = labelsViewModel,
|
||||
labels = labels,
|
||||
name = labelName
|
||||
)
|
||||
|
||||
state.addChip(LabelChipView(label))
|
||||
filterTextValue = TextFieldValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 10.dp)
|
||||
.padding(top = 10.dp, bottom = 5.dp)
|
||||
|
|
@ -364,7 +380,7 @@ fun LabelsSelectionSheetContent(
|
|||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(text = stringResource(R.string.label_selection_sheet_text_create, filterTextValue.text))
|
||||
Text(text = stringResource(R.string.label_selection_sheet_text_create, filterTextValue.text.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,16 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.lifecycle.*
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.dataService.*
|
||||
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
|
||||
import app.omnivore.omnivore.models.ServerSyncStatus
|
||||
import app.omnivore.omnivore.networking.*
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItem
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.components.LabelSwatchHelper
|
||||
import app.omnivore.omnivore.ui.library.SavedItemAction
|
||||
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
|
||||
import com.google.gson.Gson
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
|
@ -42,6 +24,24 @@ class LabelsViewModel @Inject constructor(
|
|||
private val dataService: DataService,
|
||||
private val networker: Networker
|
||||
): ViewModel() {
|
||||
val labelNameMaxLength = 64
|
||||
|
||||
enum class Error {
|
||||
LabelNameTooLong
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not the provided label name is valid.
|
||||
* @param labelName The name of the label.
|
||||
* @return null if valid, [Error] otherwise.
|
||||
*/
|
||||
fun validateLabelName(labelName: String): Error? {
|
||||
if (labelName.count() > labelNameMaxLength) {
|
||||
return Error.LabelNameTooLong
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun createNewSavedItemLabelWithTemp(labelName: String, hexColorValue: String): SavedItemLabel {
|
||||
val tempId = UUID.randomUUID().toString()
|
||||
|
|
@ -56,21 +56,6 @@ class LabelsViewModel @Inject constructor(
|
|||
serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dataService.db.savedItemLabelDao().insertAll(listOf(res))
|
||||
|
||||
val newLabel = networker.createNewLabel(CreateLabelInput(color = presentIfNotNull(res.color), name = res.name))
|
||||
if (newLabel != null) {
|
||||
try {
|
||||
dataService.db.savedItemLabelDao().updateTempLabel(tempId, newLabel.id)
|
||||
} catch (e: Exception) {
|
||||
Log.d("EXCEPTION: ", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,55 +304,44 @@ class LibraryViewModel @Inject constructor(
|
|||
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val syncedLabels = labels.filter { it.serverSyncStatus == ServerSyncStatus.IS_SYNCED.rawValue }
|
||||
val unsyncedLabels = labels.filter { it.serverSyncStatus != ServerSyncStatus.IS_SYNCED.rawValue }
|
||||
val input = SetLabelsInput(
|
||||
pageId = savedItemID,
|
||||
labels = Optional.presentIfNotNull(labels.map { CreateLabelInput(color = Optional.presentIfNotNull(it.color), name = it.name) }),
|
||||
)
|
||||
|
||||
var labelCreationError = false
|
||||
val createdLabels = unsyncedLabels.mapNotNull { label ->
|
||||
val result = networker.createNewLabel(CreateLabelInput(
|
||||
name = label.name,
|
||||
color = presentIfNotNull(label.color),
|
||||
description = presentIfNotNull(label.labelDescription),
|
||||
))
|
||||
result?.let {
|
||||
val updatedLabels = networker.updateLabelsForSavedItem(input)
|
||||
|
||||
// Figure out which of the labels are new
|
||||
updatedLabels?.let { updatedLabels ->
|
||||
val existingNamedLabels = dataService.db.savedItemLabelDao()
|
||||
.namedLabels(updatedLabels.map { it.labelFields.name })
|
||||
val existingNames = existingNamedLabels.map { it.name }
|
||||
val newNamedLabels = updatedLabels.filter { !existingNames.contains(it.labelFields.name) }
|
||||
|
||||
dataService.db.savedItemLabelDao().insertAll(newNamedLabels.map {
|
||||
SavedItemLabel(
|
||||
savedItemLabelId = result.id,
|
||||
name = result.name,
|
||||
color = result.color,
|
||||
createdAt = result.createdAt.toString(),
|
||||
labelDescription = result.description,
|
||||
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
|
||||
savedItemLabelId = it.labelFields.id,
|
||||
name = it.labelFields.name,
|
||||
color = it.labelFields.color,
|
||||
createdAt = null,
|
||||
labelDescription = null
|
||||
)
|
||||
})
|
||||
|
||||
val allNamedLabels = dataService.db.savedItemLabelDao()
|
||||
.namedLabels(updatedLabels.map { it.labelFields.name })
|
||||
val crossRefs = allNamedLabels.map {
|
||||
SavedItemAndSavedItemLabelCrossRef(
|
||||
savedItemLabelId = it.savedItemLabelId,
|
||||
savedItemId = savedItemID
|
||||
)
|
||||
} ?: run {
|
||||
labelCreationError = true
|
||||
null
|
||||
}
|
||||
}
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID)
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
dataService.db.savedItemLabelDao().insertAll(createdLabels)
|
||||
|
||||
val allLabels = syncedLabels + createdLabels
|
||||
|
||||
val input = SetLabelsInput(labelIds = Optional.presentIfNotNull(allLabels.map { it.savedItemLabelId }), pageId = savedItemID)
|
||||
val networkResult = networker.updateLabelsForSavedItem(input)
|
||||
|
||||
val crossRefs = allLabels.map {
|
||||
SavedItemAndSavedItemLabelCrossRef(
|
||||
savedItemLabelId = it.savedItemLabelId,
|
||||
savedItemId = savedItemID
|
||||
)
|
||||
}
|
||||
|
||||
// Remove all labels first
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID)
|
||||
|
||||
// Add back the current labels
|
||||
dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
|
||||
|
||||
if (!networkResult || labelCreationError) {
|
||||
snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_error)
|
||||
} else {
|
||||
snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_success)
|
||||
} ?: run {
|
||||
snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_error)
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.omnivore.omnivore.R
|
||||
|
||||
@Composable
|
||||
fun OpenLinkView(webReaderViewModel: WebReaderViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier = Modifier
|
||||
.padding(top = 25.dp)
|
||||
.padding(horizontal = 50.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Row {
|
||||
Text(webReaderViewModel.currentLink.toString(),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = Color.DarkGray,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(top = 25.dp)) {
|
||||
Button(onClick = { webReaderViewModel.openCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_open_in_browser))
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Button(onClick = { webReaderViewModel.saveCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_save_to_omnivore))
|
||||
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Button(onClick = {webReaderViewModel.copyCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_copy_link))
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Button(onClick = {webReaderViewModel.resetBottomSheet() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.EventTracker
|
||||
import app.omnivore.omnivore.dataService.DataService
|
||||
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
|
||||
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
|
||||
|
|
@ -21,11 +21,16 @@ import com.pspdfkit.document.download.DownloadJob
|
|||
import com.pspdfkit.document.download.DownloadRequest
|
||||
import com.pspdfkit.document.download.Progress
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.lang.Double.max
|
||||
import java.lang.Double.min
|
||||
import java.lang.Exception
|
||||
import java.net.URLEncoder
|
||||
import java.nio.file.FileSystem
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -38,8 +43,8 @@ data class PDFReaderParams(
|
|||
@HiltViewModel
|
||||
class PDFReaderViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository,
|
||||
private val networker: Networker,
|
||||
private val eventTracker: EventTracker,
|
||||
private val dataService: DataService,
|
||||
private val networker: Networker
|
||||
): ViewModel() {
|
||||
var annotationUnderNoteEdit: Annotation? = null
|
||||
val pdfReaderParamsLiveData = MutableLiveData<PDFReaderParams?>(null)
|
||||
|
|
@ -47,21 +52,51 @@ class PDFReaderViewModel @Inject constructor(
|
|||
|
||||
fun loadItem(slug: String, context: Context) {
|
||||
viewModelScope.launch {
|
||||
loadItemFromDB(slug)
|
||||
loadItemFromNetwork(slug, context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItemFromDB(slug: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val persistedItem = dataService.db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
|
||||
persistedItem?.let { persistedItem ->
|
||||
persistedItem?.savedItem?.localPDF?.let { localPDF ->
|
||||
val localFile = File(localPDF)
|
||||
|
||||
if (localFile.exists()) {
|
||||
val articleContent = ArticleContent(
|
||||
title = persistedItem.savedItem.title,
|
||||
htmlContent = "",
|
||||
highlights = persistedItem.highlights,
|
||||
contentStatus = "SUCCEEDED",
|
||||
objectID = "",
|
||||
labelsJSONString = Gson().toJson(persistedItem.labels)
|
||||
)
|
||||
|
||||
pdfReaderParamsLiveData.postValue(
|
||||
PDFReaderParams(
|
||||
persistedItem.savedItem,
|
||||
articleContent,
|
||||
Uri.fromFile(localFile)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItemFromNetwork(slug: String, context: Context) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val articleQueryResult = networker.savedItem(slug)
|
||||
|
||||
val article = articleQueryResult.item ?: return@launch
|
||||
|
||||
val article = articleQueryResult.item ?: return@withContext
|
||||
val request = DownloadRequest.Builder(context)
|
||||
.uri(article.pageURLString)
|
||||
.build()
|
||||
|
||||
val job = DownloadJob.startDownload(request)
|
||||
|
||||
job.setProgressListener(object : DownloadJob.ProgressListenerAdapter() {
|
||||
override fun onProgress(progress: Progress) {
|
||||
// progressBar.setProgress((100f * progress.bytesReceived / progress.totalBytes).toInt())
|
||||
}
|
||||
|
||||
override fun onComplete(output: File) {
|
||||
val articleContent = ArticleContent(
|
||||
title = article.title,
|
||||
|
|
@ -72,22 +107,18 @@ class PDFReaderViewModel @Inject constructor(
|
|||
labelsJSONString = Gson().toJson(articleQueryResult.labels)
|
||||
)
|
||||
|
||||
val pdfReaderParams = PDFReaderParams(article, articleContent, Uri.fromFile(output))
|
||||
|
||||
eventTracker.track("link_read",
|
||||
com.posthog.android.Properties()
|
||||
.putValue("linkID", pdfReaderParams.item.savedItemId)
|
||||
.putValue("slug", pdfReaderParams.item.slug)
|
||||
.putValue("originalArticleURL", pdfReaderParams.item.pageURLString)
|
||||
.putValue("loaded_from", "network")
|
||||
)
|
||||
|
||||
currentReadingProgress = article.readingProgress
|
||||
pdfReaderParamsLiveData.postValue(pdfReaderParams)
|
||||
pdfReaderParamsLiveData.postValue(
|
||||
PDFReaderParams(
|
||||
article,
|
||||
articleContent,
|
||||
Uri.fromFile(output)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onError(exception: Throwable) {
|
||||
// handleDownloadError(exception)
|
||||
// handleDownloadError(exception)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ data class WebReaderContent(
|
|||
url: `${item.pageURLString}`,
|
||||
title: `${articleContent.title.replace("`", "\\`")}`,
|
||||
content: document.getElementById('_omnivore-htmlContent').innerHTML,
|
||||
originalArticleUrl: "${item.pageURLString}",
|
||||
originalArticleUrl: "${item.publisherURLString}",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: ${item.readingProgress},
|
||||
readingProgressAnchorIndex: ${item.readingProgressAnchor},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.omnivore.omnivore.ui.components.LabelsViewModel
|
||||
|
|
@ -450,38 +449,3 @@ fun BottomSheetUI(title: String?, content: @Composable () -> Unit) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun OpenLinkView(webReaderViewModel: WebReaderViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier = Modifier
|
||||
.padding(top = 50.dp)
|
||||
.padding(horizontal = 50.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Row {
|
||||
Button(onClick = { webReaderViewModel.openCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_open_in_browser))
|
||||
|
||||
}
|
||||
}
|
||||
Row() {
|
||||
Button(onClick = { webReaderViewModel.saveCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_save_to_omnivore))
|
||||
|
||||
}
|
||||
}
|
||||
Row() {
|
||||
Button(onClick = {webReaderViewModel.copyCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_copy_link))
|
||||
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Button(onClick = {webReaderViewModel.resetBottomSheet() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.open_link_view_action_cancel))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,16 +20,20 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import androidx.compose.ui.text.toUpperCase
|
||||
import androidx.compose.ui.unit.*
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
|
||||
import app.omnivore.omnivore.ui.components.LabelChipColors
|
||||
import app.omnivore.omnivore.ui.library.SavedItemAction
|
||||
import app.omnivore.omnivore.ui.library.SavedItemFilter
|
||||
import app.omnivore.omnivore.ui.library.SavedItemViewModel
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
|
|
@ -45,14 +49,14 @@ fun SavedItemCard(
|
|||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = onClickHandler,
|
||||
onLongClick = {
|
||||
savedItemViewModel.actionsMenuItemLiveData.postValue(savedItem)
|
||||
}
|
||||
)
|
||||
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.background)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onClickHandler,
|
||||
onLongClick = {
|
||||
savedItemViewModel.actionsMenuItemLiveData.postValue(savedItem)
|
||||
}
|
||||
)
|
||||
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.background)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
|
@ -108,8 +112,10 @@ fun SavedItemCard(
|
|||
)
|
||||
}
|
||||
|
||||
FlowRow(modifier = Modifier.fillMaxWidth().padding(10.dp)) {
|
||||
savedItem.labels.sortedWith(compareBy { it.name.toLowerCase(Locale.current) }).forEach { label ->
|
||||
FlowRow(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)) {
|
||||
savedItem.labels.filter { !isFlairLabel(it) }.sortedWith(compareBy { it.name.toLowerCase(Locale.current) }).forEach { label ->
|
||||
val chipColors = LabelChipColors.fromHex(label.color)
|
||||
|
||||
LabelChip(
|
||||
|
|
@ -196,6 +202,82 @@ fun readingProgress(item: SavedItemWithLabelsAndHighlights): String {
|
|||
// return ""
|
||||
//}
|
||||
|
||||
|
||||
public enum class FlairIcon(
|
||||
public val rawValue: String,
|
||||
public val sortOrder: Int
|
||||
) {
|
||||
FEED("feed", 0),
|
||||
RSS("rss", 0),
|
||||
FAVORITE("favorite", 1),
|
||||
NEWSLETTER("newsletter", 2),
|
||||
RECOMMENDED("recommended", 3),
|
||||
PINNED("pinned", 4)
|
||||
}
|
||||
|
||||
val FLAIR_ICON_NAMES = listOf("feed", "rss", "favorite", "newsletter", "recommended", "pinned")
|
||||
|
||||
fun isFlairLabel(label: SavedItemLabel): Boolean {
|
||||
return FLAIR_ICON_NAMES.contains(label.name.toLowerCase(Locale.current))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun flairIcons(item: SavedItemWithLabelsAndHighlights) {
|
||||
val labels = item.labels.filter { isFlairLabel(it) }.map {
|
||||
FlairIcon.valueOf(it.name.toUpperCase(Locale.current))
|
||||
}
|
||||
labels.forEach {
|
||||
when (it) {
|
||||
FlairIcon.RSS,
|
||||
FlairIcon.FEED -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.flair_feed),
|
||||
contentDescription = "Feed flair Icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 5.0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
FlairIcon.FAVORITE -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.flaire_favorite),
|
||||
contentDescription = "Favorite flair Icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 5.0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
FlairIcon.NEWSLETTER -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.flair_newsletter),
|
||||
contentDescription = "Newsletter flair Icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 5.0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
FlairIcon.RECOMMENDED -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.flair_recommended),
|
||||
contentDescription = "Recommended flair Icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 5.0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
FlairIcon.PINNED -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.flair_pinned),
|
||||
contentDescription = "Pinned flair Icon",
|
||||
modifier = Modifier
|
||||
.padding(end = 5.0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun readInfo(item: SavedItemWithLabelsAndHighlights) {
|
||||
Row(
|
||||
|
|
@ -203,6 +285,9 @@ fun readInfo(item: SavedItemWithLabelsAndHighlights) {
|
|||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 15.dp)
|
||||
) {
|
||||
// Show flair here
|
||||
flairIcons(item)
|
||||
|
||||
Text(
|
||||
text = estimatedReadingTime(item),
|
||||
style = TextStyle(
|
||||
|
|
|
|||
31
android/Omnivore/app/src/main/res/drawable/flair_feed.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="17">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0.739,0.566h16v16h-16z"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M8.739,3.232L3.405,5.899L8.739,8.566L14.072,5.899L8.739,3.232Z"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#FF7B03"
|
||||
android:strokeColor="#FF7B03"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M3.405,8.566L8.739,11.233L14.072,8.566"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF7B03"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M3.405,11.232L8.739,13.899L14.072,11.232"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FF7B03"
|
||||
android:strokeLineCap="round"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="17">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0.739,0.566h16v16h-16z"/>
|
||||
<path
|
||||
android:pathData="M15.406,5.59V11.9C15.406,12.41 15.211,12.901 14.861,13.272C14.511,13.643 14.032,13.867 13.523,13.896L13.406,13.9H4.072C3.562,13.9 3.071,13.705 2.7,13.355C2.329,13.005 2.106,12.526 2.076,12.017L2.072,11.9V5.59L8.369,9.788L8.446,9.832C8.537,9.876 8.637,9.9 8.739,9.9C8.84,9.9 8.94,9.876 9.032,9.832L9.109,9.788L15.406,5.59Z"
|
||||
android:fillColor="#007AFF"/>
|
||||
<path
|
||||
android:pathData="M13.405,3.232C14.125,3.232 14.757,3.612 15.109,4.184L8.739,8.43L2.369,4.184C2.536,3.912 2.765,3.685 3.038,3.52C3.311,3.355 3.62,3.258 3.938,3.237L4.072,3.232H13.405Z"
|
||||
android:fillColor="#007AFF"/>
|
||||
</group>
|
||||
</vector>
|
||||
13
android/Omnivore/app/src/main/res/drawable/flair_pinned.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="17">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0.739,0.566h16v16h-16z"/>
|
||||
<path
|
||||
android:pathData="M10.814,2.706L10.877,2.762L14.543,6.428C14.656,6.541 14.724,6.691 14.736,6.85C14.747,7.009 14.702,7.166 14.607,7.295C14.512,7.423 14.375,7.513 14.219,7.548C14.064,7.584 13.901,7.562 13.76,7.488L11.645,9.602L10.696,12.134C10.671,12.2 10.635,12.263 10.591,12.318L10.544,12.372L9.544,13.372C9.429,13.486 9.276,13.555 9.114,13.565C8.952,13.575 8.792,13.526 8.664,13.426L8.601,13.371L6.739,11.509L4.21,14.038C4.09,14.157 3.929,14.226 3.76,14.232C3.59,14.237 3.426,14.177 3.299,14.065C3.171,13.953 3.092,13.797 3.076,13.629C3.06,13.46 3.108,13.292 3.212,13.158L3.267,13.095L5.795,10.566L3.934,8.704C3.819,8.589 3.75,8.437 3.74,8.275C3.73,8.113 3.779,7.952 3.879,7.824L3.934,7.762L4.934,6.762C4.984,6.711 5.042,6.669 5.106,6.637L5.171,6.609L7.702,5.659L9.816,3.546C9.744,3.411 9.72,3.255 9.749,3.105C9.778,2.955 9.858,2.819 9.975,2.721C10.092,2.623 10.239,2.567 10.392,2.565C10.544,2.562 10.693,2.612 10.814,2.706Z"
|
||||
android:fillColor="#3D3D3D"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="17">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0.739,0.566h16v16h-16z"/>
|
||||
<path
|
||||
android:pathData="M9.406,2.566C9.916,2.566 10.407,2.761 10.778,3.111C11.149,3.461 11.372,3.94 11.402,4.449L11.406,4.566V7.233H12.739C13.229,7.233 13.702,7.413 14.068,7.739C14.434,8.064 14.668,8.513 14.726,9L14.736,9.116L14.739,9.233L14.726,9.364L14.055,12.718C13.801,13.802 13.054,14.582 12.182,14.572L12.072,14.566H6.739C6.576,14.566 6.418,14.506 6.296,14.398C6.174,14.289 6.096,14.14 6.077,13.978L6.072,13.9L6.073,7.542C6.073,7.425 6.104,7.311 6.162,7.209C6.221,7.108 6.305,7.024 6.406,6.966C6.691,6.802 6.93,6.57 7.103,6.291C7.277,6.012 7.379,5.695 7.401,5.368L7.406,5.233V4.566C7.406,4.036 7.616,3.527 7.991,3.152C8.366,2.777 8.875,2.566 9.406,2.566Z"
|
||||
android:fillColor="#FEC43F"/>
|
||||
<path
|
||||
android:pathData="M4.072,7.232C4.236,7.232 4.393,7.292 4.515,7.401C4.637,7.509 4.715,7.659 4.734,7.821L4.739,7.899V13.899C4.739,14.062 4.679,14.22 4.57,14.342C4.462,14.464 4.312,14.542 4.15,14.561L4.072,14.566H3.406C3.069,14.566 2.745,14.439 2.499,14.21C2.252,13.981 2.101,13.668 2.076,13.332L2.072,13.232V8.566C2.072,8.229 2.199,7.905 2.428,7.659C2.657,7.412 2.97,7.261 3.306,7.236L3.406,7.232H4.072Z"
|
||||
android:fillColor="#FEC43F"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="17dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="17"
|
||||
android:viewportHeight="17">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0.739,0.566h16v16h-16z"/>
|
||||
<path
|
||||
android:pathData="M5.391,2.615C5.981,2.515 6.586,2.548 7.162,2.712C7.738,2.877 8.269,3.168 8.717,3.565L8.741,3.587L8.764,3.567C9.191,3.192 9.694,2.913 10.238,2.747C10.782,2.582 11.355,2.534 11.919,2.607L12.083,2.631C12.794,2.754 13.459,3.067 14.006,3.536C14.554,4.006 14.965,4.615 15.194,5.299C15.424,5.982 15.465,6.716 15.312,7.421C15.159,8.126 14.818,8.776 14.326,9.303L14.206,9.427L14.174,9.454L9.207,14.373C9.093,14.487 8.941,14.555 8.78,14.565C8.619,14.575 8.46,14.526 8.332,14.428L8.269,14.373L3.274,9.425C2.745,8.911 2.368,8.259 2.187,7.544C2.005,6.828 2.025,6.076 2.244,5.371C2.463,4.666 2.873,4.035 3.429,3.549C3.984,3.063 4.664,2.739 5.391,2.615Z"
|
||||
android:fillColor="#F8023B"/>
|
||||
</group>
|
||||
</vector>
|
||||
207
android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<resources>
|
||||
<string name="app_name">Omnivore</string>
|
||||
<string name="welcome_title">絕不錯過精彩閱讀</string>
|
||||
<string name="learn_more">深入了解</string>
|
||||
<string name="welcome_subtitle">在無干擾的閱讀器中儲存文章,以便稍後閱讀。</string>
|
||||
<string name="highlight_menu_action">標記</string>
|
||||
<string name="copy_menu_action">複製</string>
|
||||
<string name="annotate_menu_action">註解</string>
|
||||
<string name="pdf_remove_highlight">移除</string>
|
||||
<string name="pdf_highlight_menu_action">標記</string>
|
||||
<string name="pdf_highlight_copy">複製</string>
|
||||
<string name="highlight_note">註解</string>
|
||||
<string name="copyTextSelection">複製</string>
|
||||
<string name="pdf_highlight_menu_note">註解</string>
|
||||
|
||||
<!-- Apple Auth -->
|
||||
<string name="apple_auth_text">使用 Apple 繼續</string>
|
||||
<string name="apple_auth_loading">正在登入...</string>
|
||||
|
||||
<!-- Create User Profile -->
|
||||
<string name="create_user_profile_title">建立您的個人檔案</string>
|
||||
<string name="create_user_profile_loading">載入中...</string>
|
||||
<string name="create_user_profile_action_cancel">取消註冊</string>
|
||||
<string name="create_user_profile_action_submit">送出</string>
|
||||
<string name="create_user_profile_field_placeholder_name">名稱</string>
|
||||
<string name="create_user_profile_field_label_name">名稱</string>
|
||||
<string name="create_user_profile_field_placeholder_username">使用者名稱</string>
|
||||
<string name="create_user_profile_field_label_username">使用者名稱</string>
|
||||
<string name="create_user_profile_error_msg">請輸入有效的名稱和使用者名稱。</string>
|
||||
|
||||
<!-- Email Login -->
|
||||
<string name="email_login_loading">載入中...</string>
|
||||
<string name="email_login_action_back">返回社交登入頁面</string>
|
||||
<string name="email_login_action_no_account">還沒有帳戶?</string>
|
||||
<string name="email_login_action_forgot_password">忘記密碼?</string>
|
||||
<string name="email_login_action_login">登入</string>
|
||||
<string name="email_login_field_placeholder_email">user@email.com</string>
|
||||
<string name="email_login_field_label_email">電子郵件</string>
|
||||
<string name="email_login_field_placeholder_password">密碼</string>
|
||||
<string name="email_login_field_label_password">密碼</string>
|
||||
<string name="email_login_error_msg">請輸入電子郵件地址和密碼。</string>
|
||||
|
||||
<!-- Email Sign Up -->
|
||||
<string name="email_signup_verification_message">我們已向 %1$s 傳送驗證電子郵件。請驗證您的電子郵件,然後點選下面的按鈕。</string>
|
||||
<string name="email_signup_check_status">檢查狀態</string>
|
||||
<string name="email_signup_action_use_different_email">使用不同的電子郵件?</string>
|
||||
<string name="email_signup_loading">載入中...</string>
|
||||
<string name="email_signup_action_back">返回社交登入頁面</string>
|
||||
<string name="email_signup_action_already_have_account">已經有帳戶?</string>
|
||||
<string name="email_signup_action_sign_up">註冊</string>
|
||||
<string name="email_signup_field_placeholder_email">user@email.com</string>
|
||||
<string name="email_signup_field_label_email">電子郵件</string>
|
||||
<string name="email_signup_field_placeholder_password">密碼</string>
|
||||
<string name="email_signup_field_label_password">密碼</string>
|
||||
<string name="email_signup_field_placeholder_name">名稱</string>
|
||||
<string name="email_signup_field_label_name">名稱</string>
|
||||
<string name="email_signup_field_placeholder_username">使用者名稱</string>
|
||||
<string name="email_signup_field_label_username">使用者名稱</string>
|
||||
<string name="email_signup_error_msg">請完成所有欄位。</string>
|
||||
|
||||
<!-- Google Auth -->
|
||||
<string name="google_auth_text">使用 Google 繼續</string>
|
||||
<string name="google_auth_loading">正在登入...</string>
|
||||
|
||||
<!-- LoginViewModel -->
|
||||
<string name="login_view_model_self_hosting_settings_updated">自建伺服器設定已更新。</string>
|
||||
<string name="login_view_model_self_hosting_settings_reset">自建伺服器設定已重設。</string>
|
||||
<string name="login_view_model_username_validation_length_error_msg">使用者名稱必須介於 4 到 15 個字元之間。</string>
|
||||
<string name="login_view_model_username_validation_alphanumeric_error_msg">使用者名稱只能包含字母和數字。</string>
|
||||
<string name="login_view_model_username_not_available_error_msg">此使用者名稱不可用。</string>
|
||||
<string name="login_view_model_connection_error_msg">抱歉,我們無法連線到伺服器。</string>
|
||||
<string name="login_view_model_something_went_wrong_error_msg">出了些問題。請檢查您的電子郵件/密碼,然後再試一次。</string>
|
||||
<string name="login_view_model_something_went_wrong_two_error_msg">出了些問題。請檢查您的登入資訊,然後再試一次。</string>
|
||||
<string name="login_view_model_google_auth_error_msg">無法使用 Google 進行身份驗證。</string>
|
||||
<string name="login_view_model_missing_auth_token_error_msg">找不到身份驗證權杖。</string>
|
||||
|
||||
<!-- SelfHostedView -->
|
||||
<string name="self_hosted_view_loading">載入中...</string>
|
||||
<string name="self_hosted_view_action_reset">重設</string>
|
||||
<string name="self_hosted_view_action_back">返回</string>
|
||||
<string name="self_hosted_view_action_save">儲存</string>
|
||||
<string name="self_hosted_view_action_learn_more">了解更多關於自建伺服器 Omnivore 的資訊</string>
|
||||
<string name="self_hosted_view_field_api_url_label">API 伺服器</string>
|
||||
<string name="self_hosted_view_field_web_url_label">Web 伺服器</string>
|
||||
<string name="self_hosted_view_error_msg">請輸入 API 伺服器和 Web 伺服器地址。</string>
|
||||
|
||||
<!-- WelcomeScreen -->
|
||||
<string name="welcome_screen_action_dismiss">忽略</string>
|
||||
<string name="welcome_screen_action_continue_with_email">使用電子郵件繼續</string>
|
||||
<string name="welcome_screen_action_self_hosting_options">自建伺服器選項</string>
|
||||
|
||||
<!-- LabelCreationDialog -->
|
||||
<string name="label_creation_title">建立新標籤</string>
|
||||
<string name="label_creation_content">指定名稱和顏色。</string>
|
||||
<string name="label_creation_action_create">建立</string>
|
||||
<string name="label_creation_action_cancel">取消</string>
|
||||
<string name="label_creation_label_placeholder">標籤名稱</string>
|
||||
|
||||
<!-- LabelSelectionSheet -->
|
||||
<string name="label_selection_sheet_title">按標籤篩選</string>
|
||||
<string name="label_selection_sheet_title_alt">設定標籤</string>
|
||||
<string name="label_selection_sheet_action_cancel">取消</string>
|
||||
<string name="label_selection_sheet_action_search">搜尋</string>
|
||||
<string name="label_selection_sheet_action_save">儲存</string>
|
||||
<string name="label_selection_sheet_text_create">建立名為 \"%1$s\" 的新標籤</string>
|
||||
<string name="label_selection_sheet_label_too_long_error_msg">提供的名稱太長(必須小於或等於 %1$d 個字元)</string>
|
||||
|
||||
<!-- LibraryFilterBar -->
|
||||
<string name="library_filter_bar_label_labels">標籤</string>
|
||||
|
||||
<!-- LibraryNavigationBar -->
|
||||
<string name="library_nav_bar_title">圖書館</string>
|
||||
<string name="library_nav_bar_title_alt"></string>
|
||||
<string name="library_nav_bar_field_placeholder_search">搜尋</string>
|
||||
|
||||
<!-- LibraryViewModel -->
|
||||
<string name="library_view_model_snackbar_success">標籤已更新</string>
|
||||
<string name="library_view_model_snackbar_error">無法設定標籤</string>
|
||||
|
||||
<!-- NotebookView -->
|
||||
<string name="notebook_view_title">筆記本</string>
|
||||
<string name="notebook_view_action_copy">複製</string>
|
||||
<string name="notebook_view_snackbar_msg">筆記本已複製</string>
|
||||
|
||||
<!-- EditNoteModal -->
|
||||
<string name="edit_note_modal_title">註解</string>
|
||||
<string name="edit_note_modal_action_save">儲存</string>
|
||||
<string name="edit_note_modal_action_cancel">取消</string>
|
||||
|
||||
<!-- ArticleNotes -->
|
||||
<string name="article_notes_title">文章註解</string>
|
||||
<string name="article_notes_action_add_notes">新增註解...</string>
|
||||
|
||||
<!-- HighlightsList -->
|
||||
<string name="highlights_list_title">標記</string>
|
||||
<string name="highlights_list_action_copy">複製</string>
|
||||
<string name="highlights_list_snackbar_msg">標記已複製</string>
|
||||
<string name="highlights_list_action_add_note">新增註解...</string>
|
||||
<string name="highlights_list_error_msg_no_highlights">您尚未在此頁面新增任何標記。</string>
|
||||
|
||||
<!-- ReaderPreferencesView -->
|
||||
<string name="reader_preferences_view_font_size">字型大小:</string>
|
||||
<string name="reader_preferences_view_margin">邊距</string>
|
||||
<string name="reader_preferences_view_line_spacing">行距</string>
|
||||
<string name="reader_preferences_view_theme">主題:</string>
|
||||
<string name="reader_preferences_view_auto">自動</string>
|
||||
<string name="reader_preferences_view_high_constrast_text">高對比文字</string>
|
||||
<string name="reader_preferences_view_justify_text">對齊文字</string>
|
||||
|
||||
<!-- WebReaderLoadingContainer -->
|
||||
<string name="web_reader_loading_container_error_msg">我們無法取得您的內容。</string>
|
||||
<string name="web_reader_loading_container_bottom_sheet_reader_preferences">閱讀器偏好設定</string>
|
||||
<string name="web_reader_loading_container_bottom_sheet_notebook">筆記本</string>
|
||||
<string name="web_reader_loading_container_bottom_sheet_open_link">開啟連結</string>
|
||||
|
||||
<!-- OpenLinkView -->
|
||||
<string name="open_link_view_action_open_in_browser">在瀏覽器中開啟</string>
|
||||
<string name="open_link_view_action_save_to_omnivore">儲存到 Omnivore</string>
|
||||
<string name="open_link_view_action_copy_link">複製連結</string>
|
||||
<string name="open_link_view_action_cancel">取消</string>
|
||||
|
||||
<!-- WebReaderViewModel -->
|
||||
<string name="web_reader_view_model_save_link_success">連結已儲存</string>
|
||||
<string name="web_reader_view_model_save_link_error">儲存連結時出錯</string>
|
||||
<string name="web_reader_view_model_copy_link_success">連結已複製</string>
|
||||
|
||||
<!-- SaveContent -->
|
||||
<string name="save_content_msg">儲存中</string>
|
||||
<string name="save_content_action_read_now">現在閱讀</string>
|
||||
<string name="save_content_action_read_later">稍後閱讀</string>
|
||||
<string name="save_content_action_dismiss">忽略</string>
|
||||
|
||||
<!-- SaveViewModel -->
|
||||
<string name="save_view_model_msg">正在儲存到 Omnivore...</string>
|
||||
<string name="save_view_model_error_not_logged_in">您尚未登入。請在儲存前登入。</string>
|
||||
<string name="save_view_model_page_saved_success">頁面已儲存</string>
|
||||
<string name="save_view_model_page_saved_error">儲存您的頁面時出錯</string>
|
||||
|
||||
<!-- SavedItemContextMenu -->
|
||||
<string name="saved_item_context_menu_action_edit_labels">編輯標籤</string>
|
||||
<string name="saved_item_context_menu_action_archive">封存</string>
|
||||
<string name="saved_item_context_menu_action_unarchive">取消封存</string>
|
||||
<string name="saved_item_context_menu_action_share_original">分享原始內容</string>
|
||||
<string name="saved_item_context_menu_action_remove_item">移除項目</string>
|
||||
|
||||
<!-- LogoutDialog -->
|
||||
<string name="logout_dialog_title">登出</string>
|
||||
<string name="logout_dialog_confirm_msg">您確定要登出嗎?</string>
|
||||
<string name="logout_dialog_action_confirm">確認</string>
|
||||
<string name="logout_dialog_action_cancel">取消</string>
|
||||
|
||||
<!-- ManageAccount -->
|
||||
<string name="manage_account_title">管理帳戶</string>
|
||||
<string name="manage_account_action_reset_data_cache">重設資料快取</string>
|
||||
|
||||
<!-- PolicyWebView -->
|
||||
<string name="policy_webview_title">設定</string>
|
||||
|
||||
<!-- SettingsView -->
|
||||
<string name="settings_view_title">設定</string>
|
||||
<string name="settings_view_setting_row_documentation">文件</string>
|
||||
<string name="settings_view_setting_row_feedback">回饋</string>
|
||||
<string name="settings_view_setting_row_privacy_policy">隱私政策</string>
|
||||
<string name="settings_view_setting_row_terms_and_conditions">條款和條件</string>
|
||||
<string name="settings_view_setting_row_manage_account">管理帳戶</string>
|
||||
<string name="settings_view_setting_row_logout">登出</string>
|
||||
</resources>
|
||||
|
|
@ -103,6 +103,7 @@
|
|||
<string name="label_selection_sheet_action_search">Search</string>
|
||||
<string name="label_selection_sheet_action_save">Save</string>
|
||||
<string name="label_selection_sheet_text_create">Create a new label named \"%1$s\"</string>
|
||||
<string name="label_selection_sheet_label_too_long_error_msg">The name provided is too long (must be less or equal than %1$d characters)</string>
|
||||
|
||||
<!-- LibraryFilterBar -->
|
||||
<string name="library_filter_bar_label_labels">Labels</string>
|
||||
|
|
|
|||
|
|
@ -1400,7 +1400,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
|
|
@ -1435,7 +1435,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1490,7 +1490,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1831,7 +1831,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.34.0;
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -96,7 +96,18 @@ final class PDFViewerViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
return try await dataService.loadPDFData(slug: pdfItem.slug, pageURLString: pdfItem.originalArticleURL)
|
||||
if let result = try? await dataService.loadPDFData(slug: pdfItem.slug, downloadURL: pdfItem.downloadURL) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Downloading failed, try to get the article again, and then download
|
||||
if let content = try? await dataService.loadArticleContentWithRetries(itemID: pdfItem.itemID, username: "me") {
|
||||
// refetched the content, now try one more time then throw
|
||||
if let result = try await dataService.loadPDFData(slug: pdfItem.slug, downloadURL: content.downloadURL) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
print("error downloading PDF", error)
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import Views
|
|||
private func trackReadEvent() {
|
||||
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
|
||||
guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return }
|
||||
guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.downloadURL else { return }
|
||||
|
||||
EventTracker.track(
|
||||
.linkRead(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="21G531" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="22G74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="annotation" optional="YES" attributeType="String"/>
|
||||
<attribute name="color" optional="YES" attributeType="String"/>
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="createdId" optional="YES" attributeType="String"/>
|
||||
<attribute name="descriptionText" optional="YES" attributeType="String"/>
|
||||
<attribute name="downloadURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="htmlContent" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="imageURLString" optional="YES" attributeType="String"/>
|
||||
|
|
|
|||
|
|
@ -16,19 +16,22 @@ public struct ArticleContent {
|
|||
public let highlightsJSONString: String
|
||||
public let contentStatus: ArticleContentStatus
|
||||
public let objectID: NSManagedObjectID?
|
||||
public let downloadURL: String
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
htmlContent: String,
|
||||
highlightsJSONString: String,
|
||||
contentStatus: ArticleContentStatus,
|
||||
objectID: NSManagedObjectID?
|
||||
objectID: NSManagedObjectID?,
|
||||
downloadURL: String
|
||||
) {
|
||||
self.title = title
|
||||
self.htmlContent = htmlContent
|
||||
self.highlightsJSONString = highlightsJSONString
|
||||
self.contentStatus = contentStatus
|
||||
self.objectID = objectID
|
||||
self.downloadURL = downloadURL
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,12 +56,14 @@ public struct JSONArticle: Decodable {
|
|||
public let isArchived: Bool
|
||||
public let language: String?
|
||||
public let wordsCount: Int?
|
||||
public let downloadURL: String
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
var unwrappedID: String { id ?? "" }
|
||||
var unwrappedSlug: String { slug ?? "" }
|
||||
var unwrappedTitle: String { title ?? "" }
|
||||
var unwrappedDownloadURLString: String { downloadURL ?? "" }
|
||||
var unwrappedPageURLString: String { pageURLString ?? "" }
|
||||
var unwrappedSavedAt: Date { savedAt ?? Date() }
|
||||
var unwrappedCreatedAt: Date { createdAt ?? Date() }
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ public struct PDFItem {
|
|||
public let readingProgressAnchor: Int
|
||||
public let isArchived: Bool
|
||||
public let isRead: Bool
|
||||
public let originalArticleURL: String
|
||||
public let downloadURL: String
|
||||
public let highlights: [Highlight]
|
||||
|
||||
public static func make(item: LinkedItem) -> PDFItem? {
|
||||
|
|
@ -32,7 +32,7 @@ public struct PDFItem {
|
|||
readingProgressAnchor: Int(item.readingProgressAnchor),
|
||||
isArchived: item.isArchived,
|
||||
isRead: item.isRead,
|
||||
originalArticleURL: item.unwrappedPageURLString,
|
||||
downloadURL: item.unwrappedPageURLString,
|
||||
highlights: item.highlights.asArray(of: Highlight.self)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ extension DataService {
|
|||
htmlContent: fetchResult.htmlContent,
|
||||
highlightsJSONString: fetchResult.highlights.asJSONString,
|
||||
contentStatus: fetchResult.item.isPDF ? .succeeded : fetchResult.item.state,
|
||||
objectID: objectID
|
||||
objectID: objectID,
|
||||
downloadURL: fetchResult.item.downloadURL
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +92,8 @@ extension DataService {
|
|||
.filter { $0.serverSyncStatus != ServerSyncStatus.needsDeletion.rawValue }
|
||||
.map { InternalHighlight.make(from: $0) }.asJSONString,
|
||||
contentStatus: .succeeded,
|
||||
objectID: linkedItem.objectID
|
||||
objectID: linkedItem.objectID,
|
||||
downloadURL: linkedItem.downloadURL ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +168,7 @@ extension DataService {
|
|||
}
|
||||
|
||||
if articleProps.item.isPDF, needsPDFDownload {
|
||||
_ = try await loadPDFData(slug: articleProps.item.slug, pageURLString: articleProps.item.pageURLString)
|
||||
_ = try await loadPDFData(slug: articleProps.item.slug, downloadURL: articleProps.item.downloadURL)
|
||||
}
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
|
|
@ -231,7 +233,7 @@ extension DataService {
|
|||
_ = try await saveURL(id: id, url: url)
|
||||
}
|
||||
} catch {
|
||||
// We don't propogate these errors, we just let it pass through so
|
||||
// We don't propagate these errors, we just let it pass through so
|
||||
// the user can attempt to fetch content again.
|
||||
print("Error syncUnsyncedArticleContent", error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import Models
|
|||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func loadPDFData(slug: String, pageURLString: String) async throws -> URL? {
|
||||
guard let url = URL(string: pageURLString) else {
|
||||
func loadPDFData(slug: String, downloadURL: String) async throws -> URL? {
|
||||
guard let url = URL(string: downloadURL) else {
|
||||
throw BasicError.message(messageText: "No PDF URL found")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ extension DataService {
|
|||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
@ -316,6 +317,7 @@ private let searchItemSelection = Selection.SearchItem {
|
|||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ struct InternalLinkedItem {
|
|||
let originalHtml: String?
|
||||
let language: String?
|
||||
let wordsCount: Int?
|
||||
let downloadURL: String
|
||||
let recommendations: [InternalRecommendation]
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.originalHtml = originalHtml
|
||||
linkedItem.language = language
|
||||
linkedItem.wordsCount = Int64(wordsCount ?? 0)
|
||||
linkedItem.downloadURL = downloadURL
|
||||
|
||||
// Remove existing labels in case a label had been deleted
|
||||
if let existingLabels = linkedItem.labels {
|
||||
|
|
@ -146,6 +148,7 @@ extension JSONArticle {
|
|||
originalHtml: nil,
|
||||
language: language,
|
||||
wordsCount: wordsCount,
|
||||
downloadURL: downloadURL,
|
||||
recommendations: [],
|
||||
labels: []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,35 @@ import Models
|
|||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
enum FlairLabels: String {
|
||||
case pinned
|
||||
case favorite
|
||||
case recommended
|
||||
case newsletter
|
||||
case rss
|
||||
case feed
|
||||
|
||||
var icon: Image {
|
||||
switch self {
|
||||
case .pinned: return Image.flairPinned
|
||||
case .favorite: return Image.flairFavorite
|
||||
case .recommended: return Image.flairRecommended
|
||||
case .newsletter: return Image.flairNewsletter
|
||||
case .feed, .rss: return Image.flairFeed
|
||||
}
|
||||
}
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .feed, .rss: return 0
|
||||
case .favorite: return 1
|
||||
case .newsletter: return 2
|
||||
case .recommended: return 3
|
||||
case .pinned: return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func draggableItem(item: LinkedItem) -> some View {
|
||||
#if os(iOS)
|
||||
|
|
@ -124,8 +153,30 @@ public struct LibraryItemCard: View {
|
|||
return ""
|
||||
}
|
||||
|
||||
var flairLabels: [FlairLabels] {
|
||||
item.sortedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return FlairLabels(rawValue: name.lowercased())
|
||||
}
|
||||
return nil
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
|
||||
var nonFlairLabels: [LinkedItemLabel] {
|
||||
item.sortedLabels.filter { label in
|
||||
if let name = label.name, FlairLabels(rawValue: name.lowercased()) != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var readInfo: some View {
|
||||
AnyView(HStack {
|
||||
HStack(alignment: .center, spacing: 5.0) {
|
||||
ForEach(flairLabels, id: \.self) {
|
||||
$0.icon
|
||||
}
|
||||
|
||||
let fgcolor = Color.isDarkMode ? Color.themeDarkWhiteGray : Color.themeMiddleGray
|
||||
Text("\(estimatedReadingTime)")
|
||||
.font(.caption2).fontWeight(.medium)
|
||||
|
|
@ -146,7 +197,7 @@ public struct LibraryItemCard: View {
|
|||
.font(.caption2).fontWeight(.medium)
|
||||
.foregroundColor(fgcolor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
var imageBox: some View {
|
||||
|
|
@ -227,6 +278,6 @@ public struct LibraryItemCard: View {
|
|||
}
|
||||
|
||||
var labels: some View {
|
||||
LabelsFlowLayout(labels: item.sortedLabels)
|
||||
LabelsFlowLayout(labels: nonFlairLabels)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,11 @@ public extension Image {
|
|||
static var unarchive: Image { Image("unarchive", bundle: .module) }
|
||||
static var remove: Image { Image("remove", bundle: .module) }
|
||||
static var label: Image { Image("label", bundle: .module) }
|
||||
|
||||
static var flairFeed: Image { Image("flair-feed", bundle: .module) }
|
||||
static var flairFavorite: Image { Image("flair-favorite", bundle: .module) }
|
||||
|
||||
static var flairNewsletter: Image { Image("flair-newsletter", bundle: .module) }
|
||||
static var flairPinned: Image { Image("flair-pinned", bundle: .module) }
|
||||
static var flairRecommended: Image { Image("flair-recommended", bundle: .module) }
|
||||
}
|
||||
|
|
|
|||
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-favorite.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame-2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-2 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-2 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-favorite.imageset/Frame-2 1.png
vendored
Normal file
|
After Width: | Height: | Size: 514 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-favorite.imageset/Frame-2 2.png
vendored
Normal file
|
After Width: | Height: | Size: 739 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-favorite.imageset/Frame-2.png
vendored
Normal file
|
After Width: | Height: | Size: 339 B |
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-feed.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-feed.imageset/Frame 1.png
vendored
Normal file
|
After Width: | Height: | Size: 491 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-feed.imageset/Frame 2.png
vendored
Normal file
|
After Width: | Height: | Size: 513 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-feed.imageset/Frame.png
vendored
Normal file
|
After Width: | Height: | Size: 397 B |
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-newsletter.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame-1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-1 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-1 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-newsletter.imageset/Frame-1 1.png
vendored
Normal file
|
After Width: | Height: | Size: 507 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-newsletter.imageset/Frame-1 2.png
vendored
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-newsletter.imageset/Frame-1.png
vendored
Normal file
|
After Width: | Height: | Size: 322 B |
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-pinned.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame-3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-3 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-3 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-pinned.imageset/Frame-3 1.png
vendored
Normal file
|
After Width: | Height: | Size: 473 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-pinned.imageset/Frame-3 2.png
vendored
Normal file
|
After Width: | Height: | Size: 654 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-pinned.imageset/Frame-3.png
vendored
Normal file
|
After Width: | Height: | Size: 351 B |
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-recommended.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Frame-4.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-4 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Frame-4 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-recommended.imageset/Frame-4 1.png
vendored
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-recommended.imageset/Frame-4 2.png
vendored
Normal file
|
After Width: | Height: | Size: 621 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/flair-recommended.imageset/Frame-4.png
vendored
Normal file
|
After Width: | Height: | Size: 328 B |
|
|
@ -0,0 +1,206 @@
|
|||
// Unit test Entry -- Do not remove this or add entries before this one.
|
||||
// This allows us to check for syntax errors in this file with a unit test
|
||||
"unitTestLeadingEntry" = "僅供測試用途。";
|
||||
|
||||
// share extension
|
||||
"saveArticleSavedState" = "已儲存至 Omnivore";
|
||||
"saveArticleProcessingState" = "正在儲存至 Omnivore";
|
||||
"extensionAppUnauthorized" = "請先從應用程式登入 Omnivore,然後再儲存您的第一個連結。";
|
||||
"saveToOmnivore" = "儲存至 Omnivore";
|
||||
|
||||
// audio player
|
||||
"audioPlayerReplay" = "重新播放";
|
||||
|
||||
// Highlights List Card
|
||||
"highlightCardHighlightByOther" = "由其他人標註";
|
||||
"highlightCardNoHighlightsOnPage" = "您尚未在此頁面新增任何標註。";
|
||||
|
||||
// Labels View
|
||||
"labelsViewAssignNameColor" = "指定名稱和顏色。";
|
||||
"createLabelMessage" = "建立新標籤";
|
||||
"labelsPurposeDescription" = "使用標籤來建立連結的精選集。";
|
||||
"labelNamePlaceholder" = "標籤名稱";
|
||||
|
||||
// Manage Account View
|
||||
"manageAccountDelete" = "刪除帳戶";
|
||||
"manageAccountResetCache" = "重設資料快取";
|
||||
"manageAccountConfirmDeleteMessage" = "您確定要刪除您的帳戶嗎?此動作無法撤銷。";
|
||||
|
||||
// Newsletter Emails View
|
||||
"newsletterEmailsExisting" = "現有電子郵件(點選以複製)";
|
||||
"createNewEmailMessage" = "建立新的電子郵件地址";
|
||||
"newslettersDescription" = "將 PDF 加入您的圖書館,或使用 Omnivore 電子郵件地址訂閱電子報。";
|
||||
"noCurrentSubscriptionsMessage" = "您目前沒有任何訂閱。";
|
||||
|
||||
// Profile View
|
||||
"profileConfirmLogoutMessage" = "您確定要登出嗎?";
|
||||
|
||||
// Devices View
|
||||
"devicesTokensTitle" = "已註冊的裝置權杖(滑動以移除)";
|
||||
"devicesCreated" = "已建立:";
|
||||
|
||||
// Push Notification Settings
|
||||
"notificationsEnabled" = "已啟用通知";
|
||||
"notificationsExplainer" = "啟用推播通知將授予 Omnivore 裝置傳送通知的權限,\n但您可以控制哪些通知被傳送。";
|
||||
"notificationsTriggerExplainer" = "推播通知是由您的\n[帳戶規則](https://omnivore.app/settings/rules) 觸發,您可以在線上編輯。";
|
||||
"notificationsEnable" = "啟用推播通知?";
|
||||
"notificationsGeneralExplainer" = "當電子報連結抵達您的收件匣時獲得通知。或從我們的分享擴充功能接收提醒。";
|
||||
"notificationsOptionDeny" = "不,謝謝";
|
||||
"notificationsOptionEnable" = "是的,請繼續";
|
||||
|
||||
// Community Modal
|
||||
"communityHeadline" = "協助建立 Omnivore 社群";
|
||||
"communityAppstoreReview" = "在 AppStore 上評價";
|
||||
"communityTweet" = "關於 Omnivore 的推文";
|
||||
"communityFollowTwitter" = "在 Twitter 上追蹤我們";
|
||||
"communityJoinDiscord" = "在 Discord 上加入我們";
|
||||
"communityStarGithub" = "在 GitHub 上給我們星星";
|
||||
|
||||
// Clubs View
|
||||
"clubsLearnTitle" = "了解更多關於俱樂部的資訊";
|
||||
"clubsName" = "俱樂部名稱";
|
||||
"clubsCreate" = "建立新俱樂部";
|
||||
"clubsYours" = "您的俱樂部";
|
||||
"clubsNotAMemberMessage" = "您並非任何俱樂部的成員。\n建立新俱樂部並將邀請連結傳送給您的朋友以開始。\n\n在測試版期間,您最多可以建立三個俱樂部,每個俱樂部\n可以有最多十二個使用者。";
|
||||
"clubsErrorCopying" = "複製邀請 URL 時發生錯誤";
|
||||
"clubsAdminDenyViewing" = "此俱樂部的管理員不允許檢視所有成員。";
|
||||
"clubsNoMembers" = "此俱樂部沒有任何成員。透過傳送\n邀請連結將使用者加入您的俱樂部。";
|
||||
"clubsLeave" = "離開俱樂部";
|
||||
"clubsLeaveConfirm" = "您確定要離開此俱樂部嗎?不會刪除任何資料,但您將停止接收來自俱樂部的推薦。";
|
||||
"clubsNoneJoined" = "您沒有可以發布的俱樂部。\n加入俱樂部或建立自己的俱樂部以開始推薦文章。";
|
||||
|
||||
// Subscriptions
|
||||
"subscriptionsErrorRetrieving" = "抱歉,我們無法取得您的訂閱。";
|
||||
"subscriptionsNone" = "您目前沒有任何訂閱。";
|
||||
//"subscriptions.error.retrieving" = "Last received: \(updatedDate.formatted())"; // unused for now
|
||||
|
||||
// Text to Speech
|
||||
"texttospeechLanguageDefault" = "預設語言";
|
||||
"texttospeechSettingsAudio" = "音訊設定";
|
||||
"texttospeechSettingsEnablePrefetch" = "啟用音訊預取";
|
||||
"texttospeechBetaSignupInProcess" = "正在報名測試版";
|
||||
"texttospeechBetaRealisticVoiceLimit" = "您正在參加超逼真語音測試版。在測試版期間,您每天可以聆聽 10,000 個單詞的音訊。";
|
||||
"texttospeechBetaRequestReceived" = "我們已收到您參加超逼真語音示範的請求。當有空位時,我們將透過電子郵件通知您。";
|
||||
"texttospeechBetaWaitlist" = "超逼真語音目前處於有限的測試版,並僅提供英語。啟用此功能將會將您加入測試版佇列。";
|
||||
|
||||
// Sign in/up
|
||||
"registrationNoAccount" = "還沒有帳戶?";
|
||||
"registrationForgotPassword" = "忘記密碼?";
|
||||
"registrationStatusCheck" = "檢查狀態";
|
||||
"registrationUseDifferentEmail" = "使用不同的電子郵件?";
|
||||
"registrationFullName" = "全名";
|
||||
"registrationUsername" = "使用者名稱";
|
||||
"registrationAlreadyHaveAccount" = "已經有帳戶?";
|
||||
"registrationBio" = "個人簡介(選填)";
|
||||
"registrationWelcome" = "歡迎來到 Omnivore!";
|
||||
"registrationUsernameAssignedPrefix" = "您的使用者名稱是:";
|
||||
"registrationChangeUsername" = "更改使用者名稱";
|
||||
"registrationEdit" = "編輯";
|
||||
"googleAuthButton" = "使用 Google 繼續";
|
||||
"registrationViewSignUpHeadline" = "註冊";
|
||||
"loginErrorInvalidCreds" = "提供的登入憑證無效。";
|
||||
|
||||
// Recommendation
|
||||
"recommendationToPrefix" = "至:";
|
||||
"recommendationAddNote" = "新增註解(選填)";
|
||||
//"recommendationToPrefix" = "Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : """; // unused for now
|
||||
"recommendationError" = "推薦此頁面時發生錯誤";
|
||||
|
||||
// Web Reader
|
||||
"readerCopyLink" = "複製連結";
|
||||
"readerSave" = "儲存至 Omnivore";
|
||||
"readerError" = "發生錯誤";
|
||||
|
||||
// Debug Menu
|
||||
"menuDebugTitle" = "除錯選單";
|
||||
"menuDebugApiEnv" = "API 環境:";
|
||||
|
||||
// Navigation
|
||||
"navigationSelectLink" = "從您的圖書館選擇一個連結";
|
||||
"navigationSelectSidebarToggle" = "切換側邊欄";
|
||||
|
||||
// Welcome View
|
||||
"welcomeTitle" = "專為認真讀者設計的稍後閱讀工具。";
|
||||
"welcomeLearnMore" = "了解更多";
|
||||
"welcomeSignupAgreement" = "註冊即表示您同意 Omnivore 的\n";
|
||||
"welcomeTitleTermsOfService" = "服務條款";
|
||||
"welcomeTitleAndJoiner" = " 和 ";
|
||||
"welcomeTitleEmailContinue" = "使用電子郵件繼續";
|
||||
|
||||
// Keyboard Commands
|
||||
"keyboardCommandDecreaseFont" = "減小字型大小";
|
||||
"keyboardCommandIncreaseFont" = "增大字型大小";
|
||||
"keyboardCommandDecreaseMargin" = "減小邊距";
|
||||
"keyboardCommandIncreaseMargin" = "增大邊距";
|
||||
"keyboardCommandDecreaseLineSpacing" = "減小行距";
|
||||
"keyboardCommandIncreaseLineSpacing" = "增大行距";
|
||||
|
||||
// Library
|
||||
//"library.by.author.suffix" = "by \(author)" // unused
|
||||
//"Recommended by \(byStr) in \(inStr)" // unused
|
||||
|
||||
// Generic
|
||||
"genericSnooze" = "稍後提醒";
|
||||
"genericClose" = "關閉";
|
||||
"genericCreate" = "建立";
|
||||
"genericConfirm" = "確認";
|
||||
"genericProfile" = "個人資料";
|
||||
"genericNext" = "下一步";
|
||||
"genericName" = "名稱";
|
||||
"genericOk" = "確定";
|
||||
"genericRetry" = "重試";
|
||||
"genericEmail" = "電子郵件";
|
||||
"genericPassword" = "密碼";
|
||||
"genericSubmit" = "送出";
|
||||
"genericContinue" = "繼續";
|
||||
"genericSend" = "傳送";
|
||||
"genericOptions" = "選項";
|
||||
"genericOpen" = "開啟";
|
||||
"genericChangeApply" = "套用變更";
|
||||
"genericTitle" = "標題";
|
||||
"genericAuthor" = "作者";
|
||||
"genericDescription" = "描述";
|
||||
"genericSave" = "儲存";
|
||||
"genericLoading" = "載入中...";
|
||||
"genericFontFamily" = "字型家族";
|
||||
"genericHighContrastText" = "高對比文字";
|
||||
"enableHighlightOnReleaseText" = "自動標註模式";
|
||||
"enableJustifyText" = "對齊文字";
|
||||
"genericFont" = "字型";
|
||||
"genericHighlight" = "標註";
|
||||
"labelsGeneric" = "標籤";
|
||||
"emailsGeneric" = "電子郵件";
|
||||
"subscriptionsGeneric" = "訂閱";
|
||||
"textToSpeechGeneric" = "文字轉語音";
|
||||
"privacyPolicyGeneric" = "隱私權政策";
|
||||
"termsAndConditionsGeneric" = "使用條款";
|
||||
"feedbackGeneric" = "意見回饋";
|
||||
"manageAccountGeneric" = "管理帳戶";
|
||||
"logoutGeneric" = "登出";
|
||||
"doneGeneric" = "完成";
|
||||
"cancelGeneric" = "取消";
|
||||
"exportGeneric" = "匯出";
|
||||
"inboxGeneric" = "收件匣";
|
||||
"readLaterGeneric" = "稍後閱讀";
|
||||
"newslettersGeneric" = "電子報";
|
||||
"allGeneric" = "全部";
|
||||
"archivedGeneric" = "已封存";
|
||||
"highlightedGeneric" = "已標註";
|
||||
"filesGeneric" = "檔案";
|
||||
"newestGeneric" = "最新";
|
||||
"oldestGeneric" = "最舊";
|
||||
"recentlyReadGeneric" = "最近閱讀";
|
||||
"recentlyPublishedGeneric" = "最近發布";
|
||||
"clubsGeneric" = "俱樂部";
|
||||
"filterGeneric" = "篩選器";
|
||||
"errorGeneric" = "發生錯誤,請再試一次。";
|
||||
"pushNotificationsGeneric" = "推播通知";
|
||||
"dismissButton" = "關閉";
|
||||
"errorNetwork" = "我們無法連線到網路。";
|
||||
"documentationGeneric" = "文件";
|
||||
|
||||
// TODO: search navigationTitle, toggle, section, button, Label, title: ", CreateProfileViewModel, TextField, .keyboardShortcut
|
||||
|
||||
// Unit test Entry -- Do not remove this or add entries after this one.
|
||||
// This allows us to check for syntax errors in this file with a unit test
|
||||
"unitTestTrailingEntry" = "僅供測試用途。";
|
||||
|
|
@ -9,6 +9,6 @@
|
|||
"dependencies": {
|
||||
"express": "^4.18.1",
|
||||
"express-graphql": "^0.12.0",
|
||||
"graphql": "^16.4.0"
|
||||
"graphql": "^16.8.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -194,10 +194,10 @@ get-intrinsic@^1.0.2:
|
|||
has "^1.0.3"
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
graphql@^16.4.0:
|
||||
version "16.6.0"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb"
|
||||
integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==
|
||||
graphql@^16.8.1:
|
||||
version "16.8.1"
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
||||
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
||||
|
||||
has-symbols@^1.0.3:
|
||||
version "1.0.3"
|
||||
|
|
|
|||
178
docs/guides/getting-started/ar/getting-started-guide.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
|
||||
# الشروع في إستعمال أمنيفور
|
||||
|
||||
تطبيقة أومنيفور هي تطبيقة للقراءة لاحقاً تتيح لك من تسجيل وتنظيم كل ما تقرأه على الإنترنت
|
||||
|
||||
هذا الدليل سيوضح لك كيفية استخدام الوظائف الأساسية والميزات المتقدمة في أومنيفور، مقسمة إلى أربعة نشاطات رئيسية
|
||||
|
||||
- حفض
|
||||
- قراءة
|
||||
- تنظيم
|
||||
- اندماجات
|
||||
|
||||
|
||||
المكتبة هي مركز تجربتك في أومنيفور، حيث يمكنك الوصول بسرعة إلى أي روابط قمت بحفظها.
|
||||
الروابط المحفوظة تبقى في مكتبتك إلى الأبد ما لم تقم بحذفها.
|
||||
|
||||
## حفظ
|
||||
|
||||
هناك خمس طرق رئيسية لحفظ الروابط للصفحات أو المقالات التي ترغب في قراءتها لاحقًا:
|
||||
|
||||
1 - الحفظ من مكتبة أومنيفور الخاصة بك
|
||||
2 - الحفظ من متصفح
|
||||
3 - الحفظ من هاتف أو جهاز لوحي (آي أو إس أو أندرويد)
|
||||
4 - الاشتراك في النشرات الإخبارية عبر البريد الإلكتروني
|
||||
5 - حفظ ملفات بي دي إف من جهاز ماك
|
||||
|
||||
|
||||
### الحفض من مكتبة أمنيفور
|
||||
|
||||
1 - في الزاوية العليا اليمنى من مكتبتك، انقر على زر إضافة رابط.
|
||||
2- أدخل العنوان الذي ترغب في حفظه وانقر على إضافة رابط.
|
||||
3 - سيظهر الرابط في مكتبتك في المرة التالية التي تقوم فيها بتحديثها.
|
||||
|
||||
|
||||
### الحفظ من المتصفح
|
||||
|
||||
1 - قم بتحميل وتثبيت إضافة أومنيفور لمتصفحك:
|
||||
|
||||
- [كروم ](https://omnivore.app/install/chrome)
|
||||
- [ايدج](https://omnivore.app/install/edge)
|
||||
- [فاير فوكس](https://omnivore.app/install/firefox)
|
||||
- [سفري](https://omnivore.app/install/safari)
|
||||
|
||||
2 -انتقل إلى الصفحة التي ترغب في حفظها وانقر على زر أومنيفور في شريط أدوات المتصفح أو قائمة الإضافات.
|
||||
3 - أو بدلاً عن ذلك يمكنك النقر بزر فأرة الحاسوب الأيمن على أي رابط تشعبي أو اختر **حفظ إلى أمنيفور** من القائمة.
|
||||
4 - سيظهر الرابط في مكتبتك في المرة التالية التي تقوم فيها بتحديثها.
|
||||
|
||||
|
||||
### الحفظ من الهاتف أو الجهاز اللوحي
|
||||
|
||||
أفضل طريقة لحفظ الروابط من جهازك المحمول هي عبر تطبيق أمنيفور. يمكنك تحميل التطبيق هنا:
|
||||
|
||||
|
||||
أي أو إس (ايباد أو أيفون) (https://omnivore.app/install/ios)
|
||||
أندرويد
|
||||
|
||||
|
||||
بعد تثبيت تطبيق الهاتف المحمول:
|
||||
|
||||
1 - في المتصفح الخاص بك، انتقل إلى الصفحة التي ترغب في حفظها واضغط على زر شارك
|
||||
2 - اضغط على أيقونة أومنيفور في قائمة الشارك
|
||||
3 - سيظهر الرابط في مكتبتك عندما تقوم بتحديثها في المرة القادمة
|
||||
|
||||
|
||||
### الاشتراكات في النشرة الإخبارية عبر البريد الإلكتروني
|
||||
|
||||
1 - على موقع أو تطبيق أومنيفور، اضغط على صورتك أو الحروف الأولى أو الصورة الرمزية في الزاوية العليا اليمنى للوصول إلى قائمة الملف الشخصي.
|
||||
اختر البريد الإلكتروني من القائمة.
|
||||
2 - اضغط على إنشاء عنوان بريد إلكتروني جديد لإضافة عنوان بريد إلكتروني جديد (مثل: username-123_abc@inbox.omnivore.app) إلى القائمة.
|
||||
3 - انقر على أيقونة النسخ بجوار عنوان البريد الإلكتروني
|
||||
4 - انتقل إلى صفحة التسجيل للنشرةgetting-started-guide.md الإخبارية التي ترغب في الاشتراك فيها
|
||||
5 - الصق عنوان البريد الإلكتروني أومنيفور في نموذج التسجيل
|
||||
6 - سيتم توصيل النشرات الإخبارية الجديدة تلقائيًا إلى صندوق الوارد أومنيفور الخاص بك
|
||||
|
||||
|
||||
### حفظ ملفات البي دي إيف من جهاز الماك
|
||||
|
||||
1 - قم بتثبيت تطبيقة الماك(https://omnivore.app/install/mac)
|
||||
2 - قم بتحديد موقع ملف البي دي إف الذي ترغب في حفظه ثم انقر على إسم الملف بالزر الايمن من فأرة الحاسوب
|
||||
3 - اختر شارك من القائمة واختر أومنيفور
|
||||
4 - سيظهر الرابط في مكتبتك عندما تقوم بتحديثها في المرة القادمة
|
||||
|
||||
|
||||
## القراءة
|
||||
|
||||
انقر على أي رابط محفوظ في مكتبتك لدخول وضع القراءة
|
||||
تقوم أومنيفور بتنسيق الصفحات لتسهيل القراءة والتظليل، وإزالة الإعلانات والتلهية لقراءة خالية من التشتيت. كما يجعل وضع التركيز على نص المقالات أصغر حجمًا وأسرع في التحميل.
|
||||
أثناء القراءة، يمكنك:
|
||||
- <span style="text-decoration:underline;">تغيير التنسيق</span>
|
||||
- <span style="text-decoration:underline;">تسليط الضوء على النص</span>
|
||||
- <span style="text-decoration:underline;">إضافة ملاحظات</span>
|
||||
- <span style="text-decoration:underline;">عرض كافة النقاط البارزة والملاحظات المحفوظة</span>
|
||||
- <span style="text-decoration:underline;">تتبع تقدم القراءة</span>
|
||||
|
||||
### تغيير التنسيق
|
||||
|
||||
1 - **_المظهر_**: اضغط على صورتك، أو الحروف الأولى، أو الصورة الرمزية في ا
|
||||
لزاوية العليا اليمنى للوصول إلى قائمة الملف الشخصي. اختر الصورة المصغرة البيضاء أو السوداء لاختيار مظهر فاتح أو مظهر داكن
|
||||
|
||||
2 - **_ تنسيق النص_** : اضغط على ايقونة Aa لتعديل حجم النص، الخط،الهوامش وتباعد الأسطر
|
||||
|
||||
|
||||
### تسليط الضوء على النص
|
||||
|
||||
1 - حدد النص الذي ترغب في تسليط الضوء عليه
|
||||
2 - اضغط على زر تسليط الضوء
|
||||
3 - سيظهر النص مظللاً عندما تعاود قراءة المقال في المرة القادمة
|
||||
|
||||
|
||||
### إضافة ملاحظات
|
||||
|
||||
1 - قم بتظليل قسم من النص حيث ترغب في إضافة ملاحظة
|
||||
2 - اضغط على زر ملاحظة، اكتب ملاحظتك، ثم اضغط على حفظ
|
||||
3 - ستظهر أيقونة الملاحظة عندما تعيد قراءة هذا المقال في المرة القادمة
|
||||
|
||||
### عرض جميع النصوص المظللة والملاحظات المحفوظة
|
||||
1 - اضغط على أيقونة تظليل/ملاحظة لرؤية قائمة بجميع النصوص المظللة والملاحظات التي أضفتها إلى هذه الصفحة.
|
||||
2 - لإزالة ملاحظة أو تظليل، اخترها من القائمة ثم اضغط على أيقونة السلة
|
||||
|
||||
|
||||
### تتبع تقدم القراءة
|
||||
|
||||
يقوم أومنيفور بتتبع تقدم القراءة الخاص بك تلقائيًا
|
||||
عبر أجهزتك المختلفة بحيث يمكنك استئناف القراءة من حيث توقفت بسهولة. سيظهر شريط التقدم في أعلى كل رابط في مكتبتك بعد بدء القراءة.
|
||||
|
||||
|
||||
## تنظيم
|
||||
|
||||
بشكل تلقائي، يعرض صندوق الوارد في المكتبة جميع الروابط التي قد قمت بحفظها. لإدارة قائمتك والحفاظ على تنظيم قراءتك، يوفر أمنيفور الإجراءات التالية:
|
||||
|
||||
- <span style="text-decoration:underline;">الأرشفة</span>
|
||||
- <span style="text-decoration:underline;">التسميات</span>
|
||||
- <span style="text-decoration:underline;">البحث</span>
|
||||
- <span style="text-decoration:underline;">الفلاتر</span>
|
||||
|
||||
### الأرشفة
|
||||
|
||||
1 - اضغط على أيقونة القائمة بجوار الرابط الذي ترغب في أرشفته (على تطبيق الجوال، اضغط مع الاستمرار على الرابط لفتح القائمة)
|
||||
2 - اختر **أرشفة**
|
||||
3 - الرابط سيختفي من المكتبة التلقائية لكن سيضهر إذا اخترت فلتر الارشفة(إنظر إلى <span style="text-decoration:underline;">الفلاتر </span> )
|
||||
|
||||
### التسميات
|
||||
|
||||
1 - اضغط على أيقونة القائمة بجوار أي رابط واختر تعيين التسميات.
|
||||
2 - اختر تسمية موجودة من القائمة أو اضغط على تعديل التسميات لإنشاء تسمية جديدة
|
||||
3 - ستظهر التسمية بجوار الرابط في مكتبتك. اضغط عليها لعرض جميع الروابط التي تحمل نفس التسمية
|
||||
4 - على الهاتف المحمول فقط: اضغط على التسميات لرؤية قائمة جميع التسميات التي استخدمتها . اضغط على واحدة لعرض جميع الروابط التي تحمل نفس التسمية
|
||||
5 - ملاحظة : أمنيفور سيقوم بتعيين بعض التسميات اتوماتيكيا مثل "Newsletters"
|
||||
|
||||
### البحث
|
||||
|
||||
1 - للبحث في جميع الروابط التي قمت بحفظها، أدخل كلمة أو عبارة في شريط البحث
|
||||
2 - يمكنك دمج الكلمات الرئيسية مع التسميات والفلاتر لتركيز بحثك بشكل أكبر[تعرف اكثر على البحث المتقدم ](https://docs.omnivore.app/using/searcl)
|
||||
|
||||
|
||||
### الفيلتر
|
||||
|
||||
1 - استخدم قائمة **فلتر** لتحسين عرض المكتبة الخاصة بك (قد تكون بعض فلتر مرئية بشكل طبيعي)
|
||||
2 - اختر **القراءة لاحقاً** لعرض قائمة بجميع الروابط الغير مؤرشفة باستثناء النشرات الإخبارية
|
||||
3 - اختر **التمييزات** لعرض النصوص التي قمت بتمييزها في جميع الصفحات المحفوظة
|
||||
4 - اختر **هذا اليوم ** لعرض قائمة الروابط التي قمت بحفظها اليوم
|
||||
5 - اختر **النشرات الإخبارية** لعرض الروابط المحفوظة عبر اشتراكاتك في النشرات الإخبارية
|
||||
|
||||
|
||||
### الاندماجات
|
||||
أمنيفور يتيح لك إستعمال اندماجات بقواعد المعرفة وتطبيقات تدوين الملاحظات مثل:
|
||||
- لغس سيك
|
||||
- ويب هوكس
|
||||
|
||||
### لغس سيك
|
||||
بفضل وصلة لغس سيك، يمكنك مزامنة جميع المقالات والتمييزات والملاحظات إلى لغس سيك ، قاعدة معرفة شهيرة.
|
||||
للحصول على معلومات حول كيفية إعداد واستخدام لغس سيك يرجى الرجوع لهذا الدليل [Omnivore for Logseq Plugin Guide](https://briansunter.com/graph/#/page/omnivore-logseq-guide)
|
||||
|
||||
|
||||
### ويب هوكس
|
||||
يمكن لي أمنيفور تشغيل الويب هوكس عندما تحفض رابط تقروه
|
||||
مثال : <span style="text-decoration:underline;">This example</span> يريك إستعمال ويب هوكس لكتابة كل الروابط المحفوظة على جوجل شيتس المسجلة على جوجل درايف
|
||||
|
||||
|
|
@ -40,4 +40,4 @@
|
|||
"yarn": "1.22.19"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +129,10 @@ export class LibraryItem {
|
|||
@JoinColumn({ name: 'upload_file_id' })
|
||||
uploadFile?: UploadFile
|
||||
|
||||
// get upload_file_id without joining relations
|
||||
@Column('text', { nullable: true })
|
||||
uploadFileId?: string
|
||||
|
||||
@Column('enum', { enum: ContentReaderType, default: ContentReaderType.WEB })
|
||||
contentReader!: ContentReaderType
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,15 @@ export class Subscription {
|
|||
@Column('timestamp', { nullable: true })
|
||||
lastFetchedAt?: Date | null
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
lastFetchedChecksum?: string | null
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
scheduledAt?: Date | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export enum RegistrationType {
|
|||
export enum StatusType {
|
||||
Active = 'ACTIVE',
|
||||
Pending = 'PENDING',
|
||||
Deleted = 'DELETED',
|
||||
}
|
||||
|
||||
@Entity()
|
||||
|
|
|
|||
|
|
@ -2092,6 +2092,7 @@ export enum SaveArticleReadingProgressErrorCode {
|
|||
}
|
||||
|
||||
export type SaveArticleReadingProgressInput = {
|
||||
force?: InputMaybe<Scalars['Boolean']>;
|
||||
id: Scalars['ID'];
|
||||
readingProgressAnchorIndex?: InputMaybe<Scalars['Int']>;
|
||||
readingProgressPercent: Scalars['Float'];
|
||||
|
|
@ -2616,9 +2617,8 @@ export enum SubscribeErrorCode {
|
|||
}
|
||||
|
||||
export type SubscribeInput = {
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
subscriptionType?: InputMaybe<SubscriptionType>;
|
||||
url?: InputMaybe<Scalars['String']>;
|
||||
url: Scalars['String'];
|
||||
};
|
||||
|
||||
export type SubscribeResult = SubscribeError | SubscribeSuccess;
|
||||
|
|
@ -2978,7 +2978,9 @@ export type UpdateSubscriptionInput = {
|
|||
description?: InputMaybe<Scalars['String']>;
|
||||
id: Scalars['ID'];
|
||||
lastFetchedAt?: InputMaybe<Scalars['Date']>;
|
||||
lastFetchedChecksum?: InputMaybe<Scalars['String']>;
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
scheduledAt?: InputMaybe<Scalars['Date']>;
|
||||
status?: InputMaybe<SubscriptionStatus>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1571,6 +1571,7 @@ enum SaveArticleReadingProgressErrorCode {
|
|||
}
|
||||
|
||||
input SaveArticleReadingProgressInput {
|
||||
force: Boolean
|
||||
id: ID!
|
||||
readingProgressAnchorIndex: Int
|
||||
readingProgressPercent: Float!
|
||||
|
|
@ -2058,9 +2059,8 @@ enum SubscribeErrorCode {
|
|||
}
|
||||
|
||||
input SubscribeInput {
|
||||
name: String
|
||||
subscriptionType: SubscriptionType
|
||||
url: String
|
||||
url: String!
|
||||
}
|
||||
|
||||
union SubscribeResult = SubscribeError | SubscribeSuccess
|
||||
|
|
@ -2391,7 +2391,9 @@ input UpdateSubscriptionInput {
|
|||
description: String
|
||||
id: ID!
|
||||
lastFetchedAt: Date
|
||||
lastFetchedChecksum: String
|
||||
name: String
|
||||
scheduledAt: Date
|
||||
status: SubscriptionStatus
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,4 +17,46 @@ export const libraryItemRepository = appDataSource
|
|||
countByCreatedAt(createdAt: Date) {
|
||||
return this.countBy({ createdAt })
|
||||
},
|
||||
|
||||
createByPopularRead(name: string, userId: string) {
|
||||
return this.query(
|
||||
`
|
||||
INSERT INTO omnivore.library_item (
|
||||
slug,
|
||||
readable_content,
|
||||
original_content,
|
||||
description,
|
||||
title,
|
||||
author,
|
||||
original_url,
|
||||
item_type,
|
||||
thumbnail,
|
||||
published_at,
|
||||
site_name,
|
||||
user_id,
|
||||
word_count
|
||||
)
|
||||
SELECT
|
||||
slug,
|
||||
readable_content,
|
||||
original_content,
|
||||
description,
|
||||
title,
|
||||
author,
|
||||
original_url,
|
||||
$1,
|
||||
thumbnail,
|
||||
published_at,
|
||||
site_name,
|
||||
$2,
|
||||
word_count
|
||||
FROM
|
||||
omnivore.popular_read
|
||||
WHERE
|
||||
key = $3
|
||||
RETURNING *
|
||||
`,
|
||||
['ARTICLE', userId, name]
|
||||
) as Promise<LibraryItem[]>
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { In } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { User } from './../entity/user'
|
||||
import { StatusType, User } from './../entity/user'
|
||||
|
||||
const TOP_USERS = [
|
||||
'jacksonh',
|
||||
|
|
@ -16,7 +16,7 @@ export const MAX_RECORDS_LIMIT = 1000
|
|||
|
||||
export const userRepository = appDataSource.getRepository(User).extend({
|
||||
findById(id: string) {
|
||||
return this.findOneBy({ id })
|
||||
return this.findOneBy({ id, status: StatusType.Active })
|
||||
},
|
||||
|
||||
findByEmail(email: string) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
TypeaheadSearchSuccess,
|
||||
UpdateReason,
|
||||
UpdatesSinceError,
|
||||
UpdatesSinceErrorCode,
|
||||
UpdatesSinceSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getColumns } from '../../repository'
|
||||
|
|
@ -53,7 +54,6 @@ import { getInternalLabelWithColor } from '../../repository/label'
|
|||
import { libraryItemRepository } from '../../repository/library_item'
|
||||
import { userRepository } from '../../repository/user'
|
||||
import { createPageSaveRequest } from '../../services/create_page_save_request'
|
||||
import { findHighlightsByLibraryItemId } from '../../services/highlights'
|
||||
import {
|
||||
addLabelsToLibraryItem,
|
||||
findLabelsByIds,
|
||||
|
|
@ -62,11 +62,11 @@ import {
|
|||
} from '../../services/labels'
|
||||
import {
|
||||
createLibraryItem,
|
||||
findLibraryItemById,
|
||||
findLibraryItemByUrl,
|
||||
findLibraryItemsByPrefix,
|
||||
searchLibraryItems,
|
||||
updateLibraryItem,
|
||||
updateLibraryItemReadingProgress,
|
||||
updateLibraryItems,
|
||||
} from '../../services/library_item'
|
||||
import { parsedContentToLibraryItem } from '../../services/save_page'
|
||||
|
|
@ -82,14 +82,12 @@ import {
|
|||
cleanUrl,
|
||||
errorHandler,
|
||||
generateSlug,
|
||||
isBase64Image,
|
||||
isParsingTimeout,
|
||||
libraryItemToArticle,
|
||||
libraryItemToSearchItem,
|
||||
titleForFilePath,
|
||||
userDataToUser,
|
||||
} from '../../utils/helpers'
|
||||
import { createImageProxyUrl } from '../../utils/imageproxy'
|
||||
import {
|
||||
contentConverter,
|
||||
getDistillerResult,
|
||||
|
|
@ -98,10 +96,7 @@ import {
|
|||
parsePreparedContent,
|
||||
} from '../../utils/parser'
|
||||
import { parseSearchQuery, sortParamsToSort } from '../../utils/search'
|
||||
import {
|
||||
getStorageFileDetails,
|
||||
makeStorageFilePublic,
|
||||
} from '../../utils/uploads'
|
||||
import { getStorageFileDetails } from '../../utils/uploads'
|
||||
import { itemTypeForContentType } from '../upload_files'
|
||||
|
||||
export enum ArticleFormat {
|
||||
|
|
@ -310,7 +305,6 @@ export const createArticleResolver = authorized<
|
|||
pubsub
|
||||
)
|
||||
}
|
||||
await makeStorageFilePublic(uploadFileData.id, uploadFileData.fileName)
|
||||
}
|
||||
|
||||
let libraryItemToReturn: LibraryItem
|
||||
|
|
@ -570,16 +564,11 @@ export const saveArticleReadingProgressResolver = authorized<
|
|||
readingProgressPercent,
|
||||
readingProgressAnchorIndex,
|
||||
readingProgressTopPercent,
|
||||
force,
|
||||
},
|
||||
},
|
||||
{ uid, pubsub }
|
||||
{ log, pubsub, uid }
|
||||
) => {
|
||||
const libraryItem = await findLibraryItemById(id, uid)
|
||||
|
||||
if (!libraryItem) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
if (
|
||||
readingProgressPercent < 0 ||
|
||||
readingProgressPercent > 100 ||
|
||||
|
|
@ -590,40 +579,47 @@ export const saveArticleReadingProgressResolver = authorized<
|
|||
) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
|
||||
}
|
||||
// If we have a top percent, we only save it if it's greater than the current top percent
|
||||
// or set to zero if the top percent is zero.
|
||||
const readingProgressTopPercentToSave = readingProgressTopPercent
|
||||
? Math.max(
|
||||
readingProgressTopPercent,
|
||||
libraryItem.readingProgressTopPercent || 0
|
||||
try {
|
||||
if (force) {
|
||||
// update reading progress without checking the current value
|
||||
const updatedItem = await updateLibraryItem(
|
||||
id,
|
||||
{
|
||||
readingProgressBottomPercent: readingProgressPercent,
|
||||
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
|
||||
readingProgressHighestReadAnchor:
|
||||
readingProgressAnchorIndex ?? undefined,
|
||||
readAt: new Date(),
|
||||
},
|
||||
uid,
|
||||
pubsub
|
||||
)
|
||||
: readingProgressTopPercent === 0
|
||||
? 0
|
||||
: undefined
|
||||
// If setting to zero we accept the update, otherwise we require it
|
||||
// be greater than the current reading progress.
|
||||
const updatedPart: QueryDeepPartialEntity<LibraryItem> = {
|
||||
readingProgressBottomPercent:
|
||||
readingProgressPercent === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
readingProgressPercent,
|
||||
libraryItem.readingProgressBottomPercent
|
||||
),
|
||||
readingProgressHighestReadAnchor:
|
||||
readingProgressAnchorIndex === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
readingProgressAnchorIndex || 0,
|
||||
libraryItem.readingProgressHighestReadAnchor
|
||||
),
|
||||
readingProgressTopPercent: readingProgressTopPercentToSave,
|
||||
readAt: new Date(),
|
||||
}
|
||||
const updatedItem = await updateLibraryItem(id, updatedPart, uid, pubsub)
|
||||
|
||||
return {
|
||||
updatedArticle: libraryItemToArticle(updatedItem),
|
||||
return {
|
||||
updatedArticle: libraryItemToArticle(updatedItem),
|
||||
}
|
||||
}
|
||||
|
||||
// update reading progress only if the current value is lower
|
||||
const updatedItem = await updateLibraryItemReadingProgress(
|
||||
id,
|
||||
uid,
|
||||
readingProgressPercent,
|
||||
readingProgressTopPercent,
|
||||
readingProgressAnchorIndex,
|
||||
pubsub
|
||||
)
|
||||
if (!updatedItem) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
|
||||
}
|
||||
|
||||
return {
|
||||
updatedArticle: libraryItemToArticle(updatedItem),
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('saveArticleReadingProgressResolver error', error)
|
||||
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.Unauthorized] }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -634,7 +630,7 @@ export const searchResolver = authorized<
|
|||
QuerySearchArgs
|
||||
>(async (_obj, params, { log, uid }) => {
|
||||
const startCursor = params.after || ''
|
||||
const first = params.first || 10
|
||||
const first = Math.min(params.first || 10, 100) // limit to 100 items
|
||||
|
||||
// the query size is limited to 255 characters
|
||||
if (params.query && params.query.length > 255) {
|
||||
|
|
@ -649,7 +645,7 @@ export const searchResolver = authorized<
|
|||
size: first + 1, // fetch one more item to get next cursor
|
||||
sort: searchQuery.sort,
|
||||
includePending: true,
|
||||
includeContent: params.includeContent || false,
|
||||
includeContent: !!params.includeContent,
|
||||
...searchQuery,
|
||||
},
|
||||
uid
|
||||
|
|
@ -665,25 +661,7 @@ export const searchResolver = authorized<
|
|||
libraryItems.pop()
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
libraryItems.map(async (libraryItem) => {
|
||||
if (
|
||||
libraryItem.highlightAnnotations &&
|
||||
libraryItem.highlightAnnotations.length > 0
|
||||
) {
|
||||
// fetch highlights for each item
|
||||
libraryItem.highlights = await findHighlightsByLibraryItemId(
|
||||
libraryItem.id,
|
||||
uid
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const edges = libraryItems.map((libraryItem) => {
|
||||
if (libraryItem.siteIcon && !isBase64Image(libraryItem.siteIcon)) {
|
||||
libraryItem.siteIcon = createImageProxyUrl(libraryItem.siteIcon, 128, 128)
|
||||
}
|
||||
if (params.includeContent && libraryItem.readableContent) {
|
||||
// convert html to the requested format
|
||||
const format = params.format || ArticleFormat.Html
|
||||
|
|
@ -747,7 +725,12 @@ export const updatesSinceResolver = authorized<
|
|||
|
||||
const startCursor = after || ''
|
||||
const size = first || 10
|
||||
const startDate = new Date(since)
|
||||
let startDate = new Date(since)
|
||||
if (isNaN(startDate.getTime())) {
|
||||
// for android app compatibility
|
||||
startDate = new Date(0)
|
||||
}
|
||||
|
||||
const { libraryItems, count } = await searchLibraryItems(
|
||||
{
|
||||
from: Number(startCursor),
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@
|
|||
import { Subscription } from '../entity/subscription'
|
||||
import {
|
||||
Article,
|
||||
Highlight,
|
||||
Label,
|
||||
PageType,
|
||||
Recommendation,
|
||||
SearchItem,
|
||||
} from '../generated/graphql'
|
||||
import { findHighlightsByLibraryItemId } from '../services/highlights'
|
||||
import { findLabelsByLibraryItemId } from '../services/labels'
|
||||
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
|
||||
import { findUploadFileById } from '../services/upload_file'
|
||||
import {
|
||||
highlightDataToHighlight,
|
||||
isBase64Image,
|
||||
recommandationDataToRecommendation,
|
||||
validatedDate,
|
||||
wordsCount,
|
||||
|
|
@ -461,7 +465,7 @@ export const functionResolvers = {
|
|||
async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
|
||||
if (
|
||||
(item.pageType == PageType.File || item.pageType == PageType.Book) &&
|
||||
ctx.uid &&
|
||||
ctx.claims &&
|
||||
item.uploadFileId
|
||||
) {
|
||||
const upload = await findUploadFileById(item.uploadFileId)
|
||||
|
|
@ -483,30 +487,64 @@ export const functionResolvers = {
|
|||
if (item.wordCount) return item.wordCount
|
||||
return item.content ? wordsCount(item.content) : undefined
|
||||
},
|
||||
siteIcon(item: { siteIcon?: string }) {
|
||||
if (item.siteIcon && !isBase64Image(item.siteIcon)) {
|
||||
return createImageProxyUrl(item.siteIcon, 128, 128)
|
||||
}
|
||||
|
||||
return item.siteIcon
|
||||
},
|
||||
async highlights(
|
||||
item: {
|
||||
id: string
|
||||
highlights?: Highlight[]
|
||||
highlightAnnotations?: string[] | null
|
||||
},
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.highlights) return item.highlights
|
||||
|
||||
if (item.highlightAnnotations && item.highlightAnnotations.length > 0) {
|
||||
const highlights = await findHighlightsByLibraryItemId(item.id, ctx.uid)
|
||||
return highlights.map(highlightDataToHighlight)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
async labels(
|
||||
item: { id: string; labels?: Label[] },
|
||||
item: { id: string; labels?: Label[]; labelNames?: string[] | null },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.labels) return item.labels
|
||||
|
||||
return findLabelsByLibraryItemId(item.id, ctx.uid)
|
||||
if (item.labelNames && item.labelNames.length > 0) {
|
||||
return findLabelsByLibraryItemId(item.id, ctx.uid)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
async recommendations(
|
||||
item: {
|
||||
id: string
|
||||
recommendations?: Recommendation[]
|
||||
recommenderNames?: string[] | null
|
||||
},
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (item.recommendations) return item.recommendations
|
||||
|
||||
const recommendations = await findRecommendationsByLibraryItemId(
|
||||
item.id,
|
||||
ctx.uid
|
||||
)
|
||||
return recommendations.map(recommandationDataToRecommendation)
|
||||
if (item.recommenderNames && item.recommenderNames.length > 0) {
|
||||
const recommendations = await findRecommendationsByLibraryItemId(
|
||||
item.id,
|
||||
ctx.uid
|
||||
)
|
||||
return recommendations.map(recommandationDataToRecommendation)
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
},
|
||||
Subscription: {
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ export const uploadImportFileResolver = authorized<
|
|||
UploadImportFileSuccess,
|
||||
UploadImportFileError,
|
||||
MutationUploadImportFileArgs
|
||||
>(async (_, { type, contentType }, { claims: { uid }, log }) => {
|
||||
>(async (_, { type, contentType }, { uid }) => {
|
||||
if (!VALID_CONTENT_TYPES.includes(contentType)) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({ id: uid })
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [UploadImportFileErrorCode.Unauthorized],
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ export const addPopularReadResolver = authorized<
|
|||
AddPopularReadError,
|
||||
MutationAddPopularReadArgs
|
||||
>(async (_, { name }, { uid }) => {
|
||||
const item = await addPopularRead(uid, name)
|
||||
if (!item) {
|
||||
const items = await addPopularRead(uid, name)
|
||||
if (items.length === 0) {
|
||||
return { errorCodes: [AddPopularReadErrorCode.NotFound] }
|
||||
}
|
||||
|
||||
return {
|
||||
pageId: item.id,
|
||||
pageId: items[0].id,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export type PartialCreateReactionSuccess = Merge<
|
|||
|
||||
// if ((!userArticleId && !highlightId) || (userArticleId && highlightId)) {
|
||||
// // One reaction target is required
|
||||
// // Higlight replies hasn't supported yet
|
||||
// // Highlight replies hasn't supported yet
|
||||
// return {
|
||||
// errorCodes: [CreateReactionErrorCode.BadTarget],
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ export const createGroupResolver = authorized<
|
|||
MutationCreateGroupArgs
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
const userData = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const userData = await userRepository.findById(uid)
|
||||
if (!userData) {
|
||||
return {
|
||||
errorCodes: [CreateGroupErrorCode.Unauthorized],
|
||||
|
|
@ -107,9 +105,7 @@ export const createGroupResolver = authorized<
|
|||
export const groupsResolver = authorized<GroupsSuccess, GroupsError>(
|
||||
async (_, __, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [GroupsErrorCode.Unauthorized],
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ export const savePageResolver = authorized<
|
|||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSavePageArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: ctx.uid,
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
|
|
@ -30,9 +30,7 @@ export const savePageResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
id: ctx.uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
|
@ -44,11 +42,7 @@ export const saveUrlResolver = authorized<
|
|||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSaveUrlArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
const {
|
||||
claims: { uid },
|
||||
} = ctx
|
||||
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
|
|
@ -60,9 +54,7 @@ export const saveUrlResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
|
@ -74,9 +66,9 @@ export const saveFileResolver = authorized<
|
|||
SaveSuccess,
|
||||
SaveError,
|
||||
MutationSaveFileArgs
|
||||
>(async (_, { input }, ctx) => {
|
||||
>(async (_, { input }, { uid }) => {
|
||||
analytics.track({
|
||||
userId: ctx.uid,
|
||||
userId: uid,
|
||||
event: 'link_saved',
|
||||
properties: {
|
||||
url: input.url,
|
||||
|
|
@ -86,9 +78,7 @@ export const saveFileResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
id: ctx.uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [SaveErrorCode.Unauthorized] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID =
|
|||
export const sendInstallInstructionsResolver = authorized<
|
||||
SendInstallInstructionsSuccess,
|
||||
SendInstallInstructionsError
|
||||
>(async (_parent, _args, { claims, log }) => {
|
||||
>(async (_parent, _args, { uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
|
||||
if (!user) {
|
||||
return { errorCodes: [SendInstallInstructionsErrorCode.Unauthorized] }
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ export const subscriptionsResolver = authorized<
|
|||
}
|
||||
|
||||
const subscriptions = await queryBuilder
|
||||
.orderBy(`subscription.${sortBy}`, sortOrder, 'NULLS LAST')
|
||||
.orderBy('subscription.status', 'ASC')
|
||||
.addOrderBy(`subscription.${sortBy}`, sortOrder, 'NULLS LAST')
|
||||
.getMany()
|
||||
|
||||
return {
|
||||
|
|
@ -174,26 +175,8 @@ export const subscribeResolver = authorized<
|
|||
SubscribeSuccessPartial,
|
||||
SubscribeError,
|
||||
MutationSubscribeArgs
|
||||
>(async (_, { input }, { authTrx, uid, log }) => {
|
||||
log.info('subscribeResolver')
|
||||
|
||||
>(async (_, { input }, { uid, log }) => {
|
||||
try {
|
||||
// find existing subscription
|
||||
const subscription = await authTrx((t) =>
|
||||
t.getRepository(Subscription).findOneBy({
|
||||
url: input.url || undefined,
|
||||
name: input.name || undefined,
|
||||
user: { id: uid },
|
||||
status: SubscriptionStatus.Active,
|
||||
type: input.subscriptionType || SubscriptionType.Rss, // default to rss
|
||||
})
|
||||
)
|
||||
if (subscription) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
|
||||
}
|
||||
}
|
||||
|
||||
analytics.track({
|
||||
userId: uid,
|
||||
event: 'subscribed',
|
||||
|
|
@ -203,49 +186,83 @@ export const subscribeResolver = authorized<
|
|||
},
|
||||
})
|
||||
|
||||
// create new rss subscription
|
||||
if (input.url) {
|
||||
const MAX_RSS_SUBSCRIPTIONS = 150
|
||||
// validate rss feed
|
||||
const feed = await parser.parseURL(input.url)
|
||||
|
||||
// limit number of rss subscriptions to 50
|
||||
const newSubscriptions = (await authTrx((t) =>
|
||||
t.query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon)
|
||||
select $1, $2, $3, $4, $5, $6 from omnivore.subscriptions
|
||||
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
|
||||
having count(*) < $7
|
||||
returning *;`,
|
||||
[
|
||||
feed.title,
|
||||
input.url,
|
||||
feed.description || null,
|
||||
SubscriptionType.Rss,
|
||||
uid,
|
||||
feed.image?.url || null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
)
|
||||
)) as Subscription[]
|
||||
|
||||
if (newSubscriptions.length === 0) {
|
||||
// find existing subscription
|
||||
const existingSubscription = await getRepository(Subscription).findOneBy({
|
||||
url: input.url,
|
||||
user: { id: uid },
|
||||
type: SubscriptionType.Rss,
|
||||
})
|
||||
if (existingSubscription) {
|
||||
if (existingSubscription.status === SubscriptionStatus.Active) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.ExceededMaxSubscriptions],
|
||||
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
|
||||
}
|
||||
}
|
||||
|
||||
// create a cloud task to fetch rss feed item for the new subscription
|
||||
await enqueueRssFeedFetch(uid, newSubscriptions[0])
|
||||
// re-subscribe
|
||||
const updatedSubscription = await getRepository(Subscription).save({
|
||||
...existingSubscription,
|
||||
status: SubscriptionStatus.Active,
|
||||
})
|
||||
|
||||
// create a cloud task to fetch rss feed item for resub subscription
|
||||
await enqueueRssFeedFetch({
|
||||
userIds: [uid],
|
||||
url: input.url,
|
||||
subscriptionIds: [updatedSubscription.id],
|
||||
scheduledDates: [new Date()], // fetch immediately
|
||||
fetchedDates: [updatedSubscription.lastFetchedAt || null],
|
||||
checksums: [updatedSubscription.lastFetchedChecksum || null],
|
||||
})
|
||||
|
||||
return {
|
||||
subscriptions: newSubscriptions,
|
||||
subscriptions: [updatedSubscription],
|
||||
}
|
||||
}
|
||||
|
||||
log.info('missing url or name')
|
||||
// create new rss subscription
|
||||
const MAX_RSS_SUBSCRIPTIONS = 150
|
||||
// validate rss feed
|
||||
const feed = await parser.parseURL(input.url)
|
||||
|
||||
// limit number of rss subscriptions to 150
|
||||
const results = (await getRepository(Subscription).query(
|
||||
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon)
|
||||
select $1, $2, $3, $4, $5, $6 from omnivore.subscriptions
|
||||
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
|
||||
having count(*) < $7
|
||||
returning *;`,
|
||||
[
|
||||
feed.title,
|
||||
input.url,
|
||||
feed.description || null,
|
||||
SubscriptionType.Rss,
|
||||
uid,
|
||||
feed.image?.url || null,
|
||||
MAX_RSS_SUBSCRIPTIONS,
|
||||
]
|
||||
)) as Subscription[]
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.ExceededMaxSubscriptions],
|
||||
}
|
||||
}
|
||||
|
||||
const newSubscription = results[0]
|
||||
|
||||
// create a cloud task to fetch rss feed item for the new subscription
|
||||
await enqueueRssFeedFetch({
|
||||
userIds: [uid],
|
||||
url: input.url,
|
||||
subscriptionIds: [newSubscription.id],
|
||||
scheduledDates: [new Date()], // fetch immediately
|
||||
fetchedDates: [null],
|
||||
checksums: [null],
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [SubscribeErrorCode.BadRequest],
|
||||
subscriptions: [newSubscription],
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('failed to subscribe', error)
|
||||
|
|
@ -290,7 +307,11 @@ export const updateSubscriptionResolver = authorized<
|
|||
lastFetchedAt: input.lastFetchedAt
|
||||
? new Date(input.lastFetchedAt)
|
||||
: undefined,
|
||||
lastFetchedChecksum: input.lastFetchedChecksum || undefined,
|
||||
status: input.status || undefined,
|
||||
scheduledAt: input.scheduledAt
|
||||
? new Date(input.scheduledAt)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return repo.findOneByOrFail({
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
contentReaderForLibraryItem,
|
||||
generateUploadFilePathName,
|
||||
generateUploadSignedUrl,
|
||||
getFilePublicUrl,
|
||||
} from '../../utils/uploads'
|
||||
|
||||
const isFileUrl = (url: string): boolean => {
|
||||
|
|
@ -110,13 +109,12 @@ export const uploadFileRequestResolver = authorized<
|
|||
input.contentType
|
||||
)
|
||||
|
||||
const publicUrl = getFilePublicUrl(uploadFilePathName)
|
||||
|
||||
// If this is a file URL, we swap in the GCS public URL
|
||||
// If this is a file URL, we swap in a special URL
|
||||
const attachmentUrl = `https://omnivore.app/attachments/${uploadFilePathName}`
|
||||
if (isFileUrl(input.url)) {
|
||||
await authTrx(async (tx) => {
|
||||
await tx.getRepository(UploadFile).update(uploadFileId, {
|
||||
url: publicUrl,
|
||||
url: attachmentUrl,
|
||||
status: UploadFileStatus.Initialized,
|
||||
})
|
||||
})
|
||||
|
|
@ -142,8 +140,8 @@ export const uploadFileRequestResolver = authorized<
|
|||
const uploadFileId = uploadFileData.id
|
||||
const item = await createLibraryItem(
|
||||
{
|
||||
originalUrl: isFileUrl(input.url) ? publicUrl : input.url,
|
||||
id: input.clientRequestId || undefined,
|
||||
originalUrl: isFileUrl(input.url) ? attachmentUrl : input.url,
|
||||
user: { id: uid },
|
||||
title,
|
||||
readableContent: '',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import * as jwt from 'jsonwebtoken'
|
||||
import { RegistrationType, User as UserEntity } from '../../entity/user'
|
||||
import {
|
||||
RegistrationType,
|
||||
StatusType,
|
||||
User as UserEntity,
|
||||
} from '../../entity/user'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteAccountError,
|
||||
|
|
@ -38,6 +42,7 @@ import {
|
|||
import { userRepository } from '../../repository/user'
|
||||
import { createUser } from '../../services/create_user'
|
||||
import { sendVerificationEmail } from '../../services/send_emails'
|
||||
import { updateUser } from '../../services/user'
|
||||
import { authorized, userDataToUser } from '../../utils/helpers'
|
||||
import { validateUsername } from '../../utils/usernamePolicy'
|
||||
import { WithDataSourcesContext } from '../types'
|
||||
|
|
@ -47,9 +52,7 @@ export const updateUserResolver = authorized<
|
|||
UpdateUserError,
|
||||
MutationUpdateUserArgs
|
||||
>(async (_, { input: { name, bio } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdateUserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
|
@ -87,9 +90,7 @@ export const updateUserProfileResolver = authorized<
|
|||
UpdateUserProfileError,
|
||||
MutationUpdateUserProfileArgs
|
||||
>(async (_, { input: { userId, username, pictureUrl } }, { uid, authTrx }) => {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: userId,
|
||||
})
|
||||
const user = await userRepository.findById(userId)
|
||||
if (!user) {
|
||||
return { errorCodes: [UpdateUserProfileErrorCode.Unauthorized] }
|
||||
}
|
||||
|
|
@ -112,6 +113,7 @@ export const updateUserProfileResolver = authorized<
|
|||
profile: {
|
||||
username: lowerCasedUsername,
|
||||
},
|
||||
status: StatusType.Active,
|
||||
})
|
||||
if (existingUser?.id) {
|
||||
return {
|
||||
|
|
@ -156,6 +158,7 @@ export const googleLoginResolver: ResolverFn<
|
|||
|
||||
const user = await userRepository.findOneBy({
|
||||
email,
|
||||
status: StatusType.Active,
|
||||
})
|
||||
if (!user?.id) {
|
||||
return { errorCodes: [LoginErrorCode.UserNotFound] }
|
||||
|
|
@ -251,9 +254,7 @@ export const getMeUserResolver: ResolverFn<
|
|||
return undefined
|
||||
}
|
||||
|
||||
const user = await userRepository.findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
const user = await userRepository.findById(claims.uid)
|
||||
if (!user) {
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -277,12 +278,17 @@ export const getUserResolver: ResolverFn<
|
|||
const userId =
|
||||
id ||
|
||||
(username &&
|
||||
(await userRepository.findOneBy({ profile: { username } }))?.id)
|
||||
(
|
||||
await userRepository.findOneBy({
|
||||
profile: { username },
|
||||
status: StatusType.Active,
|
||||
})
|
||||
)?.id)
|
||||
if (!userId) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
||||
const userRecord = await userRepository.findOneBy({ id: userId })
|
||||
const userRecord = await userRepository.findById(userId)
|
||||
if (!userRecord) {
|
||||
return { errorCodes: [UserErrorCode.UserNotFound] }
|
||||
}
|
||||
|
|
@ -313,9 +319,10 @@ export const deleteAccountResolver = authorized<
|
|||
DeleteAccountSuccess,
|
||||
DeleteAccountError,
|
||||
MutationDeleteAccountArgs
|
||||
>(async (_, { userID }, { authTrx, log }) => {
|
||||
const result = await authTrx(async (t) => {
|
||||
return t.withRepository(userRepository).delete(userID)
|
||||
>(async (_, { userID }, { log }) => {
|
||||
// soft delete user
|
||||
const result = await updateUser(userID, {
|
||||
status: StatusType.Deleted,
|
||||
})
|
||||
if (!result.affected) {
|
||||
log.error('Error deleting user account')
|
||||
|
|
@ -334,9 +341,7 @@ export const updateEmailResolver = authorized<
|
|||
MutationUpdateEmailArgs
|
||||
>(async (_, { input: { email } }, { authTrx, uid, log }) => {
|
||||
try {
|
||||
const user = await userRepository.findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
const user = await userRepository.findById(uid)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
suggestedUsername,
|
||||
} from './jwt_helpers'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { StatusType } from '../../entity/user'
|
||||
|
||||
const appleBaseURL = 'https://appleid.apple.com'
|
||||
const audienceName = 'app.omnivore.app'
|
||||
|
|
@ -122,6 +123,7 @@ export async function handleAppleWebAuth(
|
|||
const user = await userRepository.findOneBy({
|
||||
sourceUserId: decodedTokenResult.sourceUserId,
|
||||
source: 'APPLE',
|
||||
status: StatusType.Active,
|
||||
})
|
||||
const userId = user?.id
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from './google_auth'
|
||||
import { createWebAuthToken } from './jwt_helpers'
|
||||
import { createMobileAccountCreationResponse } from './mobile/account_creation'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
export interface SignupRequest {
|
||||
email: string
|
||||
|
|
@ -80,6 +81,15 @@ export const isValidSignupRequest = (obj: any): obj is SignupRequest => {
|
|||
)
|
||||
}
|
||||
|
||||
// The hourly limiter is used on the create account,
|
||||
// and reset password endpoints
|
||||
// this limits users to five operations per an hour
|
||||
const hourlyLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
skip: (req) => env.dev.isLocal,
|
||||
})
|
||||
|
||||
export function authRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
|
|
@ -108,6 +118,7 @@ export function authRouter() {
|
|||
)
|
||||
router.post(
|
||||
'/create-account',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req, res) => {
|
||||
const { name, bio, username } = req.body
|
||||
|
|
@ -421,7 +432,7 @@ export function authRouter() {
|
|||
const { email, password } = req.body
|
||||
try {
|
||||
const user = await userRepository.findByEmail(email.trim())
|
||||
if (!user?.id) {
|
||||
if (!user || user.status === StatusType.Deleted) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.UserNotFound}`
|
||||
)
|
||||
|
|
@ -480,6 +491,7 @@ export function authRouter() {
|
|||
|
||||
router.post(
|
||||
'/email-signup',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
if (!isValidSignupRequest(req.body)) {
|
||||
|
|
@ -599,6 +611,7 @@ export function authRouter() {
|
|||
|
||||
router.post(
|
||||
'/forgot-password',
|
||||
hourlyLimiter,
|
||||
cors<express.Request>(corsConfig),
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
const email = req.body.email?.trim() as string // trim whitespace
|
||||
|
|
@ -610,7 +623,7 @@ export function authRouter() {
|
|||
|
||||
try {
|
||||
const user = await userRepository.findByEmail(email)
|
||||
if (!user) {
|
||||
if (!user || user.status === StatusType.Deleted) {
|
||||
return res.redirect(`${env.client.url}/auth/reset-sent`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { google, oauth2_v2 as oauthV2 } from 'googleapis'
|
||||
import { OAuth2Client } from 'googleapis-common'
|
||||
import url from 'url'
|
||||
import { StatusType } from '../../entity/user'
|
||||
import { env, homePageURL } from '../../env'
|
||||
import { LoginErrorCode } from '../../generated/graphql'
|
||||
import { userRepository } from '../../repository/user'
|
||||
|
|
@ -130,6 +131,7 @@ export async function handleGoogleWebAuth(
|
|||
const user = await userRepository.findOneBy({
|
||||
email,
|
||||
source: 'GOOGLE',
|
||||
status: StatusType.Active,
|
||||
})
|
||||
const userId = user?.id
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export async function createMobileEmailSignInResponse(
|
|||
}
|
||||
|
||||
const user = await userRepository.findByEmail(email.trim())
|
||||
if (!user?.id || !user?.password) {
|
||||
if (!user || !user.password || user.status === StatusType.Deleted) {
|
||||
throw new Error('user not found')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export function contentServiceRouter() {
|
|||
if (msg.description) itemToUpdate.description = msg.description
|
||||
|
||||
// This event is fired after the file is fully uploaded,
|
||||
// so along with updateing content, we mark it as
|
||||
// so along with updating content, we mark it as
|
||||
// succeeded.
|
||||
itemToUpdate.state = LibraryItemState.Succeeded
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
generateUploadFilePathName,
|
||||
generateUploadSignedUrl,
|
||||
getStorageFileDetails,
|
||||
makeStorageFilePublic,
|
||||
} from '../../utils/uploads'
|
||||
|
||||
export function emailAttachmentRouter() {
|
||||
|
|
@ -143,11 +142,12 @@ export function emailAttachmentRouter() {
|
|||
return res.status(400).send('BAD REQUEST')
|
||||
}
|
||||
|
||||
const uploadFileUrlOverride = await makeStorageFilePublic(
|
||||
uploadFileData.id,
|
||||
uploadFileData.fileName
|
||||
const uploadFilePathName = generateUploadFilePathName(
|
||||
uploadFileId,
|
||||
uploadFile.fileName
|
||||
)
|
||||
|
||||
const uploadFileUrlOverride = `https://omnivore.app/attachments/${uploadFilePathName}`
|
||||
const uploadFileHash = uploadFileDetails.md5Hash
|
||||
const itemType =
|
||||
uploadFile.contentType === 'application/pdf'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { Subscription } from '../../entity/subscription'
|
|||
import { SubscriptionStatus, SubscriptionType } from '../../generated/graphql'
|
||||
import { readPushSubscription } from '../../pubsub'
|
||||
import { getRepository } from '../../repository'
|
||||
import { enqueueRssFeedFetch } from '../../utils/createTask'
|
||||
import {
|
||||
enqueueRssFeedFetch,
|
||||
RssSubscriptionGroup,
|
||||
} from '../../utils/createTask'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export function rssFeedRouter() {
|
||||
|
|
@ -22,21 +25,33 @@ export function rssFeedRouter() {
|
|||
return res.status(200).send('Expired')
|
||||
}
|
||||
|
||||
// get all active rss feed subscriptions
|
||||
const subscriptions = await getRepository(Subscription).find({
|
||||
select: ['id', 'url', 'user', 'lastFetchedAt'],
|
||||
where: {
|
||||
type: SubscriptionType.Rss,
|
||||
status: SubscriptionStatus.Active,
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
// get active rss feed subscriptions scheduled for fetch and group by feed url
|
||||
const subscriptionGroups = (await getRepository(Subscription).query(
|
||||
`
|
||||
SELECT
|
||||
url,
|
||||
ARRAY_AGG(id) AS "subscriptionIds",
|
||||
ARRAY_AGG(user_id) AS "userIds",
|
||||
ARRAY_AGG(last_fetched_at) AS "fetchedDates",
|
||||
ARRAY_AGG(coalesce(scheduled_at, NOW())) AS "scheduledDates",
|
||||
ARRAY_AGG(last_fetched_checksum) AS checksums
|
||||
FROM
|
||||
omnivore.subscriptions
|
||||
WHERE
|
||||
type = $1
|
||||
AND status = $2
|
||||
AND (scheduled_at <= NOW() OR scheduled_at IS NULL)
|
||||
GROUP BY
|
||||
url
|
||||
`,
|
||||
[SubscriptionType.Rss, SubscriptionStatus.Active]
|
||||
)) as RssSubscriptionGroup[]
|
||||
|
||||
// create a cloud taks to fetch rss feed item for each subscription
|
||||
await Promise.all(
|
||||
subscriptions.map((subscription) => {
|
||||
subscriptionGroups.map((subscriptionGroup) => {
|
||||
try {
|
||||
return enqueueRssFeedFetch(subscription.user.id, subscription)
|
||||
return enqueueRssFeedFetch(subscriptionGroup)
|
||||
} catch (error) {
|
||||
logger.info('error creating rss feed fetch task', error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function userRouter() {
|
|||
return
|
||||
}
|
||||
try {
|
||||
const user = await userRepository.findOneBy({ id: claims.uid })
|
||||
const user = await userRepository.findById(claims.uid)
|
||||
if (!user) {
|
||||
res.status(400).send('Bad Request')
|
||||
return
|
||||
|
|
|
|||
|
|
@ -637,6 +637,7 @@ const schema = gql`
|
|||
readingProgressTopPercent: Float
|
||||
readingProgressPercent: Float!
|
||||
readingProgressAnchorIndex: Int
|
||||
force: Boolean
|
||||
}
|
||||
enum SaveArticleReadingProgressErrorCode {
|
||||
NOT_FOUND
|
||||
|
|
@ -2538,8 +2539,7 @@ const schema = gql`
|
|||
}
|
||||
|
||||
input SubscribeInput {
|
||||
url: String
|
||||
name: String
|
||||
url: String!
|
||||
subscriptionType: SubscriptionType
|
||||
}
|
||||
|
||||
|
|
@ -2548,7 +2548,9 @@ const schema = gql`
|
|||
name: String
|
||||
description: String
|
||||
lastFetchedAt: Date
|
||||
lastFetchedChecksum: String
|
||||
status: SubscriptionStatus
|
||||
scheduledAt: Date
|
||||
}
|
||||
|
||||
union UpdateSubscriptionResult =
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ export const createApp = (): {
|
|||
app.use(json({ limit: '100mb' }))
|
||||
app.use(urlencoded({ limit: '100mb', extended: true }))
|
||||
|
||||
// set to true if behind a reverse proxy/load balancer
|
||||
app.set('trust proxy', env.server.trustProxy)
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: async (req) => {
|
||||
|
|
@ -65,7 +68,7 @@ export const createApp = (): {
|
|||
const token = getTokenByRequest(req)
|
||||
try {
|
||||
const claims = await getClaimsByToken(token)
|
||||
return claims ? 100 : 15
|
||||
return claims ? 60 : 15
|
||||
} catch (e) {
|
||||
console.log('non-authenticated request')
|
||||
return 15
|
||||
|
|
|
|||
|
|
@ -280,3 +280,7 @@ export const getGroupsWhereUserCanPost = async (
|
|||
.innerJoinAndSelect('members.user', 'user')
|
||||
.getMany()
|
||||
}
|
||||
|
||||
export const deleteGroup = async (groupId: string) => {
|
||||
return getRepository(Group).delete(groupId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -431,13 +431,88 @@ export const updateLibraryItem = async (
|
|||
|
||||
await pubsub.entityUpdated<QueryDeepPartialEntity<LibraryItem>>(
|
||||
EntityType.PAGE,
|
||||
{ ...libraryItem, id },
|
||||
{
|
||||
...libraryItem,
|
||||
id,
|
||||
// don't send original content and readable content
|
||||
originalContent: undefined,
|
||||
readableContent: undefined,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
return updatedLibraryItem
|
||||
}
|
||||
|
||||
export const updateLibraryItemReadingProgress = async (
|
||||
id: string,
|
||||
userId: string,
|
||||
bottomPercent: number,
|
||||
topPercent: number | null = null,
|
||||
anchorIndex: number | null = null,
|
||||
pubsub = createPubSubClient()
|
||||
): Promise<LibraryItem | null> => {
|
||||
// If we have a top percent, we only save it if it's greater than the current top percent
|
||||
// or set to zero if the top percent is zero.
|
||||
const result = (await authTrx(
|
||||
async (tx) =>
|
||||
tx.getRepository(LibraryItem).query(
|
||||
`
|
||||
UPDATE omnivore.library_item
|
||||
SET reading_progress_top_percent = CASE
|
||||
WHEN reading_progress_top_percent < $2 THEN $2
|
||||
WHEN $2 = 0 THEN 0
|
||||
ELSE reading_progress_top_percent
|
||||
END,
|
||||
reading_progress_bottom_percent = CASE
|
||||
WHEN reading_progress_bottom_percent < $3 THEN $3
|
||||
WHEN $3 = 0 THEN 0
|
||||
ELSE reading_progress_bottom_percent
|
||||
END,
|
||||
reading_progress_highest_read_anchor = CASE
|
||||
WHEN reading_progress_top_percent < $4 THEN $4
|
||||
WHEN $4 = 0 THEN 0
|
||||
ELSE reading_progress_highest_read_anchor
|
||||
END,
|
||||
read_at = now()
|
||||
WHERE id = $1 AND (
|
||||
(reading_progress_top_percent < $2 OR $2 = 0) OR
|
||||
(reading_progress_bottom_percent < $3 OR $3 = 0) OR
|
||||
(reading_progress_highest_read_anchor < $4 OR $4 = 0)
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
reading_progress_top_percent as "readingProgressTopPercent",
|
||||
reading_progress_bottom_percent as "readingProgressBottomPercent",
|
||||
reading_progress_highest_read_anchor as "readingProgressHighestReadAnchor",
|
||||
read_at as "readAt"
|
||||
`,
|
||||
[id, topPercent, bottomPercent, anchorIndex]
|
||||
),
|
||||
undefined,
|
||||
userId
|
||||
)) as [LibraryItem[], number]
|
||||
if (result[1] === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedItem = result[0][0]
|
||||
await pubsub.entityUpdated<QueryDeepPartialEntity<LibraryItem>>(
|
||||
EntityType.PAGE,
|
||||
{
|
||||
id,
|
||||
readingProgressBottomPercent: updatedItem.readingProgressBottomPercent,
|
||||
readingProgressTopPercent: updatedItem.readingProgressTopPercent,
|
||||
readingProgressHighestReadAnchor:
|
||||
updatedItem.readingProgressHighestReadAnchor,
|
||||
readAt: updatedItem.readAt,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
export const createLibraryItems = async (
|
||||
libraryItems: DeepPartial<LibraryItem>[],
|
||||
userId: string
|
||||
|
|
@ -452,7 +527,8 @@ export const createLibraryItems = async (
|
|||
export const createLibraryItem = async (
|
||||
libraryItem: DeepPartial<LibraryItem>,
|
||||
userId: string,
|
||||
pubsub = createPubSubClient()
|
||||
pubsub = createPubSubClient(),
|
||||
skipPubSub = false
|
||||
): Promise<LibraryItem> => {
|
||||
const newLibraryItem = await authTrx(
|
||||
async (tx) =>
|
||||
|
|
@ -466,9 +542,18 @@ export const createLibraryItem = async (
|
|||
userId
|
||||
)
|
||||
|
||||
await pubsub.entityCreated<LibraryItem>(
|
||||
if (skipPubSub) {
|
||||
return newLibraryItem
|
||||
}
|
||||
|
||||
await pubsub.entityCreated<DeepPartial<LibraryItem>>(
|
||||
EntityType.PAGE,
|
||||
newLibraryItem,
|
||||
{
|
||||
...newLibraryItem,
|
||||
// don't send original content and readable content
|
||||
originalContent: undefined,
|
||||
readableContent: undefined,
|
||||
},
|
||||
userId
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@ export const createNewsletterEmail = async (
|
|||
userId: string,
|
||||
confirmationCode?: string
|
||||
): Promise<NewsletterEmail> => {
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['profile'],
|
||||
})
|
||||
const user = await userRepository.findById(userId)
|
||||
if (!user) {
|
||||
return Promise.reject({
|
||||
errorCode: CreateNewsletterEmailErrorCode.Unauthorized,
|
||||
|
|
|
|||
|
|
@ -1,93 +1,23 @@
|
|||
import * as httpContext from 'express-http-context2'
|
||||
import { readFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { DeepPartial, EntityManager } from 'typeorm'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import { appDataSource } from '../data_source'
|
||||
import { LibraryItem } from '../entity/library_item'
|
||||
import { PageType } from '../generated/graphql'
|
||||
import { authTrx } from '../repository'
|
||||
import { libraryItemRepository } from '../repository/library_item'
|
||||
import { generateSlug, stringToHash, wordsCount } from '../utils/helpers'
|
||||
import { logger } from '../utils/logger'
|
||||
import { createLibraryItem } from './library_item'
|
||||
|
||||
type PopularRead = {
|
||||
url: string
|
||||
title: string
|
||||
author: string
|
||||
description: string
|
||||
previewImage?: string
|
||||
publishedAt: Date
|
||||
siteName: string
|
||||
|
||||
content: string
|
||||
originalHtml: string
|
||||
}
|
||||
|
||||
const popularRead = (key: string): PopularRead | undefined => {
|
||||
const metadata = popularReads.find((pr) => pr.key === key)
|
||||
if (!metadata) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(
|
||||
path.resolve(__dirname, `popular_reads/${key}-content.html`),
|
||||
'utf8'
|
||||
)
|
||||
const originalHtml = readFileSync(
|
||||
path.resolve(__dirname, `./popular_reads/${key}-original.html`),
|
||||
'utf8'
|
||||
)
|
||||
if (!content || !originalHtml) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
content,
|
||||
originalHtml,
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('error adding popular read', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const popularReadToLibraryItem = (
|
||||
export const addPopularRead = async (
|
||||
userId: string,
|
||||
name: string,
|
||||
userId: string
|
||||
): DeepPartial<LibraryItem> | null => {
|
||||
const pr = popularRead(name)
|
||||
if (!pr) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
slug: generateSlug(pr.title),
|
||||
readableContent: pr.content,
|
||||
originalContent: pr.originalHtml,
|
||||
description: pr.description,
|
||||
title: pr.title,
|
||||
author: pr.author,
|
||||
originalUrl: pr.url,
|
||||
itemType: PageType.Article,
|
||||
textContentHash: stringToHash(pr.content),
|
||||
thumbnail: pr.previewImage,
|
||||
publishedAt: pr.publishedAt,
|
||||
siteName: pr.siteName,
|
||||
user: { id: userId },
|
||||
wordCount: wordsCount(pr.content),
|
||||
}
|
||||
}
|
||||
|
||||
export const addPopularRead = async (userId: string, name: string) => {
|
||||
const itemToSave = popularReadToLibraryItem(name, userId)
|
||||
if (!itemToSave) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createLibraryItem(itemToSave, userId)
|
||||
entityManager?: EntityManager
|
||||
) => {
|
||||
return authTrx(
|
||||
async (tx) =>
|
||||
tx
|
||||
.withRepository(libraryItemRepository)
|
||||
.createByPopularRead(name, userId),
|
||||
entityManager,
|
||||
userId
|
||||
)
|
||||
}
|
||||
|
||||
const addPopularReads = async (
|
||||
|
|
@ -95,15 +25,15 @@ const addPopularReads = async (
|
|||
userId: string,
|
||||
entityManager: EntityManager
|
||||
) => {
|
||||
const libraryItems = names
|
||||
.map((name) => popularReadToLibraryItem(name, userId))
|
||||
.filter((pr) => pr !== null) as DeepPartial<LibraryItem>[]
|
||||
|
||||
return authTrx(
|
||||
async (tx) => tx.withRepository(libraryItemRepository).save(libraryItems),
|
||||
entityManager,
|
||||
userId
|
||||
)
|
||||
// insert one by one to ensure that the order is preserved
|
||||
for (const name of names) {
|
||||
try {
|
||||
await addPopularRead(userId, name, entityManager)
|
||||
} catch (error) {
|
||||
logger.error('failed to add popular read', error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addPopularReadsForNewUser = async (
|
||||
|
|
@ -132,81 +62,3 @@ export const addPopularReadsForNewUser = async (
|
|||
defaultReads.push('omnivore_get_started')
|
||||
await addPopularReads(defaultReads, userId, em)
|
||||
}
|
||||
|
||||
const popularReads = [
|
||||
{
|
||||
key: 'omnivore_get_started',
|
||||
url: 'https://blog.omnivore.app/p/getting-started-with-omnivore',
|
||||
title: 'Getting Started with Omnivore',
|
||||
author: 'The Omnivore Team',
|
||||
description: 'Get the most out of Omnivore by learning how to use it.',
|
||||
previewImage:
|
||||
'https://proxy-prod.omnivore-image-cache.app/320x320,sxQnqya1QNApB7ZAGPj9K20AU6sw0UAnjmAIy2ub8hUU/https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F658efff4-341a-4720-8cf6-9b2bdbedfaa7_800x668.gif',
|
||||
publishedAt: new Date('2021-10-13'),
|
||||
siteName: 'Omnivore Blog',
|
||||
},
|
||||
{
|
||||
key: 'omnivore_ios',
|
||||
url: 'https://blog.omnivore.app/p/saving-links-from-your-iphone-or',
|
||||
title: 'Saving Links from Your iPhone or iPad',
|
||||
author: 'Omnivore',
|
||||
description: 'Learn how to save articles on iOS.',
|
||||
previewImage:
|
||||
'https://proxy-prod.omnivore-image-cache.app/320x320,sWDfv7sARTIdAlx6Rw_6t-QwL3T9aniEJRa1-jVaglNg/https://substackcdn.com/image/youtube/w_728,c_limit/k6RkIqepAig',
|
||||
publishedAt: new Date('2021-10-19'),
|
||||
siteName: 'Omnivore Blog',
|
||||
},
|
||||
{
|
||||
key: 'omnivore_organize',
|
||||
url: 'https://blog.omnivore.app/p/organize-your-omnivore-library-with',
|
||||
title: 'Organize your Omnivore library with labels',
|
||||
author: 'The Omnivore Team',
|
||||
description: 'Use labels to organize your Omnivore library.',
|
||||
previewImage:
|
||||
'https://proxy-prod.omnivore-image-cache.app/320x320,sTgJ5Q0XIg_EHdmPWcxtXFmkjn8T6hkJt7S9ziClagYo/https://substackcdn.com/image/fetch/w_1200,h_600,c_fill,f_jpg,q_auto:good,fl_progressive:steep,g_auto/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fdaf07af7-5cdb-4ecc-aace-1a46de3e9c58_1827x1090.png',
|
||||
publishedAt: new Date('2022-04-18'),
|
||||
siteName: 'Omnivore Blog',
|
||||
},
|
||||
{
|
||||
key: 'rlove_carnitas',
|
||||
url: 'https://medium.com/@rlove/carnitas-ff0ef1044ae9',
|
||||
title: 'Slow-Braised Carnitas Recipe',
|
||||
author: 'Robert Love',
|
||||
description:
|
||||
'Carnitas is a wonderful Mexican dish, pork shoulder cooked until tender and then given a great crisp. In Mexico, carnitas is eaten on its own, in tacos, or in tortas. This is not an authentic recipe.',
|
||||
previewImage:
|
||||
'https://proxy-prod.omnivore-image-cache.app/88x88,sIcDXt3Ar0baKG1e1Yi1e2VUZFL85xPlOeEfAxF-s-Nw/https://miro.medium.com/max/1200/1*Wl-dMBJpSgPUxUOnPQthyg.jpeg',
|
||||
publishedAt: new Date('2017-02-24'),
|
||||
siteName: '@rlove',
|
||||
},
|
||||
{
|
||||
key: 'power_read_it_later',
|
||||
url: 'https://fortelabs.co/blog/the-secret-power-of-read-it-later-apps',
|
||||
title: 'The Secret Power of ‘Read It Later’ Apps',
|
||||
author: 'Tiago Forte',
|
||||
description:
|
||||
'At the end of 2014 I received an email informing me that I had read over a million words in the ‘read it later’ app Pocket',
|
||||
previewImage:
|
||||
'https://proxy-prod.omnivore-image-cache.app/320x320,sGN5R34M5z068QMXDZD32CQD6mCbxc47hWXm__JVUePE/https://fortelabs.com/wp-content/uploads/2015/11/1rPXwIczUJRCE54v8FfAHGw.jpeg',
|
||||
publishedAt: new Date('2022-01-24'),
|
||||
siteName: 'Forte Labs',
|
||||
},
|
||||
{
|
||||
key: 'elad_meetings',
|
||||
url: 'http://blog.eladgil.com/2018/07/meeting-etiquette.html',
|
||||
title: 'Better Meetings',
|
||||
author: 'Elad Gil',
|
||||
description: 'How to make meetings more productive.',
|
||||
publishedAt: new Date('2018-07-02'),
|
||||
siteName: 'Elad Blog',
|
||||
},
|
||||
{
|
||||
key: 'jonbo_digital_tools',
|
||||
url: 'https://jon.bo/posts/digital-tools/',
|
||||
title: 'Digital Tools I Wish Existed',
|
||||
author: 'Jonathan Borichevskiy',
|
||||
description: `My digital life in a nutshell: I discover relevant content I don’t have time to consume...`,
|
||||
publishedAt: new Date('2019-11-28'),
|
||||
siteName: 'JON.BO',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<div itemprop="description articleBody" id="post-body-9042058857940265690" dir="ltr" trbidi="on">
|
||||
<p><span>As a company scales the number of meetings initially grows faster then headcount. With more people comes more coordination. Most companies have bad meeting etiquette, which means an enormous amount of time is wasted. The following steps help increase meeting efficiency:</span><br>
|
||||
<span><br></span> <b><span>1. Determine who is necessary in the meeting.</span></b><br>
|
||||
<span>Are there really 20 people needed in the room? Separate the must haves, from the nice to haves, from the politically expedient to be there.</span><br>
|
||||
<span><br></span> <b><span>2. Send out an agenda in advance.</span></b><br>
|
||||
<span>What will be discussed? How much time do you really need to spend per topic? Maybe the 60 minute meeting should really be 30 minutes? Like wedding planning, meetings fill the time available to them.</span><br>
|
||||
<span><br></span> <span>Similarly, what preparation should people do in advance? Is there a document to pre-read or data to look at in advance so people come prepared?</span><br>
|
||||
<span><br></span> <b><span>3. Set up (projecting, hangout or conference line, etc.) in advance if you can.</span></b><br>
|
||||
<span>If you are running the meeting and are able to do so, arrive in advance. Dial into the conference line or start the Hangout and start projecting. Instead of wasting 5 minutes in setup with 10 people there, do it early.</span><br>
|
||||
<span><br></span> <span>If your conference rooms are always fully booked back to back (often the case as the fast growing companies are always running out of space), it is hard to get into the room 5 minutes early. Companies can facilitate this by having an :05 policy. I.e. meetings start at 2:05 but the meeting owner books the room starting at 2:00 so has 5 minutes to set up.</span><br>
|
||||
<span><br></span> <b><span>4. Kick off the meeting with objectives.</span></b><br>
|
||||
<span>Review the agenda and purpose of the meeting. Is it to make a decision on a product? To brainstorm a new feature? To review a sales pipeline and prioritize leads? If the meeting does not have a clear objective, you should cancel it. If the same topic is repeated over and over in a weekly meeting without progress, decide if escalation or another method of breaking a bottleneck is needed. The same meeting should not take place 5 times.</span><br>
|
||||
<span><br></span> <b><span>5. Assign a note taker.</span></b><br>
|
||||
<span>Who is responsible for taking notes in the meeting? Any meeting with more then 3-4 people should send out notes. The meeting owner can assign/delegate note taker up front.</span><br>
|
||||
<span><br></span> <b><span>6. Send out meeting notes.</span></b><br>
|
||||
<span>Meeting notes help the rest of the company know what was discussed or decided. It allows different stakeholders to follow up if they were unable to attend. Notes increase cross-company transparency dramatically [1].</span><br>
|
||||
<span><br></span> <span>Meeting notes would optimally include:</span><br>
|
||||
<span>a. Subject/topic of meeting</span><br>
|
||||
<span>b. Date</span><br>
|
||||
<span>c. Attendees</span><br>
|
||||
<span>d. Actions/decisions</span><br>
|
||||
<span>e. Agenda</span><br>
|
||||
<span>f. Detailed notes</span><br>
|
||||
<span><span><br></span> <b><span>7. Clean up the meeting calendar ongoing.</span></b></span><br>
|
||||
<span>Unnecessary regular meetings accumulate like rust on a company. Ask all meeting owners to go through and kill meetings once a quarter. (a) What meetings are still necessary or useful? (b) Who should still attend? People can be dropped and added as well to rebalance who should attend.</span><br>
|
||||
<span><span><br></span> <b><span>NOTES</span></b></span><br>
|
||||
<span>[1] There may be legal reasons to not take notes in specific meetings. Consult with the company's GC or external counsel on training for meeting note etiquette.</span>
|
||||
</p>
|
||||
<p><span><b>MY BOOK</b></span>
|
||||
</p>
|
||||
<p><span><span>You can </span><a href="https://www.amazon.com/High-Growth-Handbook-Elad-Gil/dp/1732265100/">pre-order the High Growth Handbook here</a><span>.</span></span><br>
|
||||
<span><br></span>
|
||||
</p>
|
||||
</div>
|
||||
</DIV>
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<div>
|
||||
<p> My digital life in a nutshell: I discover relevant content I don’t have time to consume, I find time and become overwhelmed with my scattered backlog, I wish the content were in a different format, and then I’m unable to find something again once I’ve consumed it. <a href="https://andymatuschak.org/books/">Not retaining enough</a> is a valid problem but we’ll tackle that one later. </p>
|
||||
<p> There’s a lot of generalization in my summary but <strong>the core issue is an extraordinarily high level of friction in the process of finding, organizing, and sharing digital content</strong>. During the past few years I’ve noticed: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> The more seamless the acquisition & ingestion, the more engaged I am with the content </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Insights are just as likely to be found in a 400-page book as in a 40-minute podcast </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Notes and their subsequent review are essential for long-term retention </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Recommendations from other humans are as good, if not better, than algorithmic suggestions </p>
|
||||
</li>
|
||||
</ul>
|
||||
<p> In the rest of this post I attempt to explain the digital tools I wish existed, and how the the currently available tools do not suffice. What are also probably lacking are my <a href="https://www.buildingasecondbrain.com/">habits and workflows</a> around this - but I’m looking at tools specifically here. </p>
|
||||
<h2 id="queue-management-for-inbound-digital-content"> Queue management for inbound digital content <a href="#queue-management-for-inbound-digital-content">#</a>
|
||||
</h2>
|
||||
<p> Where to begin? Probably the most common problem I see myself and other people dealing with is processing the incoming deluge of articles to read and videos to watch. This isn’t all personal recommendations - it encompasses any and all content I think my future self would appreciate me consuming. A list of issues, roughly by order of appearance: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Content (or links to it) arrive from a variety of sources including text messages from friends, email conversations, tweetstorms and replies, references in books, suggestions in real-world conversations, and more. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Every book, article, post, or tweet has the potential to lead to more content. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Content is published in a variety of formats including but not limited to images, sound files, videos, Google Drive docs, diagrams, long-form paywalled articles, PDFs, powerpoint presentations, and base 64 encoded blobs. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I have little visibility into required time investment and foundational context until I’ve opened it and started thinking about it. Should I sit down with a pen and paper to read this or can I skim it while waiting for my coffee? </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Learning, work, news, and entertainment all have different priorities in my life (roughly in that order). </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I would like to batch process content in different “streams” regardless of where they are stored. For example: I have two hours, let me work through interesting text content my friends sent me last week. Or: show me all the interesting/relevant videos I’ve queued over the past month. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I’m not always connected to a stable internet connection. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> If it’s a long piece of content I want my position saved reliably so I can resume at a later point. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I often want it in a different format than the one it was originally published in (audio → text, text → audio, pdf → ebook). Automated conversion works but is cumbersome. Listening to text articles requires sending them to a special app and converting articles to ebooks is annoying and loses a lot of formatting and navigation. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I love to respond to a person’s recommendation - preferably before they’ve forgotten why they sent me it in the first place. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> I’d like a centralized history of content tied to my notes and annotations in case I want to find it again later. It feels like every week I’m speaking with someone and I remember a blog post I read a few months ago they might find relevant … or was it a Reddit post? Can I find it my history? Oh no, it’s been replaced with <code>[deleted]</code> … find an archived copy… rinse and repeat. </p>
|
||||
</li>
|
||||
</ul>
|
||||
<figure>
|
||||
<img src="https://proxy-prod.omnivore-image-cache.app/0x0,sZWS0YDk87CXTDX82w3BSkkX8cxYRNQ8xkVbVMW8wRJM/https://imgs.xkcd.com/comics/icon_swap.png">
|
||||
<figcaption> Relevant XKCD, as is tradition </figcaption>
|
||||
</figure>
|
||||
<p> Following my curiosity feels like chasing a caffeinated bunny around while real understanding requires time, perspective, and reflection. The internet makes the former much easier - so I find myself constantly balancing the two. Additionally, my energy and attention levels vary throughout the day and it’s far easier to just open Twitter rather than continue reading a long-form article I started on my laptop two days ago. Too often I default to the lower-friction one. </p>
|
||||
<p> Honorable Mentions: <a href="https://getpocket.com/">Pocket</a>, <a href="https://www.instapaper.com/">Instapaper</a>
|
||||
</p>
|
||||
<h2 id="a-universal-book-log-recommendation-sharing-system"> A universal book log, recommendation & sharing system <a href="#a-universal-book-log-recommendation-sharing-system">#</a>
|
||||
</h2>
|
||||
<p> I love exploring other peoples’ reading lists. Here’s <a href="http://fakehost/books">my own</a>. I find everyone keeps their reading lists in different formats on different platforms. Plaintext lists are nice but hard to parse. Spreadsheets are easy to parse but a pain to manage. Third-party services aren’t interoperable, require logins, and are not future-proof. </p>
|
||||
<p> Part of the problem here is <a href="https://people.well.com/user/doctorow/metacrap.htm">metadata is hard</a>. Someone has to sit there and fill out the author, title, subtitle, summary, page count - and they’re probably not going to do it for free. Amazon is a good at it but <a href="https://stallman.org/amazon.html#publishing">is hostile to publishers</a>. Goodreads has much potential but <a href="https://onezero.medium.com/almost-everything-about-goodreads-is-broken-662e424244d5">seems to have stagnated</a>. Linking to the book’s Wikipedia entry would be my preference but very few books have an entry. </p>
|
||||
<p> Whatever this tool for managing my ever-growing reading list will be, it should: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Let me compare my reading list with another to see overlap. I find this a wonderful way to spark conversation and find common interests. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Allow me to tag books instead of placing them into static lists (think clusters or tag clouds). </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Be tied to my highlights, annotations, and bookmarks in a non-proprietary, searchable, and shareable format. Make them public if I want to. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Save context on where and when I found this book: why I thought it was important to read, when I read it, what I wrote down while reading it, and what other content I discovered through it. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Let me query this tool like a relational database. For example: show me all books about scaling startups recommended by people I follow on Twitter or by people they follow. The current Twitter search makes me feel like I’m using a government site created before I myself even knew what a computer was. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Help me <a href="https://www.lesswrong.com/posts/Kmch6T2YscMyLFJD9/rational-reading-thoughts-on-prioritizing-books">deal with prioritization</a>. My reading list is a mess and I can’t be alone. Are certain books better read before others? Prerequisites? Could three of them be replaced with one? What are the other books by the this author? Are they worth reading too? Why exactly did I think reading this 800 page book was relevant when I added it? <a href="https://www.samuelthomasdavies.com/book-summaries/health-fitness/the-checklist-manifesto/">Is 80% of the content attainable from a blog post?</a> Where is that post? Has someone in my network written a rebuttal to the ideas in this book? The list goes on and on. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Provide relevant suggestions with the typical recommender approach based on what people interested in the same topics also enjoyed reading and learning from. </p>
|
||||
</li>
|
||||
</ul>
|
||||
<p> Honorable Mentions: None :( </p>
|
||||
<h2 id="intelligent-pdf-viewers-ebook-readers-audiobook-podcast-players"> Intelligent PDF viewers, eBook readers, audiobook & podcast players <a href="#intelligent-pdf-viewers-ebook-readers-audiobook-podcast-players">#</a>
|
||||
</h2>
|
||||
<figure>
|
||||
<img src="https://proxy-prod.omnivore-image-cache.app/0x0,sECm2yNG0SpfWJthGyGHiTFk_7du_Ueh2VLXh_jLdtzU/https://d33wubrfki0l68.cloudfront.net/5aa98bca712e33bc729c180c9a588f4d5ac7e9af/c5011/digital-tools/ebook-concept.png">
|
||||
<figcaption> Functionality I want in my document reader </figcaption>
|
||||
</figure>
|
||||
<p> Reading is incredible and I love my Kindle. But eBooks today are just a step above OCR’ing a book and slapping on a few basic features which have existed for 30+ years. While I’m reading an eBook I want to: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Have relevant illustrations, graphs, and tables appear for duration of their mentions so I don’t have to flip back and forth between them. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> See glossary terms and their definitions which appear on this page. Highlighting and searching a term is great but the author may have added important context to the glossary definition. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> View popular annotations and highlights across <strong>all</strong> mediums - not just by other readers who own an Amazon Kindle readers and purchased this book version and also happened to highlight it enough times. A quote was referenced in 300 blog articles? A two sentence excerpt retweeted 50,000 times? You bet I want to know! </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Follow referenced information easily. You cited a paper - great, let’s look at the footnotes. Oh, the full reference is in the back of the book. Online list of citations? Of course not! Drop a bookmark, navigate to the back of the book, pull out my laptop, find the paper. Of course, a paywall. Grab a snack. Acquire the PDF. Search for keywords to try to find the referenced information. Sigh, 2019. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Not be hindered by the DRM system. Copyright is important and I want to support authors but it’s insane to me all these content licenses I’m acquiring can’t be donated to a library upon account closure. Yes, legal DRM-free eBooks exist but they aren’t without their own issues. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Seamlessly switch between devices and formats while retaining my position. Something like Whispersync (<a href="https://www.amazon.com/gp/feature.html?ie=UTF8&docId=1000827761">a neat idea</a> but come on, I’m not made of money. Also, see above points). </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Let me use a digital or physical keyboard instead of an e-ink keyboard to type my annotations. A possibility here is a companion app, which feels like a notes app but ties my notes to their location/text in the book I’m reading. </p>
|
||||
</li>
|
||||
</ul>
|
||||
<figure>
|
||||
<img src="https://proxy-prod.omnivore-image-cache.app/0x0,sXee_1nhUYbWBBt7y_vCFpm-qbhSzFfklSS0e77sALK4/https://d33wubrfki0l68.cloudfront.net/7bc95c0c51628e5ba06ddb7e688a58c600e503c8/81599/digital-tools/audiobook-player.png">
|
||||
<figcaption> What I want my audiobook player to look like </figcaption>
|
||||
</figure>
|
||||
<p> Most of these points above also apply to my experience listening to podcasts, audiobooks, and watching Youtube videos and interviews. I find myself wishing I could: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Navigate them more comfortably. Both Libby and Audible leave much to be desired in terms of navigation. Finding a quote I remember hearing to three days ago is basically blindly stumbling around - and I lose my current spot too. Seeing a list of chapter numbers for the book I’m <strong>listening to</strong> has been helpful a grand total of 0 times. And how cool would it be to drop a bookmark from my bluetooth-connected headphones as I’m biking down a street. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> View an auto-generated transcript of a podcast as I’m listening to it. It should have easy-to-follow links to references to other podcasts, media, books and support searching for key terms. YouTube already transcribes all of their videos and Google Meet now generates live captions as we’re talking - why can’t we do something similar with podcast apps? </p>
|
||||
</li>
|
||||
</ul>
|
||||
<p> Honorable Mentions: <a href="https://readwise.io/">Readwise</a>, <a href="https://www.weavatools.com/">Weava</a>, <a href="https://www.descript.com/">Descript</a>, <a href="https://otter.ai/">Otter.ai</a>, <a href="https://getpolarized.io/#features">Polar</a>
|
||||
</p>
|
||||
<h2 id="a-centralized-search-interface-for-my-digital-brain-memex"> A centralized search interface for my digital brain (memex) <a href="#a-centralized-search-interface-for-my-digital-brain-memex">#</a>
|
||||
</h2>
|
||||
<p> I want to be able to open an interface, type three words, and instantly see results from everything my digital self has interacted with. Emails, years of full-text browsing history, text messages, Slack messages across <strong>all</strong> my organizations, calendar invites and events, books, podcast transcripts I’ve consumed, Twitter and Instagram DMs, PDFs I’ve downloaded, bash commands, videos I’ve seen, my online and offline files, notes, blog post drafts - I really do mean everything. </p>
|
||||
<p> I acutely feel the need for this when I’m trying to find something I know I’ve seen online but can’t remember where I saw it. Google is wonderful for finding new information, but absolutely poor for re-finding things. Chrome’s history has so much potential - but I suspect Google would much rather have us look at their ads a few additional times rather than go direct to the source. I accept I might be in the minority on this one. Regardless, this tool should: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Accept and parse the following queries: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> spacex announcement type:video 2016 </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> links from:jon@test.org topic:python </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> paper on temperature, productivity referenced in book:Uninhabitable Earth </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> type:pdf habits digital interfaces </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> reading comprehension type:blog post </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> printer ink receipt </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> type:book read:2017 finance </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> file:py datetime parse </p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p> Respect my privacy: hosted on something I control and never mined for ads. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Support all my devices with two-way sync so I can search and add to it wherever I am. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Be extensible: allow me to easily ingest my own information and extend with desired functionality. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Cluster information based on content, tags, geo-location, connected people, conversations, source, and other factors I’m not even aware of. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Notify me about changes to documents and webpages I’ve visited. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Allow a rough export of my research on a topic (like, a knowledge dump off everything I’ve consumed on pandas) with the ability to easily share it. </p>
|
||||
</li>
|
||||
</ul>
|
||||
<p> Honorable Mentions: <a href="https://worldbrain.io/">Memex by Worldbrain.io</a>, <a href="https://roamresearch.com/">Roam Research</a>, <a href="https://www.notion.so/">Notion</a>, <a href="https://coda.io/welcome">Coda.io</a>, <a href="https://www.alfredapp.com/">Alfred</a>, <a href="https://trovenow.com/">Trove</a>, <a href="https://localnative.app/">Local Native</a>, <a href="https://github.com/pirate/ArchiveBox">ArchiveBox</a>, <a href="https://raindrop.io/">Raindrop</a>
|
||||
</p>
|
||||
<h2 id="parting-thoughts"> Parting Thoughts <a href="#parting-thoughts">#</a>
|
||||
</h2>
|
||||
<p> I’m fascinated with a better bridge between our minds and our digital devices. A well-designed tool should disappear and allow complete attention to the task at hand, but digital devices today are far from this ideal - often due to arcane copyright laws or profit-seeking. These aren’t new ideas by any means. See Vannevar Bush’s <a href="https://en.wikipedia.org/wiki/As_We_May_Think">original conception</a> of a memex over 70 years ago. We are way overdue for this. I see enormous potential at combining a true memex with all of our personal data (health, fitness, biometrics) along with our habits, goals, tasks, reflections, and communication tools. </p>
|
||||
<p> It seems to me that as information becomes more abundant, the connections drawn between disparate pieces are becoming increasingly important. The easier it is to share that graph with other people, the faster we can learn from each other and understand complex relationships. I’m excited for a world where knowledge is easier to discover, validate, dispute, understand, retain, and share. </p>
|
||||
<p> I hope to cover my thoughts on processes, note-taking apps, and knowledge graphs next. Stay tuned <a href="https://mailchi.mp/0e81591ed912/jborichevskiy">here</a>. My thanks to Arthur Tyukayev, Alex Ly, <a href="https://twitter.com/davidmeh">David Heimann</a>, <a href="https://twitter.com/ylimedeg">Em deGrandpré</a>, <a href="https://twitter.com/alexeyguzey">Alexey Guzey</a>, Sam Tkachuk, and <a href="https://twitter.com/briantimar">Brian Timar</a> for reading drafts of this and providing wonderful feedback. </p>
|
||||
<p>
|
||||
<a href="https://news.ycombinator.com/item?id=21659876">HN Discussion</a>
|
||||
</p>
|
||||
<h2 id="appendix"> Appendix <a href="#appendix">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
<a href="https://beepb00p.xyz/sad-infra.html">The sad state of personal data and infrastructure (beepb00p.xyz)</a> <a href="https://zettelkasten.de/posts/reading-web-rss-note-taking">Note-Taking when Reading the Web and RSS</a>
|
||||
</p>
|
||||
</div>
|
||||
</DIV>
|
||||
|
|
@ -1,453 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>
|
||||
Digital Tools I Wish Existed :: up & to the right — Jonathan Borichevskiy
|
||||
</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
|
||||
<meta name="description" content="My digital life in a nutshell: I discover relevant content I don&rsquo;t have time to consume, I find time and become overwhelmed with my scattered backlog, I wish the content were in a different format, and then I&rsquo;m unable to find something again once I&rsquo;ve consumed it. Not retaining enough is a valid problem but we&rsquo;ll tackle that one later. There&rsquo;s a lot of generalization in my summary but the core issue is an extraordinarily high level of friction in the process of finding, organizing, and sharing digital content." />
|
||||
<meta name="keywords" content="" />
|
||||
<meta name="robots" content="noodp" />
|
||||
<link rel="canonical" href="https://jon.bo/posts/digital-tools/" />
|
||||
<link rel="stylesheet" href="https://jon.bo/assets/style.css" />
|
||||
<link rel="stylesheet" href="https://jon.bo/style.css" />
|
||||
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="https://jon.bo/img/apple-touch-icon-144-precomposed.png" />
|
||||
<link rel="shortcut icon" href="https://jon.bo/img/favicon.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Digital Tools I Wish Existed" />
|
||||
<meta name="twitter:description" content="My digital life in a nutshell: I discover relevant content I don’t have time to consume, I find time and become overwhelmed with my scattered backlog, I wish the content were in a different format, and then I’m unable to find something again once I’ve consumed it. Not retaining enough is a valid problem but we’ll tackle that one later. There’s a lot of generalization in my summary but the core issue is an extraordinarily high level of friction in the process of finding, organizing, and sharing digital content." />
|
||||
<meta property="og:title" content="Digital Tools I Wish Existed" />
|
||||
<meta property="og:description" content="My digital life in a nutshell: I discover relevant content I don’t have time to consume, I find time and become overwhelmed with my scattered backlog, I wish the content were in a different format, and then I’m unable to find something again once I’ve consumed it. Not retaining enough is a valid problem but we’ll tackle that one later. There’s a lot of generalization in my summary but the core issue is an extraordinarily high level of friction in the process of finding, organizing, and sharing digital content." />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="https://jon.bo/posts/digital-tools/" />
|
||||
<meta property="article:published_time" content="2019-11-28T02:09:25-05:00" />
|
||||
<meta property="article:modified_time" content="2020-11-28T13:37:37-06:00" />
|
||||
<meta property="og:site_name" content="up & to the right" />
|
||||
<script src="https://hypothes.is/embed.js" async="async"></script>
|
||||
<link rel="sidebar" href="https://hypothes.is/app.html" type="application/annotator+html" data-hypothesis-asset="" />
|
||||
<link rel="notebook" href="https://hypothes.is/notebook" type="application/annotator+html" data-hypothesis-asset="" />
|
||||
<link rel="preload" as="style" href="https://cdn.hypothes.is/hypothesis/1.1062.0/build/styles/annotator.css?310566" data-hypothesis-asset="" />
|
||||
<link rel="hypothesis-client" href="https://cdn.hypothes.is/hypothesis/1.1062.0/build/boot.js" type="application/annotator+javascript" data-hypothesis-asset="" />
|
||||
<script src="https://cdn.hypothes.is/hypothesis/1.1062.0/build/scripts/annotator.bundle.js?dbbf5e" data-hypothesis-asset=""></script>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.hypothes.is/hypothesis/1.1062.0/build/styles/highlights.css?5dd56a" data-hypothesis-asset="" />
|
||||
</head>
|
||||
<body class="dark-theme hypothesis-highlights-always-on">
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<span class="header__inner"><a href="/about" class="logo" style="text-decoration:none"><span class="logo__mark"><svg xmlns="http://www.w3.org/2000/svg" class="greater-icon" width="44" height="44" viewbox="0 0 44 44">
|
||||
<path fill="none" stroke="#fff" stroke-width="2" d="M15 8 29.729 22.382 15 35.367"></path></svg></span><span class="logo__text">up & to the right</span> </a></span>
|
||||
<nav class="menu">
|
||||
<ul class="menu__inner menu__inner--desktop">
|
||||
<li>
|
||||
<span class="header__right"><a href="/ideas">ideas</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/now">now</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/books">words-in</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/posts">words-out</a></span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="menu__inner menu__inner--mobile">
|
||||
<li>
|
||||
<span class="header__right"><a href="/ideas">ideas</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/now">now</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/books">words-in</a></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="header__right"><a href="/posts">words-out</a></span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav><span class="header__right"><span class="menu-trigger hidden"><svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"></path></svg></span><span class="theme-toggle"><svg class="theme-toggler" width="24" height="24" viewbox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 41C32.4934 41 41 32.4934 41 22 41 11.5066 32.4934 3 22 3 11.5066 3 3 11.5066 3 22 3 32.4934 11.5066 41 22 41zM7 22C7 13.7157 13.7157 7 22 7V37C13.7157 37 7 30.2843 7 22z"></path></svg></span></span>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="post">
|
||||
<h2 class="post-title">
|
||||
<a href="https://jon.bo/posts/digital-tools/">Digital Tools I Wish Existed</a>
|
||||
</h2>
|
||||
<h3 class="post-subtitle"></h3>
|
||||
<div class="post-meta">
|
||||
<span class="post-date">Published: 2019-11-28</span>
|
||||
</div><span class="post-tags" style="margin-top:10px">#<a href="https://jon.bo/tags/memex/">memex</a>  #<a href="https://jon.bo/tags/information/">information</a>  #<a href="https://jon.bo/tags/learning/">learning</a>  #<a href="https://jon.bo/tags/web/">web</a>  #<a href="https://jon.bo/tags/internet/">internet</a> </span>
|
||||
<div class="post-content">
|
||||
<p>
|
||||
My digital life in a nutshell: I discover relevant content I don’t have time to consume, I find time and become overwhelmed with my scattered backlog, I wish the content were in a different format, and then I’m unable to find something again once I’ve consumed it. <a href="https://andymatuschak.org/books/">Not retaining enough</a> is a valid problem but we’ll tackle that one later.
|
||||
</p>
|
||||
<p>
|
||||
There’s a lot of generalization in my summary but <strong>the core issue is an extraordinarily high level of friction in the process of finding, organizing, and sharing digital content</strong>. During the past few years I’ve noticed:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
The more seamless the acquisition & ingestion, the more engaged I am with the content
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Insights are just as likely to be found in a 400-page book as in a 40-minute podcast
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Notes and their subsequent review are essential for long-term retention
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Recommendations from other humans are as good, if not better, than algorithmic suggestions
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
In the rest of this post I attempt to explain the digital tools I wish existed, and how the the currently available tools do not suffice. What are also probably lacking are my <a href="https://www.buildingasecondbrain.com/">habits and workflows</a> around this - but I’m looking at tools specifically here.
|
||||
</p>
|
||||
<h2 id="queue-management-for-inbound-digital-content">
|
||||
Queue management for inbound digital content <a class="headline-hash" href="#queue-management-for-inbound-digital-content">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
Where to begin? Probably the most common problem I see myself and other people dealing with is processing the incoming deluge of articles to read and videos to watch. This isn’t all personal recommendations - it encompasses any and all content I think my future self would appreciate me consuming. A list of issues, roughly by order of appearance:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Content (or links to it) arrive from a variety of sources including text messages from friends, email conversations, tweetstorms and replies, references in books, suggestions in real-world conversations, and more.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Every book, article, post, or tweet has the potential to lead to more content.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Content is published in a variety of formats including but not limited to images, sound files, videos, Google Drive docs, diagrams, long-form paywalled articles, PDFs, powerpoint presentations, and base 64 encoded blobs.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I have little visibility into required time investment and foundational context until I’ve opened it and started thinking about it. Should I sit down with a pen and paper to read this or can I skim it while waiting for my coffee?
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Learning, work, news, and entertainment all have different priorities in my life (roughly in that order).
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I would like to batch process content in different “streams” regardless of where they are stored. For example: I have two hours, let me work through interesting text content my friends sent me last week. Or: show me all the interesting/relevant videos I’ve queued over the past month.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I’m not always connected to a stable internet connection.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
If it’s a long piece of content I want my position saved reliably so I can resume at a later point.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I often want it in a different format than the one it was originally published in (audio → text, text → audio, pdf → ebook). Automated conversion works but is cumbersome. Listening to text articles requires sending them to a special app and converting articles to ebooks is annoying and loses a lot of formatting and navigation.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I love to respond to a person’s recommendation - preferably before they’ve forgotten why they sent me it in the first place.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
I’d like a centralized history of content tied to my notes and annotations in case I want to find it again later. It feels like every week I’m speaking with someone and I remember a blog post I read a few months ago they might find relevant … or was it a Reddit post? Can I find it my history? Oh no, it’s been replaced with <code>[deleted]</code> … find an archived copy… rinse and repeat.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<figure class="center">
|
||||
<img src="https://imgs.xkcd.com/comics/icon_swap.png" />
|
||||
<figcaption class="center">
|
||||
Relevant XKCD, as is tradition
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
Following my curiosity feels like chasing a caffeinated bunny around while real understanding requires time, perspective, and reflection. The internet makes the former much easier - so I find myself constantly balancing the two. Additionally, my energy and attention levels vary throughout the day and it’s far easier to just open Twitter rather than continue reading a long-form article I started on my laptop two days ago. Too often I default to the lower-friction one.
|
||||
</p>
|
||||
<p>
|
||||
Honorable Mentions: <a href="https://getpocket.com/">Pocket</a>, <a href="https://www.instapaper.com/">Instapaper</a>
|
||||
</p>
|
||||
<h2 id="a-universal-book-log-recommendation-sharing-system">
|
||||
A universal book log, recommendation & sharing system <a class="headline-hash" href="#a-universal-book-log-recommendation-sharing-system">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
I love exploring other peoples’ reading lists. Here’s <a href="/books">my own</a>. I find everyone keeps their reading lists in different formats on different platforms. Plaintext lists are nice but hard to parse. Spreadsheets are easy to parse but a pain to manage. Third-party services aren’t interoperable, require logins, and are not future-proof.
|
||||
</p>
|
||||
<p>
|
||||
Part of the problem here is <a href="https://people.well.com/user/doctorow/metacrap.htm">metadata is hard</a>. Someone has to sit there and fill out the author, title, subtitle, summary, page count - and they’re probably not going to do it for free. Amazon is a good at it but <a href="https://stallman.org/amazon.html#publishing">is hostile to publishers</a>. Goodreads has much potential but <a href="https://onezero.medium.com/almost-everything-about-goodreads-is-broken-662e424244d5">seems to have stagnated</a>. Linking to the book’s Wikipedia entry would be my preference but very few books have an entry.
|
||||
</p>
|
||||
<p>
|
||||
Whatever this tool for managing my ever-growing reading list will be, it should:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Let me compare my reading list with another to see overlap. I find this a wonderful way to spark conversation and find common interests.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Allow me to tag books instead of placing them into static lists (think clusters or tag clouds).
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Be tied to my highlights, annotations, and bookmarks in a non-proprietary, searchable, and shareable format. Make them public if I want to.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Save context on where and when I found this book: why I thought it was important to read, when I read it, what I wrote down while reading it, and what other content I discovered through it.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Let me query this tool like a relational database. For example: show me all books about scaling startups recommended by people I follow on Twitter or by people they follow. The current Twitter search makes me feel like I’m using a government site created before I myself even knew what a computer was.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Help me <a href="https://www.lesswrong.com/posts/Kmch6T2YscMyLFJD9/rational-reading-thoughts-on-prioritizing-books">deal with prioritization</a>. My reading list is a mess and I can’t be alone. Are certain books better read before others? Prerequisites? Could three of them be replaced with one? What are the other books by the this author? Are they worth reading too? Why exactly did I think reading this 800 page book was relevant when I added it? <a href="https://www.samuelthomasdavies.com/book-summaries/health-fitness/the-checklist-manifesto/">Is 80% of the content attainable from a blog post?</a> Where is that post? Has someone in my network written a rebuttal to the ideas in this book? The list goes on and on.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Provide relevant suggestions with the typical recommender approach based on what people interested in the same topics also enjoyed reading and learning from.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Honorable Mentions: None :(
|
||||
</p>
|
||||
<h2 id="intelligent-pdf-viewers-ebook-readers-audiobook-podcast-players">
|
||||
Intelligent PDF viewers, eBook readers, audiobook & podcast players <a class="headline-hash" href="#intelligent-pdf-viewers-ebook-readers-audiobook-podcast-players">#</a>
|
||||
</h2>
|
||||
<figure class="center">
|
||||
<img src="https://d33wubrfki0l68.cloudfront.net/5aa98bca712e33bc729c180c9a588f4d5ac7e9af/c5011/digital-tools/ebook-concept.png" />
|
||||
<figcaption class="center">
|
||||
Functionality I want in my document reader
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
Reading is incredible and I love my Kindle. But eBooks today are just a step above OCR’ing a book and slapping on a few basic features which have existed for 30+ years. While I’m reading an eBook I want to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Have relevant illustrations, graphs, and tables appear for duration of their mentions so I don’t have to flip back and forth between them.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
See glossary terms and their definitions which appear on this page. Highlighting and searching a term is great but the author may have added important context to the glossary definition.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
View popular annotations and highlights across <strong>all</strong> mediums - not just by other readers who own an Amazon Kindle readers and purchased this book version and also happened to highlight it enough times. A quote was referenced in 300 blog articles? A two sentence excerpt retweeted 50,000 times? You bet I want to know!
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Follow referenced information easily. You cited a paper - great, let’s look at the footnotes. Oh, the full reference is in the back of the book. Online list of citations? Of course not! Drop a bookmark, navigate to the back of the book, pull out my laptop, find the paper. Of course, a paywall. Grab a snack. Acquire the PDF. Search for keywords to try to find the referenced information. Sigh, 2019.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Not be hindered by the DRM system. Copyright is important and I want to support authors but it’s insane to me all these content licenses I’m acquiring can’t be donated to a library upon account closure. Yes, legal DRM-free eBooks exist but they aren’t without their own issues.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Seamlessly switch between devices and formats while retaining my position. Something like Whispersync (<a href="https://www.amazon.com/gp/feature.html?ie=UTF8&docId=1000827761">a neat idea</a> but come on, I’m not made of money. Also, see above points).
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Let me use a digital or physical keyboard instead of an e-ink keyboard to type my annotations. A possibility here is a companion app, which feels like a notes app but ties my notes to their location/text in the book I’m reading.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<figure class="center">
|
||||
<img src="https://d33wubrfki0l68.cloudfront.net/7bc95c0c51628e5ba06ddb7e688a58c600e503c8/81599/digital-tools/audiobook-player.png" />
|
||||
<figcaption class="center">
|
||||
What I want my audiobook player to look like
|
||||
</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
Most of these points above also apply to my experience listening to podcasts, audiobooks, and watching Youtube videos and interviews. I find myself wishing I could:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Navigate them more comfortably. Both Libby and Audible leave much to be desired in terms of navigation. Finding a quote I remember hearing to three days ago is basically blindly stumbling around - and I lose my current spot too. Seeing a list of chapter numbers for the book I’m <strong>listening to</strong> has been helpful a grand total of 0 times. And how cool would it be to drop a bookmark from my bluetooth-connected headphones as I’m biking down a street.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
View an auto-generated transcript of a podcast as I’m listening to it. It should have easy-to-follow links to references to other podcasts, media, books and support searching for key terms. YouTube already transcribes all of their videos and Google Meet now generates live captions as we’re talking - why can’t we do something similar with podcast apps?
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Honorable Mentions: <a href="https://readwise.io/">Readwise</a>, <a href="https://www.weavatools.com/">Weava</a>, <a href="https://www.descript.com/">Descript</a>, <a href="https://otter.ai/">Otter.ai</a>, <a href="https://getpolarized.io/#features">Polar</a>
|
||||
</p>
|
||||
<h2 id="a-centralized-search-interface-for-my-digital-brain-memex">
|
||||
A centralized search interface for my digital brain (memex) <a class="headline-hash" href="#a-centralized-search-interface-for-my-digital-brain-memex">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
I want to be able to open an interface, type three words, and instantly see results from everything my digital self has interacted with. Emails, years of full-text browsing history, text messages, Slack messages across <strong>all</strong> my organizations, calendar invites and events, books, podcast transcripts I’ve consumed, Twitter and Instagram DMs, PDFs I’ve downloaded, bash commands, videos I’ve seen, my online and offline files, notes, blog post drafts - I really do mean everything.
|
||||
</p>
|
||||
<p>
|
||||
I acutely feel the need for this when I’m trying to find something I know I’ve seen online but can’t remember where I saw it. Google is wonderful for finding new information, but absolutely poor for re-finding things. Chrome’s history has so much potential - but I suspect Google would much rather have us look at their ads a few additional times rather than go direct to the source. I accept I might be in the minority on this one. Regardless, this tool should:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
Accept and parse the following queries:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
spacex announcement type:video 2016
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
links from:jon@test.org topic:python
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
paper on temperature, productivity referenced in book:Uninhabitable Earth
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
type:pdf habits digital interfaces
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
reading comprehension type:blog post
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
printer ink receipt
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
type:book read:2017 finance
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
file:py datetime parse
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Respect my privacy: hosted on something I control and never mined for ads.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Support all my devices with two-way sync so I can search and add to it wherever I am.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Be extensible: allow me to easily ingest my own information and extend with desired functionality.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Cluster information based on content, tags, geo-location, connected people, conversations, source, and other factors I’m not even aware of.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Notify me about changes to documents and webpages I’ve visited.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
Allow a rough export of my research on a topic (like, a knowledge dump off everything I’ve consumed on pandas) with the ability to easily share it.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Honorable Mentions: <a href="https://worldbrain.io/">Memex by Worldbrain.io</a>, <a href="https://roamresearch.com/">Roam Research</a>, <a href="https://www.notion.so/">Notion</a>, <a href="https://coda.io/welcome">Coda.io</a>, <a href="https://www.alfredapp.com/">Alfred</a>, <a href="https://trovenow.com/">Trove</a>, <a href="https://localnative.app/">Local Native</a>, <a href="https://github.com/pirate/ArchiveBox">ArchiveBox</a>, <a href="https://raindrop.io/">Raindrop</a>
|
||||
</p>
|
||||
<h2 id="parting-thoughts">
|
||||
Parting Thoughts <a class="headline-hash" href="#parting-thoughts">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
I’m fascinated with a better bridge between our minds and our digital devices. A well-designed tool should disappear and allow complete attention to the task at hand, but digital devices today are far from this ideal - often due to arcane copyright laws or profit-seeking. These aren’t new ideas by any means. See Vannevar Bush’s <a href="https://en.wikipedia.org/wiki/As_We_May_Think">original conception</a> of a memex over 70 years ago. We are way overdue for this. I see enormous potential at combining a true memex with all of our personal data (health, fitness, biometrics) along with our habits, goals, tasks, reflections, and communication tools.
|
||||
</p>
|
||||
<p>
|
||||
It seems to me that as information becomes more abundant, the connections drawn between disparate pieces are becoming increasingly important. The easier it is to share that graph with other people, the faster we can learn from each other and understand complex relationships. I’m excited for a world where knowledge is easier to discover, validate, dispute, understand, retain, and share.
|
||||
</p>
|
||||
<p>
|
||||
I hope to cover my thoughts on processes, note-taking apps, and knowledge graphs next. Stay tuned <a href="https://mailchi.mp/0e81591ed912/jborichevskiy">here</a>. My thanks to Arthur Tyukayev, Alex Ly, <a href="https://twitter.com/davidmeh">David Heimann</a>, <a href="https://twitter.com/ylimedeg">Em deGrandpré</a>, <a href="https://twitter.com/alexeyguzey">Alexey Guzey</a>, Sam Tkachuk, and <a href="https://twitter.com/briantimar">Brian Timar</a> for reading drafts of this and providing wonderful feedback.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://news.ycombinator.com/item?id=21659876">HN Discussion</a>
|
||||
</p>
|
||||
<p>
|
||||
2019-12-09: fixes grammar
|
||||
</p>
|
||||
<h2 id="appendix">
|
||||
Appendix <a class="headline-hash" href="#appendix">#</a>
|
||||
</h2>
|
||||
<p>
|
||||
<a href="https://beepb00p.xyz/sad-infra.html">The sad state of personal data and infrastructure (beepb00p.xyz)</a> <a href="https://zettelkasten.de/posts/reading-web-rss-note-taking">Note-Taking when Reading the Web and RSS</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<div class="pagination__title">
|
||||
<span class="pagination__title-h">Read other posts</span>
|
||||
<hr />
|
||||
</div>
|
||||
<div class="pagination__buttons">
|
||||
<span class="button previous"><a href="https://jon.bo/posts/year-in-review-2019/"><span class="button__icon">←</span> <span class="button__text">Year in Review: 2019</span></a></span> <span class="button next"><a href="https://jon.bo/posts/healthy-living/"><span class="button__text">Healthy Living</span> <span class="button__icon">→</span></a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="footer__inner">
|
||||
<div class="copyright copyright--user">
|
||||
<a href="/about">jon.bo</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://jon.bo/assets/main.js"></script>
|
||||
<script src="https://jon.bo/assets/prism.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<article>
|
||||
<div>
|
||||
<h3> Omnivore is a read-it-later app that lets you save and organize everything you read online. </h3>
|
||||
</div>
|
||||
<div dir="auto">
|
||||
<p> This guide will show you how to use Omnivore’s basic functions and advanced features, divided into four main activities: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Saving </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Reading </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Organizing </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Integrations </p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<span>The</span> <strong>Library</strong> <span>is the center of your Omnivore experience, where you can quickly access any links you have saved. Saved links remain in your Library forever unless you delete them.</span>
|
||||
</p>
|
||||
<p> There are five ways to save links to pages or articles that you wish to read later: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Saving from Your Omnivore Library </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Saving from a Browser </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Saving from a Phone or Tablet (iOS or Android) </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Newsletter Subscriptions via Email </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Saving PDFs from a Mac</span><br>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3> Saving from Your Omnivore Library </h3>
|
||||
<p>
|
||||
<span>1. In the upper right corner of your Library, tap the</span> <strong>Add Link</strong> <span>button.</span><br>
|
||||
<span>2. Enter the URL you wish to save and tap</span> <strong>Add Link</strong><span>.</span><br>
|
||||
<span>3. The link will appear in your Library the next time you refresh it.</span><br>
|
||||
</p>
|
||||
<h3> Saving from a Browser </h3>
|
||||
<p> 1. Download and install the Omnivore extension for your browser: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/chrome" rel="">Chrome </a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/edge" rel="">Edge</a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/firefox" rel="">Firefox</a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/safari" rel="">Safari</a>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<span>2. Navigate to the page you wish to save and tap the Omnivore button in your browser’s toolbar or Extensions menu.</span><br>
|
||||
<span>3. Alternatively, you can right-click (command+click on Mac) on any hyperlink and select</span> <strong>Save to Omnivore</strong> <span>from the menu.</span><br>
|
||||
<span>4. The link will appear in your Library the next time you refresh it.</span><br>
|
||||
</p>
|
||||
<h3> Saving from a Phone or Tablet </h3>
|
||||
<p> The best way to save links from your mobile device is via the Omnivore app. You can download the app here: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://omnivore.app/install/ios" rel="">iOS (iPhone or iPad)</a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<a href="https://play.google.com/store/apps/details?id=app.omnivore.omnivore" rel="">Android (Currently in pre-release)</a>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p> Once the mobile app is installed: </p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>In your browser, navigate to the page you wish to save and tap the</span> <strong>Share</strong> <span>button.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the</span> <strong>Omnivore</strong> <span>icon in the Share menu.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The link will appear in your Library the next time you refresh it. </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Newsletter Subscriptions via Email </h3>
|
||||
<p>
|
||||
<span>1. On the Omnivore website or app, tap your photo, initial, or avatar in the top right corner to access the profile menu. Select</span> <strong>Emails</strong> <span>from the menu.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>2. Tap</span> <strong>Create a New Email Address</strong> <span>to add a new email address (ex: username-123abc@inbox.omnivore.app) to the list.</span>
|
||||
</p>
|
||||
<p> 3. Click the Copy icon next to the email address. </p>
|
||||
<p>
|
||||
<span>4. Navigate to the signup page for the newsletter you wish to subscribe to.</span><br>
|
||||
<span>5. Paste the Omnivore email address into the signup form.</span>
|
||||
</p>
|
||||
<p> 6. New newsletters will be automatically delivered to your Omnivore inbox. </p>
|
||||
<p> NOTE: If Omnivore receive's an email that does not look like an article, such as a welcome message, or note from the author, it will be forwarded to your Omnivore account email address (the email you registered with). </p>
|
||||
<h3> Saving PDFs from a Mac </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>Install the</span> <a href="https://omnivore.app/install/mac" rel="">Mac App</a><span>. </span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> On your Mac, locate the PDF you wish to save and right-click or ctrl+click on the file name. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Share</strong> <span>from the menu and choose</span> <strong>Omnivore</strong><span>.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The link will appear in your Library the next time you refresh it. </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h2> Reading </h2>
|
||||
<p> Click any link saved in your Library to enter the Reader view. </p>
|
||||
<p> Omnivore formats pages for easy reading and highlighting, removing ads and clutter for distraction-free reading. The text-focused view also makes articles smaller and quicker to load. </p>
|
||||
<p> While reading, you can: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Change Formatting </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Highlight Text </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Add Notes </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> View All Saved Highlights and Notes </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Track Reading Progress </p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3> Change Formatting </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<em><strong>Theme:</strong></em> <span>Tap your photo, initial, or avatar in the top right corner to access the profile menu. Select the white or black thumbnail to choose the Light or Dark theme.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<em><strong>Text Formatting:</strong></em> <span>Tap the Aa icon to adjust the text size, font, margins, and line spacing.</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Highlight Text </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p> Select the text you wish to highlight. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the</span> <strong>Highlight</strong> <span>button.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The text will appear highlighted next time you view the article. </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Add Notes </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p> Highlight a section of text where you wish to add a note. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the</span> <strong>Note</strong> <span>button, type your note, and tap</span> <strong>Save</strong><span>.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The Note icon will appear next time you view this article. </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> View All Saved Highlights and Notes </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p> Tap the Highlight/Note icon to see a list of all the highlighted text and notes you have added to this page. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> To remove a note or highlight, select it from the list and tap the Trash icon. </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Track Reading Progress </h3>
|
||||
<p> Omnivore automatically keeps track of your reading progress across your different devices so you can easily pick up where you left off. A progress bar will appear at the top of each link in your Library after you have started reading. </p>
|
||||
<h2> Organizing </h2>
|
||||
<p> By default, the Library inbox displays all links you have saved. To manage your list and keep your reading organized, Omnivore provides the following actions: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Archiving </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Labels </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Search </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Filters </p>
|
||||
</li>
|
||||
</ul>
|
||||
<h2> Archiving </h2>
|
||||
<ol>
|
||||
<li>
|
||||
<p> Tap the Menu icon next to the link you wish to archive (on the mobile app, long press the link to open the menu). </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Archive</strong><span>.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The link will disappear from the default Library view, but will show up if you select the Archived filter (see Filters below). </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3>
|
||||
<strong>Labels</strong>
|
||||
</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the Menu icon next to any link and select</span> <strong>Set Label</strong><span>s.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select an existing label from the list or tap</span> <strong>Edit Labels</strong> <span>to create a new one.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> The label will appear next to the link in your Library. Tap it to view all links with the same label. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<em>Omnivore mobile app only</em><span>: tap</span> <strong>Labels</strong> <span>to see a complete list of all labels you have used; tap one to view all links with the same label</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Note: Omnivore will automatically assign some labels, such as “Newsletters.” </p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Search </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p> To search through all your saved links, enter a keyword or phrase in the search bar. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>You can combine keywords with labels and filters to focus your search even further.</span> <a href="https://docs.omnivore.app/using/search.html" rel="">Learn more about advanced search</a><span>.</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h3> Filters </h3>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>Use the</span> <strong>Filters</strong> <span>menu to refine your Library view (some filters may be visible by default).</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Read Later</strong> <span>to view a list of all your non-archived links except Newsletters.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Highlights</strong> <span>to view the text selections you have highlighted in all your saved pages. </span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Today</strong> <span>to view a list of links you saved today.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Select</span> <strong>Newsletters</strong> <span>to view links saved via your newsletter subscriptions.</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h2> Integrations </h2>
|
||||
<p> Omnivore allows integrations with knowledge bases and note-taking apps including: </p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Logseq </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Webhooks </p>
|
||||
</li>
|
||||
</ul>
|
||||
<h3> Logseq </h3>
|
||||
<p>
|
||||
<span>With Omnivore's Logseq plugin you can sync all your saved articles, highlights, and notes into Logseq, a popular knowledge base. For information on setting up and using the Logseq plugin, please refer to this helpful</span> <a href="https://briansunter.com/graph/#/page/omnivore-logseq-guide" rel="">Omnivore for Logseq Plugin Guide</a><span>.</span>
|
||||
</p>
|
||||
<h3> Webhooks </h3>
|
||||
<p>
|
||||
<span>Omnivore can trigger webhooks when you save a link or add highlights to a page you are reading.</span> <a href="https://blog.omnivore.app/p/syncing-all-your-notes-to-google" rel="">This example</a> <span>shows webhooks being used to write all saved links to a Google Sheets spreadsheet stored on a Google Drive.</span>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</DIV>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<DIV class="page" id="readability-page-1">
|
||||
<article>
|
||||
<div dir="auto">
|
||||
<p>
|
||||
<span>With the</span> <a href="https://omnivore.app/install/ios" rel="">Omnivore app for iOS</a><span>, it’s easy to save web pages and articles or archive web content to read later.</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>The Omnivore app uses the</span> <em>iOS Share System</em><span>, which lets you send items from one app (such as Safari) to another (such as Messages or Mail). </span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p> Step 1: Log in to the Omnivore app. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Step 2: Add Omnivore to your Share menu favorites. </p>
|
||||
</li>
|
||||
<li>
|
||||
<p> Step 3: Save links to your Omnivore Library. </p>
|
||||
</li>
|
||||
</ul>
|
||||
<div id="youtube2-k6RkIqepAig" data-attrs="{"videoId":"k6RkIqepAig","startTime":null,"endTime":null}">
|
||||
<P>
|
||||
<iframe src="https://www.youtube-nocookie.com/embed/k6RkIqepAig?rel=0&autoplay=0&showinfo=0&enablejsapi=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe>
|
||||
</P>
|
||||
</div>
|
||||
<p> You must be logged in before you can save links via the Share menu. If you don’t already have an Omnivore account, you can sign up for free from the login screen. </p>
|
||||
<p>
|
||||
<em>Note:</em> <span>If you haven’t installed the iOS app, download it here:</span> <a href="https://omnivore.app/install/ios" rel="">https://omnivore.app/install/ios</a>
|
||||
</p>
|
||||
<h2>
|
||||
<strong>Step 2: Add Omnivore to your Share menu favorites.</strong>
|
||||
</h2>
|
||||
<p> Start by viewing the Share menu from within any supported iOS app (we’ve used Safari for this example). </p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the</span> <strong>Share</strong> <span>icon at the bottom of the screen.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Swipe left to the end of the list of app icons and tap</span> <strong>More</strong><span>.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap</span> <strong>Edit</strong> <span>at the top of the screen.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Scroll down until you see the</span> <strong>Omnivore</strong> <span>icon and tap the</span> <strong>+</strong> <span>icon next to it. </span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Press and hold the three-bar icon and drag Omnivore to one of the top positions under Favorites. Tap</span> <strong>Done</strong> <span>to close the menu.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<strong>Omnivore</strong> <span>will appear as one of the first options the next time you use the Share feature (you may need to restart Safari).</span>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<h2>
|
||||
<strong>Step 3: Save links to your Omnivore Library</strong>
|
||||
</h2>
|
||||
<p>
|
||||
<span>Start by navigating to the page or article you wish to save. Please note that Omnivore will save the content that appears on your screen (not just a link), so</span> <em>if the page is behind a paywall and you are logged into the paywalled site, you will save the paid content.</em><span> </span>
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>
|
||||
<span>While viewing the page you’d like to save, tap the</span> <strong>Share</strong> <span>icon.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tap the</span> <strong>Omnivore</strong> <span>icon in the Share menu.</span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
<span>Tag the article with one or more labels (optional) and tap</span> <strong>Read Now</strong> <span>or</span> <strong>Read Later</strong><span>. </span>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p> If you choose Read Later, the link will appear in your Library the next time you open the Omnivore app. </p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
</DIV>
|
||||