Merge pull request #3415 from omnivore-app/main

Web production deployment
This commit is contained in:
Jackson Harper 2024-01-23 18:40:30 +08:00 committed by GitHub
commit 3b3663fabc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
264 changed files with 9690 additions and 4610 deletions

View file

@ -55,8 +55,7 @@ Omnivore takes advantage of some great open source software:
## Importing Libraries
If you have a library you'd like to import, [@davidohlin](https://github.com/davidohlin) has created
a tool that imports a list of CSV URLs: [omnivore-import](https://github.com/davidohlin/instapaper-to-omnivore-import)
Check out our [docs](https://docs.omnivore.app/using/importing.html) for information on importing your data from other apps.
## How to setup local development :computer:

View file

@ -8,7 +8,9 @@ plugins {
def keystorePropertiesFile = rootProject.file("app/external/keystore.properties");
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
compileSdk 33
@ -17,8 +19,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 158
versionName "0.0.158"
versionCode 188
versionName "0.0.188"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -26,24 +28,27 @@ android {
}
}
signingConfigs{
release{
signingConfigs {
release {
keyAlias 'key0'
storeFile file('external/omnivore-prod.keystore')
storePassword keystoreProperties['prodStorePassword']
keyPassword keystoreProperties['prodKeyPassword']
}
debug{
keyAlias 'androiddebugkey'
storeFile file('external/omnivore-demo.keystore')
storePassword keystoreProperties['demoStorePassword']
keyPassword keystoreProperties['demoKeyPassword']
debug {
if (keystoreProperties['demoStorePassword'] && keystoreProperties['demoKeyPassword']) {
keyAlias 'androiddebugkey'
storeFile file('external/omnivore-demo.keystore')
storePassword keystoreProperties['demoStorePassword']
keyPassword keystoreProperties['demoKeyPassword']
}
}
}
buildTypes {
debug{
debug {
signingConfig signingConfigs.debug
applicationIdSuffix ".debug"
buildConfigField("String", "OMNIVORE_API_URL", "\"https://api-demo.omnivore.app\"")
buildConfigField("String", "OMNIVORE_WEB_URL", "\"https://demo.omnivore.app\"")
buildConfigField("String", "OMNIVORE_GAUTH_SERVER_CLIENT_ID", "\"267918240109-eu2ar09unac3lqqigluknhk7t0021b54.apps.googleusercontent.com\"")

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Dev - Omnivore</string>
</resources>

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

@ -24,12 +24,15 @@ class EventTracker @Inject constructor(val app: Context) {
PostHog.setSingletonInstance(posthog)
}
fun registerUser(userID: String, intercomHash: String?) {
fun registerUser(userID: String, intercomHash: String?, isDebug: Boolean) {
posthog.identify(userID)
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
if (!isDebug) {
Intercom.client().loginIdentifiedUser(Registration.create().withUserId(userID))
intercomHash?.let { intercomHash ->
Intercom.client().setUserHash(intercomHash)
}
}
}
fun track(eventName: String, properties: Properties = Properties()) {

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

@ -130,7 +130,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch {
val viewer = networker.viewer()
viewer?.let {
eventTracker.registerUser(viewer.userID, viewer.intercomHash)
eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG)
}
}
}

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

@ -0,0 +1,8 @@
package app.omnivore.omnivore.ui.library
enum class LibraryBottomSheetState {
HIDDEN,
ADD_LINK,
LABEL,
EDIT
}

View file

@ -63,7 +63,7 @@ fun LibraryFilterBar(viewModel: LibraryViewModel) {
modifier = Modifier.padding(end = 6.dp)
)
AssistChip(
onClick = { viewModel.showLabelsSelectionSheetLiveData.value = true },
onClick = { viewModel.bottomSheetState.value = LibraryBottomSheetState.LABEL },
label = { Text(stringResource(R.string.library_filter_bar_label_labels)) },
trailingIcon = {
Icon(

View file

@ -3,37 +3,52 @@ package app.omnivore.omnivore.ui.library
import android.content.Intent
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.FloatTweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.DismissDirection
import androidx.compose.material.DismissState
import androidx.compose.material.FractionalThreshold
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.SwipeToDismiss
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material3.*
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
import androidx.compose.runtime.*
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -47,370 +62,415 @@ import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.ui.save.SaveState
import app.omnivore.omnivore.ui.save.SaveViewModel
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LibraryView(
libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel,
navController: NavHostController
libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel,
navController: NavHostController
) {
val scaffoldState: ScaffoldState = rememberScaffoldState()
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
val showEditInfoSheet: Boolean by libraryViewModel.showEditInfoSheetLiveData.observeAsState(false)
val scaffoldState: ScaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()
val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden,
confirmValueChange = { it != ModalBottomSheetValue.Hidden }
)
val coroutineScope = rememberCoroutineScope()
if (showLabelsSelectionSheet || showAddLinkSheet || showEditInfoSheet) {
coroutineScope.launch {
modalBottomSheetState.show()
}
} else {
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
libraryViewModel.snackbarMessage?.let {
coroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(it)
libraryViewModel.clearSnackbarMessage()
}
}
ModalBottomSheetLayout(
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
BottomSheetContent(libraryViewModel, labelsViewModel, saveViewModel,editInfoViewModel)
Spacer(modifier = Modifier.weight(1.0F))
}
) {
Scaffold(
scaffoldState = scaffoldState,
topBar = {
LibraryNavigationBar(
savedItemViewModel = libraryViewModel,
onSearchClicked = { navController.navigate(Routes.Search.route) },
onAddLinkClicked = { libraryViewModel.showAddLinkSheetLiveData.value = true },
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
)
},
) { paddingValues ->
LibraryViewContent(
libraryViewModel,
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
)
}
}
}
@Composable
fun BottomSheetContent(libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel
) {
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
val showEditInfoSheet: Boolean by libraryViewModel.showEditInfoSheetLiveData.observeAsState(false)
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(listOf())
if (showLabelsSelectionSheet) {
BottomSheetUI {
if (currentSavedItemData != null) {
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = currentSavedItemData.labels,
onCancel = {
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
libraryViewModel.currentItemLiveData.value = null
},
isLibraryMode = false,
onSave = {
if (it != labels) {
libraryViewModel.updateSavedItemLabels(
savedItemID = currentSavedItemData.savedItem.savedItemId,
labels = it
)
}
libraryViewModel.currentItemLiveData.value = null
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
} else { // Is used in library mode
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(),
onCancel = { libraryViewModel.showLabelsSelectionSheetLiveData.value = false },
isLibraryMode = true,
onSave = {
libraryViewModel.updateAppliedLabels(it)
libraryViewModel.currentItemLiveData.value = null
libraryViewModel.showLabelsSelectionSheetLiveData.value = false
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
}
}
} else if (showAddLinkSheet) {
BottomSheetUI {
AddLinkSheetContent(
viewModel = saveViewModel,
onCancel = {
libraryViewModel.showAddLinkSheetLiveData.value = false
saveViewModel.state.value = SaveState.DEFAULT
},
onLinkAdded = {
libraryViewModel.showAddLinkSheetLiveData.value = false
saveViewModel.state.value = SaveState.DEFAULT
}
)
}
} else if (showEditInfoSheet) {
BottomSheetUI {
EditInfoSheetContent(
savedItemId = currentSavedItemData?.savedItem?.savedItemId,
title = currentSavedItemData?.savedItem?.title,
author = currentSavedItemData?.savedItem?.author,
description = currentSavedItemData?.savedItem?.descriptionText,
viewModel = editInfoViewModel,
onCancel = {
libraryViewModel.showEditInfoSheetLiveData.value = false
libraryViewModel.currentItemLiveData.value = null
},
onUpdated = {
libraryViewModel.showEditInfoSheetLiveData.value = false
libraryViewModel.currentItemLiveData.value = null
libraryViewModel.refresh()
}
)
}
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
val pullRefreshState = rememberPullRefreshState(
refreshing = libraryViewModel.isRefreshing,
onRefresh = { libraryViewModel.refresh() }
)
val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState()
val cardsData: List<SavedItemWithLabelsAndHighlights> by libraryViewModel.itemsLiveData.observeAsState(
listOf()
)
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(horizontal = 6.dp)
) {
item {
LibraryFilterBar(libraryViewModel)
}
items(
items = cardsData,
key = { item -> item.savedItem.savedItemId }
) { cardDataWithLabels ->
val swipeThreshold = 0.40f
val currentThresholdFraction = remember { mutableStateOf(0f) }
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
val swipeState = rememberDismissState(
confirmStateChange = {
if (it == DismissValue.DismissedToEnd ||
currentThresholdFraction.value < swipeThreshold ||
currentThresholdFraction.value > 1.0f) {
false
}
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
if (currentItem.isArchived) {
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
} else {
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
}
} else if (it == DismissValue.DismissedToStart) { // Deleting.
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
}
true
}
)
SwipeToDismiss(
state = swipeState,
modifier = Modifier.padding(vertical = 4.dp),
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
dismissThresholds = { FractionalThreshold(swipeThreshold) },
background = {
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
val color by animateColorAsState(
when (swipeState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Green
DismissValue.DismissedToStart -> Color.Red
}, label = "backgroundColor"
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> if (currentItem.isArchived) Icons.Default.Unarchive else Icons.Default.Archive
DismissDirection.EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (swipeState.targetValue == DismissValue.Default) 0.75f else 1f,
label = "scaleAnimation"
)
Box(
Modifier.fillMaxSize().background(color).padding(horizontal = 20.dp),
contentAlignment = alignment
) {
currentThresholdFraction.value = swipeState.progress.fraction
Icon(
icon,
contentDescription = null,
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
val selected = currentItem.savedItemId == selectedItem?.savedItem?.savedItemId
SavedItemCard(
selected = selected,
savedItemViewModel = libraryViewModel,
savedItem = cardDataWithLabels,
onClickHandler = {
libraryViewModel.actionsMenuItemLiveData.postValue(null)
val activityClass =
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug)
context.startActivity(intent)
},
actionHandler = {
libraryViewModel.handleSavedItemAction(
currentItem.savedItemId,
it
)
}
)
},
)
when {
swipeState.isDismissed(DismissDirection.EndToStart) -> Reset(state = swipeState)
swipeState.isDismissed(DismissDirection.StartToEnd) -> Reset(state = swipeState)
}
}
}
InfiniteListHandler(listState = listState) {
if (cardsData.isEmpty()) {
Log.d("sync", "loading with load func")
libraryViewModel.initialLoad()
} else {
Log.d("sync", "loading with search api")
libraryViewModel.loadUsingSearchAPI()
}
}
PullRefreshIndicator(
refreshing = libraryViewModel.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
val showBottomSheet: LibraryBottomSheetState by libraryViewModel.bottomSheetState.observeAsState(
LibraryBottomSheetState.HIDDEN
)
// LabelsSelectionSheet(viewModel = libraryViewModel)
}
libraryViewModel.snackbarMessage?.let {
coroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(it)
libraryViewModel.clearSnackbarMessage()
}
}
when (showBottomSheet) {
LibraryBottomSheetState.ADD_LINK -> {
AddLinkBottomSheet(saveViewModel) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
}
LibraryBottomSheetState.LABEL -> {
LabelBottomSheet(
libraryViewModel,
labelsViewModel
) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
}
LibraryBottomSheetState.EDIT -> {
EditBottomSheet(
editInfoViewModel,
libraryViewModel
) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
}
LibraryBottomSheetState.HIDDEN -> {
}
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
LibraryNavigationBar(
savedItemViewModel = libraryViewModel,
onSearchClicked = { navController.navigate(Routes.Search.route) },
onAddLinkClicked = { showAddLinkBottomSheet(libraryViewModel) },
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
)
},
) { paddingValues ->
LibraryViewContent(
libraryViewModel,
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
)
}
}
fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun LabelBottomSheet(
libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(
listOf()
)
if (currentSavedItemData != null) {
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = currentSavedItemData.labels,
onCancel = {
libraryViewModel.currentItemLiveData.value = null
onDismiss()
},
isLibraryMode = false,
onSave = {
if (it != labels) {
libraryViewModel.updateSavedItemLabels(
savedItemID = currentSavedItemData.savedItem.savedItemId,
labels = it
)
}
libraryViewModel.currentItemLiveData.value = null
onDismiss()
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
} else { // Is used in library mode
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(),
onCancel = { onDismiss() },
isLibraryMode = true,
onSave = {
libraryViewModel.updateAppliedLabels(it)
libraryViewModel.currentItemLiveData.value = null
onDismiss()
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkBottomSheet(
saveViewModel: SaveViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
AddLinkSheetContent(
viewModel = saveViewModel,
onCancel = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
},
onLinkAdded = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditBottomSheet(
editInfoViewModel: EditInfoViewModel,
libraryViewModel: LibraryViewModel,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
containerColor = MaterialTheme.colorScheme.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
EditInfoSheetContent(
savedItemId = currentSavedItemData?.savedItem?.savedItemId,
title = currentSavedItemData?.savedItem?.title,
author = currentSavedItemData?.savedItem?.author,
description = currentSavedItemData?.savedItem?.descriptionText,
viewModel = editInfoViewModel,
onCancel = {
libraryViewModel.currentItemLiveData.value = null
onDismiss()
},
onUpdated = {
libraryViewModel.currentItemLiveData.value = null
libraryViewModel.refresh()
onDismiss()
}
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
val context = LocalContext.current
val listState = rememberLazyListState()
val pullRefreshState = rememberPullRefreshState(
refreshing = libraryViewModel.isRefreshing,
onRefresh = { libraryViewModel.refresh() }
)
val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState()
val cardsData: List<SavedItemWithLabelsAndHighlights> by libraryViewModel.itemsLiveData.observeAsState(
listOf()
)
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.padding(horizontal = 6.dp)
) {
item {
LibraryFilterBar(libraryViewModel)
}
items(
items = cardsData,
key = { item -> item.savedItem.savedItemId }
) { cardDataWithLabels ->
val swipeThreshold = 0.45f
val currentThresholdFraction = remember { mutableStateOf(0f) }
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
val swipeState = rememberDismissState(
confirmStateChange = {
when(it) {
DismissValue.Default -> {
return@rememberDismissState false
}
DismissValue.DismissedToEnd -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
}
DismissValue.DismissedToStart -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
}
}
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
if (currentItem.isArchived) {
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
} else {
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
}
} else if (it == DismissValue.DismissedToStart) { // Deleting.
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
}
true
}
)
SwipeToDismiss(
state = swipeState,
modifier = Modifier.padding(vertical = 4.dp),
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
dismissThresholds = { FractionalThreshold(swipeThreshold) },
background = {
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
val color by animateColorAsState(
when (swipeState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Green
DismissValue.DismissedToStart -> Color.Red
}, label = "backgroundColor"
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> if (currentItem.isArchived) Icons.Default.Unarchive else Icons.Default.Archive
DismissDirection.EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (swipeState.targetValue == DismissValue.Default) 0.75f else 1f,
label = "scaleAnimation"
)
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
) {
currentThresholdFraction.value = swipeState.progress.fraction
Icon(
icon,
contentDescription = null,
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
val selected =
currentItem.savedItemId == selectedItem?.savedItem?.savedItemId
SavedItemCard(
selected = selected,
savedItemViewModel = libraryViewModel,
savedItem = cardDataWithLabels,
onClickHandler = {
libraryViewModel.actionsMenuItemLiveData.postValue(null)
val activityClass =
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug)
context.startActivity(intent)
},
actionHandler = {
libraryViewModel.handleSavedItemAction(
currentItem.savedItemId,
it
)
}
)
},
)
when {
swipeState.isDismissed(DismissDirection.EndToStart) -> Reset(state = swipeState)
swipeState.isDismissed(DismissDirection.StartToEnd) -> Reset(state = swipeState)
}
}
}
InfiniteListHandler(listState = listState) {
if (cardsData.isEmpty()) {
Log.d("sync", "loading with load func")
libraryViewModel.initialLoad()
} else {
Log.d("sync", "loading with search api")
libraryViewModel.loadUsingSearchAPI()
}
}
PullRefreshIndicator(
refreshing = libraryViewModel.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
// LabelsSelectionSheet(viewModel = libraryViewModel)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun Reset(state: DismissState) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.dismissDirection) {
scope.launch {
state.reset()
state.animateTo(DismissValue.Default, FloatTweenSpec(duration= 0, delay = 0))
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.dismissDirection) {
scope.launch {
state.reset()
state.animateTo(DismissValue.Default, FloatTweenSpec(duration = 0, delay = 0))
}
}
}
}
@Composable
private fun BottomSheetUI(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
) {
content()
}
}
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit
) {
val loadMore = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
val loadMore = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - buffer)
lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
}

View file

@ -20,155 +20,160 @@ import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider
): ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private var cursor: String? = null
private var librarySearchCursor: String? = null
private var cursor: String? = null
private var librarySearchCursor: String? = null
// These are used to make sure we handle search result
// responses in the right order
private var searchIdx = 0
private var receivedIdx = 0
// These are used to make sure we handle search result
// responses in the right order
private var searchIdx = 0
private var receivedIdx = 0
var snackbarMessage by mutableStateOf<String?>(null)
private set
var snackbarMessage by mutableStateOf<String?>(null)
private set
// Live Data
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
allowedArchiveStates = listOf(0),
sortKey = "newest",
requiredLabels = listOf(),
excludedLabels = listOf(),
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
)
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
val showEditInfoSheetLiveData = MutableLiveData(false)
val showAddLinkSheetLiveData = MutableLiveData(false)
val currentItemLiveData = MutableLiveData<String?>(null)
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
// Live Data
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
allowedArchiveStates = listOf(0),
sortKey = "newest",
requiredLabels = listOf(),
excludedLabels = listOf(),
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
)
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
val bottomSheetState = MutableLiveData(LibraryBottomSheetState.HIDDEN)
val currentItemLiveData = MutableLiveData<String?>(null)
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
var isRefreshing by mutableStateOf(false)
private var hasLoadedInitialFilters = false
var isRefreshing by mutableStateOf(false)
private var hasLoadedInitialFilters = false
private fun loadInitialFilterValues() {
if (hasLoadedInitialFilters) { return }
hasLoadedInitialFilters = false
viewModelScope.launch {
withContext(Dispatchers.IO) {
dataService.syncLabels()
}
}
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
CoroutineScope(Dispatchers.IO).launch {
dataService.fetchSavedItemContent(slug)
private fun loadInitialFilterValues() {
if (hasLoadedInitialFilters) {
return
}
}
}
}
hasLoadedInitialFilters = false
fun clearSnackbarMessage() {
snackbarMessage = null
}
fun refresh() {
cursor = null
librarySearchCursor = null
isRefreshing = true
load()
}
private fun getLastSyncTime(): Instant? = runBlocking {
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
try {
return@let Instant.parse(it)
} catch (e: Exception) {
return@let null
}
}
}
fun initialLoad() {
if (getLastSyncTime() == null) {
hasLoadedInitialFilters = false
cursor = null
librarySearchCursor = null
searchIdx = 0
receivedIdx = 0
}
if (hasLoadedInitialFilters) { return }
load()
}
fun load() {
loadInitialFilterValues()
viewModelScope.launch {
syncItems()
loadUsingSearchAPI()
}
}
fun loadUsingSearchAPI() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(cursor = librarySearchCursor, query = searchQueryString())
result.cursor?.let {
librarySearchCursor = it
}
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
viewModelScope.launch {
withContext(Dispatchers.IO) {
dataService.syncLabels()
}
}
result.savedItems.map {
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
CoroutineScope(Dispatchers.IO).launch {
dataService.fetchSavedItemContent(slug)
}
}
}
}
}
}
fun updateSavedItemFilter(filter: SavedItemFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
appliedFilterLiveData.value = filter
handleFilterChanges()
fun clearSnackbarMessage() {
snackbarMessage = null
}
}
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
appliedSortFilterLiveData.value = filter
handleFilterChanges()
fun refresh() {
cursor = null
librarySearchCursor = null
isRefreshing = true
load()
}
}
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
viewModelScope.launch {
activeLabelsLiveData.value = labels
handleFilterChanges()
private fun getLastSyncTime(): Instant? = runBlocking {
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
try {
return@let Instant.parse(it)
} catch (e: Exception) {
return@let null
}
}
}
fun initialLoad() {
if (getLastSyncTime() == null) {
hasLoadedInitialFilters = false
cursor = null
librarySearchCursor = null
searchIdx = 0
receivedIdx = 0
}
if (hasLoadedInitialFilters) {
return
}
load()
}
fun load() {
loadInitialFilterValues()
viewModelScope.launch {
syncItems()
loadUsingSearchAPI()
}
}
fun loadUsingSearchAPI() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(
cursor = librarySearchCursor,
query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
result.savedItems.map {
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
}
}
}
}
fun updateSavedItemFilter(filter: SavedItemFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
appliedFilterLiveData.value = filter
handleFilterChanges()
}
}
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
appliedSortFilterLiveData.value = filter
handleFilterChanges()
}
}
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
viewModelScope.launch {
activeLabelsLiveData.value = labels
handleFilterChanges()
}
}
}
// fun sortKey(appliedSortKey: String) {
// when(appliedSortKey) {
@ -176,208 +181,228 @@ class LibraryViewModel @Inject constructor(
// }
// }
private fun handleFilterChanges() {
librarySearchCursor = null
private fun handleFilterChanges() {
librarySearchCursor = null
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
else -> "newest"
}
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
else -> "newest"
}
val allowedArchiveStates = when (appliedFilterLiveData.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedArchiveStates = when (appliedFilterLiveData.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedContentReaders = when(appliedFilterLiveData.value) {
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
else -> listOf("WEB", "PDF", "EPUB")
}
val allowedContentReaders = when (appliedFilterLiveData.value) {
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
else -> listOf("WEB", "PDF", "EPUB")
}
var requiredLabels = when(appliedFilterLiveData.value) {
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
}
var requiredLabels = when (appliedFilterLiveData.value) {
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
}
activeLabelsLiveData.value?.let { it ->
requiredLabels = requiredLabels + it.map { it.name }
}
activeLabelsLiveData.value?.let { it ->
requiredLabels = requiredLabels + it.map { it.name }
}
val excludeLabels = when(appliedFilterLiveData.value) {
SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS")
else -> listOf()
}
val excludeLabels = when (appliedFilterLiveData.value) {
SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS")
else -> listOf()
}
val newData = dataService.db.savedItemDao().filteredLibraryData(
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,
allowedContentReaders = allowedContentReaders
)
val newData = dataService.db.savedItemDao().filteredLibraryData(
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,
allowedContentReaders = allowedContentReaders
)
itemsLiveData.removeSource(itemsLiveDataInternal)
itemsLiveDataInternal = newData
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
}
}
private suspend fun syncItems() {
val syncStart = Instant.now()
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
withContext(Dispatchers.IO) {
performItemSync(cursor = null, since = lastSyncDate.toString(), count = 0, startTime = syncStart.toString())
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
}
private suspend fun performItemSync(cursor: String?, since: String, count: Int, startTime: String, isInitialBatch: Boolean = true) {
dataService.syncOfflineItemsWithServerIfNeeded()
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
// Fetch content for the initial batch only
if (isInitialBatch) {
for (slug in result.savedItemSlugs) {
delay(250)
contentRequestChannel.send(slug)
}
itemsLiveData.removeSource(itemsLiveDataInternal)
itemsLiveDataInternal = newData
itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue)
}
}
val totalCount = count + result.count
private suspend fun syncItems() {
val syncStart = Instant.now()
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
performItemSync(
cursor = result.cursor,
since = since,
count = totalCount,
startTime = startTime,
isInitialBatch = false
)
} else {
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
withContext(Dispatchers.IO) {
performItemSync(
cursor = null,
since = lastSyncDate.toString(),
count = 0,
startTime = syncStart.toString()
)
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
}
}
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
deleteSavedItem(itemID)
}
SavedItemAction.Archive -> {
archiveSavedItem(itemID)
}
SavedItemAction.Unarchive -> {
unarchiveSavedItem(itemID)
}
SavedItemAction.EditLabels -> {
currentItemLiveData.value = itemID
showLabelsSelectionSheetLiveData.value = true
}
SavedItemAction.EditInfo -> {
currentItemLiveData.value = itemID
showEditInfoSheetLiveData.value = true
}
}
actionsMenuItemLiveData.postValue(null)
}
private suspend fun performItemSync(
cursor: String?,
since: String,
count: Int,
startTime: String,
isInitialBatch: Boolean = true
) {
dataService.syncOfflineItemsWithServerIfNeeded()
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
}
}
// Fetch content for the initial batch only
if (isInitialBatch) {
for (slug in result.savedItemSlugs) {
delay(250)
contentRequestChannel.send(slug)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
}
}
val totalCount = count + result.count
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = setSavedItemLabels(
networker = networker,
dataService = dataService,
savedItemID = savedItemID,
labels = labels
)
snackbarMessage = if (result) {
resourceProvider.getString(R.string.library_view_model_snackbar_success)
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
performItemSync(
cursor = result.cursor,
since = since,
count = totalCount,
startTime = startTime,
isInitialBatch = false
)
} else {
resourceProvider.getString(R.string.library_view_model_snackbar_error)
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
}
}
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
deleteSavedItem(itemID)
}
SavedItemAction.Archive -> {
archiveSavedItem(itemID)
}
SavedItemAction.Unarchive -> {
unarchiveSavedItem(itemID)
}
SavedItemAction.EditLabels -> {
currentItemLiveData.value = itemID
bottomSheetState.value = LibraryBottomSheetState.LABEL
}
SavedItemAction.EditInfo -> {
currentItemLiveData.value = itemID
bottomSheetState.value = LibraryBottomSheetState.EDIT
}
}
actionsMenuItemLiveData.postValue(null)
}
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
}
}
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = setSavedItemLabels(
networker = networker,
dataService = dataService,
savedItemID = savedItemID,
labels = labels
)
snackbarMessage = if (result) {
resourceProvider.getString(R.string.library_view_model_snackbar_success)
} else {
resourceProvider.getString(R.string.library_view_model_snackbar_error)
}
CoroutineScope(Dispatchers.Main).launch {
handleFilterChanges()
}
}
}
}
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val newLabel = networker.createNewLabel(
CreateLabelInput(
color = Optional.presentIfNotNull(hexColorValue), name = labelName
)
)
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
}
}
}
}
fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? {
currentItemLiveData.value?.let { itemID ->
return itemsLiveData.value?.first { it.savedItem.savedItemId == itemID }
}
CoroutineScope(Dispatchers.Main).launch {
handleFilterChanges()
return null
}
private fun searchQueryString(): String {
var query =
"${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
activeLabelsLiveData.value?.let {
if (it.isNotEmpty()) {
query += " label:"
query += it.joinToString { label -> label.name }
}
}
}
return query
}
}
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val newLabel = networker.createNewLabel(CreateLabelInput(color = Optional.presentIfNotNull(hexColorValue), name = labelName))
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
}
}
}
}
fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? {
currentItemLiveData.value?.let { itemID ->
return itemsLiveData.value?.first { it.savedItem.savedItemId == itemID }
}
return null
}
private fun searchQueryString(): String {
var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
activeLabelsLiveData.value?.let {
if (it.isNotEmpty()) {
query += " label:"
query += it.joinToString { label -> label.name }
}
}
return query
}
}
enum class SavedItemAction {
Delete,
Archive,
Unarchive,
EditLabels,
EditInfo,
Delete,
Archive,
Unarchive,
EditLabels,
EditInfo,
}

