mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3076 from omnivore-app/main
Web production deployment
This commit is contained in:
commit
5048eef8fa
134 changed files with 3257 additions and 1878 deletions
17
README.md
17
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[](https://github.com/omnivore-app/omnivore/actions/workflows/run-tests.yaml)
|
||||
[](https://discord.gg/h2z5rppzz9)
|
||||
[](https://pkm.social/@omnivore)
|
||||
[](https://pkm.social/@omnivore)
|
||||
[](https://twitter.com/OmnivoreApp)
|
||||

|
||||
|
||||
|
|
@ -34,7 +34,6 @@ We also have a free hosted version of Omnivore at [omnivore.app](https://omnivor
|
|||
|
||||
<img width="981" alt="web-screenshot-listview" src="https://github.com/omnivore-app/omnivore/assets/75189/df7c797a-4255-42f4-a686-ad94866cb580">
|
||||
|
||||
|
||||
## Join us on Discord! :speech_balloon:
|
||||
|
||||
We're building our community on Discord. [Join us!](https://discord.gg/h2z5rppzz9)
|
||||
|
|
@ -100,10 +99,22 @@ with docker compose and the frontend locally:
|
|||
```bash
|
||||
docker compose up api content-fetch
|
||||
cd packages/web
|
||||
cp .env.template .env
|
||||
cp .env.template .env.local
|
||||
yarn dev
|
||||
```
|
||||
|
||||
You will need to configure some values in the new `.env.local` file. These are
|
||||
the values for running the `web` service directly on your host machine and
|
||||
running `api` and `content-fetch` within docker:
|
||||
|
||||
```sh
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_HIGHLIGHTS_BASE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_LOCAL_BASE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_SERVER_BASE_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_LOCAL_SERVER_BASE_URL=http://localhost:4000
|
||||
```
|
||||
|
||||
### Running the puppeteer-parse service outside of Docker
|
||||
|
||||
To save pages you need to run the `puppeteer-parse` service.
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ android {
|
|||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 122
|
||||
versionName "0.0.122"
|
||||
versionCode 124
|
||||
versionName "0.0.124"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
|
@ -132,19 +132,19 @@ dependencies {
|
|||
|
||||
implementation 'com.apollographql.apollo3:apollo-runtime:3.7.2'
|
||||
|
||||
implementation 'androidx.compose.material3:material3:1.1.0-alpha03'
|
||||
implementation 'androidx.compose.material3:material3-window-size-class:1.1.0-alpha03'
|
||||
implementation 'androidx.compose.material3:material3:1.1.2'
|
||||
implementation 'androidx.compose.material3:material3-window-size-class:1.1.2'
|
||||
|
||||
implementation 'com.google.android.gms:play-services-auth:20.4.0'
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:0.25.1"
|
||||
|
||||
implementation 'io.coil-kt:coil-compose:2.2.0'
|
||||
implementation 'io.coil-kt:coil-compose:2.3.0'
|
||||
|
||||
implementation 'com.google.code.gson:gson:2.8.9'
|
||||
implementation 'com.google.code.gson:gson:2.9.0'
|
||||
implementation 'com.pspdfkit:pspdfkit:8.4.1'
|
||||
|
||||
implementation 'com.posthog.android:posthog:1.+'
|
||||
implementation 'com.posthog.android:posthog:2.0.3'
|
||||
implementation 'io.intercom.android:intercom-sdk:15.1.0'
|
||||
|
||||
// Room Deps
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -18,6 +18,7 @@ import app.omnivore.omnivore.ui.components.LabelsViewModel
|
|||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.library.SearchViewModel
|
||||
import app.omnivore.omnivore.ui.root.RootView
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.pspdfkit.PSPDFKit
|
||||
|
|
@ -37,6 +38,7 @@ class MainActivity : ComponentActivity() {
|
|||
val settingsViewModel: SettingsViewModel by viewModels()
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
val labelsViewModel: LabelsViewModel by viewModels()
|
||||
val saveViewModel: SaveViewModel by viewModels()
|
||||
|
||||
val context = this
|
||||
|
||||
|
|
@ -57,7 +59,13 @@ class MainActivity : ComponentActivity() {
|
|||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
) {
|
||||
RootView(loginViewModel, searchViewModel, libraryViewModel, settingsViewModel, labelsViewModel)
|
||||
RootView(
|
||||
loginViewModel,
|
||||
searchViewModel,
|
||||
libraryViewModel,
|
||||
settingsViewModel,
|
||||
labelsViewModel,
|
||||
saveViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package app.omnivore.omnivore.models
|
||||
|
||||
public enum class ServerSyncStatus(
|
||||
public val rawValue: Int,
|
||||
enum class ServerSyncStatus(
|
||||
val rawValue: Int,
|
||||
) {
|
||||
IS_SYNCED(0),
|
||||
IS_SYNCING(1),
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ fun CreateUserProfileView(viewModel: LoginViewModel) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserProfileFields(
|
||||
name: String,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ fun EmailLoginView(viewModel: LoginViewModel) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginFields(
|
||||
email: String,
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ fun EmailSignUpForm(viewModel: LoginViewModel) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmailSignUpFields(
|
||||
email: String,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,6 @@ fun SelfHostedView(viewModel: LoginViewModel) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelfHostedFields(
|
||||
apiServer: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
package app.omnivore.omnivore.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.save.SaveState
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
|
||||
@Composable
|
||||
fun AddLinkSheetContent(
|
||||
saveViewModel: SaveViewModel,
|
||||
onCancel: () -> Unit,
|
||||
onLinkAdded: () -> Unit
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val clipboardText = clipboardManager.getText()?.text
|
||||
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
fun showToast(msg: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
msg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
val saveState: SaveState by saveViewModel.saveState.observeAsState(SaveState.NONE)
|
||||
val isSaving = MutableLiveData(false)
|
||||
|
||||
when (saveState) {
|
||||
SaveState.NONE -> {
|
||||
isSaving.value = false
|
||||
}
|
||||
SaveState.SAVING -> {
|
||||
isSaving.value = true
|
||||
}
|
||||
SaveState.ERROR -> {
|
||||
isSaving.value = false
|
||||
showToast(context.getString(R.string.add_link_sheet_save_url_error))
|
||||
}
|
||||
SaveState.SAVED -> {
|
||||
isSaving.value = false
|
||||
showToast(context.getString(R.string.add_link_sheet_save_url_success))
|
||||
|
||||
onLinkAdded()
|
||||
}
|
||||
}
|
||||
|
||||
fun addLink(url: String) {
|
||||
if (!saveViewModel.validateUrl(url)) {
|
||||
showToast(context.getString(R.string.add_link_sheet_invalid_url_error))
|
||||
return
|
||||
}
|
||||
|
||||
saveViewModel.saveURL(url)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Top,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 5.dp)
|
||||
) {
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(text = stringResource(R.string.add_link_sheet_action_cancel))
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.add_link_sheet_title), fontWeight = FontWeight.ExtraBold)
|
||||
|
||||
TextButton(onClick = { addLink(textFieldValue.text) }) {
|
||||
Text(stringResource(R.string.add_link_sheet_action_add_link))
|
||||
}
|
||||
}
|
||||
|
||||
if (isSaving.value == true) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.height(16.dp)
|
||||
.width(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
placeholder = { Text(stringResource(R.string.add_link_sheet_text_field_placeholder)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
leadingIcon = { Icon(imageVector = Icons.Default.Link, contentDescription = "linkIcon") },
|
||||
onValueChange = { textFieldValue = it },
|
||||
modifier = Modifier.focusRequester(focusRequester).padding(top = 24.dp).fillMaxWidth()
|
||||
)
|
||||
|
||||
if (clipboardText != null) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = {
|
||||
textFieldValue = TextFieldValue(
|
||||
text = clipboardText,
|
||||
selection = TextRange(clipboardText.length))
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.add_link_sheet_action_paste_from_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,6 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.window.Dialog
|
||||
import app.omnivore.omnivore.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelCreationDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
var labelName by rememberSaveable { mutableStateOf("") }
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import app.omnivore.omnivore.R
|
|||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.ui.components.LabelChipColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LibraryFilterBar(viewModel: LibraryViewModel) {
|
||||
var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighligh
|
|||
fun LibraryNavigationBar(
|
||||
savedItemViewModel: SavedItemViewModel,
|
||||
onSearchClicked: () -> Unit,
|
||||
onAddLinkClicked: () -> Unit,
|
||||
onSettingsIconClick: () -> Unit
|
||||
) {
|
||||
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(null)
|
||||
|
|
@ -101,6 +102,13 @@ fun LibraryNavigationBar(
|
|||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onAddLinkClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onSettingsIconClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package app.omnivore.omnivore.ui.library
|
|||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
@ -11,17 +10,11 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.DrawerValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -29,18 +22,18 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.Routes
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
|
||||
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
|
||||
import app.omnivore.omnivore.ui.components.AddLinkSheetContent
|
||||
import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
|
||||
import app.omnivore.omnivore.ui.components.LabelsViewModel
|
||||
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
|
||||
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
|
||||
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -50,10 +43,12 @@ import kotlinx.coroutines.launch
|
|||
fun LibraryView(
|
||||
libraryViewModel: LibraryViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
navController: NavHostController
|
||||
) {
|
||||
val scaffoldState: ScaffoldState = rememberScaffoldState()
|
||||
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
|
||||
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val modalBottomSheetState = rememberModalBottomSheetState(
|
||||
|
|
@ -61,7 +56,7 @@ fun LibraryView(
|
|||
confirmStateChange = { it != ModalBottomSheetValue.Hidden }
|
||||
)
|
||||
|
||||
if (showLabelsSelectionSheet) {
|
||||
if (showLabelsSelectionSheet || showAddLinkSheet) {
|
||||
coroutineScope.launch {
|
||||
modalBottomSheetState.show()
|
||||
}
|
||||
|
|
@ -82,7 +77,7 @@ fun LibraryView(
|
|||
sheetBackgroundColor = Color.Transparent,
|
||||
sheetState = modalBottomSheetState,
|
||||
sheetContent = {
|
||||
BottomSheetContent(libraryViewModel, labelsViewModel)
|
||||
BottomSheetContent(libraryViewModel, labelsViewModel, saveViewModel)
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
) {
|
||||
|
|
@ -92,6 +87,7 @@ fun LibraryView(
|
|||
LibraryNavigationBar(
|
||||
savedItemViewModel = libraryViewModel,
|
||||
onSearchClicked = { navController.navigate(Routes.Search.route) },
|
||||
onAddLinkClicked = { libraryViewModel.showAddLinkSheetLiveData.value = true },
|
||||
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
|
||||
)
|
||||
},
|
||||
|
|
@ -106,8 +102,9 @@ fun LibraryView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun BottomSheetContent(libraryViewModel: LibraryViewModel, labelsViewModel: LabelsViewModel) {
|
||||
fun BottomSheetContent(libraryViewModel: LibraryViewModel, labelsViewModel: LabelsViewModel, saveViewModel: SaveViewModel) {
|
||||
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
|
||||
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
|
||||
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
|
||||
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(listOf())
|
||||
|
||||
|
|
@ -155,6 +152,14 @@ fun BottomSheetContent(libraryViewModel: LibraryViewModel, labelsViewModel: Labe
|
|||
)
|
||||
}
|
||||
}
|
||||
} else if (showAddLinkSheet) {
|
||||
BottomSheetUI {
|
||||
AddLinkSheetContent(
|
||||
saveViewModel = saveViewModel,
|
||||
onCancel = { libraryViewModel.showAddLinkSheetLiveData.value = false },
|
||||
onLinkAdded = { libraryViewModel.showAddLinkSheetLiveData.value = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class LibraryViewModel @Inject constructor(
|
|||
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
|
||||
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
|
||||
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
|
||||
val showAddLinkSheetLiveData = MutableLiveData(false)
|
||||
val labelsSelectionCurrentItemLiveData = MutableLiveData<String?>(null)
|
||||
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
|
||||
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
|
||||
|
|
|
|||
|
|
@ -246,7 +246,6 @@ fun ArticleNotes(viewModel: NotebookViewModel, item: SavedItemWithLabelsAndHighl
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Highlight?) -> Unit) {
|
||||
val highlights = item.highlights?.filter { it.type == "HIGHLIGHT" } ?: listOf()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ class AnnotationEditFragment : DialogFragment() {
|
|||
this.onCancel = onCancel
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import androidx.compose.ui.unit.sp
|
|||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.Routes
|
||||
import app.omnivore.omnivore.dataService.DataService
|
||||
import app.omnivore.omnivore.ui.auth.LoginViewModel
|
||||
import app.omnivore.omnivore.ui.auth.WelcomeScreen
|
||||
import app.omnivore.omnivore.ui.components.LabelsViewModel
|
||||
|
|
@ -23,6 +21,7 @@ import app.omnivore.omnivore.ui.library.LibraryView
|
|||
import app.omnivore.omnivore.ui.library.SearchView
|
||||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.library.SearchViewModel
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import app.omnivore.omnivore.ui.settings.PolicyWebView
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
|
@ -33,7 +32,8 @@ fun RootView(
|
|||
searchViewModel: SearchViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
) {
|
||||
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
val systemUiController = rememberSystemUiController()
|
||||
|
|
@ -59,6 +59,7 @@ fun RootView(
|
|||
libraryViewModel = libraryViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel
|
||||
)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = loginViewModel)
|
||||
|
|
@ -80,6 +81,7 @@ fun PrimaryNavigator(
|
|||
searchViewModel: SearchViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
|
|
@ -89,6 +91,7 @@ fun PrimaryNavigator(
|
|||
libraryViewModel = libraryViewModel,
|
||||
navController = navController,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package app.omnivore.omnivore.ui.save
|
|||
|
||||
import android.content.ContentValues
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -52,7 +53,16 @@ class SaveViewModel @Inject constructor(
|
|||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
|
||||
}
|
||||
|
||||
fun cleanUrl(text: String): String? {
|
||||
/**
|
||||
* Checks whether or not the provided URL is valid.
|
||||
* @param url The potential URL to validate.
|
||||
* @return true if valid, false otherwise.
|
||||
*/
|
||||
fun validateUrl(url: String): Boolean {
|
||||
return Patterns.WEB_URL.matcher(url).matches()
|
||||
}
|
||||
|
||||
private fun cleanUrl(text: String): String? {
|
||||
val pattern = Pattern.compile("\\b(?:https?|ftp)://\\S+")
|
||||
val matcher = pattern.matcher(text)
|
||||
|
||||
|
|
|
|||
|
|
@ -204,4 +204,14 @@
|
|||
<string name="settings_view_setting_row_terms_and_conditions">Terms and Conditions</string>
|
||||
<string name="settings_view_setting_row_manage_account">Manage Account</string>
|
||||
<string name="settings_view_setting_row_logout">Logout</string>
|
||||
|
||||
<!-- AddLinkSheet -->
|
||||
<string name="add_link_sheet_title">Add Link</string>
|
||||
<string name="add_link_sheet_text_field_placeholder">Add Link</string>
|
||||
<string name="add_link_sheet_action_add_link">Add</string>
|
||||
<string name="add_link_sheet_action_cancel">Cancel</string>
|
||||
<string name="add_link_sheet_action_paste_from_clipboard">Get from clipboard</string>
|
||||
<string name="add_link_sheet_invalid_url_error">Invalid URL</string>
|
||||
<string name="add_link_sheet_save_url_error">Error while saving link!</string>
|
||||
<string name="add_link_sheet_save_url_success">Link successfully saved!</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1400,7 +1400,7 @@
|
|||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
MARKETING_VERSION = 1.36.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.35.0;
|
||||
MARKETING_VERSION = 1.36.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.35.0;
|
||||
MARKETING_VERSION = 1.36.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1831,7 +1831,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.35.0;
|
||||
MARKETING_VERSION = 1.36.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
|
||||
PRODUCT_NAME = Omnivore;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -3,11 +3,17 @@ import Utils
|
|||
import Views
|
||||
|
||||
public extension PlatformViewController {
|
||||
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
|
||||
static func makeShareExtensionController(
|
||||
viewModel: ShareExtensionViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
extensionContext: NSExtensionContext?
|
||||
) -> PlatformViewController {
|
||||
registerFonts()
|
||||
|
||||
let hostingController = PlatformHostingController(
|
||||
rootView: ShareExtensionView(extensionContext: extensionContext)
|
||||
rootView: ShareExtensionView(viewModel: viewModel,
|
||||
labelsViewModel: labelsViewModel,
|
||||
extensionContext: extensionContext)
|
||||
)
|
||||
#if os(iOS)
|
||||
hostingController.view.layer.cornerRadius = 12
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
@Published public var status: ShareExtensionStatus = .processing
|
||||
@Published public var title: String = ""
|
||||
@Published public var url: String?
|
||||
@Published public var iconURL: URL?
|
||||
@Published public var highlightData: HighlightData?
|
||||
@Published public var linkedItem: LinkedItem?
|
||||
@Published public var requestId = UUID().uuidString.lowercased()
|
||||
@Published var debugText: String?
|
||||
@Published var noteText: String = ""
|
||||
|
||||
let services = Services()
|
||||
let queue = OperationQueue()
|
||||
|
||||
public init() {}
|
||||
|
||||
func handleReadNowAction(extensionContext: NSExtensionContext?) {
|
||||
#if os(iOS)
|
||||
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
|
||||
|
|
@ -60,6 +64,22 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
if let linkedItem = linkedItem {
|
||||
if let noteHighlight = linkedItem.noteHighlight, let noteHighlightID = noteHighlight.id {
|
||||
services.dataService.updateHighlightAttributes(highlightID: noteHighlightID, annotation: noteText)
|
||||
} else {
|
||||
let createdHighlightId = UUID().uuidString.lowercased()
|
||||
let createdShortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
|
||||
|
||||
_ = services.dataService.createNote(shortId: createdShortId,
|
||||
highlightID: createdHighlightId,
|
||||
articleId: linkedItem.unwrappedID,
|
||||
annotation: noteText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func queueSaveOperation(_ payload: PageScrapePayload) {
|
||||
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
|
||||
|
|
@ -88,9 +108,10 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
let hostname = URL(string: payload.url)?.host ?? ""
|
||||
|
||||
switch payload.contentType {
|
||||
case let .html(html: _, title: title, highlightData: highlightData):
|
||||
case let .html(html: _, title: title, iconURL: iconURL, highlightData: highlightData):
|
||||
self.title = title ?? ""
|
||||
self.url = hostname
|
||||
self.iconURL = iconURL
|
||||
self.highlightData = highlightData
|
||||
case .none:
|
||||
self.url = hostname
|
||||
|
|
@ -145,7 +166,7 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
localPdfURL: localUrl,
|
||||
url: pageScrapePayload.url
|
||||
)
|
||||
case let .html(html, title, _):
|
||||
case let .html(html, title, _, _):
|
||||
newRequestID = try await services.dataService.createPage(
|
||||
id: requestId,
|
||||
originalHtml: html,
|
||||
|
|
@ -187,7 +208,21 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
if let title = self.linkedItem?.title {
|
||||
self.title = title
|
||||
}
|
||||
self.url = self.linkedItem?.pageURLString
|
||||
if let iconURL = self.linkedItem?.imageURL {
|
||||
self.iconURL = iconURL
|
||||
}
|
||||
if let noteHighlight = self.linkedItem?.highlights?
|
||||
.compactMap({ $0 as? Highlight })
|
||||
.first(where: { $0.type == "NOTE" }),
|
||||
let noteText = noteHighlight.annotation
|
||||
{
|
||||
self.noteText = noteText
|
||||
}
|
||||
if let urlStr = self.linkedItem?.pageURLString, let hostname = URL(string: urlStr)?.host {
|
||||
self.url = hostname
|
||||
} else {
|
||||
self.url = self.linkedItem?.pageURLString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// AddNoteSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/26/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public struct AddNoteSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4)
|
||||
}
|
||||
|
||||
func saveNote() {
|
||||
viewModel.saveNote()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
TextEditor(text: $viewModel.noteText)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.focused($focusedField, equals: .noteEditor)
|
||||
.task {
|
||||
self.focusedField = .noteEditor
|
||||
}
|
||||
.background(Color.extensionBackground)
|
||||
.navigationTitle("Add Note")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading: Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Cancel")
|
||||
}))
|
||||
.navigationBarItems(trailing: Button(action: {
|
||||
saveNote()
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Save").bold()
|
||||
}))
|
||||
}.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// EditInfoSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/30/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public struct EditInfoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
let highlightId = UUID().uuidString.lowercased()
|
||||
let shortId = NanoID.generate(alphabet: NanoID.Alphabet.urlSafe.rawValue, size: 8)
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 10, right: 4)
|
||||
}
|
||||
|
||||
// func saveInfo() {
|
||||
// if let linkedItem = viewModel.linkedItem {
|
||||
// _ = viewModel.services.dataService.updateLinkedItemTitleAndDescription(itemID: linkedItem.unwrappedID, title: title, description: description, author: author)
|
||||
// } else {
|
||||
// // Maybe we shouldn't even allow this UI without linkeditem existing
|
||||
// }
|
||||
// }
|
||||
|
||||
public var body: some View {
|
||||
if let item = viewModel.linkedItem {
|
||||
LinkedItemMetadataEditView(item: item) { title, _ in
|
||||
viewModel.title = title
|
||||
}
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
//
|
||||
// EditLabelsSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/27/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
public struct EditLabelsSheet: View {
|
||||
@State var text = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject var labelsViewModel: LabelsViewModel
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case noteEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
public init(viewModel: ShareExtensionViewModel, labelsViewModel: LabelsViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
_labelsViewModel = StateObject(wrappedValue: labelsViewModel)
|
||||
|
||||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: idx)
|
||||
} else {
|
||||
labelsViewModel.labelSearchFilter = ZWSP
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
||||
func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
labelsViewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
VStack {
|
||||
LabelsEntryView(
|
||||
searchTerm: $labelsViewModel.labelSearchFilter,
|
||||
viewModel: labelsViewModel
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if labelsViewModel.labelSearchFilter.count >= 63 {
|
||||
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach(labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter), id: \.self) { label in
|
||||
Button(
|
||||
action: {
|
||||
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: idx)
|
||||
} else {
|
||||
labelsViewModel.labelSearchFilter = ZWSP
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label).allowsHitTesting(false)
|
||||
Spacer()
|
||||
if isSelected(label) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
)
|
||||
.padding(.vertical, 5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(macOS)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.background(Color.extensionBackground)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.extensionBackground)
|
||||
.navigationTitle("Set Labels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing: Button(action: {
|
||||
if let linkedItem = viewModel.linkedItem, let linkedItemId = linkedItem.id {
|
||||
labelsViewModel.saveItemLabelChanges(
|
||||
itemID: linkedItemId,
|
||||
dataService: viewModel.services.dataService
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Done").bold()
|
||||
}))
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
.task {
|
||||
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// MiniShareExtensionView.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct MiniShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
|
||||
@State var showToast = true
|
||||
|
||||
var body: some View {
|
||||
ProgressView()
|
||||
.popup(isPresented: $showToast) {
|
||||
Text("Saving to Omnivore")
|
||||
.padding(20)
|
||||
} customize: {
|
||||
$0
|
||||
.type(.toast)
|
||||
.position(.bottom)
|
||||
.animation(.spring())
|
||||
.closeOnTapOutside(true)
|
||||
.backgroundColor(.black.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,9 @@ import Views
|
|||
// swiftlint:disable file_length type_body_length
|
||||
public struct ShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
@StateObject var labelsViewModel = LabelsViewModel()
|
||||
@StateObject private var viewModel = ShareExtensionViewModel()
|
||||
@StateObject var viewModel: ShareExtensionViewModel
|
||||
@StateObject var labelsViewModel: LabelsViewModel
|
||||
|
||||
@State var reminderTime: ReminderTime?
|
||||
@State var hideUntilReminded = false
|
||||
@State var previousLabels: [LinkedItemLabel]?
|
||||
@State var messageText: String?
|
||||
@State var showSearchLabels = false
|
||||
|
|
@ -19,6 +17,8 @@ public struct ShareExtensionView: View {
|
|||
@State var viewState = ViewState.mainView
|
||||
@State var showHighlightInstructionAlert = false
|
||||
|
||||
@State var showAddNoteModal = false
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case titleEditor
|
||||
}
|
||||
|
|
@ -32,36 +32,13 @@ public struct ShareExtensionView: View {
|
|||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
|
||||
if selectedTime == reminderTime {
|
||||
reminderTime = nil
|
||||
hideUntilReminded = false
|
||||
} else {
|
||||
reminderTime = selectedTime
|
||||
hideUntilReminded = true
|
||||
}
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
switch viewModel.status {
|
||||
case .saved, .synced, .syncFailed(error: _):
|
||||
return "Saved to Omnivore"
|
||||
case .processing:
|
||||
return "Saving to Omnivore"
|
||||
case .failed(error: _):
|
||||
return "Error saving to Omnivore"
|
||||
}
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
switch viewModel.status {
|
||||
case .saved, .processing:
|
||||
return .appGrayText
|
||||
case .failed(error: _), .syncFailed(error: _):
|
||||
return .red
|
||||
case .synced:
|
||||
return .appGreenSuccess
|
||||
}
|
||||
public init(viewModel: ShareExtensionViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
extensionContext: NSExtensionContext?)
|
||||
{
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
_labelsViewModel = StateObject(wrappedValue: labelsViewModel)
|
||||
self.extensionContext = extensionContext
|
||||
}
|
||||
|
||||
private func localImage(from url: URL) -> Image? {
|
||||
|
|
@ -86,233 +63,111 @@ public struct ShareExtensionView: View {
|
|||
}
|
||||
}
|
||||
|
||||
var titleBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
var articleInfoBox: some View {
|
||||
HStack(alignment: .top, spacing: 15) {
|
||||
AsyncImage(url: self.viewModel.iconURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 56, height: 56)
|
||||
} else {
|
||||
Color.appButtonBackground
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56).overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.white, lineWidth: 1)
|
||||
).cornerRadius(14)
|
||||
VStack(alignment: .leading) {
|
||||
Text(self.viewModel.url ?? "")
|
||||
.font(Font.system(size: 12))
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color.extensionTextSubtle)
|
||||
.frame(height: 14)
|
||||
|
||||
Text(self.viewModel.title)
|
||||
.font(Font.system(size: 13, weight: .semibold))
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
.frame(height: 33)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}.padding(.vertical, 2)
|
||||
// Spacer()
|
||||
Image(systemName: "checkmark.circle")
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.appGreenSuccess)
|
||||
.opacity(isSynced ? 1.0 : 0.0)
|
||||
|
||||
Text(messageText ?? titleText)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(titleColor)
|
||||
|
||||
Spacer()
|
||||
// .opacity(isSynced ? 1.0 : 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
public var titleBox: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Button(action: {}, label: {
|
||||
Text("Edit")
|
||||
.font(.appFootnote)
|
||||
.padding(.trailing, 8)
|
||||
.onTapGesture {
|
||||
viewState = .editingTitle
|
||||
}
|
||||
})
|
||||
.disabled(viewState == .editingTitle)
|
||||
.opacity(viewState == .editingTitle ? 0.0 : 1.0)
|
||||
var hasNoteText: Bool {
|
||||
!viewModel.noteText.isEmpty
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if viewState != .editingTitle {
|
||||
Text(self.viewModel.title)
|
||||
.font(.appSubheadline)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(self.viewModel.url ?? "")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: 60)
|
||||
.padding()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.appGrayBorder, lineWidth: 1)
|
||||
var noteBox: some View {
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowAddNoteSheet"), object: nil)
|
||||
}, label: {
|
||||
Text(hasNoteText ? viewModel.noteText : "Add note...")
|
||||
.frame(minHeight: 50, alignment: .top)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
})
|
||||
.foregroundColor(hasNoteText ?
|
||||
Color.appGrayTextContrast : Color.extensionTextSubtle
|
||||
)
|
||||
}
|
||||
.font(Font.system(size: 13, weight: .semibold))
|
||||
.frame(height: 50, alignment: .top)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var labelsSection: some View {
|
||||
HStack {
|
||||
if viewState != .editingLabels {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(width: 34, height: 34)
|
||||
|
||||
Image(systemName: "tag")
|
||||
.font(.appCallout)
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
VStack {
|
||||
Text(LocalText.labelsGeneric)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
let labelCount = labelsViewModel.selectedLabels.count
|
||||
Text(labelCount > 0 ?
|
||||
"\(labelCount) label\(labelCount > 1 ? "s" : "") selected"
|
||||
: "Add labels to your saved link")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.appCallout)
|
||||
} else {
|
||||
VStack(spacing: 15) {
|
||||
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
|
||||
|
||||
// swiftlint:disable line_length
|
||||
ScrollView {
|
||||
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
onLabelTap: onLabelTap)
|
||||
Button(
|
||||
action: { labelsViewModel.showCreateLabelModal = true },
|
||||
label: {
|
||||
HStack {
|
||||
let trimmedLabelName = labelsViewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Image(systemName: "tag").foregroundColor(.blue)
|
||||
Text(
|
||||
labelsViewModel.labelSearchFilter.count > 0 ?
|
||||
"Create: \"\(trimmedLabelName)\" label" :
|
||||
LocalText.createLabelMessage
|
||||
).foregroundColor(.blue)
|
||||
.font(Font.system(size: 14))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(10)
|
||||
}.background(Color.appButtonBackground)
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(viewState == .editingLabels ? 0 : 16)
|
||||
.background(viewState == .editingLabels ? Color.clear : Color.appButtonBackground)
|
||||
.frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60)
|
||||
.cornerRadius(8)
|
||||
var labelsBox: some View {
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowEditLabelsSheet"), object: nil)
|
||||
}, label: {
|
||||
Label {
|
||||
Text("Add Labels").font(Font.system(size: 12, weight: .medium)).tint(Color.white)
|
||||
} icon: {
|
||||
Image.label.resizable(resizingMode: .stretch).frame(width: 17, height: 17).tint(Color.white)
|
||||
}.padding(.leading, 10).padding(.trailing, 12)
|
||||
})
|
||||
.frame(height: 28)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(24)
|
||||
}
|
||||
|
||||
var highlightSection: some View {
|
||||
HStack {
|
||||
if viewState != .viewingHighlight {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appBackground)
|
||||
.frame(width: 34, height: 34)
|
||||
var infoBox: some View {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
articleInfoBox
|
||||
|
||||
Image(systemName: "highlighter")
|
||||
.font(.appCallout)
|
||||
.frame(width: 34, height: 34)
|
||||
.foregroundColor(Color.black)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
Divider()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 1)
|
||||
.background(Color(hex: "545458")?.opacity(0.65))
|
||||
|
||||
VStack {
|
||||
Text(LocalText.genericHighlight)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
noteBox
|
||||
|
||||
Text(viewModel.highlightData != nil ?
|
||||
viewModel.highlightData!.highlightText
|
||||
: "Select text before saving to create highlight")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.appCallout)
|
||||
} else if let highlightText = self.viewModel.highlightData?.highlightText {
|
||||
Text(highlightText)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.cornerRadius(8)
|
||||
.padding(0)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: viewState == .viewingHighlight ? .infinity : 60)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
labelsBox
|
||||
}.padding(15)
|
||||
.background(Color.extensionPanelBackground)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if labelsViewModel.selectedLabels.contains(label) {
|
||||
labelsViewModel.selectedLabels.remove(label)
|
||||
} else {
|
||||
labelsViewModel.selectedLabels.insert(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtons: some View {
|
||||
HStack {
|
||||
Button(
|
||||
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
|
||||
label: {
|
||||
Label("Read Now", systemImage: "book")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.background(Color.appButtonBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(
|
||||
action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Label(LocalText.readLaterGeneric, systemImage: "text.book.closed.fill")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.black)
|
||||
.background(Color.appBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
var moreActionsMenu: some View {
|
||||
var moreMenuButton: some View {
|
||||
Menu {
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Button(LocalText.dismissButton, role: .cancel, action: {})
|
||||
}
|
||||
)
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(name: Notification.Name("ShowEditInfoSheet"), object: nil)
|
||||
}, label: {
|
||||
Label(
|
||||
"Edit Info",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
})
|
||||
Button(action: {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService,
|
||||
|
|
@ -344,161 +199,92 @@ public struct ShareExtensionView: View {
|
|||
}
|
||||
)
|
||||
} label: {
|
||||
Text("More Actions")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
var editingViewTitle: String {
|
||||
switch viewState {
|
||||
case .editingTitle:
|
||||
return "Edit Title"
|
||||
case .editingLabels:
|
||||
return LocalText.labelsGeneric
|
||||
case .viewingHighlight:
|
||||
return LocalText.genericHighlight
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func submitEditTitle() {
|
||||
if viewState == .editingTitle {
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
viewModel.submitTitleEdit(dataService: viewModel.services.dataService,
|
||||
itemID: linkedItem.unwrappedID,
|
||||
title: viewModel.title,
|
||||
description: linkedItem.descriptionText ?? "")
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 15, height: 15)
|
||||
}
|
||||
}
|
||||
viewState = .mainView
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
Button(action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}, label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.bold))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var titleBar: some View {
|
||||
HStack {
|
||||
Text("Saved to Omnivore")
|
||||
.font(Font.system(size: 22, weight: .bold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
moreMenuButton
|
||||
closeButton
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
.padding(.top, 10)
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
titleBar
|
||||
.padding(.top, 15)
|
||||
|
||||
if viewState == .mainView {
|
||||
titleBar
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
} else {
|
||||
ZStack {
|
||||
Text(editingViewTitle).bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
infoBox
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
submitEditTitle()
|
||||
}
|
||||
}, label: { Text(LocalText.doneGeneric).bold() })
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
if viewState == .mainView {
|
||||
titleBox
|
||||
}
|
||||
|
||||
if viewState == .editingTitle {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
TextEditor(text: $viewModel.title)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineSpacing(6)
|
||||
.submitLabel(.done)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.frame(height: 100)
|
||||
.focused($focusedField, equals: .titleEditor)
|
||||
.task {
|
||||
self.focusedField = .titleEditor
|
||||
}
|
||||
.onChange(of: viewModel.title) { text in
|
||||
if text.last?.isNewline == .some(true) {
|
||||
viewModel.title.removeLast()
|
||||
submitEditTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Spacer(minLength: 1)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if viewState != .editingTitle {
|
||||
if viewState != .viewingHighlight {
|
||||
labelsSection
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
previousLabels = Array(self.labelsViewModel.selectedLabels)
|
||||
viewState = .editingLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewState != .editingLabels {
|
||||
highlightSection
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
if viewModel.highlightData != nil {
|
||||
viewState = .viewingHighlight
|
||||
} else {
|
||||
showHighlightInstructionAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if UIDevice.isIPad {
|
||||
Button(action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}, label: {
|
||||
Text("Dismiss")
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.tint(Color.appGrayText)
|
||||
.padding(20)
|
||||
})
|
||||
.frame(height: 50)
|
||||
.cornerRadius(24)
|
||||
.padding(.bottom, 15)
|
||||
}
|
||||
Button(action: {
|
||||
viewModel.handleReadNowAction(extensionContext: extensionContext)
|
||||
}, label: {
|
||||
Text("Read Now")
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.tint(Color.white)
|
||||
.padding(20)
|
||||
})
|
||||
.frame(height: 50)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(24)
|
||||
.padding(.bottom, 15)
|
||||
}.frame(maxWidth: .infinity)
|
||||
}.padding(.horizontal, 15)
|
||||
.background(Color.extensionBackground)
|
||||
.onAppear {
|
||||
viewModel.savePage(extensionContext: extensionContext)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewState == .mainView {
|
||||
Divider()
|
||||
.padding(.bottom, 20)
|
||||
|
||||
primaryButtons
|
||||
|
||||
moreActionsMenu
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.onAppear {
|
||||
viewModel.savePage(extensionContext: extensionContext)
|
||||
}
|
||||
.sheet(isPresented: $labelsViewModel.showCreateLabelModal) {
|
||||
CreateLabelView(viewModel: labelsViewModel, newLabelName: labelsViewModel.labelSearchFilter)
|
||||
}
|
||||
.alert("Before saving an article select text in Safari to create a highlight on save.",
|
||||
isPresented: $showHighlightInstructionAlert) {
|
||||
Button(LocalText.genericOk, role: .cancel) { showHighlightInstructionAlert = false }
|
||||
}
|
||||
.task {
|
||||
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
|
||||
}.environmentObject(viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Combine
|
||||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ import Utils
|
|||
import Services
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct PDFViewer: View {
|
||||
enum SettingsKeys: String {
|
||||
case pageTransitionKey = "PDFViewer.pageTransition"
|
||||
|
|
@ -39,9 +41,16 @@ import Utils
|
|||
@StateObject var pdfStateObject = PDFStateObject()
|
||||
@State var readerView: Bool = false
|
||||
@State private var shareLink: ShareLink?
|
||||
|
||||
@State private var errorMessage: String?
|
||||
@State private var showNotebookView = false
|
||||
@State private var hasPerformedHighlightMutations = false
|
||||
@State private var errorAlertMessage: String?
|
||||
@State private var showErrorAlertMessage = false
|
||||
|
||||
@State private var annotation = ""
|
||||
@State private var addNoteHighlight: Highlight?
|
||||
@State private var showAnnotationModal = false
|
||||
|
||||
init(viewModel: PDFViewerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
|
@ -150,12 +159,6 @@ import Utils
|
|||
dataService: dataService
|
||||
)
|
||||
})
|
||||
// let share = MenuItem(title: "Share", block: {
|
||||
// let shortId = self.coordinator.highlightSelection(pageView: pageView, selectedText: selectedText)
|
||||
// if let shareURL = viewModel.highlightShareURL(shortId: shortId) {
|
||||
// shareLink = ShareLink(id: UUID(), url: shareURL)
|
||||
// }
|
||||
// })
|
||||
define?.title = "Lookup"
|
||||
return [copy, highlight, define].compactMap { $0 }
|
||||
})
|
||||
|
|
@ -164,17 +167,54 @@ import Utils
|
|||
if let copy = menuItems.first(where: { $0.identifier == "Copy" }) {
|
||||
result.append(copy)
|
||||
}
|
||||
|
||||
let note = MenuItem(title: "Note", block: {
|
||||
if let highlight = annotations?.compactMap({ $0 as? HighlightAnnotation }).first,
|
||||
let customHighlight = highlight.customData?["omnivoreHighlight"] as? [String: String],
|
||||
let highlightID = customHighlight["id"]?.lowercased(),
|
||||
let selectedHighlight = viewModel.findHighlight(highlightID: highlightID)
|
||||
{
|
||||
addNoteHighlight = selectedHighlight
|
||||
annotation = selectedHighlight.annotation ?? ""
|
||||
showAnnotationModal = true
|
||||
} else {
|
||||
errorMessage = "Unable to find highlight"
|
||||
showErrorAlertMessage = true
|
||||
}
|
||||
})
|
||||
result.append(note)
|
||||
let remove = MenuItem(title: "Remove", block: {
|
||||
coordinator.remove(dataService: dataService, annotations: annotations)
|
||||
})
|
||||
result.append(remove)
|
||||
|
||||
let highlights = annotations?.compactMap { $0 as? HighlightAnnotation }
|
||||
let shortId = highlights.flatMap { coordinator.shortHighlightIds($0).first }
|
||||
|
||||
return result
|
||||
})
|
||||
.sheet(isPresented: $showAnnotationModal) {
|
||||
NavigationView {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
onSave: {
|
||||
// annotationSaveTransactionID = UUID()
|
||||
if let highlightID = addNoteHighlight?.id {
|
||||
viewModel.updateAnnotation(
|
||||
highlightID: highlightID,
|
||||
annotation: annotation,
|
||||
dataService: dataService
|
||||
)
|
||||
showAnnotationModal = false
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
annotation = ""
|
||||
addNoteHighlight = nil
|
||||
showAnnotationModal = false
|
||||
},
|
||||
errorAlertMessage: $errorAlertMessage,
|
||||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
.fullScreenCover(isPresented: $readerView, content: {
|
||||
PDFReaderViewController(document: document)
|
||||
})
|
||||
|
|
@ -216,6 +256,7 @@ import Utils
|
|||
hasPerformedHighlightMutations.toggle()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class PDFViewCoordinator: NSObject, PDFDocumentViewControllerDelegate, PDFViewControllerDelegate {
|
||||
let document: Document
|
||||
let viewModel: PDFViewerViewModel
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ final class PDFViewerViewModel: ObservableObject {
|
|||
showSnackbar = true
|
||||
}
|
||||
|
||||
func findHighlight(highlightID: String) -> Highlight? {
|
||||
pdfItem.highlights.first { $0.id == highlightID }
|
||||
}
|
||||
|
||||
func loadHighlightPatches(completion onComplete: @escaping ([String]) -> Void) {
|
||||
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
|
||||
}
|
||||
|
|
@ -76,6 +80,14 @@ final class PDFViewerViewModel: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updateAnnotation(highlightID: String, annotation: String, dataService: DataService) {
|
||||
dataService.updateHighlightAttributes(highlightID: highlightID, annotation: annotation)
|
||||
|
||||
if let highlight = pdfItem.highlights.first(where: { $0.id == highlightID }) {
|
||||
highlight.annotation = annotation
|
||||
}
|
||||
}
|
||||
|
||||
func updateItemReadProgress(dataService: DataService, percent: Double, anchorIndex: Int, force: Bool = false) {
|
||||
dataService.updateLinkReadingProgress(
|
||||
itemID: pdfItem.itemID,
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@
|
|||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
.formSheet(isPresented: $showShareView) {
|
||||
ShareSheet(activityItems: [viewModel.highlightAsMarkdown(item: self.highlightParams)])
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@
|
|||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ struct FeedCardNavigationLink: View {
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
|
||||
let isInMultiSelectMode: Bool
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ extension LinkedItemFilter {
|
|||
return LocalText.readLaterGeneric
|
||||
case .newsletters:
|
||||
return LocalText.newslettersGeneric
|
||||
case .downloaded:
|
||||
return "Downloaded"
|
||||
case .feeds:
|
||||
return "Feeds"
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .all:
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
|
||||
@AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -43,12 +43,23 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var showFeatureCards: Bool {
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
!viewModel.hideFeatureSection &&
|
||||
viewModel.items.count > 0 &&
|
||||
viewModel.searchTerm.isEmpty &&
|
||||
viewModel.selectedLabels.isEmpty &&
|
||||
viewModel.negatedLabels.isEmpty &&
|
||||
LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HomeFeedView(
|
||||
listTitle: $listTitle,
|
||||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
)
|
||||
.refreshable {
|
||||
loadItems(isRefresh: true)
|
||||
|
|
@ -86,70 +97,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
// .navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName
|
||||
|
||||
Text(title)
|
||||
.font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold))
|
||||
|
||||
if prefersListLayout, isListScrolled {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
.disabled(true)
|
||||
.overlay {
|
||||
if viewModel.isLoading, !prefersListLayout, enableGrid {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.vertical)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
}, label: {
|
||||
Image.utilityMenu
|
||||
})
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
toolbarItems
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
loadItems(isRefresh: false)
|
||||
|
|
@ -215,6 +163,92 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var toolbarItems: some ToolbarContent {
|
||||
Group {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName
|
||||
|
||||
Text(title)
|
||||
.font(Font.system(size: isListScrolled ? 10 : 18, weight: .semibold))
|
||||
|
||||
if prefersListLayout, isListScrolled || !showFeatureCards {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
.disabled(true)
|
||||
.overlay {
|
||||
if viewModel.isLoading, !prefersListLayout, enableGrid {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(.vertical)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(content: {
|
||||
// Button(action: {
|
||||
// // withAnimation {
|
||||
// viewModel.isInMultiSelectMode.toggle()
|
||||
// // }
|
||||
// }, label: {
|
||||
// Label(viewModel.isInMultiSelectMode ? "End Multiselect" : "Select Multiple", systemImage: "checkmark.circle")
|
||||
// })
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.circle")
|
||||
})
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
|
||||
}, label: {
|
||||
Image.utilityMenu
|
||||
})
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
// if viewModel.isInMultiSelectMode {
|
||||
// ToolbarItemGroup(placement: .bottomBar) {
|
||||
// Button(action: {}, label: { Image(systemName: "archivebox") })
|
||||
// Button(action: {}, label: { Image(systemName: "trash") })
|
||||
// Button(action: {}, label: { Image.label })
|
||||
// Spacer()
|
||||
// Button(action: { viewModel.isInMultiSelectMode = false }, label: { Text("Cancel") })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -226,6 +260,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var prefersListLayout: Bool
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let linkRequest = viewModel.linkRequest {
|
||||
|
|
@ -237,12 +273,15 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
EmptyView()
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: LinkDestination(selectedItem: viewModel.selectedItem), isActive: $viewModel.linkIsActive) {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: viewModel.selectedItem),
|
||||
isActive: $viewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
if prefersListLayout || !enableGrid {
|
||||
HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel)
|
||||
HomeFeedListView(listTitle: $listTitle, isListScrolled: $isListScrolled, prefersListLayout: $prefersListLayout, viewModel: viewModel, showFeatureCards: showFeatureCards)
|
||||
} else {
|
||||
HomeFeedGridView(viewModel: viewModel, isListScrolled: $isListScrolled)
|
||||
}
|
||||
|
|
@ -292,6 +331,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
let showFeatureCards: Bool
|
||||
|
||||
var filtersHeader: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
|
|
@ -504,14 +545,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
|
||||
|
||||
if viewModel.listConfig.hasFeatureCards,
|
||||
!viewModel.hideFeatureSection,
|
||||
viewModel.items.count > 0,
|
||||
viewModel.searchTerm.isEmpty,
|
||||
viewModel.selectedLabels.isEmpty,
|
||||
viewModel.negatedLabels.isEmpty,
|
||||
LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox
|
||||
{
|
||||
if showFeatureCards {
|
||||
featureCard
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
|
|
@ -531,6 +565,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.background(GeometryReader { geometry in
|
||||
|
|
@ -539,10 +574,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
})
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
|
||||
if value.y < 100, value.y > 0 {
|
||||
if let date = item.savedAt {
|
||||
if topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
if item.savedAt != nil, topItem != item {
|
||||
setTopItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import Views
|
|||
@Published var itemToSnoozeID: String?
|
||||
@Published var linkRequest: LinkRequest?
|
||||
@Published var showLoadingBar = false
|
||||
@Published var isInMultiSelectMode = false
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
|
||||
|
|
@ -202,7 +203,8 @@ import Views
|
|||
await group.waitForAll()
|
||||
}
|
||||
|
||||
let shouldSearch = items.count < 1 || isRefresh
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter)
|
||||
let shouldSearch = items.count < 1 || isRefresh && filter != LinkedItemFilter.downloaded
|
||||
if shouldSearch {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
} else {
|
||||
|
|
@ -219,7 +221,10 @@ import Views
|
|||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter)
|
||||
if filter != LinkedItemFilter.downloaded {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
|
|
@ -325,8 +330,8 @@ import Views
|
|||
|
||||
func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) {
|
||||
if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) {
|
||||
let existingLabels = item.labels?.allObjects.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: existingLabels + [label.unwrappedID])
|
||||
let existingLabels = item.labels?.allObjects.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
|
||||
|
||||
item.update(inContext: dataService.viewContext)
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
|
@ -334,10 +339,10 @@ import Views
|
|||
}
|
||||
|
||||
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
|
||||
let labelIds = item.labels?
|
||||
let labels = item.labels?
|
||||
.filter { ($0 as? LinkedItemLabel)?.name != named }
|
||||
.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: labelIds)
|
||||
.compactMap { $0 as? LinkedItemLabel } ?? []
|
||||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(labels) as NSSet))
|
||||
item.update(inContext: dataService.viewContext)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -49,9 +50,16 @@ struct ApplyLabelsView: View {
|
|||
|
||||
var innerBody: some View {
|
||||
VStack {
|
||||
SearchBar(searchTerm: $viewModel.labelSearchFilter)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
LabelsEntryView(
|
||||
searchTerm: $viewModel.labelSearchFilter,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
if viewModel.labelSearchFilter.count >= 63 {
|
||||
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
||||
List {
|
||||
Section {
|
||||
|
|
@ -59,9 +67,12 @@ struct ApplyLabelsView: View {
|
|||
Button(
|
||||
action: {
|
||||
if isSelected(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
if let idx = viewModel.selectedLabels.firstIndex(of: label) {
|
||||
viewModel.selectedLabels.remove(at: idx)
|
||||
}
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
viewModel.labelSearchFilter = ZWSP
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
|
|
@ -84,11 +95,13 @@ struct ApplyLabelsView: View {
|
|||
createLabelButton
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.listStyle(.plain)
|
||||
.background(Color.extensionBackground)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle(mode.navTitle)
|
||||
.background(Color.extensionBackground)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
@ -166,7 +179,7 @@ struct ApplyLabelsView: View {
|
|||
Group {
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
if viewModel.isLoading {
|
||||
if viewModel.labels.isEmpty, viewModel.isLoading {
|
||||
EmptyView()
|
||||
} else {
|
||||
innerBody
|
||||
|
|
@ -177,14 +190,16 @@ struct ApplyLabelsView: View {
|
|||
.frame(minWidth: 400, minHeight: 600)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
await viewModel.loadLabels(dataService: dataService, item: feedItem)
|
||||
case let .highlight(highlight):
|
||||
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
|
||||
case let .list(labels):
|
||||
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
|
||||
.onAppear {
|
||||
Task {
|
||||
switch mode {
|
||||
case let .item(feedItem):
|
||||
await viewModel.loadLabels(dataService: dataService, item: feedItem)
|
||||
case let .highlight(highlight):
|
||||
await viewModel.loadLabels(dataService: dataService, highlight: highlight)
|
||||
case let .list(labels):
|
||||
await viewModel.loadLabels(dataService: dataService, initiallySelectedLabels: labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -192,9 +207,11 @@ struct ApplyLabelsView: View {
|
|||
|
||||
extension Sequence where Element == LinkedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
|
||||
if searchFilter.isEmpty {
|
||||
if searchFilter.isEmpty || searchFilter == ZWSP {
|
||||
return map { $0 } // return the identity of the sequence
|
||||
}
|
||||
return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) }
|
||||
let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1)
|
||||
let trimmed = searchFilter.suffix(from: index).lowercased()
|
||||
return filter { ($0.name ?? "").lowercased().contains(trimmed) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import SwiftUI
|
|||
import Models
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct LabelsMasonaryView: View {
|
||||
var onLabelTap: (LinkedItemLabel, TextChip) -> Void
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ import CoreData
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class LabelsViewModel: ObservableObject {
|
||||
@MainActor public final class LabelsViewModel: ObservableObject {
|
||||
let labelNameMaxLength = 64
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var unselectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var showCreateLabelModal = false
|
||||
@Published var labelSearchFilter = ""
|
||||
@Published var labelSearchFilter = ZWSP
|
||||
|
||||
public init() {}
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
self.labels = labels.sorted { left, right in
|
||||
|
|
@ -34,11 +35,14 @@ import Views
|
|||
await loadLabelsFromStore(dataService: dataService)
|
||||
for label in labels {
|
||||
if selLabels.contains(label) {
|
||||
selectedLabels.insert(label)
|
||||
if !selectedLabels.contains(label) {
|
||||
selectedLabels.append(label)
|
||||
}
|
||||
} else {
|
||||
unselectedLabels.insert(label)
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
|
||||
Task.detached(priority: .userInitiated) {
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
|
|
@ -48,16 +52,17 @@ import Views
|
|||
}
|
||||
for label in self.labels {
|
||||
if selLabels.contains(label) {
|
||||
self.selectedLabels.insert(label)
|
||||
if !self.selectedLabels.contains(label) {
|
||||
self.selectedLabels.append(label)
|
||||
}
|
||||
} else {
|
||||
self.unselectedLabels.insert(label)
|
||||
}
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadLabelsFromStore(dataService: DataService) async {
|
||||
|
|
@ -98,7 +103,7 @@ import Views
|
|||
|
||||
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
|
||||
labels.insert(label, at: 0)
|
||||
selectedLabels.insert(label)
|
||||
selectedLabels.append(label)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -111,7 +116,7 @@ import Views
|
|||
}
|
||||
|
||||
func saveItemLabelChanges(itemID: String, dataService: DataService) {
|
||||
dataService.updateItemLabels(itemID: itemID, labelIDs: selectedLabels.map(\.unwrappedID))
|
||||
dataService.setItemLabels(itemID: itemID, labels: InternalLinkedItemLabel.make(Set(selectedLabels) as NSSet))
|
||||
}
|
||||
|
||||
func saveHighlightLabelChanges(highlightID: String, dataService: DataService) {
|
||||
|
|
|
|||
198
apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift
Normal file
198
apple/OmnivoreKit/Sources/App/Views/LabelsEntryView.swift
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
let ZWSP = "\u{200B}"
|
||||
|
||||
@MainActor
|
||||
protocol Entry {
|
||||
func item(parent: LabelsEntryView) -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct LabelEntry: Entry {
|
||||
let label: LinkedItemLabel
|
||||
|
||||
func item(parent _: LabelsEntryView) -> AnyView {
|
||||
if let name = label.name, let hex = label.color, let color = Color(hex: hex) {
|
||||
return AnyView(LibraryItemLabelView(text: name, color: color))
|
||||
}
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct LabelsEntryView: View {
|
||||
@Binding var searchTerm: String
|
||||
@State var viewModel: LabelsViewModel
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
let entries: [Entry]
|
||||
|
||||
@State private var totalHeight = CGFloat.zero
|
||||
@FocusState private var textFieldFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>,
|
||||
viewModel: LabelsViewModel
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
self.viewModel = viewModel
|
||||
|
||||
self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) })
|
||||
}
|
||||
|
||||
func onTextSubmit() {
|
||||
let index = searchTerm.index(searchTerm.startIndex, offsetBy: 1)
|
||||
let trimmed = searchTerm.suffix(from: index).lowercased()
|
||||
|
||||
if trimmed.count < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
if let label = viewModel.labels.first(where: { $0.name?.lowercased() == trimmed }) {
|
||||
if !viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
} else {
|
||||
viewModel.createLabel(
|
||||
dataService: dataService,
|
||||
name: trimmed,
|
||||
color: Gradient.randomColor(str: trimmed, offset: 1),
|
||||
description: nil
|
||||
)
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deletableTextField: some View {
|
||||
let str = NSAttributedString(
|
||||
string: searchTerm,
|
||||
attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)]
|
||||
)
|
||||
// Round it up to avoid jitter when typing
|
||||
let textWidth = max(25.0, Double(Int(str.size().width + 1)))
|
||||
let result = TextField("", text: $searchTerm)
|
||||
.frame(alignment: .topLeading)
|
||||
.frame(height: 25)
|
||||
.frame(width: textWidth)
|
||||
.padding(5)
|
||||
.font(Font.system(size: 14))
|
||||
.multilineTextAlignment(.leading)
|
||||
.onChange(of: searchTerm, perform: { _ in
|
||||
if searchTerm.count >= 64 {
|
||||
searchTerm = String(searchTerm.prefix(64))
|
||||
}
|
||||
if searchTerm.isEmpty {
|
||||
if viewModel.selectedLabels.count > 0 {
|
||||
viewModel.selectedLabels.removeLast()
|
||||
searchTerm = ZWSP
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
} else {
|
||||
searchTerm = ZWSP
|
||||
}
|
||||
}
|
||||
})
|
||||
.onSubmit {
|
||||
onTextSubmit()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// func onTextDelete() -> Bool { if searchTerm.isEmpty {
|
||||
// if lastSelected {
|
||||
// if viewModel.selectedLabels.count > 0 {
|
||||
// viewModel.selectedLabels.removeLast()
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
// textFieldFocused = true
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// lastSelected = true
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
public var body: some View {
|
||||
// HStack(spacing: 0) {
|
||||
VStack {
|
||||
GeometryReader { geometry in
|
||||
self.generateLabelsContent(in: geometry)
|
||||
}
|
||||
}.padding(0)
|
||||
.frame(height: totalHeight)
|
||||
.background(Color.extensionPanelBackground)
|
||||
.cornerRadius(8)
|
||||
.onAppear {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.onTapGesture {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
|
||||
private func generateLabelsContent(in geom: GeometryProxy) -> some View {
|
||||
var width = CGFloat.zero
|
||||
var height = CGFloat.zero
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(self.entries.enumerated()), id: \.offset) { _, entry in
|
||||
entry.item(parent: self)
|
||||
.padding(5)
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width -= dim.width
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
deletableTextField
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width = 0
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
height = 0
|
||||
return result
|
||||
}).focused($textFieldFocused)
|
||||
}.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import Services
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct LibraryTabView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
|
|
|
|||
|
|
@ -95,13 +95,13 @@ struct LinkedItemMetadataEditView: View {
|
|||
var iOSBody: some View {
|
||||
NavigationView {
|
||||
editForm
|
||||
.navigationTitle("Edit Title and Description")
|
||||
.navigationTitle("Edit Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text(LocalText.cancelGeneric).foregroundColor(.appGrayTextContrast) }
|
||||
label: { Text(LocalText.cancelGeneric) }
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
|
|
@ -113,11 +113,11 @@ struct LinkedItemMetadataEditView: View {
|
|||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
label: { Text(LocalText.genericSave).foregroundColor(.appGrayTextContrast) }
|
||||
label: { Text(LocalText.genericSave).bold() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
#else
|
||||
var macOSBody: some View {
|
||||
|
|
|
|||
|
|
@ -18,17 +18,15 @@ import Views
|
|||
}
|
||||
|
||||
func loadProfileData(dataService: DataService) async {
|
||||
if let currentViewer = dataService.currentViewer {
|
||||
loadProfileCardData(viewer: currentViewer)
|
||||
return
|
||||
if let currentViewer = dataService.currentViewer,
|
||||
let name = currentViewer.name,
|
||||
let username = currentViewer.username
|
||||
{
|
||||
loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL)
|
||||
}
|
||||
|
||||
guard let viewerObjectID = try? await dataService.fetchViewer() else { return }
|
||||
|
||||
await dataService.viewContext.perform {
|
||||
if let viewer = dataService.viewContext.object(with: viewerObjectID) as? Viewer {
|
||||
self.loadProfileCardData(viewer: viewer)
|
||||
}
|
||||
if let viewer = try? await dataService.fetchViewer() {
|
||||
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,11 +45,11 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
private func loadProfileCardData(viewer: Viewer) {
|
||||
private func loadProfileCardData(name: String, username: String, profileImageURL: String?) {
|
||||
profileCardData = ProfileCardData(
|
||||
name: viewer.unwrappedName,
|
||||
username: viewer.unwrappedUsername,
|
||||
imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
|
||||
name: name,
|
||||
username: username,
|
||||
imageURL: profileImageURL.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -159,17 +157,23 @@ struct ProfileView: View {
|
|||
)
|
||||
#endif
|
||||
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
) {
|
||||
Text(LocalText.privacyPolicyGeneric)
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
if let url = URL(string: "https://omnivore.app/privacy") {
|
||||
openURL(url)
|
||||
}
|
||||
},
|
||||
label: { Text(LocalText.privacyPolicyGeneric) }
|
||||
)
|
||||
|
||||
NavigationLink(
|
||||
destination: BasicWebAppView.termsConditionsWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
) {
|
||||
Text(LocalText.termsAndConditionsGeneric)
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
if let url = URL(string: "https://omnivore.app/terms") {
|
||||
openURL(url)
|
||||
}
|
||||
},
|
||||
label: { Text(LocalText.termsAndConditionsGeneric) }
|
||||
)
|
||||
}
|
||||
|
||||
Section(footer: Text(viewModel.appVersionString)) {
|
||||
|
|
@ -202,11 +206,11 @@ struct ProfileView: View {
|
|||
|
||||
extension BasicWebAppView {
|
||||
static func privacyPolicyWebView(baseURL: URL) -> BasicWebAppView {
|
||||
omnivoreWebView(path: "/app/privacy", baseURL: baseURL)
|
||||
omnivoreWebView(path: "/privacy", baseURL: baseURL)
|
||||
}
|
||||
|
||||
static func termsConditionsWebView(baseURL: URL) -> BasicWebAppView {
|
||||
omnivoreWebView(path: "/app/terms", baseURL: baseURL)
|
||||
omnivoreWebView(path: "/terms", baseURL: baseURL)
|
||||
}
|
||||
|
||||
private static func omnivoreWebView(path: String, baseURL: URL) -> BasicWebAppView {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,19 @@
|
|||
.task { viewModel.checkPushNotificationsStatus() }
|
||||
}
|
||||
|
||||
private var notificationsText: some View {
|
||||
let markdown = "\(LocalText.notificationsExplainer)\n\n\(LocalText.notificationsTriggerExplainer)"
|
||||
if let notificationsText = try? AttributedString(
|
||||
markdown: markdown,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return Text(notificationsText)
|
||||
.accentColor(.blue)
|
||||
}
|
||||
return Text(markdown)
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
|
|
@ -75,8 +88,7 @@
|
|||
}
|
||||
|
||||
Section {
|
||||
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
|
||||
.accentColor(.blue)
|
||||
notificationsText
|
||||
}
|
||||
|
||||
Section {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import Views
|
|||
isLoading = true
|
||||
|
||||
do {
|
||||
subscriptions = try await dataService.subscriptions()
|
||||
subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
|
||||
} catch {
|
||||
hasNetworkError = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public struct RootView: View {
|
|||
if let intercomProvider = intercomProvider {
|
||||
DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger
|
||||
DataService.registerIntercomUser = intercomProvider.registerIntercomUser
|
||||
DataService.setIntercomUserHash = intercomProvider.setIntercomUserHash
|
||||
Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,15 +43,18 @@ public final class RootViewModel: ObservableObject {
|
|||
public struct IntercomProvider {
|
||||
public init(
|
||||
registerIntercomUser: @escaping (String) -> Void,
|
||||
setIntercomUserHash: @escaping (String) -> Void,
|
||||
unregisterIntercomUser: @escaping () -> Void,
|
||||
showIntercomMessenger: @escaping () -> Void
|
||||
) {
|
||||
self.registerIntercomUser = registerIntercomUser
|
||||
self.setIntercomUserHash = setIntercomUserHash
|
||||
self.unregisterIntercomUser = unregisterIntercomUser
|
||||
self.showIntercomMessenger = showIntercomMessenger
|
||||
}
|
||||
|
||||
public let registerIntercomUser: (String) -> Void
|
||||
public let setIntercomUserHash: (String) -> Void
|
||||
public let unregisterIntercomUser: () -> Void
|
||||
public let showIntercomMessenger: () -> Void
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,7 +438,8 @@ struct WebReaderContainerView: View {
|
|||
await audioController.preload(itemIDs: [item.unwrappedID])
|
||||
}
|
||||
}
|
||||
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
|
||||
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet,
|
||||
titleVisibility: .visible) {
|
||||
Button(action: {
|
||||
if let linkToOpen = linkToOpen {
|
||||
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
|
||||
|
|
@ -498,6 +499,7 @@ struct WebReaderContainerView: View {
|
|||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
}
|
||||
.sheet(isPresented: $showHighlightLabelsModal) {
|
||||
if let highlight = Highlight.lookup(byID: self.annotation, inContext: self.dataService.viewContext) {
|
||||
|
|
@ -614,7 +616,7 @@ struct WebReaderContainerView: View {
|
|||
.autohideIn(2)
|
||||
.position(.bottom)
|
||||
.animation(.spring())
|
||||
.closeOnTapOutside(true)
|
||||
.isOpaque(false)
|
||||
}
|
||||
.onReceive(NSNotification.readerSnackBarPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
|
|
|
|||
|
|
@ -87,37 +87,37 @@ struct WelcomeView: View {
|
|||
Spacer()
|
||||
}
|
||||
.sheet(isPresented: $showPrivacyModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showPrivacyModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
NavigationView {
|
||||
BasicWebAppView.privacyPolicyWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
showPrivacyModal = false
|
||||
},
|
||||
label: {
|
||||
Text(LocalText.genericClose)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTermsModal) {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
showTermsModal = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle").foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
NavigationView {
|
||||
BasicWebAppView.termsConditionsWebView(baseURL: dataService.appEnvironment.webAppBaseURL)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
showTermsModal = false
|
||||
},
|
||||
label: {
|
||||
Text(LocalText.genericClose)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAboutPage) {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,21 @@ public extension LinkedItem {
|
|||
(labels?.count ?? 0) > 0
|
||||
}
|
||||
|
||||
var noteHighlight: Highlight? {
|
||||
if let highlights = highlights?.compactMap({ $0 as? Highlight }) {
|
||||
let result = highlights
|
||||
.filter { $0.type == "NOTE" }
|
||||
.sorted(by: { $0.updatedAt ?? Date() < $1.updatedAt ?? Date() })
|
||||
.first
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var noteText: String? {
|
||||
noteHighlight?.annotation
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
readingProgress <= 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import Foundation
|
|||
|
||||
public enum LinkedItemFilter: String, CaseIterable {
|
||||
case inbox
|
||||
case feeds
|
||||
case readlater
|
||||
case newsletters
|
||||
case downloaded
|
||||
case recommended
|
||||
case all
|
||||
case archived
|
||||
|
|
@ -17,8 +19,12 @@ public extension LinkedItemFilter {
|
|||
switch self {
|
||||
case .inbox:
|
||||
return "in:inbox"
|
||||
case .feeds:
|
||||
return "label:RSS"
|
||||
case .readlater:
|
||||
return "in:library"
|
||||
case .downloaded:
|
||||
return ""
|
||||
case .newsletters:
|
||||
return "in:inbox label:Newsletter"
|
||||
case .recommended:
|
||||
|
|
@ -70,12 +76,30 @@ public extension LinkedItemFilter {
|
|||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
])
|
||||
case .downloaded:
|
||||
// include pdf only
|
||||
let hasHTMLContent = NSPredicate(
|
||||
format: "htmlContent.length > 0"
|
||||
)
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF"
|
||||
)
|
||||
let localPDFURL = NSPredicate(
|
||||
format: "localPDF.length > 0"
|
||||
)
|
||||
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [isPDFPredicate, localPDFURL])
|
||||
return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
|
||||
case .feeds:
|
||||
let feedLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, feedLabelPredicate])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public struct PageScrapePayload {
|
|||
public enum ContentType {
|
||||
case none
|
||||
case pdf(localUrl: URL)
|
||||
case html(html: String, title: String?, highlightData: HighlightData?)
|
||||
case html(html: String, title: String?, iconURL: URL?, highlightData: HighlightData?)
|
||||
}
|
||||
|
||||
public let url: String
|
||||
|
|
@ -49,9 +49,9 @@ public struct PageScrapePayload {
|
|||
self.contentType = .pdf(localUrl: localUrl)
|
||||
}
|
||||
|
||||
init(url: String, title: String?, html: String, highlightData: HighlightData?) {
|
||||
init(url: String, title: String?, html: String, iconURL: URL?, highlightData: HighlightData?) {
|
||||
self.url = url
|
||||
self.contentType = .html(html: html, title: title, highlightData: highlightData)
|
||||
self.contentType = .html(html: html, title: title, iconURL: iconURL, highlightData: highlightData)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,6 +319,11 @@ private extension PageScrapePayload {
|
|||
let html = results?["originalHTML"] as? String
|
||||
let title = results?["title"] as? String
|
||||
let contentType = results?["contentType"] as? String
|
||||
var iconURL: URL?
|
||||
|
||||
if let urlStr = results?["iconURL"] as? String {
|
||||
iconURL = URL(string: urlStr)
|
||||
}
|
||||
|
||||
// If we were not able to capture any HTML, treat this as a URL and
|
||||
// see if the backend can do better.
|
||||
|
|
@ -336,6 +341,7 @@ private extension PageScrapePayload {
|
|||
return PageScrapePayload(url: url,
|
||||
title: title,
|
||||
html: html,
|
||||
iconURL: iconURL,
|
||||
highlightData: HighlightData.make(dict: results))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ let logger = Logger(subsystem: "app.omnivore", category: "data-service")
|
|||
|
||||
public final class DataService: ObservableObject {
|
||||
public static var registerIntercomUser: ((String) -> Void)?
|
||||
public static var setIntercomUserHash: ((String) -> Void)?
|
||||
|
||||
public static var showIntercomMessenger: (() -> Void)?
|
||||
|
||||
public let appEnvironment: AppEnvironment
|
||||
|
|
@ -96,19 +98,6 @@ public final class DataService: ObservableObject {
|
|||
return try? persistentContainer.viewContext.fetch(fetchRequest).first
|
||||
}
|
||||
|
||||
public func username() async -> String? {
|
||||
if let cachedUsername = currentViewer?.username {
|
||||
return cachedUsername
|
||||
}
|
||||
|
||||
if let viewerObjectID = try? await fetchViewer() {
|
||||
let viewer = backgroundContext.object(with: viewerObjectID) as? Viewer
|
||||
return viewer?.unwrappedUsername
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
|
||||
do {
|
||||
try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue)
|
||||
|
|
@ -266,7 +255,7 @@ public final class DataService: ObservableObject {
|
|||
linkedItem.contentReader = "PDF"
|
||||
linkedItem.tempPDFURL = localUrl
|
||||
linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
case let .html(html: html, title: title, highlightData: _):
|
||||
case let .html(html: html, title: title, iconURL: _, highlightData: _):
|
||||
linkedItem.contentReader = "WEB"
|
||||
linkedItem.originalHtml = html
|
||||
linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url)
|
||||
|
|
|
|||
|
|
@ -23024,6 +23024,7 @@ extension Objects {
|
|||
let followersCount: [String: Int]
|
||||
let friendsCount: [String: Int]
|
||||
let id: [String: String]
|
||||
let intercomHash: [String: String]
|
||||
let isFriend: [String: Bool]
|
||||
let isFullUser: [String: Bool]
|
||||
let name: [String: String]
|
||||
|
|
@ -23070,6 +23071,10 @@ extension Objects.User: Decodable {
|
|||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "intercomHash":
|
||||
if let value = try container.decode(String?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
}
|
||||
case "isFriend":
|
||||
if let value = try container.decode(Bool?.self, forKey: codingKey) {
|
||||
map.set(key: field, hash: alias, value: value as Any)
|
||||
|
|
@ -23128,6 +23133,7 @@ extension Objects.User: Decodable {
|
|||
followersCount = map["followersCount"]
|
||||
friendsCount = map["friendsCount"]
|
||||
id = map["id"]
|
||||
intercomHash = map["intercomHash"]
|
||||
isFriend = map["isFriend"]
|
||||
isFullUser = map["isFullUser"]
|
||||
name = map["name"]
|
||||
|
|
@ -23206,6 +23212,21 @@ extension Fields where TypeLock == Objects.User {
|
|||
}
|
||||
}
|
||||
|
||||
func intercomHash() throws -> String? {
|
||||
let field = GraphQLField.leaf(
|
||||
name: "intercomHash",
|
||||
arguments: []
|
||||
)
|
||||
select(field)
|
||||
|
||||
switch response {
|
||||
case let .decoding(data):
|
||||
return data.intercomHash[field.alias!]
|
||||
case .mocking:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, deprecated, message: "isFriend has been replaced with viewerIsFollowing")
|
||||
func isFriend() throws -> Bool? {
|
||||
let field = GraphQLField.leaf(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import Foundation
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
extension DataService {
|
||||
public func updateItemLabels(itemID: String, labelIDs: [String]) {
|
||||
public extension DataService {
|
||||
func setItemLabels(itemID: String, labels: [InternalLinkedItemLabel]) {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
|
@ -13,21 +13,19 @@ extension DataService {
|
|||
linkedItem.removeFromLabels(existingLabels)
|
||||
}
|
||||
|
||||
for labelID in labelIDs {
|
||||
if let labelObject = LinkedItemLabel.lookup(byID: labelID, inContext: self.backgroundContext) {
|
||||
linkedItem.addToLabels(labelObject)
|
||||
}
|
||||
for label in labels {
|
||||
linkedItem.addToLabels(label.asManagedObject(inContext: self.backgroundContext))
|
||||
}
|
||||
|
||||
linkedItem.update(inContext: self.backgroundContext)
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
// Send update to server
|
||||
self.syncLabelUpdates(itemID: itemID, labelIDs: labelIDs)
|
||||
self.syncLabelUpdates(itemID: itemID, labels: labels)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLabelUpdates(itemID: String, labelIDs: [String]) {
|
||||
internal func syncLabelUpdates(itemID: String, labels: [InternalLinkedItemLabel]) {
|
||||
enum MutationResult {
|
||||
case saved(feedItem: [InternalLinkedItemLabel])
|
||||
case error(errorCode: Enums.SetLabelsErrorCode)
|
||||
|
|
@ -40,10 +38,18 @@ extension DataService {
|
|||
)
|
||||
}
|
||||
|
||||
let labelInputs = labels.compactMap { label in
|
||||
InputObjects.CreateLabelInput(
|
||||
color: OptionalArgument(label.color),
|
||||
description: OptionalArgument(label.labelDescription),
|
||||
name: label.name
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
labelIds: OptionalArgument(labelIDs),
|
||||
labels: OptionalArgument(labelInputs),
|
||||
pageId: itemID
|
||||
),
|
||||
selection: selection
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import SwiftGraphQL
|
|||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func fetchViewer() async throws -> NSManagedObjectID {
|
||||
@MainActor
|
||||
func fetchViewer() async throws -> ViewerInternal? {
|
||||
let selection = Selection<ViewerInternal, Objects.User> {
|
||||
ViewerInternal(
|
||||
userID: try $0.id(),
|
||||
|
|
@ -15,7 +16,8 @@ public extension DataService {
|
|||
name: try $0.name(),
|
||||
profileImageURL: try $0.profile(
|
||||
selection: .init { try $0.pictureUrl() }
|
||||
)
|
||||
),
|
||||
intercomHash: try $0.intercomHash()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -29,15 +31,24 @@ public extension DataService {
|
|||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { [weak self] result in
|
||||
switch result {
|
||||
case let .success(payload):
|
||||
case let .success(payload: payload):
|
||||
if UserDefaults.standard.string(forKey: Keys.userIdKey) == nil {
|
||||
UserDefaults.standard.setValue(payload.data.userID, forKey: Keys.userIdKey)
|
||||
DataService.registerIntercomUser?(payload.data.userID)
|
||||
}
|
||||
|
||||
if let self = self, let viewerID = payload.data.persist(context: self.backgroundContext) {
|
||||
continuation.resume(returning: viewerID)
|
||||
} else {
|
||||
do {
|
||||
if let intercomUserHash = payload.data.intercomHash {
|
||||
DataService.setIntercomUserHash?(intercomUserHash)
|
||||
}
|
||||
|
||||
if let self = self {
|
||||
try payload.data.persist(context: self.backgroundContext)
|
||||
continuation.resume(returning: payload.data)
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "no self found"))
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "coredata error"))
|
||||
}
|
||||
case .failure:
|
||||
|
|
@ -48,16 +59,15 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ViewerInternal {
|
||||
let userID: String
|
||||
let username: String
|
||||
let name: String
|
||||
let profileImageURL: String?
|
||||
public struct ViewerInternal {
|
||||
public let userID: String
|
||||
public let username: String
|
||||
public let name: String
|
||||
public let profileImageURL: String?
|
||||
public let intercomHash: String?
|
||||
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
var objectID: NSManagedObjectID?
|
||||
|
||||
context.performAndWait {
|
||||
func persist(context: NSManagedObjectContext) throws {
|
||||
try context.performAndWait {
|
||||
let viewer = Viewer(context: context)
|
||||
viewer.userID = userID
|
||||
viewer.username = username
|
||||
|
|
@ -66,15 +76,13 @@ private struct ViewerInternal {
|
|||
|
||||
do {
|
||||
try context.save()
|
||||
EventTracker.registerUser(userID: userID)
|
||||
logger.debug("Viewer saved succesfully")
|
||||
objectID = viewer.objectID
|
||||
EventTracker.registerUser(userID: viewer.unwrappedUserID)
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to save Viewer: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return objectID
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,23 +48,6 @@
|
|||
|
||||
child.didMove(toParent: self)
|
||||
}
|
||||
//
|
||||
// @objc func keyboardWillShow(notification: Notification) {
|
||||
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
|
||||
// if self.view.frame.origin.y == 0{
|
||||
// self.view.frame.origin.y -= keyboardSize.height
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// @objc func keyboardWillHide(notification: Notification) {
|
||||
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
|
||||
// if self.view.frame.origin.y != 0 {
|
||||
// self.view.frame.origin.y += keyboardSize.height
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ public extension Color {
|
|||
|
||||
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
|
||||
|
||||
static var circleButtonBackground: Color { Color("_circleButtonBackground", bundle: .module) }
|
||||
static var circleButtonForeground: Color { Color("_circleButtonForeground", bundle: .module) }
|
||||
static var extensionBackground: Color { Color("_extensionBackground", bundle: .module) }
|
||||
static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) }
|
||||
static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) }
|
||||
|
||||
static var noteContainer: Color { Color("_noteContainer", bundle: .module) }
|
||||
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
#if os(iOS)
|
||||
static var systemBackground: Color { Color(.systemBackground) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xE9",
|
||||
"green" : "0xE8",
|
||||
"red" : "0xE8"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x3E",
|
||||
"green" : "0x3C",
|
||||
"red" : "0x3B"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x83",
|
||||
"green" : "0x81",
|
||||
"red" : "0x81"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xAB",
|
||||
"green" : "0xA5",
|
||||
"red" : "0xA5"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xF6",
|
||||
"green" : "0xF6",
|
||||
"red" : "0xF6"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x20",
|
||||
"green" : "0x20",
|
||||
"red" : "0x20"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x30",
|
||||
"green" : "0x30",
|
||||
"red" : "0x30"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "display-p3",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x89",
|
||||
"green" : "0x89",
|
||||
"red" : "0x89"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x89",
|
||||
"green" : "0x89",
|
||||
"red" : "0x89"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0xED",
|
||||
"red" : "0xED"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2E",
|
||||
"green" : "0x2C",
|
||||
"red" : "0x2C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct FeedCard: View {
|
||||
let viewer: Viewer?
|
||||
let tapHandler: () -> Void
|
||||
@ObservedObject var item: LinkedItem
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
self.tapHandler = tapHandler
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0))
|
||||
|
||||
if let author = item.author {
|
||||
Text("By \(author)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let publisherDisplayName = item.publisherDisplayName {
|
||||
Text(publisherDisplayName)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
minWidth: 0,
|
||||
maxWidth: .infinity,
|
||||
minHeight: 0,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
Color.systemBackground
|
||||
.frame(width: 80, height: 80)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.hasLabels {
|
||||
// Category Labels
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.sortedLabels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
#if os(iOS)
|
||||
scrollView.bounces = false
|
||||
#endif
|
||||
}
|
||||
.padding(.top, 0)
|
||||
#if os(macOS)
|
||||
.onTapGesture {
|
||||
tapHandler()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
let recs = Recommendation.notViewers(viewer: viewer, item.recommendations)
|
||||
if recs.count > 0 {
|
||||
let byStr = Recommendation.byline(recs)
|
||||
let inStr = Recommendation.groupsLine(recs)
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
Text("Recommended by \(byStr) in \(inStr)")
|
||||
.font(.appCaption)
|
||||
.frame(alignment: .leading)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 8)
|
||||
.frame(
|
||||
minWidth: nil,
|
||||
idealWidth: nil,
|
||||
maxWidth: nil,
|
||||
minHeight: 70,
|
||||
idealHeight: nil,
|
||||
maxHeight: nil,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,19 +54,20 @@ struct LabelsFlowLayout: View {
|
|||
return result
|
||||
})
|
||||
}
|
||||
}.background(viewCalculator())
|
||||
}.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
private func item(for item: LinkedItemLabel) -> some View {
|
||||
LibraryItemLabelView(text: item.name!, color: Color(hex: item.color!)!)
|
||||
}
|
||||
|
||||
func viewCalculator() -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.onAppear {
|
||||
let rect = geometry.frame(in: .local)
|
||||
self.totalHeight = rect.size.height
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ public extension View {
|
|||
public struct LibraryItemCard: View {
|
||||
let viewer: Viewer?
|
||||
@ObservedObject var item: LinkedItem
|
||||
@State var noteLineLimit: Int? = 3
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?) {
|
||||
self.item = item
|
||||
|
|
@ -64,6 +65,33 @@ public struct LibraryItemCard: View {
|
|||
if item.hasLabels {
|
||||
labels
|
||||
}
|
||||
|
||||
if let note = item.noteText {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
avatarImage
|
||||
.frame(width: 20, height: 20)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, 10)
|
||||
|
||||
Text(note)
|
||||
.font(Font.system(size: 12))
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(noteLineLimit)
|
||||
.frame(minHeight: 20)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(alignment: .topLeading)
|
||||
.background(Color.noteContainer)
|
||||
.cornerRadius(5)
|
||||
.allowsHitTesting(noteLineLimit != nil)
|
||||
.onTapGesture {
|
||||
noteLineLimit = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(5)
|
||||
.padding(.top, 10)
|
||||
|
|
@ -79,6 +107,16 @@ public struct LibraryItemCard: View {
|
|||
Int(item.readingProgress) > 0
|
||||
}
|
||||
|
||||
var avatarImage: some View {
|
||||
ZStack(alignment: .center) {
|
||||
Circle()
|
||||
.foregroundColor(Color.appCtaYellow)
|
||||
Text((viewer?.name ?? "O").prefix(1))
|
||||
.font(Font.system(size: 10))
|
||||
.foregroundColor(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
var readIndicator: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
|
|
@ -281,3 +319,18 @@ public struct LibraryItemCard: View {
|
|||
LabelsFlowLayout(labels: nonFlairLabels)
|
||||
}
|
||||
}
|
||||
|
||||
struct CircleCheckboxToggleStyle: ToggleStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button(action: {
|
||||
configuration.isOn.toggle()
|
||||
}, label: {
|
||||
HStack {
|
||||
Image(systemName: configuration.isOn ? "checkmark.circle" : "circle")
|
||||
.font(Font.system(size: 18))
|
||||
.foregroundColor(configuration.isOn ? Color.blue : Color.appGrayTextContrast)
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,59 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct SearchBar: View {
|
||||
@Binding var searchTerm: String
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TextField("Search", text: $searchTerm)
|
||||
.frame(height: 36)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 28)
|
||||
.focused($isFocused)
|
||||
.overlay(
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.appGrayText)
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
|
||||
if isFocused {
|
||||
Button(
|
||||
action: {
|
||||
self.isFocused = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
.padding(.trailing, 8)
|
||||
.transition(.move(edge: .trailing))
|
||||
}
|
||||
}
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
.frame(height: 36)
|
||||
.onChange(of: isFocused) { isFocused in
|
||||
if !isFocused {
|
||||
searchTerm = ""
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
isFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import Utils
|
|||
public struct SyncStatusIcon: View {
|
||||
let status: ServerSyncStatus
|
||||
|
||||
init(status: ServerSyncStatus) {
|
||||
public init(status: ServerSyncStatus) {
|
||||
self.status = status
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ struct MainApp: App {
|
|||
RootView(
|
||||
intercomProvider: AppKeys.sharedInstance?.intercom != nil ? IntercomProvider(
|
||||
registerIntercomUser: { Intercom.registerUser(withUserId: $0) },
|
||||
setIntercomUserHash: { Intercom.setUserHash($0) },
|
||||
unregisterIntercomUser: Intercom.logout,
|
||||
showIntercomMessenger: Intercom.presentMessenger
|
||||
) : nil
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ var ShareExtension = function() {};
|
|||
|
||||
const iconURL = () => {
|
||||
try {
|
||||
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']").content
|
||||
if (previewImage) { return previewImage }
|
||||
const previewImage = document.querySelector("meta[property='og:image'], meta[name='twitter:image']")
|
||||
if (previewImage && previewImage.getAttribute("content")) { return previewImage.getAttribute("content") }
|
||||
|
||||
return document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']").href
|
||||
const appleImage = document.querySelector("link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']")
|
||||
if (appleImage && appleImage.getAttribute("href")) { return appleImage.getAttribute("href") }
|
||||
|
||||
const href = new URL(document.location.href)
|
||||
href.pathname = '/favicon.ico'
|
||||
|
||||
return href.toString()
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,60 @@
|
|||
import App
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
@objc(ShareExtensionViewController)
|
||||
final class ShareExtensionViewController: UIViewController {
|
||||
let labelsViewModel = LabelsViewModel()
|
||||
let viewModel = ShareExtensionViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowAddNoteSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(AddNoteSheet(viewModel: self.viewModel)))
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowEditLabelsSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(EditLabelsSheet(viewModel: self.viewModel, labelsViewModel: self.labelsViewModel)))
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Notification.Name("ShowEditInfoSheet"),
|
||||
object: nil,
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
self.openSheet(AnyView(EditInfoSheet(viewModel: self.viewModel)))
|
||||
}
|
||||
|
||||
embed(
|
||||
childViewController: UIViewController.makeShareExtensionController(extensionContext: extensionContext),
|
||||
heightRatio: 0.75
|
||||
childViewController: UIViewController.makeShareExtensionController(
|
||||
viewModel: viewModel,
|
||||
labelsViewModel: labelsViewModel,
|
||||
extensionContext: extensionContext
|
||||
),
|
||||
heightRatio: 0.60
|
||||
)
|
||||
}
|
||||
|
||||
func openSheet(_ rootView: AnyView) {
|
||||
let hostingController = UIHostingController(rootView: rootView)
|
||||
|
||||
present(hostingController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: "postgres:12.8"
|
||||
image: "ankane/pgvector:v0.5.1"
|
||||
container_name: "omnivore-postgres"
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
|
|
|
|||
160
docs/guides/getting-started/ja/getting-started-guide.md
Normal file
160
docs/guides/getting-started/ja/getting-started-guide.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Omnivoreをはじめよう
|
||||
|
||||
Omnivoreは、オンラインで読んだものを保存し整理できる **後で読むアプリ** です。
|
||||
|
||||
このガイドでは、Omnivoreの基本的な機能と高度な機能の使い方を紹介し、次の4つの主要な活動に分けて説明します:
|
||||
|
||||
- 保存
|
||||
- 読む
|
||||
- 整理
|
||||
- 統合
|
||||
|
||||
**ライブラリ** は、Omnivoreのエクスペリエンスの中心であり、保存したリンクにすばやくアクセスできます。保存したリンクは削除しない限り、ライブラリに永久に残ります。
|
||||
|
||||
## 保存
|
||||
|
||||
後で読むためにページや記事へのリンクを保存する主要な方法は次の5つです:
|
||||
|
||||
- Omnivoreライブラリから保存
|
||||
- ブラウザから保存
|
||||
- スマートフォンまたはタブレット(iOSまたはAndroid)から保存
|
||||
- メール経由のニュースレター購読
|
||||
- MacからPDFを保存
|
||||
|
||||
### Omnivoreライブラリから保存
|
||||
|
||||
1. ライブラリの右上隅にある **リンクを追加** ボタンをタップします。
|
||||
2. 保存したいURLを入力し、 **リンクを追加** をタップします。
|
||||
3. リンクは、次にライブラリをリフレッシュすると表示されます。
|
||||
|
||||
### ブラウザからの保存
|
||||
|
||||
1. ブラウザ用のOmnivore拡張機能をダウンロードしてインストールします:
|
||||
|
||||
- [Chrome](https://omnivore.app/install/chrome)
|
||||
- [Edge](https://omnivore.app/install/edge)
|
||||
- [Firefox](https://omnivore.app/install/firefox)
|
||||
- [Safari](https://omnivore.app/install/safari)
|
||||
|
||||
2. 保存したいページに移動し、ブラウザのツールバーまたは拡張機能メニューにあるOmnivoreボタンをタップします。
|
||||
3. または、リンクを右クリック(Macの場合はコマンド+クリック)し、メニューから**Omnivoreに保存**を選択できます。
|
||||
4. リンクは、次にライブラリをリフレッシュすると表示されます。
|
||||
|
||||
### スマートフォンまたはタブレットからの保存
|
||||
|
||||
モバイルデバイスからリンクを保存する最良の方法は、Omnivoreアプリを使用することです。アプリはこちらからダウンロードできます:
|
||||
|
||||
- [iOS(iPhoneまたはiPad)](https://omnivore.app/install/ios)
|
||||
- Android
|
||||
|
||||
モバイルアプリをインストールしたら:
|
||||
|
||||
1. ブラウザで保存したいページに移動し、**共有**ボタンをタップします。
|
||||
2. 共有メニューで**Omnivore**アイコンをタップします。
|
||||
3. リンクは、次にライブラリをリフレッシュすると表示されます。
|
||||
|
||||
### メール経由のニュースレター購読
|
||||
|
||||
1. Omnivoreのウェブサイトまたアプリで、右上隅にある写真、イニシャル、またはアバターをタップしてプロファイルメニューにアクセスします。メニューから**Eメール**を選択します。
|
||||
2. **新しいメールアドレスを作成**をタップして、リストに新しいメールアドレス(例: username-123_abc@inbox.omnivore.app)を追加します。
|
||||
3. メールアドレスの横にあるコピーのアイコンをクリックします。
|
||||
4. 購読したいニュースレターの登録ページに移動します。
|
||||
5. Omnivoreのメールアドレスを登録フォームに貼り付けます。
|
||||
6. 新しいニュースレターは自動的にOmnivoreの受信トレイに配信されます。
|
||||
|
||||
### MacからPDFを保存
|
||||
|
||||
1. [Macアプリ](https://omnivore.app/install/mac)をインストールします。
|
||||
2. Mac上で保存したいPDFを見つけ、ファイル名を右クリックまたはCtrl+クリックします。
|
||||
3. メニューから**共有**を選択し、**Omnivore**を選びます。
|
||||
4. リンクは、次にライブラリをリフレッシュすると表示されます。
|
||||
|
||||
## 読む
|
||||
|
||||
ライブラリに保存されたリンクをクリックしてリーダービューに入ります。
|
||||
|
||||
Omnivoreは、広告や雑然としたものを取り除き、気を散らさない読書をサポートするためにページをフォーマットします。テキストに焦点を当てたビューは、記事を小さくし、読み込みを迅速化します。
|
||||
|
||||
読書中に、次のことができます:
|
||||
- <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. メモまたはハイライトを削除するには、リストから選択し、ゴミ箱アイコンをタップします。
|
||||
|
||||
### 読書の進行状況を追跡
|
||||
|
||||
Omnivoreは異なるデバイス間での読書の進行状況を自動的に追跡し、前回終了した場所から簡単に再開できるようにします。読書を開始した後、ライブラリ内の各リンクの上部に進行バーが表示されます。
|
||||
|
||||
## 整理
|
||||
|
||||
デフォルトでは、ライブラリの受信トレイには保存したすべてのリンクが表示されます。リストを管理し、読書を整理するために、Omnivoreは以下のアクションを提供します:
|
||||
|
||||
- <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. _Omnivoreモバイルアプリのみ_: **ラベル** をタップして、使用したすべてのラベルの完全なリストを表示します。ラベルをタップして、同じラベルを持つすべてのリンクを表示できます。
|
||||
5. 注意: Omnivoreは「ニュースレター」など、一部のラベルを自動的に割り当てます。
|
||||
|
||||
### 検索
|
||||
|
||||
1. 保存したすべてのリンクを検索するには、検索バーにキーワードやフレーズを入力します。
|
||||
2. キーワードをラベルやフィルタと組み合わせて、さらに絞り込んだ検索を行うことができます。[詳細な検索について詳しく](https://docs.omnivore.app/using/search.html)。
|
||||
|
||||
### フィルタ
|
||||
|
||||
1. ライブラリ表示を絞り込むために **フィルタ** メニューを使用します(一部のフィルタはデフォルトで表示される場合があります)。
|
||||
2. **後で読む** を選択すると、アーカイブされていないリンクのリストが表示されます(ニュースレターを除く)。
|
||||
3. **ハイライト** を選択すると、保存したすべてのページでハイライトされたテキストが表示されます。
|
||||
4. **今日** を選択すると、今日保存したリンクのリストが表示されます。
|
||||
5. **ニュースレター** を選択すると、ニュースレターの購読を通じて保存したリンクが表示されます。
|
||||
|
||||
## 統合
|
||||
|
||||
Omnivoreは、ナレッジベースやノートアプリとの統合を許可しており、次のものが含まれています:
|
||||
|
||||
- Logseq
|
||||
- Webhooks
|
||||
|
||||
### Logseq
|
||||
|
||||
OmnivoreのLogseqプラグインを使用すると、保存した記事、ハイライト、メモをすべてLogseqに同期させることができます。Logseqは人気のあるナレッジベースです。Logseqプラグインの設定と使用に関する情報については、この有用な [Omnivore for Logseq Plugin Guide](https://briansunter.com/graph/#/page/omnivore-logseq-guide) を参照してください。
|
||||
|
||||
### Webhooks
|
||||
|
||||
Omnivoreは、リンクを保存するか、読んでいるページにハイライトを追加するときにWebhooksをトリガーできます。 <span style="text-decoration:underline;">この例</span> では、Webhooksを使用してすべての保存されたリンクをGoogle Driveに保存されたGoogle Sheetsスプレッドシートに書き込む方法が示されています。
|
||||
|
|
@ -14,6 +14,13 @@ export enum IntegrationType {
|
|||
Import = 'IMPORT',
|
||||
}
|
||||
|
||||
export enum ImportItemState {
|
||||
Unread = 'UNREAD',
|
||||
Unarchived = 'UNARCHIVED',
|
||||
Archived = 'ARCHIVED',
|
||||
All = 'ALL',
|
||||
}
|
||||
|
||||
@Entity({ name: 'integrations' })
|
||||
export class Integration {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
|
@ -49,4 +56,7 @@ export class Integration {
|
|||
|
||||
@Column('text', { nullable: true })
|
||||
taskName?: string | null
|
||||
|
||||
@Column('enum', { enum: ImportItemState, nullable: true })
|
||||
importItemState?: ImportItemState | null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -970,6 +970,13 @@ export type ImportFromIntegrationSuccess = {
|
|||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export enum ImportItemState {
|
||||
All = 'ALL',
|
||||
Archived = 'ARCHIVED',
|
||||
Unarchived = 'UNARCHIVED',
|
||||
Unread = 'UNREAD'
|
||||
}
|
||||
|
||||
export type Integration = {
|
||||
__typename?: 'Integration';
|
||||
createdAt: Scalars['Date'];
|
||||
|
|
@ -2375,7 +2382,9 @@ export enum SetIntegrationErrorCode {
|
|||
export type SetIntegrationInput = {
|
||||
enabled: Scalars['Boolean'];
|
||||
id?: InputMaybe<Scalars['ID']>;
|
||||
importItemState?: InputMaybe<ImportItemState>;
|
||||
name: Scalars['String'];
|
||||
syncedAt?: InputMaybe<Scalars['Date']>;
|
||||
token: Scalars['String'];
|
||||
type?: InputMaybe<IntegrationType>;
|
||||
};
|
||||
|
|
@ -3494,6 +3503,7 @@ export type ResolversTypes = {
|
|||
ImportFromIntegrationErrorCode: ImportFromIntegrationErrorCode;
|
||||
ImportFromIntegrationResult: ResolversTypes['ImportFromIntegrationError'] | ResolversTypes['ImportFromIntegrationSuccess'];
|
||||
ImportFromIntegrationSuccess: ResolverTypeWrapper<ImportFromIntegrationSuccess>;
|
||||
ImportItemState: ImportItemState;
|
||||
Int: ResolverTypeWrapper<Scalars['Int']>;
|
||||
Integration: ResolverTypeWrapper<Integration>;
|
||||
IntegrationType: IntegrationType;
|
||||
|
|
|
|||
|
|
@ -863,6 +863,13 @@ type ImportFromIntegrationSuccess {
|
|||
success: Boolean!
|
||||
}
|
||||
|
||||
enum ImportItemState {
|
||||
ALL
|
||||
ARCHIVED
|
||||
UNARCHIVED
|
||||
UNREAD
|
||||
}
|
||||
|
||||
type Integration {
|
||||
createdAt: Date!
|
||||
enabled: Boolean!
|
||||
|
|
@ -1834,7 +1841,9 @@ enum SetIntegrationErrorCode {
|
|||
input SetIntegrationInput {
|
||||
enabled: Boolean!
|
||||
id: ID
|
||||
importItemState: ImportItemState
|
||||
name: String!
|
||||
syncedAt: Date
|
||||
token: String!
|
||||
type: IntegrationType
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ import {
|
|||
TypeaheadSearchSuccess,
|
||||
UpdateReason,
|
||||
UpdatesSinceError,
|
||||
UpdatesSinceErrorCode,
|
||||
UpdatesSinceSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getColumns } from '../../repository'
|
||||
|
|
@ -54,6 +53,7 @@ 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,
|
||||
|
|
@ -661,28 +661,40 @@ export const searchResolver = authorized<
|
|||
libraryItems.pop()
|
||||
}
|
||||
|
||||
const edges = libraryItems.map((libraryItem) => {
|
||||
if (params.includeContent && libraryItem.readableContent) {
|
||||
// convert html to the requested format
|
||||
const format = params.format || ArticleFormat.Html
|
||||
try {
|
||||
const converter = contentConverter(format)
|
||||
if (converter) {
|
||||
libraryItem.readableContent = converter(
|
||||
libraryItem.readableContent,
|
||||
libraryItem.highlights
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error converting content', error)
|
||||
const edges = await Promise.all(
|
||||
libraryItems.map(async (libraryItem) => {
|
||||
if (
|
||||
libraryItem.highlightAnnotations &&
|
||||
libraryItem.highlightAnnotations.length > 0
|
||||
) {
|
||||
libraryItem.highlights = await findHighlightsByLibraryItemId(
|
||||
libraryItem.id,
|
||||
uid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node: libraryItemToSearchItem(libraryItem),
|
||||
cursor: endCursor,
|
||||
}
|
||||
})
|
||||
if (params.includeContent && libraryItem.readableContent) {
|
||||
// convert html to the requested format
|
||||
const format = params.format || ArticleFormat.Html
|
||||
try {
|
||||
const converter = contentConverter(format)
|
||||
if (converter) {
|
||||
libraryItem.readableContent = converter(
|
||||
libraryItem.readableContent,
|
||||
libraryItem.highlights
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error converting content', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node: libraryItemToSearchItem(libraryItem),
|
||||
cursor: endCursor,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
edges,
|
||||
|
|
|
|||
|
|
@ -3,23 +3,21 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { createHmac } from 'crypto'
|
||||
import { Subscription } from '../entity/subscription'
|
||||
import { env } from '../env'
|
||||
import {
|
||||
Article,
|
||||
Highlight,
|
||||
Label,
|
||||
PageType,
|
||||
Recommendation,
|
||||
SearchItem,
|
||||
User,
|
||||
} 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,
|
||||
|
|
@ -128,7 +126,6 @@ import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
|
|||
import { recentSearchesResolver } from './recent_searches'
|
||||
import { WithDataSourcesContext } from './types'
|
||||
import { updateEmailResolver } from './user'
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
|
|
@ -378,24 +375,6 @@ export const functionResolvers = {
|
|||
|
||||
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[]; labelNames?: string[] | null },
|
||||
_: unknown,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { DeepPartial } from 'typeorm'
|
||||
import { Integration, IntegrationType } from '../../entity/integration'
|
||||
import {
|
||||
ImportItemState,
|
||||
Integration,
|
||||
IntegrationType,
|
||||
} from '../../entity/integration'
|
||||
import { env } from '../../env'
|
||||
import {
|
||||
DeleteIntegrationError,
|
||||
|
|
@ -18,10 +22,11 @@ import {
|
|||
SetIntegrationErrorCode,
|
||||
SetIntegrationSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { createIntegrationToken } from '../../routers/auth/jwt_helpers'
|
||||
import {
|
||||
findIntegration,
|
||||
findIntegrations,
|
||||
getIntegrationService,
|
||||
getIntegrationClient,
|
||||
removeIntegration,
|
||||
saveIntegration,
|
||||
updateIntegration,
|
||||
|
|
@ -29,8 +34,8 @@ import {
|
|||
import { analytics } from '../../utils/analytics'
|
||||
import {
|
||||
deleteTask,
|
||||
enqueueExportToIntegration,
|
||||
enqueueImportFromIntegration,
|
||||
enqueueSyncWithIntegration,
|
||||
} from '../../utils/createTask'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
|
||||
|
|
@ -45,6 +50,11 @@ export const setIntegrationResolver = authorized<
|
|||
user: { id: uid },
|
||||
id: input.id || undefined,
|
||||
type: input.type || IntegrationType.Export,
|
||||
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
|
||||
importItemState:
|
||||
input.type === IntegrationType.Import
|
||||
? input.importItemState || ImportItemState.Unarchived // default to unarchived
|
||||
: undefined,
|
||||
}
|
||||
if (input.id) {
|
||||
// Update
|
||||
|
|
@ -59,7 +69,7 @@ export const setIntegrationResolver = authorized<
|
|||
integrationToSave.taskName = existingIntegration.taskName
|
||||
} else {
|
||||
// Create
|
||||
const integrationService = getIntegrationService(input.name)
|
||||
const integrationService = getIntegrationClient(input.name)
|
||||
// authorize and get access token
|
||||
const token = await integrationService.accessToken(input.token)
|
||||
if (!token) {
|
||||
|
|
@ -73,12 +83,27 @@ export const setIntegrationResolver = authorized<
|
|||
// save integration
|
||||
const integration = await saveIntegration(integrationToSave, uid)
|
||||
|
||||
if (
|
||||
integrationToSave.type === IntegrationType.Export &&
|
||||
(!integrationToSave.id || integrationToSave.enabled)
|
||||
) {
|
||||
if (integrationToSave.type === IntegrationType.Export && !input.id) {
|
||||
const authToken = await createIntegrationToken({
|
||||
uid,
|
||||
token: integration.token,
|
||||
})
|
||||
if (!authToken) {
|
||||
log.error('failed to create auth token', {
|
||||
integrationId: integration.id,
|
||||
})
|
||||
return {
|
||||
errorCodes: [SetIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// create a task to sync all the pages if new integration or enable integration (export type)
|
||||
const taskName = await enqueueSyncWithIntegration(uid, input.name)
|
||||
const taskName = await enqueueExportToIntegration(
|
||||
integration.id,
|
||||
integration.name,
|
||||
0,
|
||||
authToken
|
||||
)
|
||||
log.info('enqueued task', taskName)
|
||||
|
||||
// update task name in integration
|
||||
|
|
@ -190,7 +215,7 @@ export const importFromIntegrationResolver = authorized<
|
|||
ImportFromIntegrationSuccess,
|
||||
ImportFromIntegrationError,
|
||||
MutationImportFromIntegrationArgs
|
||||
>(async (_, { integrationId }, { claims: { uid }, log, signToken }) => {
|
||||
>(async (_, { integrationId }, { claims: { uid }, log }) => {
|
||||
log.info('importFromIntegrationResolver')
|
||||
|
||||
try {
|
||||
|
|
@ -202,15 +227,23 @@ export const importFromIntegrationResolver = authorized<
|
|||
}
|
||||
}
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const authToken = (await signToken(
|
||||
{ uid, exp },
|
||||
env.server.jwtSecret
|
||||
)) as string
|
||||
const authToken = await createIntegrationToken({
|
||||
uid: integration.user.id,
|
||||
token: integration.token,
|
||||
})
|
||||
if (!authToken) {
|
||||
return {
|
||||
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
|
||||
// create a task to import all the pages
|
||||
const taskName = await enqueueImportFromIntegration(
|
||||
integration.id,
|
||||
authToken
|
||||
integration.name,
|
||||
integration.syncedAt?.getTime() || 0,
|
||||
authToken,
|
||||
integration.importItemState || ImportItemState.Unarchived
|
||||
)
|
||||
// update task name in integration
|
||||
await updateIntegration(integration.id, { taskName }, uid)
|
||||
|
|
|
|||
|
|
@ -40,3 +40,8 @@ export function isPendingUserTokenPayload(
|
|||
'username' in object
|
||||
)
|
||||
}
|
||||
|
||||
export type IntegrationTokenPayload = {
|
||||
uid: string
|
||||
token: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { promisify } from 'util'
|
|||
import { env } from '../../env'
|
||||
import { logger } from '../../utils/logger'
|
||||
import {
|
||||
IntegrationTokenPayload,
|
||||
isPendingUserTokenPayload,
|
||||
PendingUserTokenPayload,
|
||||
} from './auth_types'
|
||||
|
|
@ -85,3 +86,22 @@ export function suggestedUsername(name: string): string {
|
|||
const suffix = Math.floor(Math.random() * 10000)
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
|
||||
export async function createIntegrationToken(
|
||||
payload: IntegrationTokenPayload
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
|
||||
const authToken = await signToken(
|
||||
{
|
||||
...payload,
|
||||
exp,
|
||||
},
|
||||
env.server.jwtSecret
|
||||
)
|
||||
logger.info('createIntegrationToken', payload)
|
||||
return authToken as string
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,22 @@
|
|||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { stringify } from 'csv-stringify'
|
||||
import express from 'express'
|
||||
import { DateTime } from 'luxon'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { IntegrationType } from '../../entity/integration'
|
||||
import { LibraryItem } from '../../entity/library_item'
|
||||
import { EntityType, readPushSubscription } from '../../pubsub'
|
||||
import { Claims } from '../../resolvers/types'
|
||||
import {
|
||||
findIntegration,
|
||||
getIntegrationService,
|
||||
updateIntegration,
|
||||
} from '../../services/integrations'
|
||||
import {
|
||||
findLibraryItemById,
|
||||
searchLibraryItems,
|
||||
} from '../../services/library_item'
|
||||
import { getClaimsByToken } from '../../utils/auth'
|
||||
import { Integration, IntegrationType } from '../../entity/integration'
|
||||
import { readPushSubscription } from '../../pubsub'
|
||||
import { getRepository } from '../../repository'
|
||||
import { enqueueExportToIntegration } from '../../utils/createTask'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { DateFilter } from '../../utils/search'
|
||||
import { createGCSFile } from '../../utils/uploads'
|
||||
|
||||
export interface Message {
|
||||
type?: EntityType
|
||||
id?: string
|
||||
userId: string
|
||||
pageId?: string
|
||||
articleId?: string
|
||||
}
|
||||
|
||||
interface ImportEvent {
|
||||
integrationId: string
|
||||
}
|
||||
|
||||
const isImportEvent = (event: any): event is ImportEvent =>
|
||||
'integrationId' in event
|
||||
import { createIntegrationToken } from '../auth/jwt_helpers'
|
||||
|
||||
export function integrationsServiceRouter() {
|
||||
const router = express.Router()
|
||||
|
||||
router.post('/:integrationName/:action', async (req, res) => {
|
||||
logger.info('start to sync with integration', {
|
||||
action: req.params.action,
|
||||
integrationName: req.params.integrationName,
|
||||
})
|
||||
router.post('/export', async (req, res) => {
|
||||
logger.info('start to sync with integration')
|
||||
|
||||
try {
|
||||
const { message: msgStr, expired } = readPushSubscription(req)
|
||||
|
||||
if (!msgStr) {
|
||||
return res.status(200).send('Bad Request')
|
||||
}
|
||||
|
|
@ -59,119 +26,39 @@ export function integrationsServiceRouter() {
|
|||
return res.status(200).send('Expired')
|
||||
}
|
||||
|
||||
const data: Message = JSON.parse(msgStr)
|
||||
const userId = data.userId
|
||||
const type = data.type
|
||||
if (!userId) {
|
||||
logger.info('No userId found in message')
|
||||
res.status(200).send('Bad Request')
|
||||
return
|
||||
}
|
||||
|
||||
const integration = await findIntegration(
|
||||
{
|
||||
name: req.params.integrationName.toUpperCase(),
|
||||
type: IntegrationType.Export,
|
||||
// find all active integrations
|
||||
const integrations = await getRepository(Integration).find({
|
||||
where: {
|
||||
enabled: true,
|
||||
type: IntegrationType.Export,
|
||||
},
|
||||
userId
|
||||
)
|
||||
if (!integration) {
|
||||
logger.info('No active integration found for user', { userId })
|
||||
res.status(200).send('No integration found')
|
||||
return
|
||||
}
|
||||
relations: ['user'],
|
||||
})
|
||||
|
||||
const action = req.params.action.toUpperCase()
|
||||
const integrationService = getIntegrationService(integration.name)
|
||||
if (action === 'SYNC_UPDATED') {
|
||||
// get updated page by id
|
||||
let id: string | undefined
|
||||
switch (type) {
|
||||
case EntityType.PAGE:
|
||||
id = data.id
|
||||
break
|
||||
case EntityType.HIGHLIGHT:
|
||||
id = data.articleId
|
||||
break
|
||||
case EntityType.LABEL:
|
||||
id = data.pageId
|
||||
break
|
||||
}
|
||||
if (!id) {
|
||||
logger.info('No id found in message')
|
||||
res.status(200).send('Bad Request')
|
||||
return
|
||||
}
|
||||
const item = await findLibraryItemById(id, userId)
|
||||
if (!item) {
|
||||
logger.info('No item found for id', { id })
|
||||
res.status(200).send('No page found')
|
||||
return
|
||||
}
|
||||
|
||||
// sync updated item with integration
|
||||
logger.info('syncing updated item with integration', {
|
||||
integrationId: integration.id,
|
||||
itemId: item.id,
|
||||
})
|
||||
|
||||
const synced = await integrationService.export(integration, [item])
|
||||
if (!synced) {
|
||||
logger.info('failed to sync item', {
|
||||
integrationId: integration.id,
|
||||
itemId: item.id,
|
||||
// create a task to sync with each integration
|
||||
await Promise.all(
|
||||
integrations.map(async (integration) => {
|
||||
const authToken = await createIntegrationToken({
|
||||
uid: integration.user.id,
|
||||
token: integration.token,
|
||||
})
|
||||
return res.status(400).send('Failed to sync')
|
||||
}
|
||||
} else if (action === 'SYNC_ALL') {
|
||||
// sync all pages of the user
|
||||
const size = 50
|
||||
|
||||
for (
|
||||
let hasNextPage = true,
|
||||
count = 0,
|
||||
after = 0,
|
||||
items: LibraryItem[] = [];
|
||||
hasNextPage;
|
||||
after += size, hasNextPage = count > after
|
||||
) {
|
||||
const syncedAt = integration.syncedAt
|
||||
// only sync pages that were updated after syncedAt
|
||||
const dateFilters: DateFilter[] = []
|
||||
syncedAt &&
|
||||
dateFilters.push({ field: 'updatedAt', startDate: syncedAt })
|
||||
const { libraryItems } = await searchLibraryItems(
|
||||
{ from: after, size, dateFilters },
|
||||
userId
|
||||
)
|
||||
items = libraryItems
|
||||
const itemIds = items.map((p) => p.id)
|
||||
|
||||
logger.info('syncing items', { pageIds: itemIds })
|
||||
|
||||
const synced = await integrationService.export(integration, items)
|
||||
if (!synced) {
|
||||
logger.error('failed to sync items', {
|
||||
pageIds: itemIds,
|
||||
if (!authToken) {
|
||||
logger.error('failed to create auth token', {
|
||||
integrationId: integration.id,
|
||||
})
|
||||
return res.status(400).send('Failed to sync')
|
||||
return
|
||||
}
|
||||
}
|
||||
// delete task name if completed
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
taskName: null,
|
||||
},
|
||||
userId
|
||||
)
|
||||
} else {
|
||||
logger.info('unknown action', { action })
|
||||
res.status(200).send('Unknown action')
|
||||
return
|
||||
}
|
||||
|
||||
const syncAt = integration.syncedAt?.getTime() || 0
|
||||
return enqueueExportToIntegration(
|
||||
integration.id,
|
||||
integration.name,
|
||||
syncAt,
|
||||
authToken
|
||||
)
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('sync with integrations failed', err)
|
||||
return res.status(500).send(err)
|
||||
|
|
@ -180,121 +67,5 @@ export function integrationsServiceRouter() {
|
|||
res.status(200).send('OK')
|
||||
})
|
||||
|
||||
// import pages from integration task handler
|
||||
router.post('/import', async (req, res) => {
|
||||
logger.info('start cloud task to import pages from integration')
|
||||
const token = req.cookies?.auth || req.headers?.authorization
|
||||
let claims: Claims | undefined
|
||||
try {
|
||||
claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('failed to get claims from token', err)
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
if (!isImportEvent(req.body)) {
|
||||
logger.info('Invalid message')
|
||||
return res.status(400).send('Bad Request')
|
||||
}
|
||||
|
||||
let writeStream: NodeJS.WritableStream | undefined
|
||||
try {
|
||||
const userId = claims.uid
|
||||
const integration = await findIntegration(
|
||||
{
|
||||
id: req.body.integrationId,
|
||||
enabled: true,
|
||||
type: IntegrationType.Import,
|
||||
},
|
||||
userId
|
||||
)
|
||||
if (!integration) {
|
||||
logger.info('No active integration found for user', { userId })
|
||||
return res.status(200).send('No integration found')
|
||||
}
|
||||
|
||||
const integrationService = getIntegrationService(integration.name)
|
||||
// import pages from integration
|
||||
logger.info('importing pages from integration', {
|
||||
integrationId: integration.id,
|
||||
})
|
||||
|
||||
let offset = 0
|
||||
const since = integration.syncedAt?.getTime() || 0
|
||||
let syncedAt = since
|
||||
|
||||
// get pages from integration
|
||||
const retrieved = await integrationService.retrieve({
|
||||
token: integration.token,
|
||||
since,
|
||||
offset,
|
||||
})
|
||||
syncedAt = retrieved.since || Date.now()
|
||||
|
||||
let retrievedData = retrieved.data
|
||||
// if there are pages to import
|
||||
if (retrievedData.length > 0) {
|
||||
// write the list of urls to a csv file and upload it to gcs
|
||||
// path style: imports/<uid>/<date>/<type>-<uuid>.csv
|
||||
const dateStr = DateTime.now().toISODate()
|
||||
const fileUuid = uuidv4()
|
||||
const fullPath = `imports/${userId}/${dateStr}/URL_LIST-${fileUuid}.csv`
|
||||
// open a write_stream to the file
|
||||
const file = createGCSFile(fullPath)
|
||||
writeStream = file.createWriteStream({
|
||||
contentType: 'text/csv',
|
||||
})
|
||||
// stringify the data and pipe it to the write_stream
|
||||
const stringifier = stringify({
|
||||
header: true,
|
||||
columns: ['url', 'state', 'labels'],
|
||||
})
|
||||
stringifier.pipe(writeStream)
|
||||
|
||||
// paginate api calls to the integration
|
||||
do {
|
||||
// write the list of urls, state and labels to the stream
|
||||
retrievedData.forEach((row) => stringifier.write(row))
|
||||
|
||||
// get next pages from the integration
|
||||
offset += retrievedData.length
|
||||
|
||||
const retrieved = await integrationService.retrieve({
|
||||
token: integration.token,
|
||||
since,
|
||||
offset,
|
||||
})
|
||||
syncedAt = retrieved.since || Date.now()
|
||||
retrievedData = retrieved.data
|
||||
|
||||
logger.info('retrieved data', {
|
||||
total: offset,
|
||||
size: retrievedData.length,
|
||||
})
|
||||
} while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages
|
||||
}
|
||||
|
||||
// update the integration's syncedAt and remove taskName
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
syncedAt: new Date(syncedAt),
|
||||
taskName: null,
|
||||
},
|
||||
userId
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('import pages from integration failed', err)
|
||||
return res.status(500).send(err)
|
||||
} finally {
|
||||
writeStream?.end()
|
||||
}
|
||||
|
||||
res.status(200).send('OK')
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1972,12 +1972,21 @@ const schema = gql`
|
|||
ALREADY_EXISTS
|
||||
}
|
||||
|
||||
enum ImportItemState {
|
||||
UNREAD
|
||||
UNARCHIVED
|
||||
ARCHIVED
|
||||
ALL
|
||||
}
|
||||
|
||||
input SetIntegrationInput {
|
||||
id: ID
|
||||
name: String!
|
||||
type: IntegrationType
|
||||
token: String!
|
||||
enabled: Boolean!
|
||||
syncedAt: Date
|
||||
importItemState: ImportItemState
|
||||
}
|
||||
|
||||
union IntegrationsResult = IntegrationsSuccess | IntegrationsError
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { DeepPartial, FindOptionsWhere } from 'typeorm'
|
||||
import { Integration } from '../../entity/integration'
|
||||
import { authTrx } from '../../repository'
|
||||
import { IntegrationService } from './integration'
|
||||
import { PocketIntegration } from './pocket'
|
||||
import { ReadwiseIntegration } from './readwise'
|
||||
import { IntegrationClient } from './integration'
|
||||
import { PocketClient } from './pocket'
|
||||
import { ReadwiseClient } from './readwise'
|
||||
|
||||
const integrations: IntegrationService[] = [
|
||||
new ReadwiseIntegration(),
|
||||
new PocketIntegration(),
|
||||
const integrations: IntegrationClient[] = [
|
||||
new ReadwiseClient(),
|
||||
new PocketClient(),
|
||||
]
|
||||
|
||||
export const getIntegrationService = (name: string): IntegrationService => {
|
||||
export const getIntegrationClient = (name: string): IntegrationClient => {
|
||||
const service = integrations.find((s) => s.name === name)
|
||||
if (!service) {
|
||||
throw new Error(`Integration service not found: ${name}`)
|
||||
throw new Error(`Integration client not found: ${name}`)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Integration } from '../../entity/integration'
|
||||
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
|
||||
import { LibraryItemState } from '../../entity/library_item'
|
||||
|
||||
export interface RetrievedData {
|
||||
url: string
|
||||
|
|
@ -19,19 +18,9 @@ export interface RetrieveRequest {
|
|||
offset?: number
|
||||
}
|
||||
|
||||
export abstract class IntegrationService {
|
||||
abstract name: string
|
||||
export interface IntegrationClient {
|
||||
name: string
|
||||
apiUrl: string
|
||||
|
||||
accessToken = async (token: string): Promise<string | null> => {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
export = async (
|
||||
integration: Integration,
|
||||
items: LibraryItem[]
|
||||
): Promise<boolean> => {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
retrieve = async (req: RetrieveRequest): Promise<RetrievedResult> => {
|
||||
return Promise.resolve({ data: [] })
|
||||
}
|
||||
accessToken(token: string): Promise<string | null>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { LibraryItemState } from '../../entity/library_item'
|
|||
import { env } from '../../env'
|
||||
import { logger } from '../../utils/logger'
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationClient,
|
||||
RetrievedResult,
|
||||
RetrieveRequest,
|
||||
} from './integration'
|
||||
|
|
@ -51,16 +51,16 @@ interface Author {
|
|||
name: string
|
||||
}
|
||||
|
||||
export class PocketIntegration extends IntegrationService {
|
||||
export class PocketClient implements IntegrationClient {
|
||||
name = 'POCKET'
|
||||
POCKET_API_URL = 'https://getpocket.com/v3'
|
||||
apiUrl = 'https://getpocket.com/v3'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Accept': 'application/json',
|
||||
}
|
||||
|
||||
accessToken = async (token: string): Promise<string | null> => {
|
||||
const url = `${this.POCKET_API_URL}/oauth/authorize`
|
||||
const url = `${this.apiUrl}/oauth/authorize`
|
||||
try {
|
||||
const response = await axios.post<{ access_token: string }>(
|
||||
url,
|
||||
|
|
@ -90,7 +90,7 @@ export class PocketIntegration extends IntegrationService {
|
|||
count = 100,
|
||||
offset = 0
|
||||
): Promise<PocketResponse | null> => {
|
||||
const url = `${this.POCKET_API_URL}/get`
|
||||
const url = `${this.apiUrl}/get`
|
||||
try {
|
||||
const response = await axios.post<PocketResponse>(
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import axios from 'axios'
|
||||
import { updateIntegration } from '.'
|
||||
import { HighlightType } from '../../entity/highlight'
|
||||
import { Integration } from '../../entity/integration'
|
||||
import { LibraryItem } from '../../entity/library_item'
|
||||
import { env } from '../../env'
|
||||
import { wait } from '../../utils/helpers'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { findHighlightsByLibraryItemId, getHighlightUrl } from '../highlights'
|
||||
import { IntegrationService } from './integration'
|
||||
import { IntegrationClient } from './integration'
|
||||
|
||||
interface ReadwiseHighlight {
|
||||
// The highlight text, (technically the only field required in a highlight object)
|
||||
|
|
@ -36,12 +29,12 @@ interface ReadwiseHighlight {
|
|||
highlight_url?: string
|
||||
}
|
||||
|
||||
export const READWISE_API_URL = 'https://readwise.io/api/v2'
|
||||
|
||||
export class ReadwiseIntegration extends IntegrationService {
|
||||
export class ReadwiseClient implements IntegrationClient {
|
||||
name = 'READWISE'
|
||||
apiUrl = 'https://readwise.io/api/v2'
|
||||
|
||||
accessToken = async (token: string): Promise<string | null> => {
|
||||
const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth`
|
||||
const authUrl = `${this.apiUrl}/auth`
|
||||
try {
|
||||
const response = await axios.get(authUrl, {
|
||||
headers: {
|
||||
|
|
@ -58,110 +51,4 @@ export class ReadwiseIntegration extends IntegrationService {
|
|||
return null
|
||||
}
|
||||
}
|
||||
export = async (
|
||||
integration: Integration,
|
||||
items: LibraryItem[]
|
||||
): Promise<boolean> => {
|
||||
let result = true
|
||||
|
||||
const highlights = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.libraryItemToReadwiseHighlight(item, integration.user.id)
|
||||
)
|
||||
)
|
||||
// If there are no highlights, we will skip the sync
|
||||
if (highlights.length > 0) {
|
||||
result = await this.syncWithReadwise(integration.token, highlights.flat())
|
||||
}
|
||||
|
||||
// update integration syncedAt if successful
|
||||
if (result) {
|
||||
logger.info('updating integration syncedAt')
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
integration.user.id
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
libraryItemToReadwiseHighlight = async (
|
||||
item: LibraryItem,
|
||||
userId: string
|
||||
): Promise<ReadwiseHighlight[]> => {
|
||||
let highlights = item.highlights
|
||||
if (!highlights) {
|
||||
highlights = await findHighlightsByLibraryItemId(item.id, userId)
|
||||
}
|
||||
|
||||
const category = item.siteName === 'Twitter' ? 'tweets' : 'articles'
|
||||
return highlights
|
||||
.map((highlight) => {
|
||||
// filter out highlights that are not of type highlight or have no quote
|
||||
if (
|
||||
highlight.highlightType !== HighlightType.Highlight ||
|
||||
!highlight.quote
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
text: highlight.quote,
|
||||
title: item.title,
|
||||
author: item.author || undefined,
|
||||
highlight_url: getHighlightUrl(item.slug, highlight.id),
|
||||
highlighted_at: new Date(highlight.createdAt).toISOString(),
|
||||
category,
|
||||
image_url: item.thumbnail || undefined,
|
||||
// location: highlight.highlightPositionAnchorIndex || undefined,
|
||||
location_type: 'order',
|
||||
note: highlight.annotation || undefined,
|
||||
source_type: 'omnivore',
|
||||
source_url: item.originalUrl,
|
||||
}
|
||||
})
|
||||
.filter((highlight) => highlight !== undefined) as ReadwiseHighlight[]
|
||||
}
|
||||
|
||||
syncWithReadwise = async (
|
||||
token: string,
|
||||
highlights: ReadwiseHighlight[],
|
||||
retryCount = 0
|
||||
): Promise<boolean> => {
|
||||
const url = `${env.readwise.apiUrl || READWISE_API_URL}/highlights`
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
highlights,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 5000, // 5 seconds
|
||||
}
|
||||
)
|
||||
return response.status === 200
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 429 && retryCount < 3) {
|
||||
logger.info('Readwise API rate limit exceeded, retrying...')
|
||||
// wait for Retry-After seconds in the header if rate limited
|
||||
// max retry count is 3
|
||||
const retryAfter = error.response?.headers['retry-after'] || '10' // default to 10 seconds
|
||||
await wait(parseInt(retryAfter, 10) * 1000)
|
||||
return this.syncWithReadwise(token, highlights, retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ const FORCE_PUPPETEER_URLS = [
|
|||
TWEET_URL_REGEX,
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/,
|
||||
]
|
||||
const ALREADY_PARSED_SOURCES = ['puppeteer-parse', 'csv-importer', 'rss-feeder']
|
||||
const ALREADY_PARSED_SOURCES = [
|
||||
'puppeteer-parse',
|
||||
'csv-importer',
|
||||
'rss-feeder',
|
||||
'pocket',
|
||||
]
|
||||
|
||||
const createSlug = (url: string, title?: Maybe<string> | undefined) => {
|
||||
const { pathname } = new URL(url)
|
||||
|
|
@ -93,7 +98,8 @@ export const savePage = async (
|
|||
state: input.state || undefined,
|
||||
rssFeedUrl: input.rssFeedUrl,
|
||||
})
|
||||
const isImported = input.source === 'csv-importer'
|
||||
const isImported =
|
||||
input.source === 'csv-importer' || input.source === 'pocket'
|
||||
|
||||
// always parse in backend if the url is in the force puppeteer list
|
||||
if (shouldParseInBackend(input)) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ interface BackendEnv {
|
|||
recommendationTaskHandlerUrl: string
|
||||
thumbnailTaskHandlerUrl: string
|
||||
rssFeedTaskHandlerUrl: string
|
||||
integrationExporterUrl: string
|
||||
integrationImporterUrl: string
|
||||
}
|
||||
fileUpload: {
|
||||
gcsUploadBucket: string
|
||||
|
|
@ -163,6 +165,8 @@ const nullableEnvVars = [
|
|||
'SENDGRID_VERIFICATION_TEMPLATE_ID',
|
||||
'REMINDER_TASK_HANDLER_URL',
|
||||
'TRUST_PROXY',
|
||||
'INTEGRATION_EXPORTER_URL',
|
||||
'INTEGRATION_IMPORTER_URL',
|
||||
] // Allow some vars to be null/empty
|
||||
|
||||
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
|
||||
|
|
@ -253,6 +257,8 @@ export function getEnv(): BackendEnv {
|
|||
recommendationTaskHandlerUrl: parse('RECOMMENDATION_TASK_HANDLER_URL'),
|
||||
thumbnailTaskHandlerUrl: parse('THUMBNAIL_TASK_HANDLER_URL'),
|
||||
rssFeedTaskHandlerUrl: parse('RSS_FEED_TASK_HANDLER_URL'),
|
||||
integrationExporterUrl: parse('INTEGRATION_EXPORTER_URL'),
|
||||
integrationImporterUrl: parse('INTEGRATION_IMPORTER_URL'),
|
||||
}
|
||||
const imageProxy = {
|
||||
url: parse('IMAGE_PROXY_URL'),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { google } from '@google-cloud/tasks/build/protos/protos'
|
|||
import axios from 'axios'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DeepPartial } from 'typeorm'
|
||||
import { ImportItemState } from '../entity/integration'
|
||||
import { Recommendation } from '../entity/recommendation'
|
||||
import { env } from '../env'
|
||||
import {
|
||||
|
|
@ -328,47 +329,6 @@ export const enqueueReminder = async (
|
|||
return createdTasks[0].name
|
||||
}
|
||||
|
||||
export const enqueueSyncWithIntegration = async (
|
||||
userId: string,
|
||||
integrationName: string
|
||||
): Promise<string> => {
|
||||
const { GOOGLE_CLOUD_PROJECT, PUBSUB_VERIFICATION_TOKEN } = process.env
|
||||
// use pubsub data format to send the userId to the task handler
|
||||
const payload = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({
|
||||
userId,
|
||||
})
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
|
||||
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
|
||||
return nanoid()
|
||||
}
|
||||
|
||||
const createdTasks = await createHttpTaskWithToken({
|
||||
project: GOOGLE_CLOUD_PROJECT,
|
||||
payload,
|
||||
taskHandlerUrl: `${
|
||||
env.queue.integrationTaskHandlerUrl
|
||||
}/${integrationName.toLowerCase()}/sync_all?token=${PUBSUB_VERIFICATION_TOKEN}`,
|
||||
priority: 'low',
|
||||
})
|
||||
|
||||
if (!createdTasks || !createdTasks[0].name) {
|
||||
logger.error(`Unable to get the name of the task`, {
|
||||
payload,
|
||||
createdTasks,
|
||||
})
|
||||
throw new CreateTaskError(`Unable to get the name of the task`)
|
||||
}
|
||||
return createdTasks[0].name
|
||||
}
|
||||
|
||||
export const enqueueTextToSpeech = async ({
|
||||
userId,
|
||||
text,
|
||||
|
|
@ -498,23 +458,29 @@ export const enqueueRecommendation = async (
|
|||
|
||||
export const enqueueImportFromIntegration = async (
|
||||
integrationId: string,
|
||||
authToken: string
|
||||
integrationName: string,
|
||||
syncAt: number, // unix timestamp in milliseconds
|
||||
authToken: string,
|
||||
state: ImportItemState
|
||||
): Promise<string> => {
|
||||
const { GOOGLE_CLOUD_PROJECT } = process.env
|
||||
const payload = {
|
||||
integrationId,
|
||||
integrationName,
|
||||
syncAt,
|
||||
state,
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Cookie: `auth=${authToken}`,
|
||||
[OmnivoreAuthorizationHeader]: authToken,
|
||||
}
|
||||
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
|
||||
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
|
||||
if (env.queue.integrationTaskHandlerUrl) {
|
||||
if (env.queue.integrationImporterUrl) {
|
||||
// Calling the handler function directly.
|
||||
setTimeout(() => {
|
||||
axios
|
||||
.post(`${env.queue.integrationTaskHandlerUrl}/import`, payload, {
|
||||
.post(env.queue.integrationImporterUrl, payload, {
|
||||
headers,
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -528,7 +494,58 @@ export const enqueueImportFromIntegration = async (
|
|||
const createdTasks = await createHttpTaskWithToken({
|
||||
project: GOOGLE_CLOUD_PROJECT,
|
||||
payload,
|
||||
taskHandlerUrl: `${env.queue.integrationTaskHandlerUrl}/import`,
|
||||
taskHandlerUrl: env.queue.integrationImporterUrl,
|
||||
priority: 'low',
|
||||
requestHeaders: headers,
|
||||
})
|
||||
|
||||
if (!createdTasks || !createdTasks[0].name) {
|
||||
logger.error(`Unable to get the name of the task`, {
|
||||
payload,
|
||||
createdTasks,
|
||||
})
|
||||
throw new CreateTaskError(`Unable to get the name of the task`)
|
||||
}
|
||||
return createdTasks[0].name
|
||||
}
|
||||
|
||||
export const enqueueExportToIntegration = async (
|
||||
integrationId: string,
|
||||
integrationName: string,
|
||||
syncAt: number, // unix timestamp in milliseconds
|
||||
authToken: string
|
||||
): Promise<string> => {
|
||||
const { GOOGLE_CLOUD_PROJECT } = process.env
|
||||
const payload = {
|
||||
integrationId,
|
||||
integrationName,
|
||||
syncAt,
|
||||
}
|
||||
|
||||
const headers = {
|
||||
[OmnivoreAuthorizationHeader]: authToken,
|
||||
}
|
||||
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
|
||||
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
|
||||
if (env.queue.integrationExporterUrl) {
|
||||
// Calling the handler function directly.
|
||||
setTimeout(() => {
|
||||
axios
|
||||
.post(env.queue.integrationExporterUrl, payload, {
|
||||
headers,
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error)
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
return nanoid()
|
||||
}
|
||||
|
||||
const createdTasks = await createHttpTaskWithToken({
|
||||
project: GOOGLE_CLOUD_PROJECT,
|
||||
payload,
|
||||
taskHandlerUrl: env.queue.integrationExporterUrl,
|
||||
priority: 'low',
|
||||
requestHeaders: headers,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
saveIntegration,
|
||||
updateIntegration,
|
||||
} from '../../src/services/integrations'
|
||||
import { READWISE_API_URL } from '../../src/services/integrations/readwise'
|
||||
import { deleteUser } from '../../src/services/user'
|
||||
import { createTestUser } from '../db'
|
||||
import { generateFakeUuid, graphqlRequest, request } from '../util'
|
||||
|
|
@ -19,6 +18,8 @@ import { generateFakeUuid, graphqlRequest, request } from '../util'
|
|||
chai.use(sinonChai)
|
||||
|
||||
describe('Integrations resolvers', () => {
|
||||
const READWISE_API_URL = 'https://readwise.io/api/v2'
|
||||
|
||||
let loginUser: User
|
||||
let authToken: string
|
||||
|
||||
|
|
@ -265,17 +266,6 @@ describe('Integrations resolvers', () => {
|
|||
expect(res.body.data.setIntegration.integration.enabled).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('creates new cloud task to sync all existing articles and highlights', async () => {
|
||||
const res = await graphqlRequest(
|
||||
query(integrationId, integrationName, token, enabled),
|
||||
authToken
|
||||
)
|
||||
const integration = await findIntegration({
|
||||
id: res.body.data.setIntegration.integration.id,
|
||||
}, loginUser.id)
|
||||
expect(integration?.taskName).not.to.be.null
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
import { Storage } from '@google-cloud/storage'
|
||||
import { expect } from 'chai'
|
||||
import { DateTime } from 'luxon'
|
||||
import 'mocha'
|
||||
import nock from 'nock'
|
||||
import sinon from 'sinon'
|
||||
import { Highlight } from '../../src/entity/highlight'
|
||||
import { Integration, IntegrationType } from '../../src/entity/integration'
|
||||
import { LibraryItem } from '../../src/entity/library_item'
|
||||
import { User } from '../../src/entity/user'
|
||||
import { env } from '../../src/env'
|
||||
import { PubSubRequestBody } from '../../src/pubsub'
|
||||
import { createHighlight, getHighlightUrl } from '../../src/services/highlights'
|
||||
import {
|
||||
deleteIntegrations,
|
||||
saveIntegration,
|
||||
updateIntegration,
|
||||
} from '../../src/services/integrations'
|
||||
import { READWISE_API_URL } from '../../src/services/integrations/readwise'
|
||||
import { deleteLibraryItemById } from '../../src/services/library_item'
|
||||
import { deleteUser } from '../../src/services/user'
|
||||
import { createTestLibraryItem, createTestUser } from '../db'
|
||||
import { MockBucket } from '../mock_storage'
|
||||
import { request } from '../util'
|
||||
|
||||
describe('Integrations routers', () => {
|
||||
const baseUrl = '/svc/pubsub/integrations'
|
||||
let token: string
|
||||
let user: User
|
||||
let authToken: string
|
||||
|
||||
before(async () => {
|
||||
user = await createTestUser('fakeUser')
|
||||
const res = await request
|
||||
.post('/local/debug/fake-user-login')
|
||||
.send({ fakeEmail: user.email })
|
||||
|
||||
const body = res.body as { authToken: string }
|
||||
authToken = body.authToken
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteUser(user.id)
|
||||
})
|
||||
|
||||
describe('sync with integrations', () => {
|
||||
const endpoint = (token: string, name = 'name', action = 'action') =>
|
||||
`${baseUrl}/${name}/${action}?token=${token}`
|
||||
let action: string
|
||||
let data: PubSubRequestBody
|
||||
let integrationName: string
|
||||
|
||||
context('when token is invalid', () => {
|
||||
before(() => {
|
||||
token = 'invalid-token'
|
||||
})
|
||||
|
||||
it('returns 200', async () => {
|
||||
return request.post(endpoint(token)).send(data).expect(200)
|
||||
})
|
||||
})
|
||||
|
||||
context('when token is valid', () => {
|
||||
before(() => {
|
||||
token = process.env.PUBSUB_VERIFICATION_TOKEN as string
|
||||
})
|
||||
|
||||
context('when data is expired', () => {
|
||||
before(() => {
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({ userId: 'userId', type: 'page' })
|
||||
).toString('base64'),
|
||||
publishTime: DateTime.now().minus({ hours: 12 }).toISO(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 200 with Expired', async () => {
|
||||
const res = await request.post(endpoint(token)).send(data).expect(200)
|
||||
expect(res.text).to.eql('Expired')
|
||||
})
|
||||
})
|
||||
|
||||
context('when userId is empty', () => {
|
||||
before(() => {
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({ userId: '', type: 'page' })
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 200', async () => {
|
||||
return request.post(endpoint(token)).send(data).expect(200)
|
||||
})
|
||||
})
|
||||
|
||||
context('when user exists', () => {
|
||||
context('when integration not found', () => {
|
||||
before(() => {
|
||||
integrationName = 'READWISE'
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({ userId: user.id, type: 'page' })
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 200 with No integration found', async () => {
|
||||
const res = await request
|
||||
.post(endpoint(token, integrationName))
|
||||
.send(data)
|
||||
.expect(200)
|
||||
expect(res.text).to.eql('No integration found')
|
||||
})
|
||||
})
|
||||
|
||||
context('when integration is readwise and enabled', () => {
|
||||
let integration: Integration
|
||||
let item: LibraryItem
|
||||
let highlight: Highlight
|
||||
let highlightsData: any
|
||||
|
||||
before(async () => {
|
||||
integration = await saveIntegration(
|
||||
{
|
||||
user,
|
||||
name: 'READWISE',
|
||||
token: 'token',
|
||||
},
|
||||
user.id
|
||||
)
|
||||
integrationName = integration.name
|
||||
// create page
|
||||
item = await createTestLibraryItem(user.id)
|
||||
|
||||
// create highlight
|
||||
const highlightPositionPercent = 25
|
||||
highlight = await createHighlight(
|
||||
{
|
||||
patch: 'test patch',
|
||||
quote: 'test quote',
|
||||
shortId: 'test shortId',
|
||||
highlightPositionPercent,
|
||||
user,
|
||||
libraryItem: item,
|
||||
},
|
||||
item.id,
|
||||
user.id
|
||||
)
|
||||
// create highlights data for integration request
|
||||
highlightsData = {
|
||||
highlights: [
|
||||
{
|
||||
text: highlight.quote,
|
||||
title: item.title,
|
||||
author: item.author ?? undefined,
|
||||
highlight_url: getHighlightUrl(item.slug, highlight.id),
|
||||
highlighted_at: highlight.createdAt.toISOString(),
|
||||
category: 'articles',
|
||||
image_url: item.thumbnail ?? undefined,
|
||||
// location: highlightPositionPercent,
|
||||
location_type: 'order',
|
||||
note: highlight.annotation ?? undefined,
|
||||
source_type: 'omnivore',
|
||||
source_url: item.originalUrl,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteIntegrations(user.id, [integration.id])
|
||||
await deleteLibraryItemById(item.id)
|
||||
})
|
||||
|
||||
context('when action is sync_updated', () => {
|
||||
before(() => {
|
||||
action = 'sync_updated'
|
||||
})
|
||||
|
||||
context('when entity type is page', () => {
|
||||
before(() => {
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({
|
||||
userId: user.id,
|
||||
type: 'page',
|
||||
id: item.id,
|
||||
})
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
// mock Readwise Highlight API
|
||||
nock(READWISE_API_URL, {
|
||||
reqheaders: {
|
||||
Authorization: `Token ${integration.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/highlights', highlightsData)
|
||||
.reply(200)
|
||||
})
|
||||
|
||||
it('returns 200 with OK', async () => {
|
||||
const res = await request
|
||||
.post(endpoint(token, integrationName, action))
|
||||
.send(data)
|
||||
.expect(200)
|
||||
expect(res.text).to.eql('OK')
|
||||
})
|
||||
|
||||
context('when readwise highlight API reaches rate limits', () => {
|
||||
before(() => {
|
||||
// mock Readwise Highlight API with rate limits
|
||||
// retry after 1 second
|
||||
nock(READWISE_API_URL, {
|
||||
reqheaders: {
|
||||
Authorization: `Token ${integration.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/highlights')
|
||||
.reply(429, 'Rate Limited', { 'Retry-After': '1' })
|
||||
// mock Readwise Highlight API after 1 second
|
||||
nock(READWISE_API_URL, {
|
||||
reqheaders: {
|
||||
Authorization: `Token ${integration.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/highlights')
|
||||
.delay(1000)
|
||||
.reply(200)
|
||||
})
|
||||
|
||||
it('returns 200 with OK', async () => {
|
||||
const res = await request
|
||||
.post(endpoint(token, integrationName, action))
|
||||
.send(data)
|
||||
.expect(200)
|
||||
expect(res.text).to.eql('OK')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('when entity type is highlight', () => {
|
||||
before(() => {
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({
|
||||
userId: user.id,
|
||||
type: 'highlight',
|
||||
articleId: item.id,
|
||||
})
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
// mock Readwise Highlight API
|
||||
nock(READWISE_API_URL, {
|
||||
reqheaders: {
|
||||
Authorization: `Token ${integration.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/highlights', highlightsData)
|
||||
.reply(200)
|
||||
})
|
||||
|
||||
it('returns 200 with OK', async () => {
|
||||
const res = await request
|
||||
.post(endpoint(token, integrationName, action))
|
||||
.send(data)
|
||||
.expect(200)
|
||||
expect(res.text).to.eql('OK')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('when action is sync_all', () => {
|
||||
before(async () => {
|
||||
action = 'sync_all'
|
||||
data = {
|
||||
message: {
|
||||
data: Buffer.from(
|
||||
JSON.stringify({
|
||||
userId: user.id,
|
||||
})
|
||||
).toString('base64'),
|
||||
publishTime: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
// mock Readwise Highlight API
|
||||
nock(READWISE_API_URL, {
|
||||
reqheaders: {
|
||||
Authorization: `Token ${integration.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/highlights', highlightsData)
|
||||
.reply(200)
|
||||
await updateIntegration(
|
||||
integration.id,
|
||||
{
|
||||
syncedAt: null,
|
||||
taskName: 'some task name',
|
||||
},
|
||||
user.id
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 200 with OK', async () => {
|
||||
const res = await request
|
||||
.post(endpoint(token, integrationName, action))
|
||||
.send(data)
|
||||
.expect(200)
|
||||
expect(res.text).to.eql('OK')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('import from integrations router', () => {
|
||||
let integration: Integration
|
||||
|
||||
before(async () => {
|
||||
token = 'test token'
|
||||
// create integration
|
||||
integration = await saveIntegration(
|
||||
{
|
||||
user: { id: user.id },
|
||||
name: 'POCKET',
|
||||
token,
|
||||
type: IntegrationType.Import,
|
||||
},
|
||||
user.id
|
||||
)
|
||||
|
||||
// mock Pocket API
|
||||
const reqBody = {
|
||||
access_token: token,
|
||||
consumer_key: env.pocket.consumerKey,
|
||||
state: 'all',
|
||||
detailType: 'complete',
|
||||
since: 0,
|
||||
sort: 'oldest',
|
||||
count: 100,
|
||||
offset: 0,
|
||||
}
|
||||
nock('https://getpocket.com', {
|
||||
reqheaders: {
|
||||
'content-type': 'application/json',
|
||||
'x-accept': 'application/json',
|
||||
},
|
||||
})
|
||||
.post('/v3/get', reqBody)
|
||||
.reply(200, {
|
||||
complete: 1,
|
||||
list: {
|
||||
'123': {
|
||||
given_url: 'https://omnivore.app/pocket-import-test,test',
|
||||
state: '0',
|
||||
tags: {
|
||||
'1234': {
|
||||
tag: 'test',
|
||||
},
|
||||
'1235': {
|
||||
tag: 'new',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
since: Date.now() / 1000,
|
||||
})
|
||||
.post('/v3/get', {
|
||||
...reqBody,
|
||||
offset: 1,
|
||||
})
|
||||
.reply(200, {
|
||||
list: {},
|
||||
})
|
||||
|
||||
// mock cloud storage
|
||||
const mockBucket = new MockBucket('test')
|
||||
sinon.replace(
|
||||
Storage.prototype,
|
||||
'bucket',
|
||||
sinon.fake.returns(mockBucket as never)
|
||||
)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
sinon.restore()
|
||||
await deleteIntegrations(user.id, [integration.id])
|
||||
})
|
||||
|
||||
context('when integration is pocket', () => {
|
||||
it('returns 200 with OK', async () => {
|
||||
return request
|
||||
.post(`${baseUrl}/import`)
|
||||
.send({
|
||||
integrationId: integration.id,
|
||||
})
|
||||
.set('Cookie', `auth=${authToken}`)
|
||||
.expect(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"],
|
||||
"include": [
|
||||
"src",
|
||||
"test",
|
||||
"../integration-handler/test/integrations.test.ts"
|
||||
],
|
||||
"exclude": ["./src/generated", "./test"]
|
||||
}
|
||||
|
|
|
|||
16
packages/db/migrations/0144.do.add_import_item_state_to_integration.sql
Executable file
16
packages/db/migrations/0144.do.add_import_item_state_to_integration.sql
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
-- Type: DO
|
||||
-- Name: add_import_item_state_to_integration
|
||||
-- Description: Add import_item_state column to integration table
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE type import_item_state_type AS ENUM (
|
||||
'UNREAD',
|
||||
'UNARCHIVED',
|
||||
'ARCHIVED',
|
||||
'ALL'
|
||||
);
|
||||
|
||||
ALTER TABLE omnivore.integrations ADD COLUMN import_item_state import_item_state_type;
|
||||
|
||||
COMMIT;
|
||||
11
packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql
Executable file
11
packages/db/migrations/0144.undo.add_import_item_state_to_integration.sql
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
-- Type: UNDO
|
||||
-- Name: add_import_item_state_to_integration
|
||||
-- Description: Add import_item_state column to integration table
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE omnivore.integrations DROP COLUMN IF EXISTS import_item_state;
|
||||
|
||||
DROP TYPE IF EXISTS import_item_state_type;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -46,7 +46,7 @@ const parseDate = (date: string): Date => {
|
|||
|
||||
export const importCsv = async (ctx: ImportContext, stream: Stream) => {
|
||||
// create metrics in redis
|
||||
await createMetrics(ctx.redisClient, ctx.userId, ctx.taskId, 'csv-importer')
|
||||
await createMetrics(ctx.redisClient, ctx.userId, ctx.taskId, ctx.source)
|
||||
|
||||
const parser = parse({
|
||||
headers: true,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue