Merge pull request #3076 from omnivore-app/main

Web production deployment
This commit is contained in:
Jackson Harper 2023-11-06 16:47:13 +08:00 committed by GitHub
commit 5048eef8fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
134 changed files with 3257 additions and 1878 deletions

View file

@ -2,7 +2,7 @@
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/omnivore-app/omnivore/Run%20tests)](https://github.com/omnivore-app/omnivore/actions/workflows/run-tests.yaml)
[![Discord](https://img.shields.io/discord/844965259462311966?label=Join%20our%20Discord)](https://discord.gg/h2z5rppzz9)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/109458738600914558?domain=https%3A%2F%2Fpkm.social)](https://pkm.social/@omnivore)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/109458738600914558?domain=https%3A%2F%2Fpkm.social)](https://pkm.social/@omnivore)
[![Twitter Follow](https://img.shields.io/twitter/follow/omnivoreapp)](https://twitter.com/OmnivoreApp)
![GitHub](https://img.shields.io/github/license/omnivore-app/omnivore)
@ -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.

View file

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

View file

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

View file

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

View file

@ -73,7 +73,6 @@ fun CreateUserProfileView(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserProfileFields(
name: String,

View file

@ -86,7 +86,6 @@ fun EmailLoginView(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginFields(
email: String,

View file

@ -140,7 +140,6 @@ fun EmailSignUpForm(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmailSignUpFields(
email: String,

View file

@ -110,7 +110,6 @@ fun SelfHostedView(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelfHostedFields(
apiServer: String,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,7 +39,6 @@ class AnnotationEditFragment : DialogFragment() {
this.onCancel = onCancel
}
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = "";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -196,6 +196,7 @@
showErrorAlertMessage: $showErrorAlertMessage
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
.formSheet(isPresented: $showShareView) {
ShareSheet(activityItems: [viewModel.highlightAsMarkdown(item: self.highlightParams)])

View file

@ -182,6 +182,7 @@
showErrorAlertMessage: $showErrorAlertMessage
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import SwiftUI
import Models
import Views
@MainActor
struct LabelsMasonaryView: View {
var onLabelTap: (LinkedItemLabel, TextChip) -> Void

View file

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

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

View file

@ -12,6 +12,7 @@ import Services
import SwiftUI
import Views
@MainActor
struct LibraryTabView: View {
@EnvironmentObject var dataService: DataService

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import Utils
public struct SyncStatusIcon: View {
let status: ServerSyncStatus
init(status: ServerSyncStatus) {
public init(status: ServerSyncStatus) {
self.status = status
}

View file

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

View file

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

View file

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

View file

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

View 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アプリを使用することです。アプリはこちらからダウンロードできます:
- [iOSiPhoneまたは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スプレッドシートに書き込む方法が示されています。

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -40,3 +40,8 @@ export function isPendingUserTokenPayload(
'username' in object
)
}
export type IntegrationTokenPayload = {
uid: string
token: string
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,10 @@
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"],
"include": [
"src",
"test",
"../integration-handler/test/integrations.test.ts"
],
"exclude": ["./src/generated", "./test"]
}

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

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

View file

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