View file

@ -1,5 +1,6 @@
package app.omnivore.omnivore.ui.reader
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -21,6 +22,7 @@ import app.omnivore.omnivore.R
@Composable
fun OpenLinkView(webReaderViewModel: WebReaderViewModel) {
val context = LocalContext.current
val isDarkMode = isSystemInDarkTheme()
Column(modifier = Modifier
.padding(top = 25.dp)
@ -28,8 +30,8 @@ fun OpenLinkView(webReaderViewModel: WebReaderViewModel) {
Row {
Text(webReaderViewModel.currentLink.toString(),
fontWeight = FontWeight.Light,
color = Color.DarkGray,
fontSize = 12.sp,
color = if (isDarkMode) Color.White else Color.DarkGray,
fontSize = 13.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)

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

@ -5,7 +5,6 @@ let package = Package(
name: "BuildTools",
platforms: [.macOS(.v10_11)],
dependencies: [
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.48.11"),
.package(url: "https://github.com/realm/SwiftLint", from: "0.44.0"),
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.2.1")
],

View file

@ -830,6 +830,7 @@
packageReferences = (
0418837C2742E99F003E0001 /* XCRemoteSwiftPackageReference "intercom-ios" */,
048F592A2790EAF800E0B494 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
04B616992B5C296700128513 /* XCRemoteSwiftPackageReference "SwiftFormat" */,
);
productRefGroup = E601FA26367162479B614F0F /* Products */;
projectDirPath = "";
@ -1388,7 +1389,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.40.0;
MARKETING_VERSION = 1.43.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
@ -1423,7 +1424,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 1.40.0;
MARKETING_VERSION = 1.43.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -1478,7 +1479,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.40.0;
MARKETING_VERSION = 1.43.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1819,7 +1820,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.40.0;
MARKETING_VERSION = 1.43.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2053,6 +2054,14 @@
minimumVersion = 8.0.0;
};
};
04B616992B5C296700128513 /* XCRemoteSwiftPackageReference "SwiftFormat" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/nicklockwood/SwiftFormat";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.53.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */

View file

@ -248,8 +248,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "872e7034f54aeee3f20acf790ecc13e1383f7360",
"version" : "0.48.4"
"revision" : "fef156a6135e584985ed26713dd2e9ee41f952cb",
"version" : "0.53.0"
}
},
{

View file

@ -22,4 +22,20 @@ public extension PlatformViewController {
#endif
return hostingController
}
static func makeLoggedOutShareExtensionController(
extensionContext: NSExtensionContext?
) -> PlatformViewController {
registerFonts()
let hostingController = PlatformHostingController(
rootView: LoggedOutShareExtensionView(extensionContext: extensionContext)
)
#if os(iOS)
hostingController.view.layer.cornerRadius = 12
hostingController.view.layer.masksToBounds = true
hostingController.view.layer.isOpaque = false
#endif
return hostingController
}
}

View file

@ -17,7 +17,7 @@ public class ShareExtensionViewModel: ObservableObject {
@Published var debugText: String?
@Published var noteText: String = ""
let services = Services()
public let services = Services()
let queue = OperationQueue()
public init() {}

View file

@ -470,3 +470,26 @@ public struct ShareExtensionView: View {
}
}
}
public struct LoggedOutShareExtensionView: View {
let extensionContext: NSExtensionContext?
public init(
extensionContext: NSExtensionContext?)
{
self.extensionContext = extensionContext
}
public var body: some View {
VStack(spacing: 20) {
Text("You are not logged in. Please open the Omnivore app and log in to start saving items to your library")
Button(action: {
if let extensionContext = extensionContext {
extensionContext.completeRequest(returningItems: nil)
}
}, label: { Text("Dismiss") })
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(20)
}
}

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 {
@ -367,51 +369,59 @@ struct AnimatingCellHeight: AnimatableModifier {
}
ToolbarItemGroup(placement: .barTrailing) {
if prefersListLayout {
if isEditMode == .active {
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
} else {
if prefersListLayout {
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
label: {
Image
.selectMultiple
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
},
label: {
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.selectMultiple
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
}
Button(
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
},
label: {
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
}
ToolbarItemGroup(placement: .bottomBar) {
@ -419,24 +429,20 @@ struct AnimatingCellHeight: AnimatableModifier {
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
isEditMode = .inactive
}, label: { Image(systemName: "trash") })
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
dim[HorizontalAlignment.center]
})
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image(systemName: "archivebox") })
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
dim[HorizontalAlignment.center]
})
}, label: { Image.toolbarTrash })
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
Spacer()
Text("\(selection.count) selected").font(.footnote)
Spacer()
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image.toolbarArchive })
.disabled(selection.count < 1)
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
}
}
}
@ -567,17 +573,17 @@ struct AnimatingCellHeight: AnimatableModifier {
HStack {
Menu(content: {
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
}, label: {
Text("Continue Reading")
})
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}, label: {
Text("Pinned")
})
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
}, label: {
Text("Newsletters")
})
@ -591,7 +597,7 @@ struct AnimatingCellHeight: AnimatableModifier {
HStack(alignment: .center) {
Image(systemName: "line.3.horizontal.decrease")
.font(Font.system(size: 13, weight: .regular))
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).title)
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title)
.font(Font.system(size: 13, weight: .medium))
}
.tint(Color(hex: "#007AFF"))
@ -747,6 +753,9 @@ struct AnimatingCellHeight: AnimatableModifier {
await viewModel.loadMore(dataService: dataService)
}
}
// reload this in case it was changed in settings
viewModel.hideFeatureSection = UserDefaults.standard.bool(forKey: UserDefaultKey.hideFeatureSection.rawValue)
}
}
}
@ -878,7 +887,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")
@ -887,7 +898,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

