Merge pull request #3415 from omnivore-app/main
Web production deployment
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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\"")
|
||||
|
|
|
|||
BIN
android/Omnivore/app/src/debug/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
android/Omnivore/app/src/debug/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
BIN
android/Omnivore/app/src/debug/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 966 B |
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/Omnivore/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
4
android/Omnivore/app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Dev - Omnivore</string>
|
||||
</resources>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package app.omnivore.omnivore.ui.library
|
||||
|
||||
enum class LibraryBottomSheetState {
|
||||
HIDDEN,
|
||||
ADD_LINK,
|
||||
LABEL,
|
||||
EDIT
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
233
android/Omnivore/app/src/main/res/values-de/strings.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
0
apple/Resources/de.lproj/LaunchScreen.strings
Normal 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
39
packages/api/src/graphql.d.ts
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
139
packages/api/src/jobs/rss/refreshAllFeeds.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
638
packages/api/src/jobs/rss/refreshFeed.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
409
packages/api/src/jobs/save_page.ts
Normal 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
|
||||
}
|
||||
14
packages/api/src/jobs/update_pdf_content.ts
Normal 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
|
||||
}
|
||||
151
packages/api/src/queue-processor.ts
Normal 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))
|
||||
}
|
||||
98
packages/api/src/redis_data_source.ts
Normal 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,
|
||||
})
|
||||