Merge branch 'omnivore-app:main' into main

This commit is contained in:
Sebastian Jäger 2024-01-11 08:46:01 +01:00 committed by GitHub
commit 08e86fe729
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 2256 additions and 805 deletions

View file

@ -19,8 +19,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 158
versionName "0.0.158"
versionCode 180
versionName "0.0.180"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,10 @@
query UpdatesSince($after: String, $first: Int, $since: Date!) {
updatesSince(after: $after, first: $first, since: $since) {
query UpdatesSince(
$folder: String
$after: String
$first: Int
$since: Date!
) {
updatesSince(after: $after, first: $first, folder: $folder, since: $since) {
... on UpdatesSinceSuccess {
edges {
cursor

View file

@ -69,6 +69,7 @@ type Article {
contentReader: ContentReader!
createdAt: Date!
description: String
folder: String!
hasContent: Boolean
hash: String!
highlights(input: ArticleHighlightsInput): [Highlight!]!
@ -154,6 +155,7 @@ union ArticleSavingRequestResult = ArticleSavingRequestError | ArticleSavingRequ
enum ArticleSavingRequestStatus {
ARCHIVED
CONTENT_NOT_FETCHED
DELETED
FAILED
PROCESSING
@ -203,6 +205,7 @@ enum BulkActionType {
ARCHIVE
DELETE
MARK_AS_READ
MOVE_TO_FOLDER
}
enum ContentReader {
@ -227,8 +230,12 @@ enum CreateArticleErrorCode {
input CreateArticleInput {
articleSavingRequestId: ID
folder: String
labels: [CreateLabelInput!]
preparedDocument: PreparedDocumentInput
publishedAt: Date
rssFeedUrl: String
savedAt: Date
skipParsing: Boolean
source: String
state: ArticleSavingRequestStatus
@ -377,6 +384,12 @@ enum CreateNewsletterEmailErrorCode {
UNAUTHORIZED
}
input CreateNewsletterEmailInput {
description: String
folder: String
name: String
}
union CreateNewsletterEmailResult = CreateNewsletterEmailError | CreateNewsletterEmailSuccess
type CreateNewsletterEmailSuccess {
@ -631,6 +644,20 @@ type DeviceTokensSuccess {
deviceTokens: [DeviceToken!]!
}
type EmptyTrashError {
errorCodes: [EmptyTrashErrorCode!]!
}
enum EmptyTrashErrorCode {
UNAUTHORIZED
}
union EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess
type EmptyTrashSuccess {
success: Boolean
}
type Feature {
createdAt: Date!
expiresAt: Date
@ -641,6 +668,19 @@ type Feature {
updatedAt: Date
}
type Feed {
author: String
createdAt: Date
description: String
id: ID
image: String
publishedAt: Date
title: String!
type: String
updatedAt: Date
url: String!
}
type FeedArticle {
annotationsCount: Int
article: Article!
@ -674,12 +714,56 @@ type FeedArticlesSuccess {
pageInfo: PageInfo!
}
type FeedEdge {
cursor: String!
node: Feed!
}
type FeedsError {
errorCodes: [FeedsErrorCode!]!
}
enum FeedsErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
input FeedsInput {
after: String
first: Int
query: String
sort: SortParams
}
union FeedsResult = FeedsError | FeedsSuccess
type FeedsSuccess {
edges: [FeedEdge!]!
pageInfo: PageInfo!
}
type FetchContentError {
errorCodes: [FetchContentErrorCode!]!
}
enum FetchContentErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
union FetchContentResult = FetchContentError | FetchContentSuccess
type FetchContentSuccess {
success: Boolean!
}
type Filter {
category: String!
category: String
createdAt: Date!
defaultFilter: Boolean
description: String
filter: String!
folder: String
id: ID!
name: String!
position: Int!
@ -901,6 +985,8 @@ type IntegrationsSuccess {
integrations: [Integration!]!
}
scalar JSON
type JoinGroupError {
errorCodes: [JoinGroupErrorCode!]!
}
@ -925,6 +1011,7 @@ type Label {
internal: Boolean
name: String!
position: Int
source: String
}
type LabelsError {
@ -1107,15 +1194,31 @@ type MoveLabelSuccess {
label: Label!
}
type MoveToFolderError {
errorCodes: [MoveToFolderErrorCode!]!
}
enum MoveToFolderErrorCode {
ALREADY_EXISTS
BAD_REQUEST
UNAUTHORIZED
}
union MoveToFolderResult = MoveToFolderError | MoveToFolderSuccess
type MoveToFolderSuccess {
success: Boolean!
}
type Mutation {
addPopularRead(name: String!): AddPopularReadResult!
bulkAction(action: BulkActionType!, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult!
createArticle(input: CreateArticleInput!): CreateArticleResult!
createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult!
createGroup(input: CreateGroupInput!): CreateGroupResult!
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
createLabel(input: CreateLabelInput!): CreateLabelResult!
createNewsletterEmail: CreateNewsletterEmailResult!
createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult!
deleteAccount(userID: ID!): DeleteAccountResult!
deleteFilter(id: ID!): DeleteFilterResult!
deleteHighlight(highlightId: ID!): DeleteHighlightResult!
@ -1124,6 +1227,8 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
emptyTrash: EmptyTrashResult!
fetchContent(id: ID!): FetchContentResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
@ -1135,6 +1240,7 @@ type Mutation {
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
moveFilter(input: MoveFilterInput!): MoveFilterResult!
moveLabel(input: MoveLabelInput!): MoveLabelResult!
moveToFolder(folder: String!, id: ID!): MoveToFolderResult!
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
recommend(input: RecommendInput!): RecommendResult!
recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult!
@ -1161,6 +1267,7 @@ type Mutation {
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
updateNewsletterEmail(input: UpdateNewsletterEmailInput!): UpdateNewsletterEmailResult!
updatePage(input: UpdatePageInput!): UpdatePageResult!
updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult!
updateUser(input: UpdateUserInput!): UpdateUserResult!
@ -1173,7 +1280,10 @@ type NewsletterEmail {
address: String!
confirmationCode: String
createdAt: Date!
description: String
folder: String!
id: ID!
name: String
subscriptionCount: Int!
}
@ -1292,6 +1402,7 @@ type Query {
article(format: String, slug: String!, username: String!): ArticleResult!
articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult!
deviceTokens: DeviceTokensResult!
feeds(input: FeedsInput!): FeedsResult!
filters: FiltersResult!
getUserPersonalization: GetUserPersonalizationResult!
groups: GroupsResult!
@ -1303,11 +1414,12 @@ type Query {
recentEmails: RecentEmailsResult!
recentSearches: RecentSearchesResult!
rules(enabled: Boolean): RulesResult!
scanFeeds(input: ScanFeedsInput!): ScanFeedsResult!
search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult!
sendInstallInstructions: SendInstallInstructionsResult!
subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult!
typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult!
updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult!
updatesSince(after: String, first: Int, folder: String, since: Date!, sort: SortParams): UpdatesSinceResult!
user(userId: ID, username: String): UserResult!
users: UsersResult!
validateUsername(username: String!): Boolean!
@ -1604,6 +1716,7 @@ enum SaveErrorCode {
input SaveFileInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
source: String!
state: ArticleSavingRequestStatus
@ -1625,6 +1738,7 @@ input SaveFilterInput {
category: String
description: String
filter: String!
folder: String
name: String!
position: Int
}
@ -1637,6 +1751,7 @@ type SaveFilterSuccess {
input SavePageInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
originalContent: String!
parseResult: ParseResult
@ -1658,6 +1773,7 @@ type SaveSuccess {
input SaveUrlInput {
clientRequestId: ID!
folder: String
labels: [CreateLabelInput!]
locale: String
publishedAt: Date
@ -1668,6 +1784,25 @@ input SaveUrlInput {
url: String!
}
type ScanFeedsError {
errorCodes: [ScanFeedsErrorCode!]!
}
enum ScanFeedsErrorCode {
BAD_REQUEST
}
input ScanFeedsInput {
opml: String
url: String
}
union ScanFeedsResult = ScanFeedsError | ScanFeedsSuccess
type ScanFeedsSuccess {
feeds: [Feed!]!
}
type SearchError {
errorCodes: [SearchErrorCode!]!
}
@ -1686,16 +1821,20 @@ type SearchItem {
contentReader: ContentReader!
createdAt: Date!
description: String
folder: String!
highlights: [Highlight!]
id: ID!
image: String
isArchived: Boolean!
labels: [Label!]
language: String
links: JSON
originalArticleUrl: String
ownedByViewer: Boolean
pageId: ID
pageType: PageType!
previewContent: String
previewContentType: String
publishedAt: Date
quote: String
readAt: Date
@ -1844,6 +1983,7 @@ input SetIntegrationInput {
importItemState: ImportItemState
name: String!
syncedAt: Date
taskName: String
token: String!
type: IntegrationType
}
@ -1874,6 +2014,7 @@ input SetLabelsInput {
labelIds: [ID!]
labels: [CreateLabelInput!]
pageId: ID!
source: String
}
union SetLabelsResult = SetLabelsError | SetLabelsSuccess
@ -1963,6 +2104,7 @@ enum SetUserPersonalizationErrorCode {
}
input SetUserPersonalizationInput {
fields: JSON
fontFamily: String
fontSize: Int
libraryLayoutType: String
@ -2068,6 +2210,10 @@ enum SubscribeErrorCode {
}
input SubscribeInput {
autoAddToLibrary: Boolean
fetchContent: Boolean
folder: String
isPrivate: Boolean
subscriptionType: SubscriptionType
url: String!
}
@ -2079,11 +2225,15 @@ type SubscribeSuccess {
}
type Subscription {
autoAddToLibrary: Boolean
count: Int!
createdAt: Date!
description: String
fetchContent: Boolean!
folder: String!
icon: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
name: String!
newsletterEmail: String
@ -2203,6 +2353,7 @@ input UpdateFilterInput {
category: String
description: String
filter: String
folder: String
id: String!
name: String
position: Int
@ -2307,6 +2458,28 @@ type UpdateLinkShareInfoSuccess {
message: String!
}
type UpdateNewsletterEmailError {
errorCodes: [UpdateNewsletterEmailErrorCode!]!
}
enum UpdateNewsletterEmailErrorCode {
BAD_REQUEST
UNAUTHORIZED
}
input UpdateNewsletterEmailInput {
description: String
folder: String
id: ID!
name: String
}
union UpdateNewsletterEmailResult = UpdateNewsletterEmailError | UpdateNewsletterEmailSuccess
type UpdateNewsletterEmailSuccess {
newsletterEmail: NewsletterEmail!
}
type UpdatePageError {
errorCodes: [UpdatePageErrorCode!]!
}
@ -2397,8 +2570,12 @@ enum UpdateSubscriptionErrorCode {
}
input UpdateSubscriptionInput {
autoAddToLibrary: Boolean
description: String
fetchContent: Boolean
folder: String
id: ID!
isPrivate: Boolean
lastFetchedAt: Date
lastFetchedChecksum: String
name: String
@ -2557,6 +2734,7 @@ enum UserErrorCode {
}
type UserPersonalization {
fields: JSON
fontFamily: String
fontSize: Int
id: ID

View file

@ -1,12 +1,17 @@
package app.omnivore.omnivore.dataService
import android.util.Log
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.persistence.entities.saveHighlightChange
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext
import java.util.*
@ -33,6 +38,8 @@ suspend fun DataService.createWebHighlight(jsonString: String, colorName: String
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val highlightChange = saveHighlightChange(db.highlightChangesDao(), createHighlightInput.articleId, highlight)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightInput.id,
savedItemId = createHighlightInput.articleId
@ -41,11 +48,7 @@ suspend fun DataService.createWebHighlight(jsonString: String, colorName: String
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(createHighlightInput)
newHighlight?.let {
db.highlightDao().update(it)
}
performHighlightChange(highlightChange)
}
}
@ -73,6 +76,8 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val highlightChange = saveHighlightChange(db.highlightChangesDao(), savedItemId, highlight)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightId,
savedItemId = savedItemId
@ -81,21 +86,7 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(input = CreateHighlightParams(
type = HighlightType.NOTE,
articleId = savedItemId,
id = createHighlightId,
shortId = shortId,
quote = null,
patch = null,
annotation = note,
highlightPositionAnchorIndex = 0,
highlightPositionPercent = 0.0
).asCreateHighlightInput())
newHighlight?.let {
db.highlightDao().update(it)
}
performHighlightChange(highlightChange)
}
return createHighlightId
@ -103,74 +94,84 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
suspend fun DataService.mergeWebHighlights(jsonString: String) {
val mergeHighlightInput = Gson().fromJson(jsonString, MergeHighlightsParams::class.java).asMergeHighlightInput()
Log.d("sync", "mergeHighlightInput: " + mergeHighlightInput.id + ": " + mergeHighlightInput)
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = mergeHighlightInput.id) ?: return@withContext
highlight.shortId = mergeHighlightInput.shortId
highlight.quote = mergeHighlightInput.quote
highlight.patch = mergeHighlightInput.patch
highlight.prefix = mergeHighlightInput.prefix.getOrNull()
highlight.annotation = mergeHighlightInput.annotation.getOrNull()
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
val highlight = Highlight(
type = "HIGHLIGHT",
highlightId = mergeHighlightInput.id,
shortId = mergeHighlightInput.shortId,
quote = mergeHighlightInput.quote,
prefix = null,
suffix = null,
patch = mergeHighlightInput.patch,
annotation = mergeHighlightInput.annotation.getOrNull(),
createdAt = null,
updatedAt = null,
createdByMe = false,
color = mergeHighlightInput.color.getOrNull(),
highlightPositionPercent = mergeHighlightInput.highlightPositionPercent.getOrNull() ?: 0.0,
highlightPositionAnchorIndex = mergeHighlightInput.highlightPositionAnchorIndex.getOrNull() ?: 0
)
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
deleteHighlight(highlightID)
}
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
val highlightChange = saveHighlightChange(
db.highlightChangesDao(),
mergeHighlightInput.articleId,
highlight,
html = mergeHighlightInput.html.getOrNull(),
overlappingIDs = mergeHighlightInput.overlapHighlightIdList
)
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = mergeHighlightInput.id,
savedItemId = mergeHighlightInput.articleId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
Log.d("sync", "Setting up highlight merge")
performHighlightChange(highlightChange)
}
}
suspend fun DataService.updateWebHighlight(jsonString: String) {
val updateHighlightParams = Gson().fromJson(jsonString, UpdateHighlightParams::class.java).asUpdateHighlightInput()
val updateHighlightParams = Gson().fromJson(jsonString, UpdateHighlightParams::class.java)
if (updateHighlightParams.highlightId == null || updateHighlightParams.libraryItemId == null) {
Log.d("error","ERROR INVALID HIGHLIGHT DATA")
return
}
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = updateHighlightParams.highlightId) ?: return@withContext
val highlight = db.highlightDao().findById(highlightId = updateHighlightParams.highlightId ?: "") ?: return@withContext
highlight.annotation = updateHighlightParams.annotation.getOrNull()
highlight.annotation = updateHighlightParams.annotation
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.updateHighlight(updateHighlightParams)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
val highlightChange = saveHighlightChange(db.highlightChangesDao(), updateHighlightParams.libraryItemId ?: "", highlight)
performHighlightChange(highlightChange)
}
}
suspend fun DataService.deleteHighlights(jsonString: String) {
val highlightIDs = Gson().fromJson(jsonString, DeleteHighlightParams::class.java).asIdList()
for (highlightID in highlightIDs) {
deleteHighlight(highlightID)
}
suspend fun DataService.deleteHighlightFromJSON(jsonString: String) {
val deleteHighlightParams = Gson().fromJson(jsonString, DeleteHighlightParams::class.java)
deleteHighlight(deleteHighlightParams.libraryItemId, deleteHighlightParams.highlightId)
}
private suspend fun DataService.deleteHighlight(highlightID: String) {
private suspend fun DataService.deleteHighlight(savedItemId: String, highlightID: String) {
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = highlightID) ?: return@withContext
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.highlightDao().update(highlight)
val highlight = db.highlightDao().findById(highlightId = highlightID)
val isUpdatedOnServer = networker.deleteHighlights(listOf(highlightID))
highlight?.let {
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.highlightDao().update(highlight)
if (isUpdatedOnServer) {
db.highlightDao().deleteById(highlightId = highlightID)
val highlightChange = saveHighlightChange(db.highlightChangesDao(), savedItemId, highlight)
performHighlightChange(highlightChange)
}
}
}

View file

@ -1,36 +1,48 @@
package app.omnivore.omnivore.dataService
import android.util.Log
import androidx.room.PrimaryKey
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.HighlightChange
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.highlightChangeToHighlight
import app.omnivore.omnivore.persistence.entities.saveHighlightChange
import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
import kotlin.math.log
suspend fun DataService.startSyncChannels() {
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
}
for (highlight in highlightSyncChannel) {
syncHighlight(highlight)
suspend fun DataService.performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
}
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (highlight in unSyncedHighlights) {
delay(250)
highlightSyncChannel.send(highlight)
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
@ -82,7 +94,9 @@ private suspend fun DataService.syncSavedItem(item: SavedItem) {
}
}
private suspend fun DataService.syncHighlight(highlight: Highlight) {
private suspend fun DataService.syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
@ -91,7 +105,6 @@ private suspend fun DataService.syncHighlight(highlight: Highlight) {
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
@ -99,7 +112,9 @@ private suspend fun DataService.syncHighlight(highlight: Highlight) {
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
@ -116,30 +131,85 @@ private suspend fun DataService.syncHighlight(highlight: Highlight) {
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val savedItemID = db.savedItemAndHighlightCrossRefDao()
.associatedSavedItemID(highlightId = highlight.highlightId)
val isCreatedOnServer = networker.createHighlight(
CreateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
articleId = savedItemID ?: "",
id = highlight.highlightId,
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
shortId = highlight.shortId
)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
if (isCreatedOnServer != null) {
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
else -> return
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}

View file

@ -5,5 +5,6 @@ enum class ServerSyncStatus(val rawValue: Int) {
IS_SYNCING(1),
NEEDS_DELETION(2),
NEEDS_CREATION(3),
NEEDS_UPDATE(4)
NEEDS_UPDATE(4),
NEEDS_MERGE(5)
}

View file

@ -5,6 +5,7 @@ import app.omnivore.omnivore.graphql.generated.CreateHighlightMutation
import app.omnivore.omnivore.graphql.generated.DeleteHighlightMutation
import app.omnivore.omnivore.graphql.generated.MergeHighlightMutation
import app.omnivore.omnivore.graphql.generated.UpdateHighlightMutation
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightErrorCode
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
@ -39,6 +40,7 @@ data class CreateHighlightParams(
data class UpdateHighlightParams(
val highlightId: String?,
val libraryItemId: String?,
val `annotation`: String?,
val sharedAt: String?,
) {
@ -73,9 +75,10 @@ data class MergeHighlightsParams(
}
data class DeleteHighlightParams(
val highlightId: String?
val highlightId: String,
val libraryItemId: String
) {
fun asIdList() = listOf(highlightId ?: "")
fun asIdList() = listOf(highlightId)
}
suspend fun Networker.deleteHighlight(jsonString: String): Boolean {
@ -107,7 +110,6 @@ suspend fun Networker.updateWebHighlight(jsonString: String): Boolean {
suspend fun Networker.updateHighlight(input: UpdateHighlightInput): Boolean {
return try {
val result = authenticatedApolloClient().mutation(UpdateHighlightMutation(input)).execute()
Log.d("Network", "update highlight result: $result")
result.data?.updateHighlight?.onUpdateHighlightSuccess?.highlight != null
} catch (e: java.lang.Exception) {
false
@ -134,18 +136,22 @@ suspend fun Networker.createWebHighlight(jsonString: String): Boolean {
return createHighlight(input) != null
}
suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
Log.d("Loggo", "created highlight input: $input")
data class CreateHighlightResult(
val failedToCreate: Boolean,
val alreadyExists: Boolean,
val newHighlight: Highlight?
)
suspend fun Networker.createHighlight(input: CreateHighlightInput): CreateHighlightResult {
try {
val result = authenticatedApolloClient().mutation(CreateHighlightMutation(input)).execute()
Log.d("Loggo", "result: ${result.data}")
val createdHighlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight
if (createdHighlight != null) {
return Highlight(
return CreateHighlightResult(
failedToCreate = false,
alreadyExists = false,
newHighlight = Highlight(
type = createdHighlight.highlightFields.type.toString(),
highlightId = createdHighlight.highlightFields.id,
shortId = createdHighlight.highlightFields.shortId,
@ -161,10 +167,22 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
highlightPositionPercent = createdHighlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = createdHighlight.highlightFields.highlightPositionAnchorIndex
)
)
} else {
return null
if (result.data?.createHighlight?.onCreateHighlightError?.errorCodes?.first() == CreateHighlightErrorCode.ALREADY_EXISTS) {
return CreateHighlightResult(
failedToCreate = false,
alreadyExists = true,
newHighlight = null
)
}
}
} catch (e: java.lang.Exception) {
return null
Log.d("sync", "error creating highlight: " +e)
}
return CreateHighlightResult(
failedToCreate = true,
alreadyExists = false,
newHighlight = null
)
}

View file

@ -20,6 +20,7 @@ suspend fun Networker.savedItemUpdates(
try {
val result = authenticatedApolloClient().query(
UpdatesSinceQuery(
folder = Optional.presentIfNotNull("all"),
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
since = since

View file

@ -10,15 +10,17 @@ import app.omnivore.omnivore.persistence.entities.*
SavedItem::class,
SavedItemLabel::class,
Highlight::class,
HighlightChange::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class
],
version = 15
version = 24
)
abstract class AppDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao
abstract fun savedItemDao(): SavedItemDao
abstract fun highlightDao(): HighlightDao
abstract fun highlightChangesDao(): HighlightChangesDao
abstract fun savedItemLabelDao(): SavedItemLabelDao
abstract fun savedItemWithLabelsAndHighlightsDao(): SavedItemWithLabelsAndHighlightsDao
abstract fun savedItemAndSavedItemLabelCrossRefDao(): SavedItemAndSavedItemLabelCrossRefDao

View file

@ -0,0 +1,127 @@
package app.omnivore.omnivore.persistence.entities
import android.util.Log
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import app.omnivore.omnivore.models.ServerSyncStatus
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import kotlinx.serialization.json.Json
@Entity
@TypeConverters(StringListTypeConverter::class)
data class HighlightChange(
@PrimaryKey val highlightId: String,
val savedItemId: String,
val type: String,
var annotation: String?,
val createdAt: String?,
val createdByMe: Boolean = true,
val markedForDeletion: Boolean = false,
var patch: String?,
var prefix: String?,
var quote: String?,
var serverSyncStatus: Int = ServerSyncStatus.IS_SYNCED.rawValue,
val html: String?,
var shortId: String,
val suffix: String?,
val updatedAt: String?,
val color: String?,
val highlightPositionPercent: Double?,
val highlightPositionAnchorIndex: Int?,
val overlappingIDs: List<String>?
)
class StringListTypeConverter {
@TypeConverter
fun listToString(data: List<String>?): String? {
data?.let {
return Gson().toJson(data)
}
return null
}
@TypeConverter
fun stringToList(jsonString: String?): List<String>? {
return if (jsonString.isNullOrEmpty()) {
null
} else {
val itemType = object : TypeToken<List<String>>() {}.type
return Gson().fromJson<List<String>>(jsonString, itemType)
}
}
}
fun saveHighlightChange(
dao: HighlightChangesDao,
savedItemId: String,
highlight: Highlight,
html: String? = null,
overlappingIDs: List<String>? = null): HighlightChange {
Log.d("sync", "saving highlight change: " + highlight.serverSyncStatus + ", " + highlight.type)
val change = HighlightChange(
savedItemId = savedItemId,
highlightId = highlight.highlightId,
type = highlight.type,
shortId = highlight.shortId,
quote = highlight.quote,
prefix = highlight.prefix,
suffix = highlight.suffix,
patch = highlight.patch,
html = html,
annotation = highlight.annotation,
createdAt = highlight.createdAt,
updatedAt = highlight.updatedAt,
createdByMe = highlight.createdByMe,
color =highlight.color,
highlightPositionPercent = highlight.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightPositionAnchorIndex,
serverSyncStatus = highlight.serverSyncStatus,
overlappingIDs = overlappingIDs
)
dao.insertAll(listOf(change))
return change
}
fun highlightChangeToHighlight(change: HighlightChange): Highlight {
return Highlight(
highlightId = change.highlightId,
type = change.type,
shortId = change.shortId,
quote = change.quote,
prefix = change.prefix,
suffix = change.suffix,
patch = change.patch,
annotation = change.annotation,
createdAt = change.createdAt,
updatedAt = change.updatedAt,
createdByMe = change.createdByMe,
color = change.color,
highlightPositionPercent = change.highlightPositionPercent,
highlightPositionAnchorIndex = change.highlightPositionAnchorIndex,
serverSyncStatus = change.serverSyncStatus
)
}
@Dao
interface HighlightChangesDao {
@Query("SELECT * FROM highlightChange WHERE serverSyncStatus != 0 ORDER BY updatedAt ASC")
fun getUnSynced(): List<HighlightChange>
@Query("DELETE FROM highlightChange WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<HighlightChange>)
}

View file

@ -0,0 +1,39 @@
package app.omnivore.omnivore.ui.auth
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
object AuthUtils {
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: ((String) -> Unit),
) = composed {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
LocalAutofillTree.current += autofillNode
this
.onGloballyPositioned {
autofillNode.boundingBox = it.boundsInWindow()
}
.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}
}

View file

@ -10,7 +10,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -25,6 +27,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.auth.AuthUtils.autofill
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
@ -86,6 +89,7 @@ fun EmailLoginView(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LoginFields(
email: String,
@ -105,6 +109,12 @@ fun LoginFields(
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_login_field_label_email)) },
@ -112,11 +122,17 @@ fun LoginFields(
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
keyboardType = KeyboardType.Email,
),
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_login_field_label_password)) },
@ -129,21 +145,22 @@ fun LoginFields(
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
Button(onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
Button(
onClick = {
if (email.isNotBlank() && password.isNotBlank()) {
onLoginClick()
focusManager.clearFocus()
} else {
Toast.makeText(
context,
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
}, colors = ButtonDefaults.buttonColors(
contentColor = Color(0xFF3D3D3D),
containerColor = Color(0xffffd234)
)
) {
Text(
text = stringResource(R.string.email_login_action_login),

View file

@ -14,7 +14,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -27,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.auth.AuthUtils.autofill
@Composable
fun EmailSignUpView(viewModel: LoginViewModel) {
@ -140,6 +143,7 @@ fun EmailSignUpForm(viewModel: LoginViewModel) {
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun EmailSignUpFields(
email: String,
@ -165,6 +169,12 @@ fun EmailSignUpFields(
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.EmailAddress,
),
onFill = { onEmailChange(it) }
),
value = email,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) },
label = { Text(stringResource(R.string.email_signup_field_label_email)) },
@ -174,6 +184,12 @@ fun EmailSignUpFields(
)
OutlinedTextField(
modifier = Modifier.autofill(
autofillTypes = listOf(
AutofillType.Password,
),
onFill = { onPasswordChange(it) }
),
value = password,
placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) },
label = { Text(stringResource(R.string.email_signup_field_label_password)) },

View file

@ -27,6 +27,7 @@ import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.save.SaveState
import app.omnivore.omnivore.ui.save.SaveViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkSheetContent(
viewModel: SaveViewModel,
@ -84,42 +85,47 @@ fun AddLinkSheetContent(
viewModel.saveURL(url)
}
Surface(
androidx.compose.material.Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
.background(MaterialTheme.colorScheme.primaryContainer),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(stringResource(R.string.add_link_sheet_title))
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.label_selection_sheet_action_cancel))
}
},
actions = {
TextButton(onClick = { addLink(textFieldValue.text) }) {
Text(stringResource(R.string.add_link_sheet_action_add_link))
}
}
)
}
) { paddingValues ->
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 5.dp)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 10.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
)
@ -129,18 +135,29 @@ fun AddLinkSheetContent(
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") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Link,
contentDescription = "linkIcon"
)
},
onValueChange = { textFieldValue = it },
modifier = Modifier.focusRequester(focusRequester).padding(top = 24.dp).fillMaxWidth()
modifier = Modifier
.focusRequester(focusRequester)
.padding(top = 24.dp)
.padding(horizontal = 10.dp)
.fillMaxWidth()
)
if (clipboardText != null) {
Button(
modifier = Modifier.padding(top = 10.dp),
modifier = Modifier.padding(top = 10.dp) .padding(horizontal = 10.dp)
,
onClick = {
textFieldValue = TextFieldValue(
text = clipboardText,
selection = TextRange(clipboardText.length))
selection = TextRange(clipboardText.length)
)
}
) {
Text(stringResource(R.string.add_link_sheet_action_paste_from_clipboard))

View file

@ -3,15 +3,47 @@
package app.omnivore.omnivore.ui.components
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Scaffold
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@ -20,8 +52,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.intl.Locale
@ -29,11 +65,15 @@ import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import com.dokar.chiptextfield.*
import com.dokar.chiptextfield.Chip
import com.dokar.chiptextfield.ChipTextField
import com.dokar.chiptextfield.ChipTextFieldDefaults
import com.dokar.chiptextfield.ChipTextFieldState
import com.dokar.chiptextfield.rememberChipTextFieldState
import com.google.accompanist.flowlayout.FlowRow
import java.util.*
//@Composable
@ -99,216 +139,233 @@ import java.util.*
//}
@Composable
fun CircleIcon(colorHex: String){
val chipColors = LabelChipColors.fromHex(colorHex)
val viewConfiguration = LocalViewConfiguration.current
val viewConfigurationOverride = remember(viewConfiguration) {
ViewConfigurationOverride(
base = viewConfiguration,
minimumTouchTargetSize = DpSize(24.dp, 24.dp)
)
}
CompositionLocalProvider(LocalViewConfiguration provides viewConfigurationOverride) {
Row(
modifier = Modifier
.padding(start = 10.dp, end = 2.dp)
.padding(vertical = 7.dp)
) {
Canvas(modifier = Modifier.size(12.dp), onDraw = {
drawCircle(color = chipColors.containerColor)
})
fun CircleIcon(colorHex: String) {
val chipColors = LabelChipColors.fromHex(colorHex)
val viewConfiguration = LocalViewConfiguration.current
val viewConfigurationOverride = remember(viewConfiguration) {
ViewConfigurationOverride(
base = viewConfiguration,
minimumTouchTargetSize = DpSize(24.dp, 24.dp)
)
}
CompositionLocalProvider(LocalViewConfiguration provides viewConfigurationOverride) {
Row(
modifier = Modifier
.padding(start = 10.dp, end = 2.dp)
.padding(vertical = 7.dp)
) {
Canvas(modifier = Modifier.size(12.dp), onDraw = {
drawCircle(color = chipColors.containerColor)
})
}
}
}
}
@Composable
fun <T : Chip> CloseButton(
state: ChipTextFieldState<T>,
chip: T,
modifier: Modifier = Modifier,
backgroundColor: Color = Color.Transparent,
strokeColor: Color = Color.White,
startPadding: Dp = 0.dp,
endPadding: Dp = 4.dp
state: ChipTextFieldState<T>,
chip: T,
modifier: Modifier = Modifier,
backgroundColor: Color = Color.Transparent,
strokeColor: Color = Color.White,
startPadding: Dp = 0.dp,
endPadding: Dp = 4.dp
) {
Row(
modifier = modifier
.padding(start = startPadding, end = endPadding)
) {
CloseButtonImpl(
onClick = { state.removeChip(chip) },
backgroundColor = backgroundColor,
strokeColor = strokeColor
)
}
Row(
modifier = modifier
.padding(start = startPadding, end = endPadding)
) {
CloseButtonImpl(
onClick = { state.removeChip(chip) },
backgroundColor = backgroundColor,
strokeColor = strokeColor
)
}
}
internal class ViewConfigurationOverride(
base: ViewConfiguration,
override val doubleTapMinTimeMillis: Long = base.doubleTapMinTimeMillis,
override val doubleTapTimeoutMillis: Long = base.doubleTapTimeoutMillis,
override val longPressTimeoutMillis: Long = base.longPressTimeoutMillis,
override val touchSlop: Float = base.touchSlop,
override val minimumTouchTargetSize: DpSize = base.minimumTouchTargetSize
base: ViewConfiguration,
override val doubleTapMinTimeMillis: Long = base.doubleTapMinTimeMillis,
override val doubleTapTimeoutMillis: Long = base.doubleTapTimeoutMillis,
override val longPressTimeoutMillis: Long = base.longPressTimeoutMillis,
override val touchSlop: Float = base.touchSlop,
override val minimumTouchTargetSize: DpSize = base.minimumTouchTargetSize
) : ViewConfiguration
@Composable
private fun CloseButtonImpl(
onClick: () -> Unit,
backgroundColor: Color,
strokeColor: Color,
modifier: Modifier = Modifier,
onClick: () -> Unit,
backgroundColor: Color,
strokeColor: Color,
modifier: Modifier = Modifier,
) {
val padding = with(LocalDensity.current) { 6.dp.toPx() }
val strokeWidth = with(LocalDensity.current) { 1.2.dp.toPx() }
val viewConfiguration = LocalViewConfiguration.current
val viewConfigurationOverride = remember(viewConfiguration) {
ViewConfigurationOverride(
base = viewConfiguration,
minimumTouchTargetSize = DpSize(24.dp, 24.dp)
)
}
CompositionLocalProvider(LocalViewConfiguration provides viewConfigurationOverride) {
Canvas(
modifier = modifier
.size(18.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick)
) {
drawLine(
color = strokeColor,
start = Offset(padding, padding),
end = Offset(size.width - padding, size.height - padding),
strokeWidth = strokeWidth
)
drawLine(
color = strokeColor,
start = Offset(padding, size.height - padding),
end = Offset(size.width - padding, padding),
strokeWidth = strokeWidth
)
val padding = with(LocalDensity.current) { 6.dp.toPx() }
val strokeWidth = with(LocalDensity.current) { 1.2.dp.toPx() }
val viewConfiguration = LocalViewConfiguration.current
val viewConfigurationOverride = remember(viewConfiguration) {
ViewConfigurationOverride(
base = viewConfiguration,
minimumTouchTargetSize = DpSize(24.dp, 24.dp)
)
}
CompositionLocalProvider(LocalViewConfiguration provides viewConfigurationOverride) {
Canvas(
modifier = modifier
.size(18.dp)
.clip(CircleShape)
.background(backgroundColor)
.clickable(onClick = onClick)
) {
drawLine(
color = strokeColor,
start = Offset(padding, padding),
end = Offset(size.width - padding, size.height - padding),
strokeWidth = strokeWidth
)
drawLine(
color = strokeColor,
start = Offset(padding, size.height - padding),
end = Offset(size.width - padding, padding),
strokeWidth = strokeWidth
)
}
}
}
}
class LabelChipView(label: SavedItemLabel) : Chip(label.name) {
val label = label
val label = label
}
fun findOrCreateLabel(labelsViewModel: LabelsViewModel, labels: List<SavedItemLabel>, name: String): SavedItemLabel {
val found = labels.find { it.name == name }
if (found != null) {
return found
}
return labelsViewModel.createNewSavedItemLabelWithTemp(name, LabelSwatchHelper.random())
fun findOrCreateLabel(
labelsViewModel: LabelsViewModel,
labels: List<SavedItemLabel>,
name: String
): SavedItemLabel {
val found = labels.find { it.name == name }
if (found != null) {
return found
}
return labelsViewModel.createNewSavedItemLabelWithTemp(name, LabelSwatchHelper.random())
}
@Composable
@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class
@OptIn(
ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class
)
fun LabelsSelectionSheetContent(
isLibraryMode: Boolean,
labels: List<SavedItemLabel>,
initialSelectedLabels: List<SavedItemLabel>,
labelsViewModel: LabelsViewModel,
onCancel: () -> Unit,
onSave: (List<SavedItemLabel>) -> Unit,
onCreateLabel: (String, String) -> Unit
isLibraryMode: Boolean,
labels: List<SavedItemLabel>,
initialSelectedLabels: List<SavedItemLabel>,
labelsViewModel: LabelsViewModel,
onCancel: () -> Unit,
onSave: (List<SavedItemLabel>) -> Unit,
onCreateLabel: (String, String) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val interactionSource = remember { MutableInteractionSource() }
val state = rememberChipTextFieldState(initialSelectedLabels.map {
LabelChipView(it)
})
val state = rememberChipTextFieldState(initialSelectedLabels.map {
LabelChipView(it)
})
val focusRequester = remember { FocusRequester() }
var filterTextValue by remember { mutableStateOf(TextFieldValue()) }
val onFilterTextValueChange: (TextFieldValue) -> Unit = { filterTextValue = it }
val focusRequester = remember { FocusRequester() }
var filterTextValue by remember { mutableStateOf(TextFieldValue()) }
val onFilterTextValueChange: (TextFieldValue) -> Unit = { filterTextValue = it }
val filteredLabels = labels.filter { label ->
val text = filterTextValue.text.toLowerCase(Locale.current)
val result = (text.isEmpty() || label.name.toLowerCase(Locale.current).startsWith(text))
val alreadySelected = state.chips.map { it.label.name }.contains(label.name)
result && !alreadySelected
}
val filteredLabels = labels.filter { label ->
val text = filterTextValue.text.toLowerCase(Locale.current)
val result = (text.isEmpty() || label.name.toLowerCase(Locale.current).startsWith(text))
val alreadySelected = state.chips.map { it.label.name }.contains(label.name)
result && !alreadySelected
}
val currentLabel = labels.find {
val text = filterTextValue.text.toLowerCase(Locale.current)
it.name.toLowerCase(Locale.current) == text
}
val currentLabel = labels.find {
val text = filterTextValue.text.toLowerCase(Locale.current)
it.name.toLowerCase(Locale.current) == text
}
val titleText = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_title) else
stringResource(R.string.label_selection_sheet_title_alt)
val titleText = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_title) else
stringResource(R.string.label_selection_sheet_title_alt)
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,
Scaffold(
modifier = Modifier
.fillMaxWidth()
) {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.label_selection_sheet_action_cancel))
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(titleText)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.label_selection_sheet_action_cancel))
}
},
actions = {
TextButton(onClick = { onSave(state.chips.map { it.label }) }) {
Text(
text = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_action_search) else
stringResource(R.string.label_selection_sheet_action_save)
)
}
}
)
}
Text(titleText, fontWeight = FontWeight.ExtraBold)
TextButton(onClick = { onSave(state.chips.map { it.label }) }) {
Text(text = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_action_search) else
stringResource(R.string.label_selection_sheet_action_save))
}
}
ChipTextField(
state = state,
value = filterTextValue,
onValueChange = onFilterTextValueChange,
onSubmit = {
if (isLibraryMode) {
currentLabel?.let {
LabelChipView(it)
}
} else {
LabelChipView(findOrCreateLabel(labelsViewModel = labelsViewModel, labels = labels, name = it.text))
}
},
chipLeadingIcon = { chip -> CircleIcon(colorHex = chip.label.color) },
chipTrailingIcon = { chip -> CloseButton(state, chip) },
interactionSource = interactionSource,
chipStyle = ChipTextFieldDefaults.chipStyle(
shape = androidx.compose.material.MaterialTheme.shapes.medium,
unfocusedBorderWidth = 0.dp,
focusedTextColor = Color(0xFFAEAEAF),
focusedBorderColor = Color(0xFF2A2A2A),
focusedBackgroundColor = Color(0xFF2A2A2A)
),
colors = androidx.compose.material.TextFieldDefaults.textFieldColors(
textColor = Color(0xFFAEAEAF),
backgroundColor = Color(0xFF3D3D3D)
),
contentPadding = PaddingValues(10.dp),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp)
.fillMaxWidth()
.padding(horizontal = 10.dp)
.focusRequester(focusRequester)
) { paddingValues ->
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 10.dp)
) {
ChipTextField(
state = state,
value = filterTextValue,
onValueChange = onFilterTextValueChange,
onSubmit = {
if (isLibraryMode) {
currentLabel?.let {
LabelChipView(it)
}
} else {
LabelChipView(
findOrCreateLabel(
labelsViewModel = labelsViewModel,
labels = labels,
name = it.text
)
)
}
},
chipLeadingIcon = { chip -> CircleIcon(colorHex = chip.label.color) },
chipTrailingIcon = { chip -> CloseButton(state, chip) },
interactionSource = interactionSource,
chipStyle = ChipTextFieldDefaults.chipStyle(
shape = androidx.compose.material.MaterialTheme.shapes.medium,
unfocusedBorderWidth = 0.dp,
focusedTextColor = Color(0xFFAEAEAF),
focusedBorderColor = Color(0xFF2A2A2A),
focusedBackgroundColor = Color(0xFF2A2A2A)
),
colors = TextFieldDefaults.textFieldColors(
textColor = MaterialTheme.colorScheme.onBackground,
backgroundColor = MaterialTheme.colorScheme.surface
),
contentPadding = PaddingValues(10.dp),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp)
.fillMaxWidth()
.padding(top = 24.dp)
.padding(horizontal = 10.dp)
.focusRequester(focusRequester)
// .onFocusEvent {
// val text = filterTextValue.text
// if (it.hasFocus) {
@ -316,78 +373,92 @@ fun LabelsSelectionSheetContent(
// onFilterTextValueChange(filterTextValue.copy(selection = TextRange(selection)))
// }
// }
)
if (!isLibraryMode && filterTextValue.text.isNotEmpty() && currentLabel == null) {
val context = LocalContext.current
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val labelName = filterTextValue.text.trim()
when(labelsViewModel.validateLabelName(labelName)) {
LabelsViewModel.Error.LabelNameTooLong -> {
Toast.makeText(
context,
context.getString(R.string.label_selection_sheet_label_too_long_error_msg,
labelsViewModel.labelNameMaxLength),
Toast.LENGTH_SHORT
).show()
}
null -> {
val label = findOrCreateLabel(
labelsViewModel = labelsViewModel,
labels = labels,
name = labelName
)
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()
}
}
}
.padding(horizontal = 10.dp)
.padding(top = 10.dp, bottom = 5.dp)
)
{
Icon(
imageVector = Icons.Filled.AddCircle,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(R.string.label_selection_sheet_text_create, filterTextValue.text.trim()))
}
}
if (filteredLabels.isNotEmpty()) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(10.dp)
.padding(bottom = 55.dp)
) {
filteredLabels.forEach { label ->
val chipColors = LabelChipColors.fromHex(label.color)
LabelChip(
name = label.name,
colors = chipColors,
modifier = Modifier
.padding(end = 10.dp, bottom = 10.dp)
.clickable {
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()
}
)
}
if (!isLibraryMode && filterTextValue.text.isNotEmpty() && currentLabel == null) {
val context = LocalContext.current
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val labelName = filterTextValue.text.trim()
when (labelsViewModel.validateLabelName(labelName)) {
LabelsViewModel.Error.LabelNameTooLong -> {
Toast
.makeText(
context,
context.getString(
R.string.label_selection_sheet_label_too_long_error_msg,
labelsViewModel.labelNameMaxLength
),
Toast.LENGTH_SHORT
)
.show()
}
null -> {
val label = findOrCreateLabel(
labelsViewModel = labelsViewModel,
labels = labels,
name = labelName
)
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()
}
}
}
.padding(horizontal = 10.dp)
.padding(top = 24.dp, bottom = 5.dp)
)
{
Icon(
imageVector = Icons.Filled.AddCircle,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(
R.string.label_selection_sheet_text_create,
filterTextValue.text.trim()
),
style = TextStyle(
color = MaterialTheme.colorScheme.onSurface
),
)
}
}
if (filteredLabels.isNotEmpty()) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(10.dp)
.padding(bottom = 55.dp)
) {
filteredLabels.forEach { label ->
val chipColors = LabelChipColors.fromHex(label.color)
LabelChip(
name = label.name,
colors = chipColors,
modifier = Modifier
.padding(end = 10.dp, bottom = 10.dp)
.clickable {
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()
}
)
}
}
}
}
}
}
}
LaunchedEffect(Unit) {
state.focusTextField()
}
LaunchedEffect(Unit) {
state.focusTextField()
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.MutableLiveData
import app.omnivore.omnivore.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditInfoSheetContent(
savedItemId: String?,
@ -68,44 +69,47 @@ fun EditInfoSheetContent(
}
}
Surface(
androidx.compose.material.Scaffold(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
.background(MaterialTheme.colorScheme.primaryContainer),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(stringResource(R.string.edit_info_sheet_title))
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.edit_info_sheet_action_cancel))
}
},
actions = {
TextButton(onClick = {
val newTitle = titleTextFieldValue.text
val newAuthor = authorTextFieldValue.text.ifEmpty { null }
val newDescription = descriptionTextFieldValue.text.ifEmpty { null }
savedItemId?.let {
viewModel.editInfo(it, newTitle, newAuthor, newDescription)
}
}) {
Text(stringResource(R.string.edit_info_sheet_action_save))
}
}
)
}
) { paddingValues ->
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 10.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
TextButton(onClick = onCancel) {
Text(text = stringResource(R.string.edit_info_sheet_action_cancel))
}
Text(stringResource(R.string.edit_info_sheet_title), fontWeight = FontWeight.ExtraBold)
TextButton(onClick = {
val newTitle = titleTextFieldValue.text
val newAuthor = authorTextFieldValue.text.ifEmpty { null }
val newDescription = descriptionTextFieldValue.text.ifEmpty { null }
savedItemId?.let {
viewModel.editInfo(it, newTitle, newAuthor, newDescription)
}
}) {
Text(stringResource(R.string.edit_info_sheet_action_save))
}
}
if (isUpdating.value == true) {
Spacer(modifier = Modifier.width(16.dp))
CircularProgressIndicator(

View file

@ -20,6 +20,7 @@ import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.Icon
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Scaffold
import androidx.compose.material.ScaffoldState
import androidx.compose.material.SwipeToDismiss
@ -31,6 +32,7 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.rememberDismissState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@ -144,14 +146,21 @@ fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun LabelBottomSheet(
libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(
listOf()
@ -206,7 +215,14 @@ fun AddLinkBottomSheet(
saveViewModel: SaveViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
AddLinkSheetContent(
viewModel = saveViewModel,
onCancel = {
@ -228,7 +244,13 @@ fun EditBottomSheet(
libraryViewModel: LibraryViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(onDismissRequest = { onDismiss() }) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
EditInfoSheetContent(
savedItemId = currentSavedItemData?.savedItem?.savedItemId,

View file

@ -300,7 +300,7 @@ class LibraryViewModel @Inject constructor(
SavedItemAction.EditLabels -> {
currentItemLiveData.value = itemID
bottomSheetState.value = LibraryBottomSheetState.EDIT
bottomSheetState.value = LibraryBottomSheetState.LABEL
}
SavedItemAction.EditInfo -> {

View file

@ -37,7 +37,6 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
val selectedWebFontName = remember { mutableStateOf(currentWebPreferences.fontFamily.displayText) }
var fontSizeSliderValue by remember { mutableStateOf(currentWebPreferences.textFontSize.toFloat()) }
var marginSliderValue by remember { mutableStateOf(currentWebPreferences.maxWidthPercentage.toFloat()) }
var lineSpacingSliderValue by remember { mutableStateOf(currentWebPreferences.lineHeight.toFloat()) }
@ -112,8 +111,8 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
fontSizeSliderValue = it
webReaderViewModel.setFontSize(it.toInt())
},
steps = 10,
valueRange = 10f..48f,
steps = 40,
valueRange = 10f..50f,
)
Text(stringResource(R.string.reader_preferences_view_margin), style = TextStyle(
@ -127,7 +126,7 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
marginSliderValue = it
webReaderViewModel.setMaxWidthPercentage(it.toInt())
},
steps = 4,
steps = 40,
valueRange = 60f..100f,
)
@ -142,7 +141,7 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
lineSpacingSliderValue = it
webReaderViewModel.setLineHeight(it.toInt())
},
steps = 8,
steps = 50,
valueRange = 100f..300f,
)

View file

@ -61,6 +61,8 @@ data class WebReaderContent(
Log.d("theme", "current theme is: ${preferences.themeKey}")
Log.d("sync", "HIGHLIGHTS JSON: ${articleContent.highlightsJSONString()}")
return """
<!DOCTYPE html>
<html>

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Home

View file

@ -324,6 +324,7 @@ class WebReaderViewModel @Inject constructor(
// }
fun handleIncomingWebMessage(actionID: String, jsonString: String) {
Log.d("sync", "incoming change: ${actionID}: ${jsonString}")
when (actionID) {
"createHighlight" -> {
viewModelScope.launch {
@ -331,13 +332,11 @@ class WebReaderViewModel @Inject constructor(
}
}
"deleteHighlight" -> {
Log.d("Loggo", "receive delete highlight action: $jsonString")
viewModelScope.launch {
dataService.deleteHighlights(jsonString)
dataService.deleteHighlightFromJSON(jsonString)
}
}
"updateHighlight" -> {
Log.d("Loggo", "receive update highlight action: $jsonString")
viewModelScope.launch {
dataService.updateWebHighlight(jsonString)
}

View file

@ -14,7 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.auth.LoginViewModel
@ -64,9 +66,12 @@ fun SettingsViewContent(loginViewModel: LoginViewModel, settingsViewModel: Setti
Box(
modifier = modifier.fillMaxSize()
) {
val version = "Omnivore Version: " + BuildConfig.VERSION_NAME
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.Start,
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
@ -74,23 +79,6 @@ fun SettingsViewContent(loginViewModel: LoginViewModel, settingsViewModel: Setti
.verticalScroll(rememberScrollState())
) {
// profile pic and name
// SettingRow(text = "Labels") { Log.d("settings", "labels button tapped") }
// RowDivider()
// SettingRow(text = "Emails") { Log.d("settings", "emails button tapped") }
// RowDivider()
// SettingRow(text = "Subscriptions") { Log.d("settings", "subscriptions button tapped") }
// RowDivider()
// SettingRow(text = "Clubs") { Log.d("settings", "clubs button tapped") }
// SectionSpacer()
// SettingRow(text = "Push Notifications") { Log.d("settings", "pn button tapped") }
// RowDivider()
// SettingRow(text = "Text to Speech") { Log.d("settings", "tts button tapped") }
//
// SectionSpacer()
SettingRow(text = stringResource(R.string.settings_view_setting_row_documentation)) {
navController.navigate(Routes.Documentation.route)
@ -118,6 +106,13 @@ fun SettingsViewContent(loginViewModel: LoginViewModel, settingsViewModel: Setti
showLogoutDialog.value = true
}
RowDivider()
Text(
text = version,
fontSize = 12.sp,
modifier = Modifier
.padding(15.dp)
)
}
if (showLogoutDialog.value) {

View file

@ -2,63 +2,67 @@ package app.omnivore.omnivore.ui.theme
import androidx.compose.ui.graphics.Color
//
val md_theme_light_primary = Color(0xFF745B00)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
//val md_theme_light_primaryContainer = Color(0xFFFFE08C)
//val md_theme_light_onPrimaryContainer = Color(0xFF241A00)
//val md_theme_light_secondary = Color(0xFF6D5E00)
//val md_theme_light_onSecondary = Color(0xFFFFFFFF)
//val md_theme_light_secondaryContainer = Color(0xFFFCE365)
//val md_theme_light_onSecondaryContainer = Color(0xFF211B00)
//val md_theme_light_tertiary = Color(0xFF4B670A)
//val md_theme_light_onTertiary = Color(0xFFFFFFFF)
//val md_theme_light_tertiaryContainer = Color(0xFFCBEF86)
//val md_theme_light_onTertiaryContainer = Color(0xFF141F00)
//val md_theme_light_error = Color(0xFFBA1A1A)
//val md_theme_light_errorContainer = Color(0xFFFFDAD6)
//val md_theme_light_onError = Color(0xFFFFFFFF)
//val md_theme_light_onErrorContainer = Color(0xFF410002)
//val md_theme_light_background = Color(0xFFF7FFED)
//val md_theme_light_onBackground = Color(0xFF032100)
//val md_theme_light_surface = Color(0xFFF7FFED)
//val md_theme_light_onSurface = Color(0xFF032100)
//val md_theme_light_surfaceVariant = Color(0xFFEBE1CF)
//val md_theme_light_onSurfaceVariant = Color(0xFF4C4639)
//val md_theme_light_outline = Color(0xFF7E7667)
//val md_theme_light_inverseOnSurface = Color(0xFFCBFFB5)
//val md_theme_light_inverseSurface = Color(0xFF083900)
//val md_theme_light_inversePrimary = Color(0xFFEFC125)
//val md_theme_light_shadow = Color(0xFF000000)
//val md_theme_light_surfaceTint = Color(0xFF745B00)
//
val md_theme_light_primaryContainer = Color(0xFFFFDDB3)
val md_theme_light_onPrimaryContainer = Color(0xFF291800)
val md_theme_light_secondary = Color(0xFF6F5B40)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFBDEBC)
val md_theme_light_onSecondaryContainer = Color(0xFF271904)
val md_theme_light_tertiary = Color(0xFF51643F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFD4EABB)
val md_theme_light_onTertiaryContainer = Color(0xFF102004)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1F1B16)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1F1B16)
val md_theme_light_surfaceVariant = Color(0xFFF0E0CF)
val md_theme_light_onSurfaceVariant = Color(0xFF4F4539)
val md_theme_light_outline = Color(0xFF817567)
val md_theme_light_inverseOnSurface = Color(0xFFF9EFE7)
val md_theme_light_inverseSurface = Color(0xFF34302A)
val md_theme_light_inversePrimary = Color(0xFFFFB951)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF825500)
val md_theme_light_outlineVariant = Color(0xFFD3C4B4)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFEFC125)
val md_theme_dark_onPrimary = Color(0xFF3D2F00)
//val md_theme_dark_primaryContainer = Color(0xFF584400)
//val md_theme_dark_onPrimaryContainer = Color(0xFFFFE08C)
//val md_theme_dark_secondary = Color(0xFFDEC64C)
//val md_theme_dark_onSecondary = Color(0xFF393000)
//val md_theme_dark_secondaryContainer = Color(0xFF524600)
//val md_theme_dark_onSecondaryContainer = Color(0xFFFCE365)
//val md_theme_dark_tertiary = Color(0xFFB0D36D)
//val md_theme_dark_onTertiary = Color(0xFF243600)
//val md_theme_dark_tertiaryContainer = Color(0xFF364E00)
//val md_theme_dark_onTertiaryContainer = Color(0xFFCBEF86)
//val md_theme_dark_error = Color(0xFFFFB4AB)
//val md_theme_dark_errorContainer = Color(0xFF93000A)
//val md_theme_dark_onError = Color(0xFF690005)
//val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
//val md_theme_dark_background = Color(0xFF032100)
//val md_theme_dark_onBackground = Color(0xFFB4F39B)
//val md_theme_dark_surface = Color(0xFF032100)
//val md_theme_dark_onSurface = Color(0xFFB4F39B)
//val md_theme_dark_surfaceVariant = Color(0xFF4C4639)
//val md_theme_dark_onSurfaceVariant = Color(0xFFCFC5B4)
//val md_theme_dark_outline = Color(0xFF989080)
//val md_theme_dark_inverseOnSurface = Color(0xFF032100)
//val md_theme_dark_inverseSurface = Color(0xFFB4F39B)
//val md_theme_dark_inversePrimary = Color(0xFF745B00)
//val md_theme_dark_shadow = Color(0xFF000000)
//val md_theme_dark_surfaceTint = Color(0xFFEFC125)
//
//val seed = Color(0xFFE2B513)
val md_theme_dark_onPrimary = Color(0xFF212121)
val md_theme_dark_primaryContainer = Color(0xFF212121)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDDB3)
val md_theme_dark_secondary = Color(0xFFDDC2A1)
val md_theme_dark_onSecondary = Color(0xFF212121)
val md_theme_dark_secondaryContainer = Color(0xFF283237)
val md_theme_dark_onSecondaryContainer = Color(0xFFFBDEBC)
val md_theme_dark_tertiary = Color(0xFFB8CEA1)
val md_theme_dark_onTertiary = Color(0xFF243515)
val md_theme_dark_tertiaryContainer = Color(0xFF3A4C2A)
val md_theme_dark_onTertiaryContainer = Color(0xFFD4EABB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF262626)
val md_theme_dark_onBackground = Color(0xFFEAE1D9)
val md_theme_dark_surface = Color(0xFF1F1B16)
val md_theme_dark_onSurface = Color(0xFFEAE1D9)
val md_theme_dark_surfaceVariant = Color(0xFF212121)
val md_theme_dark_onSurfaceVariant = Color(0xFFD3C4B4)
val md_theme_dark_outline = Color(0xFF9C8F80)
val md_theme_dark_inverseOnSurface = Color(0xFF1F1B16)
val md_theme_dark_inverseSurface = Color(0xFFEAE1D9)
val md_theme_dark_inversePrimary = Color(0xFF212121)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB951)
val md_theme_dark_outlineVariant = Color(0xFF424242)
val md_theme_dark_scrim = Color(0xFF000000)
// val seed = Color(0xFF825500)

View file

@ -2,32 +2,88 @@ package app.omnivore.omnivore.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun OmnivoreTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
useDynamicTheme: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S,
content: @Composable () -> Unit
darkTheme: Boolean = isSystemInDarkTheme(),
useDynamicTheme: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S,
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
// shapes = Shapes,
content = content
)
content = content
)
}

View file

@ -0,0 +1,233 @@
<resources>
<string name="app_name">Omnivore</string>
<string name="welcome_title">Verpasse nie wieder eine großartige Lektüre</string>
<string name="learn_more">Mehr erfahren</string>
<string name="welcome_subtitle">Speichere Artikel und lies sie später in unserem ablenkungsfreien Reader.</string>
<string name="highlight_menu_action">Markieren</string>
<string name="copy_menu_action">Kopieren</string>
<string name="annotate_menu_action">Anmerken</string>
<string name="pdf_remove_highlight">Entfernen</string>
<string name="pdf_highlight_menu_action">Markieren</string>
<string name="pdf_highlight_copy">Kopieren</string>
<string name="highlight_note">Notiz</string>
<string name="copyTextSelection">Kopieren</string>
<string name="pdf_highlight_menu_note">Notiz</string>
<!-- Apple Auth -->
<string name="apple_auth_text">Mit Apple fortfahren</string>
<string name="apple_auth_loading">Anmeldung...</string>
<!-- Create User Profile -->
<string name="create_user_profile_title">Erstelle dein Profil</string>
<string name="create_user_profile_loading">Laden...</string>
<string name="create_user_profile_action_cancel">Anmeldung abbrechen</string>
<string name="create_user_profile_action_submit">Absenden</string>
<string name="create_user_profile_field_placeholder_name">Name</string>
<string name="create_user_profile_field_label_name">Name</string>
<string name="create_user_profile_field_placeholder_username">Benutzername</string>
<string name="create_user_profile_field_label_username">Benutzername</string>
<string name="create_user_profile_error_msg">Bitte gib einen gültigen Namen und Benutzernamen ein.</string>
<!-- Email Login -->
<string name="email_login_loading">Laden...</string>
<string name="email_login_action_back">Zurück zum Social Login</string>
<string name="email_login_action_no_account">Du hast noch kein Konto?</string>
<string name="email_login_action_forgot_password">Passwort vergessen?</string>
<string name="email_login_action_login">Anmelden</string>
<string name="email_login_field_placeholder_email">benutzer@email.com</string>
<string name="email_login_field_label_email">E-Mail</string>
<string name="email_login_field_placeholder_password">Passwort</string>
<string name="email_login_field_label_password">Passwort</string>
<string name="email_login_error_msg">Bitte gib eine E-Mail-Adresse und ein Passwort ein.</string>
<!-- Email Sign Up -->
<string name="email_signup_verification_message">Wir haben eine Verifizierungs-E-Mail an %1$s gesendet. Bitte bestätige deine E-Mail und tippe dann auf den unten stehenden Knopf.</string>
<string name="email_signup_check_status">Status prüfen</string>
<string name="email_signup_action_use_different_email">Eine andere E-Mail verwenden?</string>
<string name="email_signup_loading">Laden...</string>
<string name="email_signup_action_back">Zurück zu Social Login</string>
<string name="email_signup_action_already_have_account">Du hast bereits ein Konto?</string>
<string name="email_signup_action_sign_up">Registrieren</string>
<string name="email_signup_field_placeholder_email">benutzer@email.com</string>
<string name="email_signup_field_label_email">E-Mail</string>
<string name="email_signup_field_placeholder_password">Passwort</string>
<string name="email_signup_field_label_password">Passwort</string>
<string name="email_signup_field_placeholder_name">Name</string>
<string name="email_signup_field_label_name">Name</string>
<string name="email_signup_field_placeholder_username">Name</string>
<string name="email_signup_field_label_username">Name</string>
<string name="email_signup_error_msg">Bitte fülle alle Felder aus.</string>
<!-- Google Auth -->
<string name="google_auth_text">Mit Google fortfahren</string>
<string name="google_auth_loading">Anmeldung...</string>
<!-- LoginViewModel -->
<string name="login_view_model_self_hosting_settings_updated">Einstellungen für Self-Hosting aktualisiert.</string>
<string name="login_view_model_self_hosting_settings_reset">Einstellungen für Self-Hosting zurückgesetzt.</string>
<string name="login_view_model_username_validation_length_error_msg">Benutzername muss zwischen 4 und 15 Zeichen lang sein.</string>
<string name="login_view_model_username_validation_alphanumeric_error_msg">Benutzername darf nur Buchstaben und Zahlen enthalten.</string>
<string name="login_view_model_username_not_available_error_msg">Dieser Benutzername ist nicht verfügbar.</string>
<string name="login_view_model_connection_error_msg">Entschuldigung, wir haben Probleme, eine Verbindung zum Server herzustellen.</string>
<string name="login_view_model_something_went_wrong_error_msg">Etwas ist schiefgelaufen. Bitte überprüfe deine E-Mail und dein Passwort und versuche es erneut.</string>
<string name="login_view_model_something_went_wrong_two_error_msg">Etwas ist schiefgelaufen. Bitte überprüfe deine Anmeldeinformationen und versuche es erneut.</string>
<string name="login_view_model_google_auth_error_msg">Authentifizierung mit Google fehlgeschlagen.</string>
<string name="login_view_model_missing_auth_token_error_msg">Kein Authentifizierungstoken gefunden.</string>
<!-- SelfHostedView -->
<string name="self_hosted_view_loading">Laden...</string>
<string name="self_hosted_view_action_reset">Zurücksetzen</string>
<string name="self_hosted_view_action_back">Zurück</string>
<string name="self_hosted_view_action_save">Speichern</string>
<string name="self_hosted_view_action_learn_more">Mehr über Self-Hosting von Omnivore erfahren</string>
<string name="self_hosted_view_field_api_url_label">API-Server</string>
<string name="self_hosted_view_field_web_url_label">Webserver</string>
<string name="self_hosted_view_error_msg">Bitte gib die Adressen des API-Servers und des Webservers ein.</string>
<!-- WelcomeScreen -->
<string name="welcome_screen_action_dismiss">Schließen</string>
<string name="welcome_screen_action_continue_with_email">Mit E-Mail fortfahren</string>
<string name="welcome_screen_action_self_hosting_options">Self-Hosting Optionen</string>
<!-- LabelCreationDialog -->
<string name="label_creation_title">Neues Label erstellen</string>
<string name="label_creation_content">Weise einen Namen und eine Farbe zu.</string>
<string name="label_creation_action_create">Erstellen</string>
<string name="label_creation_action_cancel">Abbrechen</string>
<string name="label_creation_label_placeholder">Label-Name</string>
<!-- LabelSelectionSheet -->
<string name="label_selection_sheet_title">Nach Label filtern</string>
<string name="label_selection_sheet_title_alt">Labels setzen</string>
<string name="label_selection_sheet_action_cancel">Abbrechen</string>
<string name="label_selection_sheet_action_search">Suchen</string>
<string name="label_selection_sheet_action_save">Speichern</string>
<string name="label_selection_sheet_text_create">Erstelle ein neues Label mit dem Namen \"%1$s\"</string>
<string name="label_selection_sheet_label_too_long_error_msg">Der angegebene Name ist zu lang (muss %1$d Zeichen oder weniger sein)</string>
<!-- LibraryFilterBar -->
<string name="library_filter_bar_label_labels">Labels</string>
<!-- LibraryNavigationBar -->
<string name="library_nav_bar_title">Bibliothek</string>
<string name="library_nav_bar_title_alt"></string>
<string name="library_nav_bar_field_placeholder_search">Suchen</string>
<!-- LibraryViewModel -->
<string name="library_view_model_snackbar_success">Labels aktualisiert</string>
<string name="library_view_model_snackbar_error">Labels konnten nicht gesetzt werden</string>
<!-- NotebookView -->
<string name="notebook_view_title">Notizbuch</string>
<string name="notebook_view_action_copy">Kopieren</string>
<string name="notebook_view_snackbar_msg">Notizbuch kopiert</string>
<!-- EditNoteModal -->
<string name="edit_note_modal_title">Notiz</string>
<string name="edit_note_modal_action_save">Speichern</string>
<string name="edit_note_modal_action_cancel">Abbrechen</string>
<!-- ArticleNotes -->
<string name="article_notes_title">Artikelnotizen</string>
<string name="article_notes_action_add_notes">Notizen hinzufügen...</string>
<!-- HighlightsList -->
<string name="highlights_list_title">Hervorhebungen</string>
<string name="highlights_list_action_copy">Kopieren</string>
<string name="highlights_list_snackbar_msg">Hervorhebung kopiert</string>
<string name="highlights_list_action_add_note">Notiz hinzufügen...</string>
<string name="highlights_list_error_msg_no_highlights">Du hast dieser Seite keine Hervorhebungen hinzugefügt.</string>
<!-- ReaderPreferencesView -->
<string name="reader_preferences_view_font_size">Schriftgröße:</string>
<string name="reader_preferences_view_margin">Rand</string>
<string name="reader_preferences_view_line_spacing">Zeilenabstand</string>
<string name="reader_preferences_view_theme">Thema:</string>
<string name="reader_preferences_view_auto">Automatisch</string>
<string name="reader_preferences_view_high_constrast_text">Hoher Textkontrast</string>
<string name="reader_preferences_view_justify_text">Text ausrichten</string>
<!-- WebReaderLoadingContainer -->
<string name="web_reader_loading_container_error_msg">Wir konnten deinen Inhalt nicht abrufen.</string>
<string name="web_reader_loading_container_bottom_sheet_reader_preferences">Lese-Einstellungen</string>
<string name="web_reader_loading_container_bottom_sheet_notebook">Notizbuch</string>
<string name="web_reader_loading_container_bottom_sheet_edit_info">Info bearbeiten</string>
<string name="web_reader_loading_container_bottom_sheet_e">Notizbuch</string>
<string name="web_reader_loading_container_bottom_sheet_open_link">Link öffnen</string>
<!-- OpenLinkView -->
<string name="open_link_view_action_open_in_browser">Im Browser öffnen</string>
<string name="open_link_view_action_save_to_omnivore">In Omnivore speichern</string>
<string name="open_link_view_action_copy_link">Link kopieren</string>
<string name="open_link_view_action_cancel">Abbrechen</string>
<!-- WebReaderViewModel -->
<string name="web_reader_view_model_save_link_success">Link gespeichert</string>
<string name="web_reader_view_model_save_link_error">Fehler beim Speichern des Links</string>
<string name="web_reader_view_model_copy_link_success">Link kopiert</string>
<!-- SaveContent -->
<string name="save_content_msg">Speichern</string>
<string name="save_content_action_read_now">Jetzt lesen</string>
<string name="save_content_action_read_later">Später lesen</string>
<string name="save_content_action_dismiss">Schließen</string>
<!-- SaveViewModel -->
<string name="save_view_model_msg">Speichern in Omnivore...</string>
<string name="save_view_model_error_not_logged_in">Du bist nicht angemeldet. Bitte melde dich an, bevor du speicherst.</string>
<string name="save_view_model_page_saved_success">Seite gespeichert</string>
<string name="save_view_model_page_saved_error">Fehler beim Speichern deiner Seite</string>
<!-- SavedItemContextMenu -->
<string name="saved_item_context_menu_action_edit_info">Info bearbeiten</string>
<string name="saved_item_context_menu_action_edit_labels">Labels bearbeiten</string>
<string name="saved_item_context_menu_action_archive">Archivieren</string>
<string name="saved_item_context_menu_action_unarchive">Aus dem Archiv wiederherstellen</string>
<string name="saved_item_context_menu_action_share_original">Original teilen</string>
<string name="saved_item_context_menu_action_remove_item">Element entfernen</string>
<!-- LogoutDialog -->
<string name="logout_dialog_title">Abmelden</string>
<string name="logout_dialog_confirm_msg">Bist du sicher, dass du dich abmelden möchtest?</string>
<string name="logout_dialog_action_confirm">Bestätigen</string>
<string name="logout_dialog_action_cancel">Abbrechen</string>
<!-- ManageAccount -->
<string name="manage_account_title">Konto verwalten</string>
<string name="manage_account_action_reset_data_cache">Cache zurücksetzen</string>
<!-- PolicyWebView -->
<string name="policy_webview_title">Einstellungen</string>
<!-- SettingsView -->
<string name="settings_view_title">Einstellungen</string>
<string name="settings_view_setting_row_documentation">Dokumentation</string>
<string name="settings_view_setting_row_feedback">Feedback</string>
<string name="settings_view_setting_row_privacy_policy">Datenschutzerklärung</string>
<string name="settings_view_setting_row_terms_and_conditions">Nutzungsbedingungen</string>
<string name="settings_view_setting_row_manage_account">Konto verwalten</string>
<string name="settings_view_setting_row_logout">Abmelden</string>
<!-- AddLinkSheet -->
<string name="add_link_sheet_title">Link hinzufügen</string>
<string name="add_link_sheet_text_field_placeholder">Link hinzufügen</string>
<string name="add_link_sheet_action_add_link">Hinzufügen</string>
<string name="add_link_sheet_action_cancel">Abbrechen</string>
<string name="add_link_sheet_action_paste_from_clipboard">Aus Zwischenablage holen</string>
<string name="add_link_sheet_invalid_url_error">Ungültiger Link</string>
<string name="add_link_sheet_save_url_error">Fehler beim Speichern des Links!</string>
<string name="add_link_sheet_save_url_success">Link erfolgreich gespeichert!</string>
<!-- EditInfoViewModel -->
<string name="edit_info_view_model_error_not_logged_in">Du bist nicht angemeldet. Bitte melde dich an, bevor du speicherst.</string>
<!-- EditInfoSheet -->
<string name="edit_info_sheet_title">Info bearbeiten</string>
<string name="edit_info_sheet_text_field_label_title">Titel</string>
<string name="edit_info_sheet_text_field_label_author">Autor</string>
<string name="edit_info_sheet_text_field_label_description">Beschreibung</string>
<string name="edit_info_sheet_action_save">Speichern</string>
<string name="edit_info_sheet_action_cancel">Abbrechen</string>
<string name="edit_info_sheet_error">Fehler beim Bearbeiten des Artikels!</string>
<string name="edit_info_sheet_success">Artikelinformationen erfolgreich aktualisiert!</string>
</resources>

View file

@ -54,8 +54,8 @@
<string name="email_signup_field_label_password">Password</string>
<string name="email_signup_field_placeholder_name">Name</string>
<string name="email_signup_field_label_name">Name</string>
<string name="email_signup_field_placeholder_username">Name</string>
<string name="email_signup_field_label_username">Name</string>
<string name="email_signup_field_placeholder_username">Username</string>
<string name="email_signup_field_label_username">Username</string>
<string name="email_signup_error_msg">Please complete all fields.</string>
<!-- Google Auth -->
@ -139,6 +139,7 @@
<string name="highlights_list_error_msg_no_highlights">You have not added any highlights to this page.</string>
<!-- ReaderPreferencesView -->
<string name="reader_preferences_font">Font:</string>
<string name="reader_preferences_view_font_size">Font Size:</string>
<string name="reader_preferences_view_margin">Margin</string>
<string name="reader_preferences_view_line_spacing">Line Spacing</string>
@ -208,6 +209,23 @@
<string name="settings_view_setting_row_manage_account">Manage Account</string>
<string name="settings_view_setting_row_logout">Logout</string>
<!-- stock filters -->
<string name="library_filter_inbox">Inbox</string>
<string name="library_filter_non_feed">Non-Feed Items</string>
<string name="library_filter_feeds">Feeds</string>
<string name="library_filter_newsletters">Newsletters</string>
<string name="library_filter_recommended">Recommended</string>
<string name="library_filter_all">All</string>
<string name="library_filter_archived">Archived</string>
<string name="library_filter_highlighted">Highlighted</string>
<string name="library_filter_files">Files</string>
<!-- stock sorts -->
<string name="library_sort_newest">Newest</string>
<string name="library_sort_oldest">Oldest</string>
<string name="library_sort_recently_read">Recently read</string>
<string name="library_sort_recently_published">Recently published</string>
<!-- AddLinkSheet -->
<string name="add_link_sheet_title">Add Link</string>
<string name="add_link_sheet_text_field_placeholder">Add Link</string>

View file

@ -1,17 +0,0 @@
package app.omnivore.omnivore
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View file

@ -1388,7 +1388,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.41.0;
MARKETING_VERSION = 1.43.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1423,7 +1423,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.41.0;
MARKETING_VERSION = 1.43.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1478,7 +1478,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.41.0;
MARKETING_VERSION = 1.43.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1819,7 +1819,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.41.0;
MARKETING_VERSION = 1.43.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1,49 +0,0 @@
#if os(iOS)
import AppIntents
import Services
import SwiftUI
@available(iOS 16.0, *)
public struct OmnivoreAppShorcuts: AppShortcutsProvider {
@AppShortcutsBuilder public static var appShortcuts: [AppShortcut] {
AppShortcut(intent: SaveToOmnivoreIntent(), phrases: ["Save URL to \(.applicationName)"])
}
}
//
// @available(iOS 16.0, *)
// struct ExportAllTransactionsIntent: AppIntent {
// static var title: LocalizedStringResource = "Export all transactions"
//
// static var description =
// IntentDescription("Exports your transaction history as CSV data.")
// }
@available(iOS 16.0, *)
struct SaveToOmnivoreIntent: AppIntent {
static var title: LocalizedStringResource = "Save to Omnivore"
static var description: LocalizedStringResource = "Save a URL to your Omnivore library"
static var parameterSummary: some ParameterSummary {
Summary("Save \(\.$link) to your Omnivore library.")
}
@Parameter(title: "link")
var link: URL
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue {
do {
let services = Services()
let requestId = UUID().uuidString.lowercased()
_ = try await services.dataService.saveURL(id: requestId, url: link.absoluteString)
return .result(dialog: "Link saved to Omnivore")
} catch {
print("error saving URL: ", error)
}
return .result(dialog: "Error saving link")
}
}
#endif

View file

@ -44,6 +44,7 @@ import Utils
@State private var errorMessage: String?
@State private var showNotebookView = false
@State private var showLabelsModal = false
@State private var hasPerformedHighlightMutations = false
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@ -131,6 +132,12 @@ import Utils
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.toggleNotebookView)
),
UIBarButtonItem(
image: UIImage(named: "label", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
style: .plain,
target: coordinator,
action: #selector(PDFViewCoordinator.toggleLabelsView)
)
]
@ -228,14 +235,14 @@ import Utils
}
.navigationViewStyle(StackNavigationViewStyle())
}
.fullScreenCover(isPresented: $readerView, content: {
.sheet(isPresented: $readerView, content: {
PDFReaderViewController(document: document)
})
.accentColor(Color(red: 255 / 255.0, green: 234 / 255.0, blue: 159 / 255.0))
.sheet(item: $shareLink) {
ShareSheet(activityItems: [$0.url])
}
.fullScreenCover(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
viewModel: NotebookViewModel(item: viewModel.pdfItem.item),
hasHighlightMutations: $hasPerformedHighlightMutations,
@ -244,6 +251,17 @@ import Utils
}
)
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(viewModel.pdfItem.item), onSave: { _ in
showLabelsModal = false
})
}.task {
viewModel.updateItemReadProgress(
dataService: dataService,
percent: viewModel.pdfItem.item.readingProgress,
anchorIndex: Int(viewModel.pdfItem.item.readingProgressAnchor)
)
}
} else if let errorMessage = errorMessage {
Text(errorMessage)
} else {
@ -483,6 +501,12 @@ import Utils
}
}
@objc public func toggleLabelsView() {
if let viewer = self.viewer {
viewer.showLabelsModal = !viewer.showLabelsModal
}
}
func shortHighlightIds(_ annotations: [HighlightAnnotation]) -> [String] {
annotations.compactMap { ($0.customData?["omnivoreHighlight"] as? [String: String])?["shortId"] }
}

View file

@ -164,10 +164,13 @@ import Views
var subPredicates = [NSPredicate]()
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), filterState.folder
)
subPredicates.append(folderPredicate)
// TODO: FOLLOWING MIGRATION: invert this once the following migration has completed
if !UserDefaults.standard.bool(forKey: "LibraryTabView::hideFollowingTab") {
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), filterState.folder
)
subPredicates.append(folderPredicate)
}
if let predicate = filterState.appliedFilter?.predicate {
subPredicates.append(predicate)
@ -255,7 +258,11 @@ import Views
}.joined(separator: ","))
}
query.append(" use:folders")
// TODO: FOLLOWING MIGRATION: invert this once the following migration has completed
if !UserDefaults.standard.bool(forKey: "LibraryTabView::hideFollowingTab") {
query.append(" use:folders")
}
print("QUERY: `\(query)`")
return query

View file

@ -19,10 +19,10 @@ struct FiltersHeader: View {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
// if UIDevice.isIPhone {
let hideFollowingTab = UserDefaults.standard.bool(forKey: "LibraryTabView::hideFollowingTab")
Menu(
content: {
ForEach(viewModel.filters.filter { $0.folder == viewModel.currentFolder }) { filter in
ForEach(viewModel.filters.filter { hideFollowingTab || $0.folder == viewModel.currentFolder }) { filter in
Button(filter.name, action: {
viewModel.appliedFilter = filter
})
@ -188,9 +188,10 @@ struct AnimatingCellHeight: AnimatableModifier {
@State var showAddLinkView = false
@State var isListScrolled = false
@State var listTitle = ""
@State var isEditMode: EditMode = .inactive
@State var showExpandedAudioPlayer = false
@Binding var isEditMode: EditMode
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@ -200,8 +201,9 @@ struct AnimatingCellHeight: AnimatableModifier {
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
init(viewModel: HomeFeedViewModel) {
init(viewModel: HomeFeedViewModel, isEditMode: Binding<EditMode>) {
_viewModel = ObservedObject(wrappedValue: viewModel)
_isEditMode = isEditMode
}
func loadItems(isRefresh: Bool) {
@ -293,7 +295,7 @@ struct AnimatingCellHeight: AnimatableModifier {
LibraryAddLinkView()
}
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
.sheet(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer(
delete: {
showExpandedAudioPlayer = false
@ -328,7 +330,7 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.selectedItem = linkedItem
viewModel.linkIsActive = true
}
.fullScreenCover(isPresented: $searchPresented) {
.sheet(isPresented: $searchPresented) {
LibrarySearchView(homeFeedViewModel: self.viewModel)
}
.task {
@ -882,7 +884,9 @@ struct AnimatingCellHeight: AnimatableModifier {
case .delete:
return AnyView(Button(
action: {
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
withAnimation(.linear(duration: 0.4)) {
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
}
},
label: {
Label("Remove", systemImage: "trash")
@ -891,7 +895,9 @@ struct AnimatingCellHeight: AnimatableModifier {
case .moveToInbox:
return AnyView(Button(
action: {
viewModel.moveToFolder(dataService: dataService, item: item, folder: "inbox")
withAnimation(.linear(duration: 0.4)) {
viewModel.moveToFolder(dataService: dataService, item: item, folder: "inbox")
}
},
label: {
Label(title: { Text("Move to Library") },

View file

@ -167,10 +167,9 @@ enum LoadingBarStyle {
let availableFolders = folderConfigs.keys
let appliedFilterName = UserDefaults.standard.string(forKey: filterKey)
filters = newFilters
filters = (defaultFilters + newFilters)
.filter { availableFolders.contains($0.folder) }
.sorted(by: { $0.position < $1.position })
+ defaultFilters
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter

View file

@ -1,34 +0,0 @@
import SwiftUI
import Utils
import Views
@MainActor
struct HomeView: View {
@State private var viewModel: HomeFeedViewModel
init(viewModel: HomeFeedViewModel) {
self.viewModel = viewModel
}
var body: some View {
#if os(iOS)
HomeFeedContainerView(viewModel: viewModel)
#elseif os(macOS)
HomeFeedView(viewModel: viewModel)
.frame(minWidth: 320)
.toolbar {
ToolbarItem {
Button(
action: {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil
)
},
label: { Label(LocalText.navigationSelectSidebarToggle, systemImage: "sidebar.left") }
)
}
}
#endif
}
}

View file

@ -6,6 +6,7 @@ import SwiftUI
@MainActor
public struct LibrarySplitView: View {
@EnvironmentObject var dataService: DataService
@State var isEditMode: EditMode = .inactive
@StateObject private var viewModel = HomeFeedViewModel(
filterKey: "lastSelected",
@ -37,7 +38,7 @@ public struct LibrarySplitView: View {
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("")
HomeFeedContainerView(viewModel: viewModel)
HomeFeedContainerView(viewModel: viewModel, isEditMode: $isEditMode)
.navigationViewStyle(.stack)
.navigationBarTitleDisplayMode(.inline)
}
@ -47,27 +48,32 @@ public struct LibrarySplitView: View {
$0.preferredPrimaryColumnWidth = 230
$0.displayModeButtonVisibility = .always
}
// .onOpenURL { url in
// inboxViewModel.linkRequest = nil
// if let deepLink = DeepLink.make(from: url) {
// switch deepLink {
// case let .search(query):
// inboxViewModel.searchTerm = query
// case let .savedSearch(named):
// if let filter = inboxViewModel.findFilter(dataService, named: named) {
// inboxViewModel.appliedFilter = filter
// }
// case let .webAppLinkRequest(requestID):
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
// withoutAnimation {
// inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
// inboxViewModel.presentWebContainer = true
// }
// }
// }
// }
// // selectedTab = "inbox"
// }
.onOpenURL { url in
viewModel.linkRequest = nil
withoutAnimation {
NotificationCenter.default.post(Notification(name: Notification.Name("PopToRoot")))
}
if let deepLink = DeepLink.make(from: url) {
switch deepLink {
case let .search(query):
viewModel.searchTerm = query
case let .savedSearch(named):
if let filter = viewModel.findFilter(dataService, named: named) {
viewModel.appliedFilter = filter
}
case let .webAppLinkRequest(requestID):
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withoutAnimation {
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
viewModel.presentWebContainer = true
}
}
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
Task {
await syncManager.syncUpdates(dataService: dataService)

View file

@ -21,6 +21,8 @@ struct LibraryTabView: View {
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
@State var isEditMode: EditMode = .inactive
@State var showExpandedAudioPlayer = false
private let syncManager = LibrarySyncManager()
@ -74,14 +76,14 @@ struct LibraryTabView: View {
TabView(selection: $selectedTab) {
if !hideFollowingTab {
NavigationView {
HomeFeedContainerView(viewModel: followingViewModel)
HomeFeedContainerView(viewModel: followingViewModel, isEditMode: $isEditMode)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("following")
}
NavigationView {
HomeFeedContainerView(viewModel: inboxViewModel)
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
@ -101,10 +103,12 @@ struct LibraryTabView: View {
.frame(height: 1)
.frame(maxWidth: .infinity)
}
CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab)
.padding(0)
if isEditMode != .active {
CustomTabBar(selectedTab: $selectedTab, hideFollowingTab: hideFollowingTab)
.padding(0)
}
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
.sheet(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer(
delete: {
showExpandedAudioPlayer = false
@ -131,6 +135,11 @@ struct LibraryTabView: View {
}
.onOpenURL { url in
inboxViewModel.linkRequest = nil
withoutAnimation {
NotificationCenter.default.post(Notification(name: Notification.Name("PopToRoot")))
}
if let deepLink = DeepLink.make(from: url) {
switch deepLink {
case let .search(query):
@ -140,6 +149,7 @@ struct LibraryTabView: View {
inboxViewModel.appliedFilter = filter
}
case let .webAppLinkRequest(requestID):
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withoutAnimation {
inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)

View file

@ -84,6 +84,11 @@ struct FiltersView: View {
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.onChange(of: viewModel.hideFollowingTab) { _ in
UserDefaults.standard.setValue(nil, forKey: "lastSelected")
UserDefaults.standard.setValue(nil, forKey: "lastSelectedFilter-inbox")
UserDefaults.standard.setValue(nil, forKey: "lastSelectedFilter-following")
}
}
private var innerBody: some View {

View file

@ -162,6 +162,15 @@ struct ProfileView: View {
)
#endif
Button(
action: {
if let url = URL(string: "https://discord.gg/h2z5rppzz9") {
openURL(url)
}
},
label: { Text("Join community on Discord") }
)
Button(
action: {
if let url = URL(string: "https://omnivore.app/privacy") {

View file

@ -210,7 +210,6 @@ struct WebReaderContainerView: View {
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
audioMenuItem()
if viewModel.hasOriginalUrl(item) {
Button(
@ -340,28 +339,6 @@ struct WebReaderContainerView: View {
.frame(maxWidth: .infinity)
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
.background(ThemeManager.currentBgColor)
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title
// We dont need to update description because its never rendered in this view
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
viewModel: NotebookViewModel(item: item),
hasHighlightMutations: $hasPerformedHighlightMutations
)
}
#endif
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
@ -421,9 +398,12 @@ struct WebReaderContainerView: View {
.statusBar(hidden: prefersHideStatusBarInReader)
#endif
.onAppear {
if item.isUnread {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0, force: false)
}
dataService.updateLinkReadingProgress(
itemID: item.unwrappedID,
readingProgress: max(item.readingProgress, 0.1),
anchorIndex: Int(item.readingProgressAnchor),
force: false
)
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
@ -450,11 +430,11 @@ struct WebReaderContainerView: View {
}, label: { Text(LocalText.readerSave) })
}
#if os(iOS)
.fullScreenCover(item: $safariWebLink) {
.sheet(item: $safariWebLink) {
SafariView(url: $0.url)
.ignoresSafeArea(.all, edges: .bottom)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
.sheet(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer(delete: { _ in
showExpandedAudioPlayer = false
audioController.stop()
@ -519,6 +499,28 @@ struct WebReaderContainerView: View {
}
}
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title
// We dont need to update description because its never rendered in this view
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
viewModel: NotebookViewModel(item: item),
hasHighlightMutations: $hasPerformedHighlightMutations
)
}
#endif
} else if let errorMessage = viewModel.errorMessage {
VStack {
if viewModel.allowRetry, viewModel.hasOriginalUrl(item) {
@ -620,6 +622,9 @@ struct WebReaderContainerView: View {
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in
pop()
}
.popup(isPresented: $viewModel.showSnackbar) {
if let operation = viewModel.snackbarOperation {
Snackbar(isShowing: $viewModel.showSnackbar, operation: operation)

View file

@ -286,7 +286,8 @@ public extension LibraryItem {
newAuthor: String? = nil,
listenPositionIndex: Int? = nil,
listenPositionOffset: Double? = nil,
listenPositionTime: Double? = nil
listenPositionTime: Double? = nil,
readAt: Date? = nil
) {
context.perform {
if let newReadingProgress = newReadingProgress {
@ -325,6 +326,10 @@ public extension LibraryItem {
self.listenPositionTime = listenPositionTime
}
if let readAt = readAt {
self.readAt = readAt
}
guard context.hasChanges else { return }
self.updatedAt = Date()

View file

@ -9,17 +9,12 @@ extension DataService {
guard let self = self else { return }
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if let force = force, !force {
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
return
}
}
print("updating reading progress: ", readingProgress, anchorIndex)
linkedItem.update(
inContext: self.backgroundContext,
newReadingProgress: readingProgress,
newAnchorIndex: anchorIndex
newAnchorIndex: anchorIndex,
readAt: Date()
)
// Send update to server

View file

@ -71,13 +71,16 @@ extension DataService {
)
}
let sort = InputObjects.SortParams(by: .updatedTime,
order: OptionalArgument(descending ? .descending : .ascending))
let sort = InputObjects.SortParams(
by: .updatedTime,
order: OptionalArgument(descending ? .descending : .ascending)
)
let query = Selection.Query {
try $0.updatesSince(
after: OptionalArgument(cursor),
first: OptionalArgument(limit),
folder: OptionalArgument("all"),
since: DateTime(from: since),
sort: OptionalArgument(sort),
selection: selection

View file

@ -22,7 +22,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "",
visible: true,
position: -1,
position: 11,
defaultFilter: true
)
}
@ -34,7 +34,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "in:trash",
visible: true,
position: -1,
position: 12,
defaultFilter: true
)
}
@ -46,7 +46,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "in:inbox is:unread",
visible: true,
position: -1,
position: 10,
defaultFilter: true
)
}
@ -58,7 +58,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "following",
filter: "",
visible: true,
position: -1,
position: 11,
defaultFilter: true
)
}
@ -70,7 +70,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "following",
filter: "in:trash",
visible: true,
position: -1,
position: 12,
defaultFilter: true
)
}
@ -82,7 +82,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "in:inbox is:unread",
visible: true,
position: -1,
position: 10,
defaultFilter: true
)
}
@ -95,7 +95,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "",
visible: true,
position: 0,
position: 10,
defaultFilter: true
),
InternalFilter(
@ -104,7 +104,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "",
visible: true,
position: 1,
position: 11,
defaultFilter: true
),
InternalFilter(
@ -113,7 +113,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "",
visible: true,
position: 2,
position: 12,
defaultFilter: true
),
InternalFilter(
@ -122,7 +122,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "",
visible: true,
position: 3,
position: 13,
defaultFilter: true
),
InternalFilter(
@ -131,7 +131,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "is:archived",
visible: true,
position: 4,
position: 14,
defaultFilter: true
),
InternalFilter(
@ -140,7 +140,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "type:file",
visible: true,
position: 5,
position: 15,
defaultFilter: true
),
InternalFilter(
@ -149,7 +149,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "has:highlights",
visible: true,
position: 6,
position: 16,
defaultFilter: true
),
InternalFilter(
@ -158,7 +158,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "inbox",
filter: "in:all",
visible: true,
position: 7,
position: 17,
defaultFilter: true
)
]
@ -172,7 +172,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "following",
filter: "in:following",
visible: true,
position: 1,
position: 10,
defaultFilter: true
),
InternalFilter(
@ -181,7 +181,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "following",
filter: "in:following label:RSS",
visible: true,
position: 2,
position: 12,
defaultFilter: true
),
InternalFilter(
@ -190,7 +190,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
folder: "following",
filter: "in:following label:Newsletter",
visible: true,
position: 3,
position: 13,
defaultFilter: true
)
]
@ -209,9 +209,6 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
}
public var predicate: NSPredicate? {
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder
)
let undeletedPredicate = NSPredicate(
format: "%K != %i AND %K != \"DELETED\"",
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
@ -226,16 +223,16 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
let feedLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate, feedLabelPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, undeletedPredicate, feedLabelPredicate])
case "Following":
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, undeletedPredicate])
case "Inbox":
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
case "Unread":
let isUnread = NSPredicate(
format: "readAt == nil"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, isUnread])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, isUnread])
case "Non-Feed Items":
// non-archived or deleted items without the Newsletter label
let nonNewsletterLabelPredicate = NSPredicate(
@ -245,7 +242,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
folderPredicate, undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
])
case "Downloaded":
// include pdf only
@ -259,51 +256,50 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
format: "localPDF.length > 0"
)
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate, localPDFURL])
return NSCompoundPredicate(orPredicateWithSubpredicates: [folderPredicate, hasHTMLContent, downloadedPDF])
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: [folderPredicate, undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate])
case "Feeds":
let feedLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, feedLabelPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, feedLabelPredicate])
case "Recommended":
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, recommendedPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, recommendedPredicate])
case "All":
// include everything undeleted
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate])
case "Archived":
let inArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, inArchivePredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
case "Deleted":
let deletedPredicate = NSPredicate(
format: "%K == %i OR %K == \"DELETED\"",
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(Models.LibraryItem.state)
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, deletedPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
case "Files":
// include pdf only
let isPDFPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, isPDFPredicate])
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
case "Highlights":
let hasHighlightsPredicate = NSPredicate(
format: "highlights.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
folderPredicate,
undeletedPredicate,
hasHighlightsPredicate
])

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,208 @@
// Unit test Entry -- Do not remove this or add entries before this one.
// This allows us to check for syntax errors in this file with a unit test
"unitTestLeadingEntry" = "Nur zu Testzwecken.";
// share extension
"saveArticleSavedState" = "In Omnivore gespeichert";
"saveArticleProcessingState" = "Wird in Omnivore gespeichert";
"extensionAppUnauthorized" = "Bitte melde dich in der App bei Omnivore an, bevor du deinen ersten Link speicherst.";
"saveToOmnivore" = "In Omnivore speichern";
// audio player
"audioPlayerReplay" = "Wiederholen";
// Highlights List Card
"highlightCardHighlightByOther" = "Markierung von ";
"highlightCardNoHighlightsOnPage" = "Du hast keine Markierungen auf dieser Seite hinzugefügt.";
// Labels View
"labelsViewAssignNameColor" = "Weise einen Namen und eine Farbe zu.";
"createLabelMessage" = "Erstelle ein neues Label";
"labelsPurposeDescription" = "Nutze Labels, um Sammlungen von Links zu erstellen.";
"labelNamePlaceholder" = "Label Name";
// Manage Account View
"manageAccountDelete" = "Konto löschen";
"manageAccountResetCache" = "Cache zurücksetzen";
"manageAccountConfirmDeleteMessage" = "Bist du sicher, dass du dein Konto löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.";
// Newsletter Emails View
"newsletterEmailsExisting" = "Vorhandene E-Mails (Tippen zum Kopieren)";
"createNewEmailMessage" = "Erstelle eine neue E-Mail-Adresse";
"newslettersDescription" = "Füge PDFs zu deiner Bibliothek hinzu oder abonniere Newsletter mit einer Omnivore-E-Mail-Adresse.";
"noCurrentSubscriptionsMessage" = "Du hast aktuell keine Abonnements.";
// Profile View
"profileConfirmLogoutMessage" = "Bist du sicher, dass du dich abmelden möchtest?";
// Devices View
"devicesTokensTitle" = "Registrierte Geräte-Tokens (wischen zum Entfernen)";
"devicesCreated" = "Erstellt: ";
// Push Notification Settings
"notificationsEnabled" = "Benachrichtigungen aktiviert";
"notificationsExplainer" = "Das Aktivieren von Push-Benachrichtigungen gibt Omnivore die Erlaubnis, Benachrichtigungen zu senden,\ndu entscheidest jedoch, welche Benachrichtigungen gesendet werden.";
"notificationsTriggerExplainer" = "Push-Benachrichtigungen werden durch deine \n[Kontoregeln](https://omnivore.app/settings/rules) ausgelöst, die du online bearbeiten kannst.";
"notificationsEnable" = "Push-Benachrichtigungen aktivieren?";
"notificationsGeneralExplainer" = "Erhalte Benachrichtigungen, wenn Newsletter-Links in deinem Posteingang eintreffen. Oder erhalte Erinnerungen, die du über unsere Erweiterung festgelegt hast.";
"notificationsOptionDeny" = "Nein, danke";
"notificationsOptionEnable" = "Ja, bitte";
// Community Modal
"communityHeadline" = "Hilf mit, die Omnivore-Community aufzubauen";
"communityAppstoreReview" = "Bewerte uns im AppStore";
"communityTweet" = "Tweete über Omnivore";
"communityFollowTwitter" = "Folge uns auf Twitter";
"communityJoinDiscord" = "Tritt unserem Discord bei";
"communityStarGithub" = "Gib uns ein Stern auf GitHub";
// Clubs View
"clubsLearnTitle" = "Erfahre mehr über Clubs";
"clubsName" = "Club Name";
"clubsCreate" = "Erstelle einen neuen Club";
"clubsYours" = "Deine Clubs";
"clubsNotAMemberMessage" = "Du bist kein Mitglied eines Clubs.\nErstelle einen neuen Club und sende den Einladungslink an deine Freunde, um loszulegen.\n\nWährend der Beta bist du darauf beschränkt, drei Clubs zu erstellen, und jeder Club\nkann maximal zwölf Benutzer haben.";
"clubsErrorCopying" = "Fehler beim Kopieren des Einladungs-Links";
"clubsAdminDenyViewing" = "Der Admin dieses Clubs erlaubt es nicht, alle Mitglieder einzusehen.";
"clubsNoMembers" = "Dieser Club hat keine Mitglieder. Füge Benutzer zu deinem Club hinzu, indem du\nihnen den Einladungslink sendest.";
"clubsLeave" = "Club verlassen";
"clubsLeaveConfirm" = "Bist du sicher, dass du diesen Club verlassen möchtest? Es werden keine Daten gelöscht, aber du wirst keine Empfehlungen mehr von diesem Club erhalten.";
"clubsNoneJoined" = "Du bist keinem Club beigetreten, in dem du posten kannst.\nTritt einem Club bei oder erstelle deinen eigenen, um Artikel zu empfehlen.";
// Subscriptions
"subscriptionsErrorRetrieving" = "Entschuldigung, wir konnten deine Abonnements nicht abrufen.";
"subscriptionsNone" = "Du hast aktuell keine Abonnements.";
//"subscriptions.error.retrieving" = "Zuletzt erhalten: (updatedDate.formatted())"; // unused for now
// Text to Speech
"texttospeechLanguageDefault" = "Standardsprache";
"texttospeechSettingsAudio" = "Audio Einstellungen";
"texttospeechSettingsEnablePrefetch" = "Audio Vorladen aktivieren";
"texttospeechBetaSignupInProcess" = "Anmeldung zur Beta läuft";
"texttospeechBetaRealisticVoiceLimit" = "Du nimmst an der Beta für ultra-realistische Stimmen teil. Während der Beta kannst du 10.000 Wörter Audio pro Tag anhören.";
"texttospeechBetaRequestReceived" = "Deine Anfrage, an der Demo für ultra-realistische Stimmen teilzunehmen, wurde erhalten. Du wirst per E-Mail informiert, wenn ein Platz verfügbar ist.";
"texttospeechBetaWaitlist" = "Ultra-realistische Stimmen sind derzeit in einer begrenzten Beta und nur für Englisch verfügbar. Das Aktivieren der Funktion wird dich zur Beta-Warteliste hinzufügen.";
// Sign in/up
"registrationNoAccount" = "Du hast noch kein Konto?";
"registrationForgotPassword" = "Passwort vergessen?";
"registrationStatusCheck" = "Status überprüfen";
"registrationUseDifferentEmail" = "Eine andere E-Mail verwenden?";
"registrationFullName" = "Vollständiger Name";
"registrationUsername" = "Benutzername";
"registrationAlreadyHaveAccount" = "Du hast bereits ein Konto?";
"registrationBio" = "Biografie (optional)";
"registrationWelcome" = "Willkommen bei Omnivore!";
"registrationUsernameAssignedPrefix" = "Dein Benutzername lautet:";
"registrationChangeUsername" = "Benutzername ändern";
"registrationEdit" = "Bearbeiten";
"googleAuthButton" = "Mit Google fortfahren";
"registrationViewSignUpHeadline" = "Registrieren";
"loginErrorInvalidCreds" = "Die angegebenen Anmeldeinformationen sind ungültig.";
// Recommendation
"recommendationToPrefix" = "An:";
"recommendationAddNote" = "Eine Notiz hinzufügen (optional)";
//"recommendationToPrefix" = "Füge deine (viewModel.highlightCount) Markierung(viewModel.highlightCount > 1 ? "en" : """; // unused for now
"recommendationError" = "Fehler beim Empfehlen dieser Seite";
// Web Reader
"readerCopyLink" = "Link kopieren";
"readerSave" = "In Omnivore speichern";
"readerError" = "Ein Fehler ist aufgetreten";
// Debug Menu
"menuDebugTitle" = "Debuggin Menü";
"menuDebugApiEnv" = "API Umgebung:";
// Navigation
"navigationSelectLink" = "Wähle einen Link aus deiner Bibliothek";
"navigationSelectSidebarToggle" = "Seitenleiste umschalten";
// Welcome View
"welcomeTitle" = "Read-it-later für anspruchsvolle Leser.";
"welcomeLearnMore" = "Mehr erfahren";
"welcomeSignupAgreement" = "Mit deiner Anmeldung stimmst du den\n";
"welcomeTitleTermsOfService" = "Nutzungsbedingungen";
"welcomeTitleAndJoiner" = " und ";
"welcomeTitleEmailContinue" = "Mit E-Mail fortfahren";
// Keyboard Commands
"keyboardCommandDecreaseFont" = "Schriftgröße verkleinern";
"keyboardCommandIncreaseFont" = "Schriftgröße vergrößern";
"keyboardCommandDecreaseMargin" = "Rand verkleinern";
"keyboardCommandIncreaseMargin" = "Rand vergrößern";
"keyboardCommandDecreaseLineSpacing" = "Zeilenabstand verkleinern";
"keyboardCommandIncreaseLineSpacing" = "Zeilenabstand vergrößern";
// Library
//"library.by.author.suffix" = "von (author)" // unused
//"Recommended by (byStr) in (inStr)" // unused
// Generic
"genericSnooze" = "Schlummern";
"genericClose" = "Schließen";
"genericCreate" = "Erstellen";
"genericConfirm" = "Bestätigen";
"genericProfile" = "Profil";
"genericNext" = "Weiter";
"genericName" = "Name";
"genericOk" = "Ok";
"genericRetry" = "Erneut versuchen";
"genericEmail" = "E-Mail";
"genericPassword" = "Passwort";
"genericSubmit" = "Absenden";
"genericContinue" = "Fortfahren";
"genericSend" = "Senden";
"genericOptions" = "Optionen";
"genericOpen" = "Öffnen";
"genericChangeApply" = "Änderungen anwenden";
"genericTitle" = "Titel";
"genericAuthor" = "Autor";
"genericDescription" = "Beschreibung";
"genericSave" = "Speichern";
"genericLoading" = "Lädt...";
"genericFontFamily" = "Schriftart";
"genericHighContrastText" = "Text in hohem Kontrast";
"enableHighlightOnReleaseText" = "Automatisches Markieren aktivieren";
"enableJustifyText" = "Text ausrichten";
"genericFont" = "Schrift";
"genericHighlight" = "Hervorheben";
"labelsGeneric" = "Labels";
"emailsGeneric" = "E-Mails";
"subscriptionsGeneric" = "Abonnements";
"textToSpeechGeneric" = "Text in Sprache";
"privacyPolicyGeneric" = "Datenschutzrichtlinie";
"termsAndConditionsGeneric" = "Geschäftsbedingungen";
"feedbackGeneric" = "Feedback";
"manageAccountGeneric" = "Konto verwalten";
"logoutGeneric" = "Abmelden";
"doneGeneric" = "Fertig";
"cancelGeneric" = "Abbrechen";
"exportGeneric" = "Exportieren";
"inboxGeneric" = "Posteingang";
"readLaterGeneric" = "Später lesen";
"newslettersGeneric" = "Newsletter";
"allGeneric" = "Alle";
"archivedGeneric" = "Archiviert";
"highlightedGeneric" = "Markiert";
"filesGeneric" = "Dateien";
"newestGeneric" = "Neueste";
"oldestGeneric" = "Älteste";
"longestGeneric" = "Längste";
"shortestGeneric" = "Kürzeste";
"recentlyReadGeneric" = "Kürzlich gelesen";
"recentlyPublishedGeneric" = "Kürzlich veröffentlicht";
"clubsGeneric" = "Clubs";
"filterGeneric" = "Filter";
"errorGeneric" = "Etwas ist schiefgelaufen, bitte versuche es erneut.";
"pushNotificationsGeneric" = "Push-Benachrichtigungen";
"dismissButton" = "Verwerfen";
"errorNetwork" = "Wir haben Probleme, eine Verbindung zum Internet herzustellen.";
"documentationGeneric" = "Dokumentation";
// TODO: search navigationTitle, toggle, section, button, Label, title: ", CreateProfileViewModel, TextField, .keyboardShortcut
// Unit test Entry -- Do not remove this or add entries after this one.
// This allows us to check for syntax errors in this file with a unit test
"unitTestTrailingEntry" = "Nur zu Testzwecken.";

View file

@ -1,6 +1,7 @@
#if os(iOS)
import App
import AppIntents
import CoreData
import Firebase
import FirebaseMessaging
import Foundation
@ -9,6 +10,72 @@
import UIKit
import Utils
@available(iOS 16.0, *)
func filterQuery(predicte: NSPredicate, sort: NSSortDescriptor, limit: Int = 10) async throws -> [LibraryItemEntity] {
let context = await Services().dataService.viewContext
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.fetchLimit = limit
fetchRequest.predicate = predicte
fetchRequest.sortDescriptors = [sort]
return try context.performAndWait {
do {
return try context.fetch(fetchRequest).map { LibraryItemEntity(item: $0) }
} catch {
throw error
}
}
}
@available(iOS 16.0, *)
struct LibraryItemEntity: AppEntity {
static var defaultQuery = LibraryItemQuery()
let id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Orignal URL")
var originalURL: String?
@Property(title: "Omnivore web URL")
var omnivoreWebURL: String
@Property(title: "Omnivore deeplink URL")
var omnivoreShortcutURL: String
init(item: Models.LibraryItem) {
self.id = UUID(uuidString: item.unwrappedID)!
self.title = item.unwrappedTitle
self.originalURL = item.pageURLString
self.omnivoreWebURL = "https://omnivore.app/me/\(item.slug!)"
self.omnivoreShortcutURL = "omnivore://read/\(item.unwrappedID)"
}
static var typeDisplayRepresentation = TypeDisplayRepresentation(
stringLiteral: "Library Item"
)
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
}
}
@available(iOS 16.0, *)
struct LibraryItemQuery: EntityQuery {
func entities(for itemIds: [UUID]) async throws -> [LibraryItemEntity] {
let predicate = NSPredicate(format: "id IN %@", itemIds)
let sort = FeaturedItemFilter.continueReading.sortDescriptor // sort by read recency
return try await filterQuery(predicte: predicate, sort: sort)
}
func suggestedEntities() async throws -> [LibraryItemEntity] {
try await filterQuery(
predicte: FeaturedItemFilter.continueReading.predicate,
sort: FeaturedItemFilter.continueReading.sortDescriptor,
limit: 10
)
}
}
@available(iOS 16.0, *)
public struct OmnivoreAppShorcuts: AppShortcutsProvider {
@AppShortcutsBuilder public static var appShortcuts: [AppShortcut] {
@ -16,15 +83,6 @@
}
}
//
// @available(iOS 16.0, *)
// struct ExportAllTransactionsIntent: AppIntent {
// static var title: LocalizedStringResource = "Export all transactions"
//
// static var description =
// IntentDescription("Exports your transaction history as CSV data.")
// }
@available(iOS 16.0, *)
struct SaveToOmnivoreIntent: AppIntent {
static var title: LocalizedStringResource = "Save to Omnivore"
@ -71,4 +129,77 @@
}
}
@available(iOS 16.4, *)
struct GetMostRecentLibraryItem: AppIntent {
static let title: LocalizedStringResource = "Get most recently read library item"
func perform() async throws -> some IntentResult & ReturnsValue<LibraryItemEntity?> {
let result = try await filterQuery(
predicte: LinkedItemFilter.all.predicate,
sort: FeaturedItemFilter.continueReading.sortDescriptor,
limit: 10
)
if let result = result.first {
return .result(value: result)
}
return .result(value: nil)
}
}
@available(iOS 16.4, *)
struct GetContinueReadingLibraryItems: AppIntent {
static let title: LocalizedStringResource = "Get your continue reading library items"
func perform() async throws -> some IntentResult & ReturnsValue<[LibraryItemEntity]> {
let result = try await filterQuery(
predicte: FeaturedItemFilter.continueReading.predicate,
sort: FeaturedItemFilter.continueReading.sortDescriptor,
limit: 10
)
return .result(value: result)
}
}
@available(iOS 16.4, *)
struct GetFollowingLibraryItems: AppIntent {
static let title: LocalizedStringResource = "Get your following library items"
func perform() async throws -> some IntentResult & ReturnsValue<[LibraryItemEntity]> {
let savedAtSort = NSSortDescriptor(key: #keyPath(Models.LibraryItem.savedAt), ascending: false)
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), "following"
)
let result = try await filterQuery(
predicte: folderPredicate,
sort: savedAtSort,
limit: 10
)
return .result(value: result)
}
}
@available(iOS 16.4, *)
struct GetSavedLibraryItems: AppIntent {
static let title: LocalizedStringResource = "Get your saved library items"
func perform() async throws -> some IntentResult & ReturnsValue<[LibraryItemEntity]> {
let savedAtSort = NSSortDescriptor(key: #keyPath(Models.LibraryItem.savedAt), ascending: false)
let folderPredicate = NSPredicate(
format: "%K == %@", #keyPath(Models.LibraryItem.folder), "inbox"
)
let result = try await filterQuery(
predicte: folderPredicate,
sort: savedAtSort,
limit: 10
)
return .result(value: result)
}
}
#endif

View file

@ -123,7 +123,6 @@ export const mergeHighlightResolver = authorized<
...newHighlightInput,
annotation:
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
labels: mergedLabels,
color,
user: { id: uid },
libraryItem: { id: input.articleId },
@ -134,6 +133,7 @@ export const mergeHighlightResolver = authorized<
const newHighlight = await mergeHighlights(
overlapHighlightIdList,
highlight,
mergedLabels,
input.articleId,
uid,
pubsub

View file

@ -1,7 +1,9 @@
import { diff_match_patch } from 'diff-match-patch'
import { DeepPartial } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { EntityLabel } from '../entity/entity_label'
import { Highlight } from '../entity/highlight'
import { Label } from '../entity/label'
import { homePageURL } from '../env'
import { createPubSubClient, EntityType } from '../pubsub'
import { authTrx } from '../repository'
@ -65,6 +67,7 @@ export const createHighlight = async (
export const mergeHighlights = async (
highlightsToRemove: string[],
highlightToAdd: DeepPartial<Highlight>,
labels: Label[],
libraryItemId: string,
userId: string,
pubsub = createPubSubClient()
@ -75,6 +78,17 @@ export const mergeHighlights = async (
await highlightRepo.delete(highlightsToRemove)
const newHighlight = await highlightRepo.createAndSave(highlightToAdd)
if (labels.length > 0) {
// save new labels
await tx.getRepository(EntityLabel).save(
labels.map((l) => ({
labelId: l.id,
highlightId: newHighlight.id,
}))
)
}
return highlightRepo.findOneOrFail({
where: { id: newHighlight.id },
relations: {

View file

@ -93,6 +93,7 @@ export enum SortOrder {
export interface Sort {
by: string
order?: SortOrder
nulls?: 'NULLS FIRST' | 'NULLS LAST'
}
interface Select {
@ -332,8 +333,10 @@ export const buildQuery = (
const order =
sortOrder === 'asc' ? SortOrder.ASCENDING : SortOrder.DESCENDING
const nulls =
order === SortOrder.ASCENDING ? 'NULLS FIRST' : 'NULLS LAST'
orders.push({ by: `library_item.${column}`, order })
orders.push({ by: `library_item.${column}`, order, nulls })
return null
}
case 'has':
@ -613,12 +616,13 @@ export const searchLibraryItems = async (
orders.push({
by: 'library_item.saved_at',
order: SortOrder.DESCENDING,
nulls: 'NULLS LAST',
})
}
// add order by
orders.forEach((order) => {
queryBuilder.addOrderBy(order.by, order.order, 'NULLS LAST')
queryBuilder.addOrderBy(order.by, order.order, order.nulls)
})
const libraryItems = await queryBuilder.skip(from).take(size).getMany()

View file

@ -1722,6 +1722,7 @@ describe('Article API', () => {
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
savedAt: new Date(1703880588),
},
{
user,
@ -1729,6 +1730,7 @@ describe('Article API', () => {
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
savedAt: new Date(1704880589),
},
{
user,
@ -1736,6 +1738,7 @@ describe('Article API', () => {
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
savedAt: new Date(1705880590),
},
],
user.id
@ -1777,6 +1780,7 @@ describe('Article API', () => {
readableContent: '<p>test 1</p>',
slug: 'test slug 1',
originalUrl: `${url}/test1`,
savedAt: new Date(1703880588),
},
{
user,
@ -1784,6 +1788,7 @@ describe('Article API', () => {
readableContent: '<p>test 2</p>',
slug: 'test slug 2',
originalUrl: `${url}/test2`,
savedAt: new Date(1704880589),
},
{
user,
@ -1791,6 +1796,7 @@ describe('Article API', () => {
readableContent: '<p>test 3</p>',
slug: 'test slug 3',
originalUrl: `${url}/test3`,
savedAt: new Date(1705880590),
},
],
user.id

View file

@ -6,10 +6,12 @@ import { User } from '../../src/entity/user'
import {
createHighlight,
deleteHighlightById,
findHighlightById,
} from '../../src/services/highlights'
import { createLabel, saveLabelsInHighlight } from '../../src/services/labels'
import { deleteUser } from '../../src/services/user'
import { createTestLibraryItem, createTestUser } from '../db'
import { generateFakeUuid, graphqlRequest, request } from '../util'
import { generateFakeShortId, generateFakeUuid, graphqlRequest, request } from '../util'
chai.use(chaiString)
@ -227,14 +229,18 @@ describe('Highlights API', () => {
context('mergeHighlightMutation', () => {
let highlightId: string
before(async () => {
beforeEach(async () => {
// create test highlight
highlightId = generateFakeUuid()
const shortHighlightId = '_short_id_1'
const shortHighlightId = generateFakeShortId()
const query = createHighlightQuery(itemId, highlightId, shortHighlightId)
await graphqlRequest(query, authToken).expect(200)
})
afterEach(async () => {
await deleteHighlightById(highlightId)
})
it('should not fail', async () => {
const newHighlightId = generateFakeUuid()
const newShortHighlightId = '_short_id_2'
@ -257,6 +263,35 @@ describe('Highlights API', () => {
expect(
res.body.data.mergeHighlight.highlight.highlightPositionAnchorIndex
).to.eq(highlightPositionAnchorIndex)
highlightId = newHighlightId
})
it('keeps the labels of the merged highlight', async () => {
// create label
const labelName = 'test label'
const labelColor = '#ff0000'
const label = await createLabel(labelName, labelColor, user.id)
await saveLabelsInHighlight([label], highlightId, user.id)
const newHighlightId = generateFakeUuid()
const newShortHighlightId = generateFakeShortId()
const query = mergeHighlightQuery(
itemId,
newHighlightId,
newShortHighlightId,
[highlightId],
)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.mergeHighlight.highlight.id).to.eq(newHighlightId)
const highlight = await findHighlightById(newHighlightId, user.id)
expect(highlight.labels).to.have.lengthOf(1)
expect(highlight.labels?.[0]?.name).to.eq(labelName)
highlightId = newHighlightId
})
})

View file

@ -1,3 +1,4 @@
import { nanoid } from 'nanoid'
import supertest from 'supertest'
import { v4 } from 'uuid'
import { createApp } from '../src/server'
@ -18,7 +19,7 @@ export const stopApolloServer = async () => {
export const graphqlRequest = (
query: string,
authToken: string,
variables?: Record<string, unknown>,
variables?: Record<string, unknown>
): supertest.Test => {
return request
.post(apollo.graphqlPath)
@ -31,3 +32,7 @@ export const graphqlRequest = (
export const generateFakeUuid = () => {
return v4()
}
export const generateFakeShortId = () => {
return nanoid(8)
}

View file

@ -14,7 +14,6 @@ const mutation = async (name, input) => {
actionID: name,
...input,
})
console.log('action result', name, result, result.result)
return result.result
} else {
// Send android a message
@ -33,6 +32,7 @@ const mutation = async (name, input) => {
case 'mergeHighlight':
return {
id: input['id'],
type: input['type'],
shortID: input['shortId'],
quote: input['quote'],
patch: input['patch'],
@ -83,8 +83,8 @@ const App = () => {
articleMutations={{
createHighlightMutation: (input) =>
mutation('createHighlight', input),
deleteHighlightMutation: (highlightId) =>
mutation('deleteHighlight', { highlightId }),
deleteHighlightMutation: (libraryItemId, highlightId) =>
mutation('deleteHighlight', { libraryItemId, highlightId }),
mergeHighlightMutation: (input) =>
mutation('mergeHighlight', input),
updateHighlightMutation: (input) =>

View file

@ -0,0 +1,6 @@
-- Type: DO
-- Name: library_item_user_id_saved_at_idx
-- Description: Add library_item_user_id_saved_at_idx index on library_item table for user_id and saved_at
-- create index for sorting concurrently to avoid locking
CREATE INDEX CONCURRENTLY IF NOT EXISTS library_item_user_id_saved_at_idx ON omnivore.library_item (user_id, saved_at DESC NULLS LAST);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: library_item_user_id_saved_at_idx
-- Description: Add library_item_user_id_saved_at_idx index on library_item table for user_id and saved_at
BEGIN;
DROP INDEX IF EXISTS omnivore.library_item_user_id_saved_at_idx;
COMMIT;

View file

@ -0,0 +1,6 @@
-- Type: DO
-- Name: library_item_user_id_updated_at_idx
-- Description: Add library_item_user_id_saved_at_idx index on library_item table for user_id and updated_at
-- create index for sorting concurrently to avoid locking
CREATE INDEX CONCURRENTLY IF NOT EXISTS library_item_user_id_updated_at_idx ON omnivore.library_item (user_id, updated_at DESC NULLS LAST);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: library_item_user_id_updated_at_idx
-- Description: Add library_item_user_id_saved_at_idx index on library_item table for user_id and updated_at
BEGIN;
DROP INDEX IF EXISTS library_item_user_id_updated_at_idx;
COMMIT;

View file

@ -0,0 +1,6 @@
-- Type: DO
-- Name: library_item_user_id_published_at_idx
-- Description: Add library_item_user_id_published_at_idx index on library_item table for user_id and published_at
-- create index for sorting concurrently to avoid locking
CREATE INDEX CONCURRENTLY IF NOT EXISTS library_item_user_id_published_at_idx ON omnivore.library_item (user_id, published_at DESC NULLS LAST);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: library_item_user_id_published_at_idx
-- Description: Add library_item_user_id_published_at_idx index on library_item table for user_id and published_at
BEGIN;
DROP INDEX IF EXISTS library_item_user_id_published_at_idx;
COMMIT;

View file

@ -0,0 +1,6 @@
-- Type: DO
-- Name: library_item_user_id_read_at_idx
-- Description: Add library_item_user_id_read_at_idx index on library_item table for user_id and read_at
-- create index for sorting concurrently to avoid locking
CREATE INDEX CONCURRENTLY IF NOT EXISTS library_item_user_id_read_at_idx ON omnivore.library_item (user_id, read_at DESC NULLS LAST);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: library_item_user_id_read_at_idx
-- Description: Add library_item_user_id_read_at_idx index on library_item table for user_id and read_at
BEGIN;
DROP INDEX IF EXISTS library_item_user_id_read_at_idx;
COMMIT;

View file

@ -0,0 +1,6 @@
-- Type: DO
-- Name: library_item_user_id_word_count_idx
-- Description: Add library_item_user_id_word_count_idx index on library_item table for user_id and word_count
-- create index for sorting concurrently to avoid locking
CREATE INDEX CONCURRENTLY IF NOT EXISTS library_item_user_id_word_count_idx ON omnivore.library_item (user_id, word_count DESC NULLS LAST);

View file

@ -0,0 +1,9 @@
-- Type: UNDO
-- Name: library_item_user_id_word_count_idx
-- Description: Add library_item_user_id_word_count_idx index on library_item table for user_id and word_count
BEGIN;
DROP INDEX IF EXISTS library_item_user_id_word_count_idx;
COMMIT;

View file

@ -0,0 +1,34 @@
-- Type: DO
-- Name: create_label_names_update_trigger
-- Description: Create label_names_update trigger in library_item table
BEGIN;
CREATE OR REPLACE FUNCTION update_label_names()
RETURNS TRIGGER AS $$
BEGIN
UPDATE omnivore.library_item
SET label_names = array_replace(label_names, OLD.name, NEW.name)
WHERE user_id = OLD.user_id AND OLD.name = ANY(label_names);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- triggers when label name is updated
CREATE TRIGGER label_names_update
AFTER UPDATE ON omnivore.labels
FOR EACH ROW
WHEN (OLD.name <> NEW.name)
EXECUTE FUNCTION update_label_names();
-- remove old trigger which is too slow
DROP TRIGGER IF EXISTS entity_labels_update ON omnivore.labels;
DROP FUNCTION IF EXISTS omnivore.update_entity_labels();
DROP INDEX IF EXISTS omnivore.library_item_saved_at_idx;
DROP INDEX IF EXISTS omnivore.library_item_updated_at_idx;
DROP INDEX IF EXISTS omnivore.library_item_read_at_idx;;
COMMIT;

View file

@ -0,0 +1,34 @@
-- Type: UNDO
-- Name: create_label_names_update_trigger
-- Description: Create label_names_update trigger in library_item table
BEGIN;
CREATE INDEX IF NOT EXISTS library_item_saved_at_idx ON omnivore.library_item (saved_at);
CREATE INDEX IF NOT EXISTS library_item_updated_at_idx ON omnivore.library_item (updated_at);
CREATE INDEX IF NOT EXISTS library_item_read_at_idx ON omnivore.library_item (read_at);
CREATE OR REPLACE FUNCTION update_entity_labels()
RETURNS trigger AS $$
BEGIN
-- update entity_labels table to trigger update on library_item table
UPDATE omnivore.entity_labels
SET label_id = NEW.id
WHERE label_id = OLD.id;
return NEW;
END;
$$ LANGUAGE plpgsql;
-- triggers when label name is updated
CREATE TRIGGER entity_labels_update
AFTER UPDATE ON omnivore.labels
FOR EACH ROW
WHEN (OLD.name <> NEW.name)
EXECUTE FUNCTION update_entity_labels();
DROP TRIGGER IF EXISTS label_names_update ON omnivore.labels;
DROP FUNCTION IF EXISTS omnivore.update_label_names();
COMMIT;

View file

@ -127,15 +127,16 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
hasMore = response.data.search.pageInfo.hasNextPage
after = response.data.search.pageInfo.endCursor
const items = response.data.search.edges.map((edge) => edge.node)
if (items.length === 0) {
break
}
const size = items.length
console.log('exporting items...', {
userId: claims.uid,
total: items.length,
size,
hasMore,
})
if (size === 0) {
break
}
const synced = await client.export(claims.token, items)
if (!synced) {
console.error('failed to export item', {
@ -144,16 +145,17 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
return res.status(400).send('Failed to sync')
}
const lastItemUpdatedAt = items[size - 1].updatedAt
console.log('updating integration...', {
userId: claims.uid,
integrationId,
syncedAt: items[items.length - 1].updatedAt,
syncedAt: lastItemUpdatedAt,
})
// update integration syncedAt if successful
const updated = await updateIntegration(
REST_BACKEND_ENDPOINT,
integrationId,
items[items.length - 1].updatedAt,
lastItemUpdatedAt,
integrationName,
claims.token,
systemToken,

View file

@ -47,7 +47,8 @@ export const search = async (
first = 50,
after = '0'
): Promise<SearchResponse | null> => {
const query = `updated:${updatedSince.toISOString()} ${
// get all the items updated since the last sync including archived items
const query = `in:all updated:${updatedSince.toISOString()} ${
highlightOnly ? 'has:highlights' : ''
} sort:updated-asc`

View file

@ -78,12 +78,15 @@ const isFeedBlocked = async (feedUrl: string, redisClient: RedisClient) => {
return false
}
const blockFeed = async (feedUrl: string, redisClient: RedisClient) => {
const incrementFeedFailure = async (
feedUrl: string,
redisClient: RedisClient
) => {
const key = feedFetchFailedRedisKey(feedUrl)
try {
const result = await redisClient.incr(key)
// expire the key in 1 day
await redisClient.expire(key, 24 * 60 * 60, 'NX')
await redisClient.expire(key, 24 * 60 * 60)
return result
} catch (error) {
@ -142,8 +145,8 @@ export const fetchAndChecksum = async (url: string) => {
return { url, content: dataStr, checksum: hash.digest('hex') }
} catch (error) {
console.log(error)
throw new Error(`Failed to fetch or hash content from ${url}.`)
console.log(`Failed to fetch or hash content from ${url}.`, error)
return null
}
}
@ -182,7 +185,9 @@ const parseFeed = async (url: string, content: string) => {
}
}
return parser.parseString(content)
// return await is needed to catch errors thrown by the parser
// otherwise the error will be caught by the outer try catch
return await parser.parseString(content)
} catch (error) {
console.log(error)
return null
@ -600,18 +605,17 @@ export const rssHandler = Sentry.GCPFunction.wrapHttpFunction(
return res.sendStatus(403)
}
// create redis client
const redisClient = await createRedisClient(
process.env.REDIS_URL,
process.env.REDIS_CERT
)
try {
if (!isRssFeedRequest(req.body)) {
console.error('Invalid request body', req.body)
return res.status(400).send('INVALID_REQUEST_BODY')
}
// create redis client
const redisClient = await createRedisClient(
process.env.REDIS_URL,
process.env.REDIS_CERT
)
const {
feedUrl,
subscriptionIds,
@ -631,10 +635,16 @@ export const rssHandler = Sentry.GCPFunction.wrapHttpFunction(
}
const fetchResult = await fetchAndChecksum(feedUrl)
if (!fetchResult) {
console.error('Failed to fetch RSS feed', feedUrl)
await incrementFeedFailure(feedUrl, redisClient)
return res.status(500).send('FAILED_TO_FETCH_RSS_FEED')
}
const feed = await parseFeed(feedUrl, fetchResult.content)
if (!feed) {
console.error('Failed to parse RSS feed', feedUrl)
await blockFeed(feedUrl, redisClient)
await incrementFeedFailure(feedUrl, redisClient)
return res.status(500).send('INVALID_RSS_FEED')
}
@ -667,6 +677,9 @@ export const rssHandler = Sentry.GCPFunction.wrapHttpFunction(
} catch (e) {
console.error('Error while saving RSS feeds', e)
res.status(500).send('INTERNAL_SERVER_ERROR')
} finally {
await redisClient.quit()
console.log('Redis client disconnected')
}
}
)

View file

@ -7,7 +7,7 @@ describe('fetchAndChecksum', () => {
it('should hash the content available', async () => {
nock('https://fake.com', {}).get('/rss.xml').reply(200, 'i am some content')
const result = await fetchAndChecksum('https://fake.com/rss.xml')
expect(result.checksum).to.eq(
expect(result?.checksum).to.eq(
'd6bc10faec048d999d0cf4b2f7103d84557fb9cd94c3bccd17884b1288949375'
)
})

View file

@ -82,6 +82,7 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element {
;(async () => {
const success = await updateHighlightMutation({
annotation: text,
libraryItemId: props.targetId,
highlightId: props.highlight?.id,
})
if (success) {

View file

@ -52,6 +52,7 @@ export function HighlightViewNote(props: HighlightViewNoteProps): JSX.Element {
;(async () => {
const success = await updateHighlightMutation({
annotation: text,
libraryItemId: props.targetId,
highlightId: props.highlight?.id,
})
if (success) {

View file

@ -310,6 +310,7 @@ export default function EpubContainer(props: EpubContainerProps): JSX.Element {
{noteTarget && (
<HighlightNoteModal
highlight={noteTarget}
libraryItemId={props.article.id}
author={props.article.author ?? ''}
title={props.article.title}
onUpdate={(highlight: Highlight) => {

View file

@ -16,6 +16,7 @@ type HighlightNoteModalProps = {
author: string
title: string
highlight?: Highlight
libraryItemId: string
onUpdate: (updatedHighlight: Highlight) => void
onOpenChange: (open: boolean) => void
createHighlightForNote?: (note?: string) => Promise<Highlight | undefined>
@ -38,6 +39,7 @@ export function HighlightNoteModal(
const saveNoteChanges = useCallback(async () => {
if (noteContent != props.highlight?.annotation && props.highlight?.id) {
const result = await updateHighlightMutation({
libraryItemId: props.libraryItemId,
highlightId: props.highlight?.id,
annotation: noteContent,
color: props.highlight?.color,

View file

@ -187,7 +187,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
}
const didDeleteHighlight =
await props.articleMutations.deleteHighlightMutation(highlightId)
await props.articleMutations.deleteHighlightMutation(
props.articleId,
highlightId
)
if (didDeleteHighlight) {
removeHighlights(
@ -222,6 +225,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
updateHighlightsCallback(highlight)
;(async () => {
const update = await props.articleMutations.updateHighlightMutation({
libraryItemId: props.articleId,
highlightId: highlight.id,
color: color,
})
@ -705,6 +709,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
const annotation = event.annotation ?? ''
const result = await props.articleMutations.updateHighlightMutation({
libraryItemId: props.articleId,
highlightId: focusedHighlight.id,
annotation: event.annotation ?? '',
})
@ -788,6 +793,7 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
highlight={highlightModalAction.highlight}
author={props.articleAuthor}
title={props.articleTitle}
libraryItemId={props.articleId}
onUpdate={updateHighlightsCallback}
onOpenChange={() =>
setHighlightModalAction({ highlightModalAction: 'none' })

View file

@ -76,6 +76,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
(note: Highlight, text: string, startTime: Date) => {
;(async () => {
const result = await updateHighlightMutation({
libraryItemId: props.item.id,
highlightId: note.id,
annotation: text,
})
@ -195,7 +196,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
highlights
?.filter((h) => h.type === 'NOTE')
.forEach(async (h) => {
const result = await deleteHighlightMutation(h.id)
const result = await deleteHighlightMutation(props.item.id, h.id)
if (!result) {
showErrorToast('Error deleting note')
}
@ -325,6 +326,7 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
;(async () => {
const highlightId = showConfirmDeleteHighlightId
const success = await deleteHighlightMutation(
props.item.id,
showConfirmDeleteHighlightId
)
mutate()

View file

@ -113,7 +113,7 @@ export default function PdfArticleContainer(
.delete(annotation)
.then(() => {
if (annotationId) {
return deleteHighlightMutation(annotationId)
return deleteHighlightMutation(props.article.id, annotationId)
}
})
.then(() => {
@ -229,7 +229,7 @@ export default function PdfArticleContainer(
}
const annotationId = annotationOmnivoreId(annotation)
if (annotationId) {
await deleteHighlightMutation(annotationId)
await deleteHighlightMutation(props.article.id, annotationId)
}
})
@ -512,7 +512,7 @@ export default function PdfArticleContainer(
const storedId = annotationOmnivoreId(annotation)
if (storedId == annotationId) {
await instance.delete(annotation)
await deleteHighlightMutation(annotationId)
await deleteHighlightMutation(props.article.id, annotationId)
const highlightIdx = highlightsRef.current.findIndex((value) => {
return value.id == annotationId
@ -576,6 +576,7 @@ export default function PdfArticleContainer(
{noteTarget && (
<HighlightNoteModal
highlight={noteTarget}
libraryItemId={props.article.id}
author={props.article.author ?? ''}
title={props.article.title}
onUpdate={(highlight: Highlight) => {

View file

@ -8,7 +8,10 @@ export type ArticleMutations = {
createHighlightMutation: (
input: CreateHighlightInput
) => Promise<Highlight | undefined>
deleteHighlightMutation: (highlightId: string) => Promise<boolean>
deleteHighlightMutation: (
libraryItemId: string,
highlightId: string
) => Promise<boolean>
mergeHighlightMutation: (
input: MergeHighlightInput
) => Promise<Highlight | undefined>

View file

@ -8,7 +8,10 @@ import {
} from './highlightGenerator'
import type { HighlightLocation } from './highlightGenerator'
import { extendRangeToWordBoundaries } from './normalizeHighlightRange'
import type { Highlight } from '../networking/fragments/highlightFragment'
import type {
Highlight,
HighlightType,
} from '../networking/fragments/highlightFragment'
import { removeHighlights } from './deleteHighlight'
import { ArticleMutations } from '../articleActions'
import { NodeHtmlMarkdown } from 'node-html-markdown'
@ -103,6 +106,7 @@ export async function createHighlight(
id,
shortId: nanoid(8),
patch,
type: 'HIGHLIGHT' as HighlightType,
color: input.color,
prefix: highlightAttributes.prefix,

View file

@ -2,6 +2,7 @@ import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export async function deleteHighlightMutation(
libraryItemId: string,
highlightId: string
): Promise<boolean> {
const mutation = gql`

View file

@ -3,6 +3,7 @@ import { gqlFetcher } from '../networkHelpers'
export type UpdateHighlightInput = {
highlightId: string
libraryItemId?: string
annotation?: string
sharedAt?: string
color?: string

View file

@ -2830,9 +2830,9 @@ fn.name@1.x.x:
integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
follow-redirects@^1.14.0:
version "1.14.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
formidable@^1.0.17:
version "1.2.2"