@ -49,6 +49,8 @@ enum LoadingBarStyle {
@AppStorage(UserDefaultKey.stopUsingFollowingPrimer.rawValue) var stopUsingFollowingPrimer = false
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
@Published var appliedFilter: InternalFilter? {
didSet {
if let filterName = appliedFilter?.name.lowercased() {
@ -167,10 +169,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
@ -364,4 +365,11 @@ enum LoadingBarStyle {
snackbar("Error modifying emails")
}
}
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
if let filter = filter {
featureFilter = filter.rawValue
fetcher.updateFeatureFilter(context: context, filter: filter)
}
}
}

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

@ -49,7 +49,7 @@ struct InnerRootView: View {
@ObservedObject var viewModel: RootViewModel
@ViewBuilder private var innerBody: some View {
if authenticator.isLoggedIn {
if authenticator.isLoggedIn, dataService.appEnvironment.environmentConfigured {
PrimaryContentView()
.task {
try? await dataService.syncOfflineItemsWithServerIfNeeded()

View file

@ -152,19 +152,21 @@ struct WebReaderContainerView: View {
label: {
textToSpeechButtonImage
}
).buttonStyle(.plain)
)
.buttonStyle(.plain)
.padding(.trailing, 4)
)
}
}
var textToSpeechButtonImage: some View {
if audioController.playbackError || audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id {
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
return Image.audioPlay
}
if audioController.isPlayingItem(itemID: item.unwrappedID) {
return AnyView(Image.audioPause.frame(width: 48, height: 48))
return Image.audioPause
}
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
return Image.audioPlay
}
#endif
@ -208,7 +210,6 @@ struct WebReaderContainerView: View {
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
audioMenuItem()
if viewModel.hasOriginalUrl(item) {
Button(
@ -299,7 +300,7 @@ struct WebReaderContainerView: View {
}
)
.buttonStyle(.plain)
.padding(.horizontal, 5)
.padding(.trailing, 5)
.popover(isPresented: $showPreferencesPopover) {
webPreferencesPopoverView
.frame(maxWidth: 400, maxHeight: 475)
@ -338,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
@ -419,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])
}
@ -448,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()
@ -517,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) {
@ -618,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

@ -54,6 +54,17 @@ public extension AppEnvironment {
)
}
var environmentConfigured: Bool {
if self == .custom {
if let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), let url = URL(string: str) {
return true
} else {
return false
}
}
return true
}
var graphqlPath: String {
"\(serverBaseURL.absoluteString)/api/graphql"
}

View file

@ -7,11 +7,10 @@ let logger = Logger(subsystem: "app.omnivore", category: "models")
/// An `NSPersistentContainer` subclass that lives in the `Models` package so that
/// the data model is looked for in the same package bundle (rather than the main bundle)
public class PersistentContainer: NSPersistentContainer {
public static func make() -> PersistentContainer {
let modelURL = Bundle.module.url(forResource: "CoreDataModel", withExtension: "momd")!
let model = NSManagedObjectModel(contentsOf: modelURL)!
let container = PersistentContainer(name: "DataModel", managedObjectModel: model)
public static var DatabaseVersion = "002"
public static var DatabaseFile = "store-v\(DatabaseVersion).sqlite"
public static func path() -> URL? {
// Store the sqlite file in the app group container.
// This allows shared access for app and app extensions.
#if os(iOS)
@ -20,7 +19,15 @@ public class PersistentContainer: NSPersistentContainer {
let appGroupID = "QJF2XZ86HB.app.omnivore.app"
#endif
let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite")
return appGroupContainer?.appendingPathComponent(DatabaseFile)
}
public static func make() -> PersistentContainer {
let modelURL = Bundle.module.url(forResource: "CoreDataModel", withExtension: "momd")!
let model = NSManagedObjectModel(contentsOf: modelURL)!
let container = PersistentContainer(name: "DataModel", managedObjectModel: model)
let appGroupContainerURL = path()
logger.debug("starting with sqlite container \(appGroupContainerURL?.absoluteString ?? "(nil)")")
container.persistentStoreDescriptions.first!.url = appGroupContainerURL

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

@ -56,20 +56,16 @@ public final class DataService: ObservableObject {
self.appEnvironment = appEnvironment
self.networker = networker
self.persistentContainer = PersistentContainer.make()
persistentContainer.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data store failed to load with error: \(error)")
}
}
self.backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.automaticallyMergesChangesFromParent = true
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
if isFirstTimeRunningNewAppBuild() {
resetLocalStorage()
} else {
persistentContainer.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data store failed to load with error: \(error)")
}
}
}
}
public func cleanupDeletedItems(in context: NSManagedObjectContext) {
@ -190,16 +186,18 @@ public final class DataService: ObservableObject {
}
}
public func resetLocalStorage() {
viewContext.perform {
// We want to specify the order of deleting items to better handle relationships
let entities = ["LibraryItem", "Viewer", "Filter", "Highlight", "NewsletterEmail",
"LinkedItemLabel", "RecentSearchItem", "Recommendation", "RecommendationGroup", "UserProfile"]
entities.forEach { entityName in
self.deleteAllEntities(entityName: entityName, inContext: self.viewContext)
public static func deleteLocalStorage() {
do {
if let storeURL = PersistentContainer.path() {
try FileManager.default.removeItem(atPath: storeURL.path)
}
} catch {
print(error.localizedDescription)
print("ERROR: ", error)
}
}
public func resetLocalStorage() {
UserDefaults.standard.set(nil, forKey: UserDefaultKey.lastSelectedTabItem.rawValue)
lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0)
@ -216,7 +214,7 @@ public final class DataService: ObservableObject {
backgroundContext = persistentContainer.newBackgroundContext()
}
private func isFirstTimeRunningNewAppBuild() -> Bool {
private static func isFirstTimeRunningNewAppBuild() -> Bool {
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String

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

@ -44,7 +44,7 @@ public extension DataService {
continuation.resume(throwing: BasicError.message(messageText: "CoreData error"))
}
case .error:
continuation.resume(throwing: BasicError.message(messageText: "Newsletter Email fetch error"))
continuation.resume(throwing: BasicError.message(messageText: "labels fetch error"))
}
}
}

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,84 @@
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
@Property(title: "Author if set")
var author: String?
@Property(title: "Site name if set")
var siteName: String?
@Property(title: "Published date if set")
var publishedAt: Date?
@Property(title: "Time the item was saved")
var savedAt: Date?
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)"
self.author = item.author
self.siteName = item.siteName
self.publishedAt = item.publishDate
self.savedAt = item.savedAt
}
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 +95,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 +141,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

@ -16,6 +16,16 @@ import Views
super.viewDidLoad()
view.backgroundColor = .clear
if !viewModel.services.authenticator.isLoggedIn,
!viewModel.services.dataService.appEnvironment.environmentConfigured
{
embed(
childViewController: UIViewController.makeLoggedOutShareExtensionController(extensionContext: extensionContext),
heightRatio: 0.60
)
return
}
NotificationCenter.default.addObserver(
forName: Notification.Name("ShowAddNoteSheet"),
object: nil,

View file

@ -1,5 +1,4 @@
cd BuildTools
SDKROOT=(xcrun --sdk macosx --show-sdk-path)
# swift package update #Uncomment this line temporarily to update the version used to the latest matching your BuildTools/Package.swift file
swift run -c release swiftformat "$SRCROOT"
swift run swiftlint --path "$SRCROOT"

View file

@ -24,9 +24,9 @@ services:
command: '/bin/sh ./packages/db/setup.sh' # Also create a demo user with email: demo@omnivore.app, password: demo_password
environment:
- PGPASSWORD=postgres
- POSTGRES_USER=postgres
- PG_HOST=postgres
- PG_USER=postgres
- PG_PASSWORD=postgres
- PG_PASSWORD=app_pass
- PG_DB=omnivore
depends_on:
postgres:

View file

@ -23,7 +23,6 @@ SAMPLE_METRICS_LOCALLY=FALSE
GCS_UPLOAD_BUCKET=
GCS_UPLOAD_SA_KEY_FILE_PATH=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
SENDER_MESSAGE=msgs@sender.domain
SENDER_FEEDBACK=feedback@sender.domain
SENDER_GENERAL=no-reply@sender.domain

View file

@ -24,7 +24,6 @@ GCS_UPLOAD_BUCKET=
GCS_UPLOAD_SA_KEY_FILE_PATH=
GCS_UPLOAD_PRIVATE_BUCKET=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
SEGMENT_WRITE_KEY='test'
PUBSUB_VERIFICATION_TOKEN='123456'
CONTENT_FETCH_URL=http://localhost:9090/

View file

@ -5,7 +5,9 @@
"scripts": {
"build": "tsc && yarn copy-files",
"dev": "ts-node-dev --files src/server.ts",
"dev_qp": "ts-node-dev --files src/queue-processor.ts",
"start": "node dist/server.js",
"start_queue_processor": "node dist/queue-processor.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit",
@ -47,6 +49,7 @@
"apollo-server-express": "^3.6.3",
"axios": "^0.27.2",
"bcryptjs": "^2.4.3",
"bullmq": "^5.1.1",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
@ -69,6 +72,7 @@
"highlightjs": "^9.16.2",
"html-entities": "^2.3.2",
"intercom-client": "^3.1.4",
"ioredis": "^5.3.2",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.3",
"linkedom": "^0.14.9",
@ -151,4 +155,4 @@
"volta": {
"extends": "../../package.json"
}
}
}

View file

@ -727,6 +727,22 @@ export type DeviceTokensSuccess = {
deviceTokens: Array<DeviceToken>;
};
export type EmptyTrashError = {
__typename?: 'EmptyTrashError';
errorCodes: Array<EmptyTrashErrorCode>;
};
export enum EmptyTrashErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess;
export type EmptyTrashSuccess = {
__typename?: 'EmptyTrashSuccess';
success?: Maybe<Scalars['Boolean']>;
};
export type Feature = {
__typename?: 'Feature';
createdAt: Scalars['Date'];
@ -1352,6 +1368,7 @@ export type Mutation = {
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteRule: DeleteRuleResult;
deleteWebhook: DeleteWebhookResult;
emptyTrash: EmptyTrashResult;
fetchContent: FetchContentResult;
generateApiKey: GenerateApiKeyResult;
googleLogin: LoginResult;
@ -3663,6 +3680,10 @@ export type ResolversTypes = {
DeviceTokensErrorCode: DeviceTokensErrorCode;
DeviceTokensResult: ResolversTypes['DeviceTokensError'] | ResolversTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: ResolverTypeWrapper<DeviceTokensSuccess>;
EmptyTrashError: ResolverTypeWrapper<EmptyTrashError>;
EmptyTrashErrorCode: EmptyTrashErrorCode;
EmptyTrashResult: ResolversTypes['EmptyTrashError'] | ResolversTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: ResolverTypeWrapper<EmptyTrashSuccess>;
Feature: ResolverTypeWrapper<Feature>;
Feed: ResolverTypeWrapper<Feed>;
FeedArticle: ResolverTypeWrapper<FeedArticle>;
@ -4169,6 +4190,9 @@ export type ResolversParentTypes = {
DeviceTokensError: DeviceTokensError;
DeviceTokensResult: ResolversParentTypes['DeviceTokensError'] | ResolversParentTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: DeviceTokensSuccess;
EmptyTrashError: EmptyTrashError;
EmptyTrashResult: ResolversParentTypes['EmptyTrashError'] | ResolversParentTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: EmptyTrashSuccess;
Feature: Feature;
Feed: Feed;
FeedArticle: FeedArticle;
@ -4975,6 +4999,20 @@ export type DeviceTokensSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashError'] = ResolversParentTypes['EmptyTrashError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EmptyTrashErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashResult'] = ResolversParentTypes['EmptyTrashResult']> = {
__resolveType: TypeResolveFn<'EmptyTrashError' | 'EmptyTrashSuccess', ParentType, ContextType>;
};
export type EmptyTrashSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashSuccess'] = ResolversParentTypes['EmptyTrashSuccess']> = {
success?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeatureResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Feature'] = ResolversParentTypes['Feature']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
expiresAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
@ -5460,6 +5498,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
emptyTrash?: Resolver<ResolversTypes['EmptyTrashResult'], ParentType, ContextType>;
fetchContent?: Resolver<ResolversTypes['FetchContentResult'], ParentType, ContextType, RequireFields<MutationFetchContentArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
@ -6647,6 +6686,9 @@ export type Resolvers<ContextType = ResolverContext> = {
DeviceTokensError?: DeviceTokensErrorResolvers<ContextType>;
DeviceTokensResult?: DeviceTokensResultResolvers<ContextType>;
DeviceTokensSuccess?: DeviceTokensSuccessResolvers<ContextType>;
EmptyTrashError?: EmptyTrashErrorResolvers<ContextType>;
EmptyTrashResult?: EmptyTrashResultResolvers<ContextType>;
EmptyTrashSuccess?: EmptyTrashSuccessResolvers<ContextType>;
Feature?: FeatureResolvers<ContextType>;
Feed?: FeedResolvers<ContextType>;
FeedArticle?: FeedArticleResolvers<ContextType>;

View file

@ -644,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
@ -1213,6 +1227,7 @@ 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!

View file

@ -7,45 +7,6 @@ declare module '*.graphql' {
export = schema
}
declare module 'knex-stringcase' {
import { Knex } from 'knex'
type StringCase =
| 'camelcase'
| 'capitalcase'
| 'constcase'
| 'cramcase'
| 'decapitalcase'
| 'dotcase'
| 'enumcase'
| 'lowercase'
| 'pascalcase'
| 'pathcase'
| 'sentencecase'
| 'snakecase'
| 'spacecase'
| 'spinalcase'
| 'titlecase'
| 'trimcase'
| 'uppercase'
interface KnexStringCaseConfig extends Knex.Config {
appStringcase?: StringCase | StringCase[]
dbStringcase?: StringCase | StringCase[]
/* eslint-disable @typescript-eslint/no-explicit-any */
beforePostProcessResponse?(
result: any[] | object,
queryContext: object
): any[] | object
beforeWrapIdentifier?(value: string, queryContext: object): string
/* eslint-enable @typescript-eslint/no-explicit-any */
ignoreStringcase?(obj: object): boolean
}
function knexStringcase(config: KnexStringCaseConfig): Knex.Config
export = knexStringcase
}
declare module 'voca/slugify' {
function slugify(subject?: string): string

View file

@ -0,0 +1,139 @@
import { Job, Queue } from 'bullmq'
import { DataSource } from 'typeorm'
import { QUEUE_NAME, getBackendQueue } from '../../queue-processor'
import { redisDataSource } from '../../redis_data_source'
import { RssSubscriptionGroup } from '../../utils/createTask'
import { stringToHash } from '../../utils/helpers'
import { validateUrl } from '../../services/create_page_save_request'
import { v4 as uuid } from 'uuid'
export type RSSRefreshContext = {
type: 'all' | 'user-added'
refreshID: string
startedAt: string
}
export const refreshAllFeeds = async (db: DataSource): Promise<boolean> => {
const refreshContext = {
type: 'all',
refreshID: uuid(),
startedAt: new Date().toISOString(),
} as RSSRefreshContext
const subscriptionGroups = (await db.createEntityManager().query(
`
SELECT
url,
ARRAY_AGG(id) AS "subscriptionIds",
ARRAY_AGG(user_id) AS "userIds",
ARRAY_AGG(last_fetched_at) AS "fetchedDates",
ARRAY_AGG(coalesce(scheduled_at, NOW())) AS "scheduledDates",
ARRAY_AGG(last_fetched_checksum) AS checksums,
ARRAY_AGG(fetch_content) AS "fetchContents",
ARRAY_AGG(coalesce(folder, $3)) AS folders
FROM
omnivore.subscriptions
WHERE
type = $1
AND status = $2
AND (scheduled_at <= NOW() OR scheduled_at IS NULL)
GROUP BY
url
`,
['RSS', 'ACTIVE', 'following']
)) as RssSubscriptionGroup[]
console.log(`rss: checking ${subscriptionGroups.length}`, { refreshContext })
for (const group of subscriptionGroups) {
try {
await updateSubscriptionGroup(group, refreshContext)
} catch (err) {
// we don't want to fail the whole job if one subscription group fails
console.error('error updating subscription group')
}
}
const finishTime = new Date()
console.log(
`rss: finished queuing subscription groups at ${finishTime.toISOString()}`,
{
refreshContext,
}
)
return true
}
const updateSubscriptionGroup = async (
group: RssSubscriptionGroup,
refreshContext: RSSRefreshContext
) => {
let feedURL = group.url
const userList = JSON.stringify(group.userIds.sort())
if (!feedURL) {
console.error('no url for feed group', group)
return
}
if (!userList) {
console.error('no userlist for feed group', group)
return
}
try {
feedURL = validateUrl(feedURL).toString()
} catch (err) {
console.log('not refreshing invalid feed url: ', { feedURL })
}
const jobid = `refresh-feed_${stringToHash(feedURL)}_${stringToHash(
userList
)}`
const payload = {
refreshContext,
subscriptionIds: group.subscriptionIds,
feedUrl: group.url,
lastFetchedTimestamps: group.fetchedDates.map(
(timestamp) => timestamp?.getTime() || 0
), // unix timestamp in milliseconds
lastFetchedChecksums: group.checksums,
scheduledTimestamps: group.scheduledDates.map((timestamp) =>
timestamp.getTime()
), // unix timestamp in milliseconds
userIds: group.userIds,
fetchContents: group.fetchContents,
folders: group.folders,
}
await queueRSSRefreshFeedJob(jobid, payload)
}
export const queueRSSRefreshAllFeedsJob = async () => {
const queue = await getBackendQueue()
if (!queue) {
return false
}
return queue.add(
'refresh-all-feeds',
{},
{
priority: 100,
}
)
}
type QueuePriority = 'low' | 'high'
export const queueRSSRefreshFeedJob = async (
jobid: string,
payload: any,
options = { priority: 'high' as QueuePriority }
): Promise<Job | undefined> => {
const queue = await getBackendQueue()
if (!queue) {
return undefined
}
return queue.add('refresh-feed', payload, {
jobId: jobid,
removeOnComplete: true,
removeOnFail: true,
priority: options.priority == 'low' ? 10 : 50,
})
}

View file

@ -0,0 +1,638 @@
import axios from 'axios'
import crypto from 'crypto'
import * as jwt from 'jsonwebtoken'
import { parseHTML } from 'linkedom'
import Parser, { Item } from 'rss-parser'
import { promisify } from 'util'
import { env } from '../../env'
import { redisDataSource } from '../../redis_data_source'
import createHttpTaskWithToken from '../../utils/createTask'
import { RSSRefreshContext } from './refreshAllFeeds'
import { updateSubscription } from '../../services/update_subscription'
type FolderType = 'following' | 'inbox'
interface RefreshFeedRequest {
subscriptionIds: string[]
feedUrl: string
lastFetchedTimestamps: number[] // unix timestamp in milliseconds
scheduledTimestamps: number[] // unix timestamp in milliseconds
lastFetchedChecksums: string[]
userIds: string[]
fetchContents: boolean[]
folders: FolderType[]
refreshContext?: RSSRefreshContext
}
export const isRefreshFeedRequest = (data: any): data is RefreshFeedRequest => {
return (
'subscriptionIds' in data &&
'feedUrl' in data &&
'lastFetchedTimestamps' in data &&
'scheduledTimestamps' in data &&
'userIds' in data &&
'lastFetchedChecksums' in data &&
'fetchContents' in data &&
'folders' in data
)
}
// link can be a string or an object
type RssFeedItemLink = string | { $: { rel?: string; href: string } }
type RssFeed = Parser.Output<{
published?: string
updated?: string
created?: string
link?: RssFeedItemLink
links?: RssFeedItemLink[]
}> & {
lastBuildDate?: string
'syn:updatePeriod'?: string
'syn:updateFrequency'?: string
'sy:updatePeriod'?: string
'sy:updateFrequency'?: string
}
type RssFeedItemMedia = {
$: { url: string; width?: string; height?: string; medium?: string }
}
export type RssFeedItem = Item & {
'media:thumbnail'?: RssFeedItemMedia
'media:content'?: RssFeedItemMedia[]
link: string
}
interface User {
id: string
folder: FolderType
}
interface FetchContentTask {
users: Map<string, User> // userId -> User
item: RssFeedItem
}
export const isOldItem = (item: RssFeedItem, lastFetchedAt: number) => {
// existing items and items that were published before 24h
const publishedAt = item.isoDate ? new Date(item.isoDate) : new Date()
return (
publishedAt <= new Date(lastFetchedAt) ||
publishedAt < new Date(Date.now() - 24 * 60 * 60 * 1000)
)
}
const feedFetchFailedRedisKey = (feedUrl: string) =>
`feed-fetch-failure:${feedUrl}`
const isFeedBlocked = async (feedUrl: string) => {
const key = feedFetchFailedRedisKey(feedUrl)
const redisClient = redisDataSource.redisClient
try {
const result = await redisClient?.get(key)
// if the feed has failed to fetch more than certain times, block it
const maxFailures = parseInt(process.env.MAX_FEED_FETCH_FAILURES ?? '10')
if (result && parseInt(result) > maxFailures) {
console.log('feed is blocked: ', feedUrl)
return true
}
} catch (error) {
console.error('Failed to check feed block status', feedUrl, error)
}
return false
}
const incrementFeedFailure = async (feedUrl: string) => {
const redisClient = redisDataSource.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)
return result
} catch (error) {
console.error('Failed to block feed', feedUrl, error)
return null
}
}
export const isContentFetchBlocked = (feedUrl: string) => {
if (feedUrl.startsWith('https://arxiv.org/')) {
return true
}
if (feedUrl.startsWith('https://lwn.net/headlines/newrss')) {
return true
}
return false
}
const getThumbnail = (item: RssFeedItem) => {
if (item['media:thumbnail']) {
return item['media:thumbnail'].$.url
}
return item['media:content']?.find((media) => media.$.medium === 'image')?.$
.url
}
export const fetchAndChecksum = async (url: string) => {
try {
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 60_000,
maxRedirects: 10,
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
Accept:
'application/rss+xml, application/rdf+xml;q=0.8, application/atom+xml;q=0.6, application/xml;q=0.4, text/xml, text/html;q=0.4',
},
})
const hash = crypto.createHash('sha256')
hash.update(response.data as Buffer)
const dataStr = (response.data as Buffer).toString()
return { url, content: dataStr, checksum: hash.digest('hex') }
} catch (error) {
console.log(`Failed to fetch or hash content from ${url}.`, error)
return null
}
}
const parseFeed = async (url: string, content: string) => {
try {
// check if url is a telegram channel
const telegramRegex = /https:\/\/t\.me\/([a-zA-Z0-9_]+)/
const telegramMatch = url.match(telegramRegex)
if (telegramMatch) {
const dom = parseHTML(content).document
const title = dom.querySelector('meta[property="og:title"]')
// post has attribute data-post
const posts = dom.querySelectorAll('[data-post]')
const items = Array.from(posts)
.map((post) => {
const id = post.getAttribute('data-post')
if (!id) {
return null
}
const url = `https://t.me/${telegramMatch[1]}/${id}`
// find the <time> element
const time = post.querySelector('time')
const dateTime = time?.getAttribute('datetime') || undefined
return {
link: url,
isoDate: dateTime,
}
})
.filter((item) => !!item) as RssFeedItem[]
return {
title: title?.getAttribute('content') || dom.title,
items,
}
}
// 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
}
}
const isItemRecentlySaved = async (userId: string, url: string) => {
const key = `recent-saved-item:${userId}:${url}`
try {
const result = await redisDataSource.redisClient?.get(key)
return !!result
} catch (err) {
console.error('error checking if item is old', err)
}
// If we failed to check, assume the item is good
return false
}
const addFetchContentTask = (
fetchContentTasks: Map<string, FetchContentTask>,
userId: string,
folder: FolderType,
item: RssFeedItem
) => {
const url = item.link
const task = fetchContentTasks.get(url)
if (!task) {
fetchContentTasks.set(url, {
users: new Map([[userId, { id: userId, folder }]]),
item,
})
} else {
task.users.set(userId, { id: userId, folder })
}
return true
}
const createTask = async (
fetchContentTasks: Map<string, FetchContentTask>,
userId: string,
feedUrl: string,
item: RssFeedItem,
fetchContent: boolean,
folder: FolderType
) => {
const isRecentlySaved = await isItemRecentlySaved(userId, item.link)
if (isRecentlySaved) {
console.log('Item recently saved', item.link)
return true
}
if (folder === 'following' && !fetchContent) {
return createItemWithPreviewContent(userId, feedUrl, item)
}
console.log(`adding fetch content task ${userId} ${item.link.trim()}`)
return addFetchContentTask(fetchContentTasks, userId, folder, item)
}
const fetchContentAndCreateItem = async (
users: User[],
feedUrl: string,
item: RssFeedItem
) => {
const payload = {
users,
source: 'rss-feeder',
url: item.link.trim(),
saveRequestId: '',
labels: [{ name: 'RSS' }],
rssFeedUrl: feedUrl,
savedAt: item.isoDate,
publishedAt: item.isoDate,
}
try {
const task = await createHttpTaskWithToken({
queue: 'omnivore-rss-feed-queue',
taskHandlerUrl: env.queue.contentFetchGCFUrl,
payload,
})
return !!task
} catch (error) {
console.error('Error while creating task', error)
return false
}
}
const createItemWithPreviewContent = async (
userId: string,
feedUrl: string,
item: RssFeedItem
) => {
const input = {
userIds: [userId],
url: item.link,
title: item.title,
author: item.creator,
description: item.summary,
addedToFollowingFrom: 'feed',
previewContent: item.content || item.contentSnippet || item.summary,
addedToFollowingBy: feedUrl,
savedAt: item.isoDate,
publishedAt: item.isoDate,
previewContentType: 'text/html', // TODO: get content type from feed
thumbnail: getThumbnail(item),
}
try {
const serviceBaseUrl = process.env.INTERNAL_API_URL
const token = process.env.PUBSUB_VERIFICATION_TOKEN
if (!serviceBaseUrl || !token) {
throw 'Environment not configured correctly'
}
// save page
const taskHandlerUrl = `${serviceBaseUrl}/svc/following/save?token=${token}`
const task = await createHttpTaskWithToken({
queue: env.queue.name,
priority: 'low',
taskHandlerUrl: taskHandlerUrl,
payload: input,
})
return !!task
} catch (error) {
console.error('Error while creating task', error)
return false
}
}
const signToken = promisify(jwt.sign)
const parser = new Parser({
customFields: {
item: [
['link', 'links', { keepArray: true }],
'published',
'updated',
'created',
['media:content', 'media:content', { keepArray: true }],
['media:thumbnail'],
],
feed: [
'lastBuildDate',
'syn:updatePeriod',
'syn:updateFrequency',
'sy:updatePeriod',
'sy:updateFrequency',
],
},
})
const getUpdateFrequency = (feed: RssFeed) => {
const updateFrequency =
feed['syn:updateFrequency'] || feed['sy:updateFrequency']
if (!updateFrequency) {
return 1
}
const frequency = parseInt(updateFrequency, 10)
if (isNaN(frequency)) {
return 1
}
return frequency
}
const getUpdatePeriodInHours = (feed: RssFeed) => {
const updatePeriod = feed['syn:updatePeriod'] || feed['sy:updatePeriod']
switch (updatePeriod) {
case 'hourly':
return 1
case 'daily':
return 24
case 'weekly':
return 7 * 24
case 'monthly':
return 30 * 24
case 'yearly':
return 365 * 24
default:
// default to hourly
return 1
}
}
// get link following the order of preference: via, alternate, self
const getLink = (links: RssFeedItemLink[]): string | undefined => {
// sort links by preference
const sortedLinks: string[] = []
links.forEach((link) => {
// if link is a string, it is the href
if (typeof link === 'string') {
return sortedLinks.push(link)
}
if (link.$.rel === 'via') {
sortedLinks[0] = link.$.href
}
if (link.$.rel === 'alternate') {
sortedLinks[1] = link.$.href
}
if (link.$.rel === 'self' || !link.$.rel) {
sortedLinks[2] = link.$.href
}
})
// return the first link that is not undefined
return sortedLinks.find((link) => !!link)
}
const processSubscription = async (
fetchContentTasks: Map<string, FetchContentTask>,
subscriptionId: string,
userId: string,
feedUrl: string,
fetchResult: { content: string; checksum: string },
lastFetchedAt: number,
scheduledAt: number,
lastFetchedChecksum: string,
fetchContent: boolean,
folder: FolderType,
feed: RssFeed
) => {
let lastItemFetchedAt: Date | null = null
let lastValidItem: RssFeedItem | null = null
if (fetchResult.checksum === lastFetchedChecksum) {
console.log('feed has not been updated', feedUrl, lastFetchedChecksum)
return
}
const updatedLastFetchedChecksum = fetchResult.checksum
// fetch feed
let itemCount = 0
const feedLastBuildDate = feed.lastBuildDate
console.log('Feed last build date', feedLastBuildDate)
if (
feedLastBuildDate &&
new Date(feedLastBuildDate) <= new Date(lastFetchedAt)
) {
console.log('Skipping old feed', feedLastBuildDate)
return
}
// save each item in the feed
for (const item of feed.items) {
// use published or updated if isoDate is not available for atom feeds
const isoDate =
item.isoDate || item.published || item.updated || item.created
console.log('Processing feed item', item.links, item.isoDate, feedUrl)
if (!item.links || item.links.length === 0) {
console.log('Invalid feed item', item)
continue
}
const link = getLink(item.links)
if (!link) {
console.log('Invalid feed item links', item.links)
continue
}
const feedItem = {
...item,
isoDate,
link,
}
const publishedAt = feedItem.isoDate
? new Date(feedItem.isoDate)
: new Date()
// remember the last valid item
if (
!lastValidItem ||
(lastValidItem.isoDate && publishedAt > new Date(lastValidItem.isoDate))
) {
lastValidItem = feedItem
}
// Max limit per-feed update
if (itemCount > 99) {
continue
}
// skip old items
if (isOldItem(feedItem, lastFetchedAt)) {
console.log('Skipping old feed item', feedItem.link)
continue
}
const created = await createTask(
fetchContentTasks,
userId,
feedUrl,
feedItem,
fetchContent,
folder
)
if (!created) {
console.error('Failed to create task for feed item', feedItem.link)
continue
}
// remember the last item fetched at
if (!lastItemFetchedAt || publishedAt > lastItemFetchedAt) {
lastItemFetchedAt = publishedAt
}
itemCount = itemCount + 1
}
// no items saved
if (!lastItemFetchedAt) {
// the feed has been fetched before, no new valid items found
if (lastFetchedAt || !lastValidItem) {
console.log('No new valid items found')
return
}
// the feed has never been fetched, save at least the last valid item
const created = await createTask(
fetchContentTasks,
userId,
feedUrl,
lastValidItem,
fetchContent,
folder
)
if (!created) {
console.error('Failed to create task for feed item', lastValidItem.link)
throw new Error('Failed to create task for feed item')
}
lastItemFetchedAt = lastValidItem.isoDate
? new Date(lastValidItem.isoDate)
: new Date()
}
const updateFrequency = getUpdateFrequency(feed)
const updatePeriodInMs = getUpdatePeriodInHours(feed) * 60 * 60 * 1000
const nextScheduledAt = scheduledAt + updatePeriodInMs * updateFrequency
// update subscription lastFetchedAt
const updatedSubscription = await updateSubscription(userId, subscriptionId, {
lastFetchedAt: lastItemFetchedAt,
lastFetchedChecksum: updatedLastFetchedChecksum,
scheduledAt: new Date(nextScheduledAt),
})
console.log('Updated subscription', updatedSubscription)
}
export const refreshFeed = async (request: any) => {
if (isRefreshFeedRequest(request)) {
return _refreshFeed(request)
}
console.log('not a feed to refresh')
return false
}
export const _refreshFeed = async (request: RefreshFeedRequest) => {
try {
const {
feedUrl,
subscriptionIds,
lastFetchedTimestamps,
scheduledTimestamps,
userIds,
lastFetchedChecksums,
fetchContents,
folders,
refreshContext,
} = request
console.log('Processing feed', feedUrl, { refreshContext: refreshContext })
const isBlocked = await isFeedBlocked(feedUrl)
if (isBlocked) {
console.log('feed is blocked: ', feedUrl)
return
}
const fetchResult = await fetchAndChecksum(feedUrl)
if (!fetchResult) {
console.error('Failed to fetch RSS feed', feedUrl)
await incrementFeedFailure(feedUrl)
return
}
const feed = await parseFeed(feedUrl, fetchResult.content)
if (!feed) {
console.error('Failed to parse RSS feed', feedUrl)
await incrementFeedFailure(feedUrl)
return
}
let allowFetchContent = true
if (isContentFetchBlocked(feedUrl)) {
console.log('fetching content blocked for feed: ', feedUrl)
allowFetchContent = false
}
console.log('Fetched feed', feed.title, new Date())
const fetchContentTasks = new Map<string, FetchContentTask>() // url -> FetchContentTask
// process each subscription sequentially
for (let i = 0; i < subscriptionIds.length; i++) {
await processSubscription(
fetchContentTasks,
subscriptionIds[i],
userIds[i],
feedUrl,
fetchResult,
lastFetchedTimestamps[i],
scheduledTimestamps[i],
lastFetchedChecksums[i],
fetchContents[i] && allowFetchContent,
folders[i],
feed
)
}
// create fetch content tasks
for (const task of fetchContentTasks.values()) {
await fetchContentAndCreateItem(
Array.from(task.users.values()),
feedUrl,
task.item
)
}
} catch (e) {
console.error('Error while saving RSS feeds', e)
}
}

View file

@ -0,0 +1,409 @@
import { Readability } from '@omnivore/readability'
import axios from 'axios'
import jwt from 'jsonwebtoken'
import { promisify } from 'util'
import { env } from '../env'
import {
ArticleSavingRequestStatus,
CreateLabelInput,
} from '../generated/graphql'
import { redisDataSource } from '../redis_data_source'
import { userRepository } from '../repository/user'
import { savePage } from '../services/save_page'
import { logger } from '../utils/logger'
const signToken = promisify(jwt.sign)
const IMPORTER_METRICS_COLLECTOR_URL = env.queue.importerMetricsUrl
const JWT_SECRET = env.server.jwtSecret
const REST_BACKEND_ENDPOINT = `${env.server.internalApiUrl}/api`
const MAX_ATTEMPTS = 2
const REQUEST_TIMEOUT = 30000 // 30 seconds
interface Data {
userId: string
url: string
articleSavingRequestId: string
state?: string
labels?: CreateLabelInput[]
source: string
folder: string
rssFeedUrl?: string
savedAt?: string
publishedAt?: string
taskId?: string
}
interface UploadFileResponse {
data: {
uploadFileRequest: {
id: string
uploadSignedUrl: string
uploadFileId: string
createdPageId: string
errorCodes?: string[]
}
}
}
interface CreateArticleResponse {
data: {
createArticle: {
createdArticle: {
id: string
}
errorCodes: string[]
}
}
}
interface SavePageResponse {
data: {
savePage: {
url: string
clientRequestId: string
errorCodes?: string[]
}
}
}
interface FetchResult {
finalUrl: string
title?: string
content?: string
contentType?: string
readabilityResult?: Readability.ParseResult
}
const isFetchResult = (obj: unknown): obj is FetchResult => {
return (
typeof obj === 'object' &&
obj !== null &&
'finalUrl' in obj &&
'title' in obj
)
}
const uploadToSignedUrl = async (
uploadSignedUrl: string,
contentType: string,
contentObjUrl: string
) => {
try {
const stream = await axios.get(contentObjUrl, {
responseType: 'stream',
timeout: REQUEST_TIMEOUT,
})
return axios.put(uploadSignedUrl, stream.data, {
headers: {
'Content-Type': contentType,
},
maxBodyLength: 1000000000,
maxContentLength: 100000000,
timeout: REQUEST_TIMEOUT,
})
} catch (error) {
console.error('error uploading to signed url', error)
return null
}
}
const getUploadIdAndSignedUrl = async (
userId: string,
url: string,
articleSavingRequestId: string
) => {
const auth = await signToken({ uid: userId }, JWT_SECRET)
const data = JSON.stringify({
query: `mutation UploadFileRequest($input: UploadFileRequestInput!) {
uploadFileRequest(input:$input) {
... on UploadFileRequestError {
errorCodes
}
... on UploadFileRequestSuccess {
id
uploadSignedUrl
}
}
}`,
variables: {
input: {
url,
contentType: 'application/pdf',
clientRequestId: articleSavingRequestId,
},
},
})
try {
const response = await axios.post<UploadFileResponse>(
`${REST_BACKEND_ENDPOINT}/graphql`,
data,
{
headers: {
Cookie: `auth=${auth as string};`,
'Content-Type': 'application/json',
},
timeout: REQUEST_TIMEOUT,
}
)
if (
response.data.data.uploadFileRequest.errorCodes &&
response.data.data.uploadFileRequest.errorCodes?.length > 0
) {
console.error(
'Error while getting upload id and signed url',
response.data.data.uploadFileRequest.errorCodes[0]
)
return null
}
return response.data.data.uploadFileRequest
} catch (e) {
console.error('error getting upload id and signed url', e)
return null
}
}
const uploadPdf = async (
url: string,
userId: string,
articleSavingRequestId: string
) => {
const uploadResult = await getUploadIdAndSignedUrl(
userId,
url,
articleSavingRequestId
)
if (!uploadResult) {
throw new Error('error while getting upload id and signed url')
}
const uploaded = await uploadToSignedUrl(
uploadResult.uploadSignedUrl,
'application/pdf',
url
)
if (!uploaded) {
throw new Error('error while uploading pdf')
}
return uploadResult.id
}
const sendCreateArticleMutation = async (userId: string, input: unknown) => {
const data = JSON.stringify({
query: `mutation CreateArticle ($input: CreateArticleInput!){
createArticle(input:$input){
... on CreateArticleSuccess{
createdArticle{
id
}
}
... on CreateArticleError{
errorCodes
}
}
}`,
variables: {
input,
},
})
const auth = await signToken({ uid: userId }, JWT_SECRET)
try {
const response = await axios.post<CreateArticleResponse>(
`${REST_BACKEND_ENDPOINT}/graphql`,
data,
{
headers: {
Cookie: `auth=${auth as string};`,
'Content-Type': 'application/json',
},
timeout: REQUEST_TIMEOUT,
}
)
if (
response.data.data.createArticle.errorCodes &&
response.data.data.createArticle.errorCodes.length > 0
) {
console.error(
'error while creating article',
response.data.data.createArticle.errorCodes[0]
)
return null
}
return response.data.data.createArticle
} catch (error) {
console.error('error creating article', error)
return null
}
}
const sendImportStatusUpdate = async (
userId: string,
taskId: string,
isImported?: boolean
) => {
try {
const auth = await signToken({ uid: userId }, JWT_SECRET)
await axios.post(
IMPORTER_METRICS_COLLECTOR_URL,
{
taskId,
status: isImported ? 'imported' : 'failed',
},
{
headers: {
Authorization: auth as string,
'Content-Type': 'application/json',
},
timeout: REQUEST_TIMEOUT,
}
)
} catch (e) {
console.error('error while sending import status update', e)
}
}
const getCachedFetchResult = async (url: string) => {
const key = `fetch-result:${url}`
if (!redisDataSource.redisClient) {
throw new Error('redis client is not initialized')
}
const result = await redisDataSource.redisClient.get(key)
if (!result) {
throw new Error('fetch result is not cached')
}
const fetchResult = JSON.parse(result) as unknown
if (!isFetchResult(fetchResult)) {
throw new Error('fetch result is not valid')
}
console.log('fetch result is cached', url)
return fetchResult
}
export const savePageJob = async (data: Data, attemptsMade: number) => {
const {
userId,
articleSavingRequestId,
state,
labels,
source,
folder,
rssFeedUrl,
savedAt,
publishedAt,
taskId,
url,
} = data
let isImported, isSaved
try {
console.log(`savePageJob: ${userId} ${url}`)
// get the fetch result from cache
const { title, content, contentType, readabilityResult } =
await getCachedFetchResult(url)
// for pdf content, we need to upload the pdf
if (contentType === 'application/pdf') {
const encodedUrl = encodeURI(url)
const uploadFileId = await uploadPdf(
encodedUrl,
userId,
articleSavingRequestId
)
const uploadedPdf = await sendCreateArticleMutation(userId, {
url: encodedUrl,
articleSavingRequestId,
uploadFileId,
state,
labels,
source,
folder,
rssFeedUrl,
savedAt,
publishedAt,
})
if (!uploadedPdf) {
throw new Error('error while saving uploaded pdf')
}
isSaved = true
isImported = true
return true
}
if (!content) {
throw new Error(
'Invalid SavePage job, fetch result missing required data'
)
}
const user = await userRepository.findById(userId)
if (!user) {
logger.error('Unable to save job, user can not be found.', {
userId,
url,
})
throw new Error('Unable to save job, user can not be found.')
}
// for non-pdf content, we need to save the page
const result = await savePage(
{
url,
clientRequestId: articleSavingRequestId,
title,
originalContent: content,
parseResult: readabilityResult,
state: state ? (state as ArticleSavingRequestStatus) : undefined,
labels: labels,
rssFeedUrl,
savedAt: savedAt ? new Date(savedAt) : new Date(),
publishedAt: publishedAt ? new Date(publishedAt) : null,
source,
folder,
},
user
)
// if (result.__typename == 'SaveError') {
// logger.error('Error saving page', { userId, url, result })
// throw new Error('Error saving page')
// }
// if the readability result is not parsed, the import is failed
isImported = !!readabilityResult
isSaved = true
} catch (e) {
if (e instanceof Error) {
console.error('error while saving page', e.message)
} else {
console.error('error while saving page', 'unknown error')
}
throw e
} finally {
const lastAttempt = attemptsMade === MAX_ATTEMPTS - 1
if (lastAttempt) {
console.log('last attempt reached', data.url)
}
if (taskId && (isSaved || lastAttempt)) {
// send import status to update the metrics for importer
await sendImportStatusUpdate(userId, taskId, isImported)
}
}
return true
}

View file

@ -0,0 +1,14 @@
import {
UpdateContentMessage,
isUpdateContentMessage,
updateContentForFileItem,
} from '../services/update_pdf_content'
import { logger } from '../utils/logger'
export const updatePDFContentJob = async (data: unknown): Promise<boolean> => {
if (isUpdateContentMessage(data)) {
return await updateContentForFileItem(data)
}
logger.log('update_pdf_content data is not a update message', { data })
return false
}

View file

@ -0,0 +1,151 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-misused-promises */
import { Job, QueueEvents, Worker, Queue } from 'bullmq'
import express, { Express } from 'express'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { appDataSource } from './data_source'
import { env } from './env'
import { refreshAllFeeds } from './jobs/rss/refreshAllFeeds'
import { refreshFeed } from './jobs/rss/refreshFeed'
import { savePageJob } from './jobs/save_page'
import { redisDataSource } from './redis_data_source'
import { CustomTypeOrmLogger } from './utils/logger'
import { updatePDFContentJob } from './jobs/update_pdf_content'
export const QUEUE_NAME = 'omnivore-backend-queue'
let backendQueue: Queue | undefined
export const getBackendQueue = async (): Promise<Queue | undefined> => {
if (backendQueue) {
await backendQueue.waitUntilReady()
return backendQueue
}
if (!redisDataSource.workerRedisClient) {
throw new Error('Can not create queues, redis is not initialized')
}
backendQueue = new Queue(QUEUE_NAME, {
connection: redisDataSource.workerRedisClient,
})
await backendQueue.waitUntilReady()
return backendQueue
}
const main = async () => {
console.log('[queue-processor]: starting queue processor')
const app: Express = express()
const port = process.env.PORT || 3002
redisDataSource.setOptions({
REDIS_URL: env.redis.url,
REDIS_CERT: env.redis.cert,
})
appDataSource.setOptions({
type: 'postgres',
host: env.pg.host,
port: env.pg.port,
schema: 'omnivore',
username: env.pg.userName,
password: env.pg.password,
database: env.pg.dbName,
logging: ['query', 'info'],
entities: [__dirname + '/entity/**/*{.js,.ts}'],
subscribers: [__dirname + '/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
logger: new CustomTypeOrmLogger(['query', 'info']),
connectTimeoutMS: 40000, // 40 seconds
maxQueryExecutionTime: 10000, // 10 seconds
})
// respond healthy to auto-scaler.
app.get('/_ah/health', (req, res) => res.sendStatus(200))
const server = app.listen(port, () => {
console.log(`[queue-processor]: started`)
})
// This is done after all the setup so it can access the
// environment that was loaded from GCP
await appDataSource.initialize()
await redisDataSource.initialize()
const redisClient = redisDataSource.redisClient
const workerRedisClient = redisDataSource.workerRedisClient
if (!workerRedisClient || !redisClient) {
throw '[queue-processor] error redis is not initialized'
}
const queue = new Queue(QUEUE_NAME, {
connection: workerRedisClient,
})
const worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
switch (job.name) {
case 'refresh-all-feeds': {
const counts = await queue.getJobCounts('wait')
if (counts.wait > 1000) {
return
}
return await refreshAllFeeds(appDataSource)
}
case 'refresh-feed': {
return await refreshFeed(job.data)
}
case 'save-page': {
return savePageJob(job.data, job.attemptsMade)
}
case 'update-pdf-content': {
return updatePDFContentJob(job.data)
}
}
return true
},
{
connection: workerRedisClient,
}
)
const queueEvents = new QueueEvents(QUEUE_NAME, {
connection: workerRedisClient,
})
queueEvents.on('added', async (job) => {
console.log('added job: ', job.jobId, job.name)
})
queueEvents.on('removed', async (job) => {
console.log('removed job: ', job.jobId)
})
queueEvents.on('completed', async (job) => {
console.log('completed job: ', job.jobId)
})
workerRedisClient.on('error', (error) => {
console.trace('[queue-processor]: redis worker error', { error })
})
redisClient.on('error', (error) => {
console.trace('[queue-processor]: redis error', { error })
})
const gracefulShutdown = async (signal: string) => {
console.log(`[queue-processor]: Received ${signal}, closing server...`)
await worker.close()
await redisDataSource.shutdown()
process.exit(0)
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
}
// only call main if the file was called from the CLI and wasn't required from another module
if (require.main === module) {
main().catch((e) => console.error(e))
}

View file

@ -0,0 +1,98 @@
import Redis, { RedisOptions } from 'ioredis'
import { env } from './env'
export type RedisDataSourceOptions = {
REDIS_URL?: string
REDIS_CERT?: string
}
export class RedisDataSource {
options: RedisDataSourceOptions
isInitialized: boolean
redisClient: Redis | undefined = undefined
workerRedisClient: Redis | undefined = undefined
constructor(options: RedisDataSourceOptions) {
this.options = options
this.isInitialized = false
}
// Forcing this to be async as we might do some more initialization in the future
async initialize(): Promise<this> {
if (this.isInitialized) throw 'Error already initialized'
this.redisClient = createIORedisClient('app', this.options)
this.workerRedisClient = createIORedisClient('worker', this.options)
this.isInitialized = true
return Promise.resolve(this)
}
setOptions(options: RedisDataSourceOptions): void {
this.options = options
}
async shutdown(): Promise<void> {
this.isInitialized = false
try {
await this.workerRedisClient?.quit()
await this.redisClient?.quit()
} catch (err) {
console.error('error while shutting down redis', err)
}
}
}
const createIORedisClient = (
name: string,
options: RedisDataSourceOptions
): Redis | undefined => {
const redisURL = options.REDIS_URL
if (!redisURL) {
throw 'Error: no redisURL supplied'
}
const tls =
redisURL.startsWith('rediss://') && options.REDIS_CERT
? {
ca: options.REDIS_CERT,
rejectUnauthorized: false,
}
: undefined
const redisOptions: RedisOptions = {
tls,
name,
connectTimeout: 10000,
maxRetriesPerRequest: null,
offlineQueue: false,
// reconnectOnError: (err: Error) => {
// const targetErrors = [/READONLY/, /ETIMEDOUT/]
// targetErrors.forEach((targetError) => {
// if (targetError.test(err.message)) {
// // Only reconnect when the error contains the keyword
// return true
// }
// })
// return false
// },
retryStrategy: (times: number) => {
// if (times > 10) {
// // End reconnecting after a specific number of tries and flush all commands with a individual error
// return null
// }
// // reconnect after
// return Math.min(times * 50, 2000)
return 10
},
}
return new Redis(redisURL, redisOptions)
}
export const redisDataSource = new RedisDataSource({
REDIS_URL: env.redis.url,
REDIS_CERT: env.redis.cert,
})

Some files were not shown because too many files have changed in this diff Show more