diff --git a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityService.kt b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityService.kt
index 6d403a215..df6a3e8dc 100644
--- a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityService.kt
+++ b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityService.kt
@@ -84,7 +84,9 @@ class AccessibilityService private constructor(
is BookStatus.Loanable -> {
val notHoldable = this.previousStatusIsNot(event, BookStatus.Holdable::class.java)
val notLoanable = this.previousStatusIsNot(event, BookStatus.Loanable::class.java)
- if (notHoldable && notLoanable) {
+ val notUnselected = this.previousStatusIsNot(event, BookStatus.Unselected::class.java)
+ val notSelected = this.previousStatusIsNot(event, BookStatus.Selected::class.java)
+ if (notHoldable && notLoanable && notUnselected && notSelected) {
this.speak(this.strings.bookReturned(book.entry.title))
} else {
// Nothing to do
@@ -126,6 +128,20 @@ class AccessibilityService private constructor(
is BookStatus.RequestingDownload,
is BookStatus.DownloadWaitingForExternalAuthentication,
is BookStatus.DownloadExternalAuthenticationInProgress,
+ is BookStatus.Selected -> {
+ if (this.previousStatusIsNot(event, BookStatus.Selected::class.java)) {
+ this.speak(this.strings.bookSelected(book.entry.title))
+ } else {
+ // Nothing to do
+ }
+ }
+ is BookStatus.Unselected -> {
+ if (this.previousStatusIsNot(event, BookStatus.Unselected::class.java)) {
+ this.speak(this.strings.bookUnselected(book.entry.title))
+ } else {
+ // Nothing to do
+ }
+ }
is BookStatus.Revoked -> {
}
null -> {
diff --git a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStrings.kt b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStrings.kt
index b49568347..c79593c78 100644
--- a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStrings.kt
+++ b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStrings.kt
@@ -33,4 +33,10 @@ class AccessibilityStrings(
override fun bookLoanLimitReached(): String =
this.resources.getString(R.string.reachedLoanLimit)
+
+ override fun bookSelected(title: String): String =
+ this.resources.getString(R.string.bookSelected, title)
+
+ override fun bookUnselected(title: String): String =
+ this.resources.getString(R.string.bookUnselected, title)
}
diff --git a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStringsType.kt b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStringsType.kt
index f7194ecf3..6ab17cbab 100644
--- a/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStringsType.kt
+++ b/simplified-accessibility/src/main/java/org/nypl/simplified/accessibility/AccessibilityStringsType.kt
@@ -13,4 +13,6 @@ interface AccessibilityStringsType {
fun bookFailedLoan(title: String): String
fun bookFailedDownload(title: String): String
fun bookLoanLimitReached(): String
+ fun bookSelected(title: String): String
+ fun bookUnselected(title: String): String
}
diff --git a/simplified-accessibility/src/main/res/values/strings.xml b/simplified-accessibility/src/main/res/values/strings.xml
index 6cbc1f1b2..bbc59e8b4 100644
--- a/simplified-accessibility/src/main/res/values/strings.xml
+++ b/simplified-accessibility/src/main/res/values/strings.xml
@@ -9,4 +9,6 @@
A reservation has been placed for the book \'%1$s\'
The book \'%1$s\' has been successfully returned
You have reached your loan limit
+ The book \'%1$s\' has been added to favorites
+ The book \'%1$s\' has been removed from favorites
diff --git a/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProvider.kt b/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProvider.kt
index 4d91ecd38..ff19cea5f 100644
--- a/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProvider.kt
+++ b/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProvider.kt
@@ -17,6 +17,7 @@ data class AccountProvider(
override val authenticationAlternatives: List,
override val supportsReservations: Boolean,
override val loansURI: URI?,
+ override val selectedURI: URI?,
override val cardCreatorURI: URI?,
override val authenticationDocumentURI: URI?,
override val catalogURI: URI,
@@ -56,6 +57,7 @@ data class AccountProvider(
authenticationDocumentURI = other.authenticationDocumentURI,
cardCreatorURI = other.cardCreatorURI,
catalogURI = other.catalogURI,
+ selectedURI = other.selectedURI,
description = other.description,
displayName = other.displayName,
eula = other.eula,
diff --git a/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProviderType.kt b/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProviderType.kt
index c285ad79f..90a3261ff 100644
--- a/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProviderType.kt
+++ b/simplified-accounts-api/src/main/java/org/nypl/simplified/accounts/api/AccountProviderType.kt
@@ -83,6 +83,12 @@ interface AccountProviderType : Comparable {
val loansURI: URI?
+ /**
+ * @return The URI of the user selected feed, if supported
+ */
+
+ val selectedURI: URI?
+
/**
* @return The URI to reset the user's password, if any
*/
diff --git a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt
index 088af4e15..a347df18d 100644
--- a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt
+++ b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt
@@ -78,6 +78,7 @@ object AccountProvidersJSON {
this.putConditionally(node, "eula", provider.eula)
this.putConditionally(node, "license", provider.license)
this.putConditionally(node, "loansURI", provider.loansURI)
+ this.putConditionally(node, "selectedURI", provider.selectedURI)
this.putConditionally(node, "logo", provider.logo)
this.putConditionally(node, "patronSettingsURI", provider.patronSettingsURI)
this.putConditionally(node, "privacyPolicy", provider.privacyPolicy)
@@ -275,6 +276,8 @@ object AccountProvidersJSON {
JSONParserUtilities.getURIOrNull(obj, "logo")
val loansURI =
JSONParserUtilities.getURIOrNull(obj, "loansURI")
+ val selectedURI =
+ JSONParserUtilities.getURIOrNull(obj, "selectedURI")
val patronSettingsURI =
JSONParserUtilities.getURIOrNull(obj, "patronSettingsURI")
val privacyPolicy =
@@ -318,6 +321,7 @@ object AccountProvidersJSON {
isProduction = isProduction,
license = license,
loansURI = loansURI,
+ selectedURI = selectedURI,
location = location,
logo = logo,
mainColor = mainColor,
diff --git a/simplified-accounts-source-ekirjasto/src/main/java/org/nypl/simplified/accounts/source/ekirjasto/AccountProviderResolution.kt b/simplified-accounts-source-ekirjasto/src/main/java/org/nypl/simplified/accounts/source/ekirjasto/AccountProviderResolution.kt
index 6e8eb06dd..2afd90dbc 100644
--- a/simplified-accounts-source-ekirjasto/src/main/java/org/nypl/simplified/accounts/source/ekirjasto/AccountProviderResolution.kt
+++ b/simplified-accounts-source-ekirjasto/src/main/java/org/nypl/simplified/accounts/source/ekirjasto/AccountProviderResolution.kt
@@ -125,6 +125,7 @@ class AccountProviderResolution(
isProduction = this.description.isProduction,
license = authDocument?.licenseURI,
loansURI = authDocument?.loansURI,
+ selectedURI = authDocument?.selectedURI,
logo = authDocument?.logoURI ?: this.description.logoURI?.hrefURI,
mainColor = authDocument?.mainColor ?: "red",
patronSettingsURI = authDocument?.patronSettingsURI,
diff --git a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt
index b529f0236..2f68bf7c1 100644
--- a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt
+++ b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt
@@ -135,7 +135,8 @@ class AccountProviderResolution(
supportsReservations = supportsReservations,
updated = updated,
location = this.description.location,
- alternateURI = alternateURI
+ alternateURI = alternateURI,
+ selectedURI = authDocument?.selectedURI
)
taskRecorder.finishSuccess(accountProvider)
diff --git a/simplified-app-ekirjasto/src/main/java/fi/kansalliskirjasto/ekirjasto/EkirjastoAccountFallback.kt b/simplified-app-ekirjasto/src/main/java/fi/kansalliskirjasto/ekirjasto/EkirjastoAccountFallback.kt
index d229ba5f9..aaf17ccbf 100644
--- a/simplified-app-ekirjasto/src/main/java/fi/kansalliskirjasto/ekirjasto/EkirjastoAccountFallback.kt
+++ b/simplified-app-ekirjasto/src/main/java/fi/kansalliskirjasto/ekirjasto/EkirjastoAccountFallback.kt
@@ -66,6 +66,7 @@ class EkirjastoAccountFallback : AccountProviderFallbackType {
isProduction = true,
license = null,
loansURI = null,
+ selectedURI = null,
logo = null,
mainColor = "orange",
patronSettingsURI = null,
diff --git a/simplified-app-ekirjasto/src/main/res/values/drawables.xml b/simplified-app-ekirjasto/src/main/res/values/drawables.xml
index 95bdf3b82..59d459b5e 100644
--- a/simplified-app-ekirjasto/src/main/res/values/drawables.xml
+++ b/simplified-app-ekirjasto/src/main/res/values/drawables.xml
@@ -3,7 +3,7 @@
@drawable/ic_elibrary_catalog_active
@drawable/ic_elibrary_mybooks_active
- @drawable/ic_elibrary_reservation_active
+ @drawable/ic_elibrary_reservation_active
@drawable/ic_elibrary_settings_active
@drawable/ic_caret_left
@drawable/ekirjasto_logo_smaller
diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt
index 4a9a25865..908e89101 100644
--- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt
+++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/BorrowTask.kt
@@ -311,10 +311,23 @@ class BorrowTask private constructor(
try {
val database = this.account.bookDatabase
- val dbEntry = database.createOrUpdate(book.id, entry)
- this.databaseEntry = dbEntry
+
+ //If the book is already in the database, it might be selected
+ //And just overwriting the existing entry with the new one would not carry that information over
+ if (database.books().contains(book.id)) {
+ //If book already in db, get the entry and insert the selected information already available
+ val dbEntry = database.entry(book.id)
+ //Build a new feed entry
+ val adjustedEntry = OPDSAcquisitionFeedEntry.newBuilderFrom(entry)
+ .setSelectedOption(dbEntry.book.entry.selected)
+ .build()
+ //Create the database entry
+ this.databaseEntry = database.createOrUpdate(book.id, adjustedEntry)
+ } else {
+ this.databaseEntry = database.createOrUpdate(book.id, entry)
+ }
this.taskRecorder.currentStepSucceeded("Book database updated.")
- return dbEntry.book
+ return this.databaseEntry!!.book
} catch (e: Exception) {
this.error("[{}]: failed to set up book database: ", book.id.brief(), e)
this.taskRecorder.currentStepFailed(
diff --git a/simplified-books-controller-api/src/main/java/org/nypl/simplified/books/controller/api/BooksControllerType.kt b/simplified-books-controller-api/src/main/java/org/nypl/simplified/books/controller/api/BooksControllerType.kt
index 65773b8df..980127598 100644
--- a/simplified-books-controller-api/src/main/java/org/nypl/simplified/books/controller/api/BooksControllerType.kt
+++ b/simplified-books-controller-api/src/main/java/org/nypl/simplified/books/controller/api/BooksControllerType.kt
@@ -114,4 +114,26 @@ interface BooksControllerType {
accountID: AccountID,
bookID: BookID
): FluentFuture>
+
+ /**
+ * Add the chosen book to a list of selected books.
+ *
+ * @param accountID The account that selected the book
+ * @param feedEntry The FeedEntry for the book
+ */
+ fun bookAddToSelected(
+ accountID: AccountID,
+ feedEntry: FeedEntry.FeedEntryOPDS
+ ) : FluentFuture>
+
+ /**
+ * Remove the chosen book to a list of selected books.
+ *
+ * @param accountID The account that removed the book
+ * @param bookID The ID of the book
+ */
+ fun bookRemoveFromSelected(
+ accountID: AccountID,
+ feedEntry: FeedEntry.FeedEntryOPDS
+ ) : FluentFuture>
}
diff --git a/simplified-books-controller/build.gradle.kts b/simplified-books-controller/build.gradle.kts
index 4cfa9e9a1..70fbba426 100644
--- a/simplified-books-controller/build.gradle.kts
+++ b/simplified-books-controller/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies {
implementation(project(":simplified-parser-api"))
implementation(project(":simplified-patron-api"))
implementation(project(":simplified-presentableerror-api"))
+ implementation(project(":simplified-futures"))
implementation(project(":simplified-profiles-api"))
implementation(project(":simplified-profiles-controller-api"))
implementation(project(":simplified-services-api"))
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt
index 754067103..acb318f9e 100644
--- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookDeleteTask.kt
@@ -1,11 +1,15 @@
package org.nypl.simplified.books.controller
+import com.io7m.jfunctional.Some
+import org.joda.time.DateTime
import org.librarysimplified.mdc.MDCKeys
import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.accounts.database.api.AccountType
import org.nypl.simplified.books.api.BookID
import org.nypl.simplified.books.book_database.api.BookDatabaseException
import org.nypl.simplified.books.book_registry.BookRegistryType
+import org.nypl.simplified.books.book_registry.BookStatus
+import org.nypl.simplified.books.book_registry.BookWithStatus
import org.nypl.simplified.profiles.api.ProfileID
import org.nypl.simplified.profiles.api.ProfilesDatabaseType
import org.nypl.simplified.taskrecorder.api.TaskRecorder
@@ -44,8 +48,16 @@ class BookDeleteTask(
MDC.put(MDCKeys.BOOK_TITLE, entry.book.entry.title)
MDCKeys.put(MDCKeys.BOOK_PUBLISHER, entry.book.entry.publisher)
- entry.delete()
- this.bookRegistry.clearFor(entry.book.id)
+ //If the book is still selected, don't delete the book, just update
+ if (entry.book.entry.selected is Some) {
+ logger.debug("Book is selected, don't delete, just update")
+ this.bookRegistry.update(BookWithStatus(entry.book, BookStatus.fromBook(entry.book)))
+ } else {
+ //Otherwise delete the db and registry entries
+ logger.debug("Book not selected delete from database and register")
+ entry.delete()
+ this.bookRegistry.clearFor(entry.book.id)
+ }
this.taskRecorder.finishSuccess(Unit)
} catch (e: Exception) {
this.taskRecorder.currentStepFailed(
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt
index 65438a05f..939751660 100644
--- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookRevokeTask.kt
@@ -1,5 +1,7 @@
package org.nypl.simplified.books.controller
+import com.io7m.jfunctional.None
+import com.io7m.jfunctional.OptionType
import com.io7m.jfunctional.Some
import com.io7m.junreachable.UnreachableCodeException
import org.joda.time.DateTime
@@ -86,12 +88,41 @@ class BookRevokeTask(
this.debug("revoke")
this.taskRecorder.beginNewStep(this.revokeStrings.revokeStarted)
+ //Set the status in bookRegistry to Requesting revoke
this.publishRequestingRevokeStatus()
+ //Setup the database, by looking up the current database entry for the book
+ //And publishing the revoke status again
this.setupBookDatabaseEntry(account)
+ //Revoke the book based on its format
this.revokeFormatHandle(account)
+ //Revoke request is sent to server, we set the revoke status again
+ //and from the answer we form a new entry,
+ //That we store to the db and register
+ //HERE
this.revokeNotifyServer(account)
- this.revokeNotifyServerDeleteBook()
- this.bookRegistry.clearFor(this.bookID)
+
+ //Get the registry entry
+ val revokeBook = bookRegistry.books()[this.bookID]
+
+ //If there is a book in the registry that is selected, just publish revoked status and then
+ //Update the database entry with the selected info
+ // And then update the registry from the database
+ if (revokeBook != null && revokeBook.book.entry.selected is Some) {
+ this.publishRevokedStatus()
+ //Update the database entry with the selected info
+ val updatedEntry = OPDSAcquisitionFeedEntry.newBuilderFrom(this.databaseEntry.book.entry)
+ .setSelectedOption(revokeBook.book.entry.selected)
+ .build()
+ //Write the entry to the database
+ this.databaseEntry.writeOPDSEntry(updatedEntry)
+ //Update the book registry, based on the book that we just updated to database
+ this.publishStatusFromDatabase()
+ } else {
+ //If not selected, we delete the book from database
+ //And we clear the registry too
+ this.revokeNotifyServerDeleteBook()
+ this.bookRegistry.clearFor(this.bookID)
+ }
return this.taskRecorder.finishSuccess(Unit)
}
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSelectTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSelectTask.kt
new file mode 100644
index 000000000..727ea87dd
--- /dev/null
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSelectTask.kt
@@ -0,0 +1,278 @@
+package org.nypl.simplified.books.controller
+
+import one.irradia.mime.api.MIMECompatibility
+import org.librarysimplified.http.api.LSHTTPClientType
+import org.librarysimplified.http.api.LSHTTPRequestBuilderType
+import org.librarysimplified.http.api.LSHTTPResponseStatus
+import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP
+import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsToProperties
+import org.nypl.simplified.accounts.api.AccountID
+import org.nypl.simplified.accounts.database.api.AccountType
+import org.nypl.simplified.books.api.Book
+import org.nypl.simplified.books.api.BookIDs
+import org.nypl.simplified.books.book_registry.BookRegistryType
+import org.nypl.simplified.books.book_registry.BookStatus
+import org.nypl.simplified.books.book_registry.BookWithStatus
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntryParser
+import org.nypl.simplified.opds.core.OPDSParseException
+import org.nypl.simplified.opds.core.getOrNull
+import org.nypl.simplified.profiles.api.ProfileID
+import org.nypl.simplified.profiles.api.ProfilesDatabaseType
+import org.nypl.simplified.taskrecorder.api.TaskRecorder
+import org.nypl.simplified.taskrecorder.api.TaskRecorderType
+import org.nypl.simplified.taskrecorder.api.TaskResult
+import org.slf4j.LoggerFactory
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.URI
+
+/**
+ * Task for selecting a particular book, and adding it to the favorites list.
+ * Extends the AbstractBookTask class.
+ */
+class BookSelectTask (
+ private val accountID: AccountID,
+ profileID: ProfileID,
+ profiles: ProfilesDatabaseType,
+ private val HTTPClient: LSHTTPClientType,
+ private val feedEntry: OPDSAcquisitionFeedEntry,
+ private val bookRegistry: BookRegistryType
+) : AbstractBookTask(accountID, profileID, profiles) {
+
+ override val taskRecorder: TaskRecorderType =
+ TaskRecorder.create()
+
+ override fun onFailure(result: TaskResult.Failure) {
+ //Do nothing
+ }
+
+ override val logger =
+ LoggerFactory.getLogger(BookSelectTask::class.java)
+
+ override fun execute(account: AccountType) : TaskResult.Success {
+ //First create database entry, so we have something we can post status to
+ this.taskRecorder.beginNewStep("Create database entry")
+ //ID of the book, can be new
+ val bookID = BookIDs.newFromOPDSEntry(this.feedEntry)
+
+ val bookInitial =
+ Book(
+ id = bookID,
+ account = this.accountID,
+ cover = null,
+ thumbnail = null,
+ entry = feedEntry,
+ formats = listOf()
+ )
+
+ // Get either the book that already was in database, or initialize a new database entry
+ val book = this.getOrCreateBookDatabaseEntry(account, bookInitial, feedEntry)
+
+ this.taskRecorder.currentStepSucceeded("Initial database entry successful")
+
+ //Try to add the selection into the database entry
+ taskRecorder.beginNewStep("Creating an OPDS Select...")
+ try {
+ //Using the alternate link as the basis for the request
+ val baseURI = feedEntry.alternate.getOrNull()
+ if (baseURI == null) {
+ logger.debug("No alternate link to use as basis for request")
+ return taskRecorder.finishSuccess(Unit)
+ }
+
+ //Form the select URI by adding the desired path
+ val currentURI = URI.create(baseURI.toString().plus("/select_book"))
+
+ taskRecorder.beginNewStep("Using $currentURI to select a book...")
+ taskRecorder.addAttribute("Select URI", currentURI.toString())
+
+ //Use the credentials available on current profile
+ val credentials = account.loginState.credentials
+
+ //Create the authorization (bearer) based on current credentials
+ val auth =
+ AccountAuthenticatedHTTP.createAuthorizationIfPresent(credentials)
+
+ //Use the POST method to ask server to add the book to selected with selected auth
+ val request =
+ HTTPClient.newRequest(currentURI!!)
+ .setMethod(
+ LSHTTPRequestBuilderType.Method.Post(
+ ByteArray(0),
+ MIMECompatibility.applicationOctetStream
+ )
+ )
+ .setAuthorization(auth)
+ .addCredentialsToProperties(credentials)
+ .build()
+
+ request.execute().use { response ->
+ when (val status = response.status) {
+ is LSHTTPResponseStatus.Responded.OK -> {
+ // Parse the answer and store the new value to the database and book register
+ this.handleOKRequest(account, currentURI, status, book)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ is LSHTTPResponseStatus.Responded.Error -> {
+ this.handleHTTPError(status)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ is LSHTTPResponseStatus.Failed -> {
+ this.handleHTTPFailure(status)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ }
+ }
+ } catch (e: SelectTaskException.SelectAccessTokenExpired) {
+ //Catch a special error for access token expiration
+ throw e
+ }
+ }
+
+ private fun getOrCreateBookDatabaseEntry(
+ account: AccountType,
+ book: Book,
+ entry: OPDSAcquisitionFeedEntry
+ ): Book {
+ this.taskRecorder.beginNewStep("Setting up a book database entry...")
+
+ try {
+ val database = account.bookDatabase
+
+ //If the book is already in the database, use that book instead of the generated one
+ //If the book is not, we initialize a db entry with the given book
+ if (!database.books().contains(book.id)) {
+ database.createOrUpdate(book.id, entry)
+ }
+ //Get the entry
+ val dbEntry = database.entry(book.id)
+ this.taskRecorder.currentStepSucceeded("Book database initialized for select")
+ return dbEntry.book
+ } catch (e: Exception) {
+ logger.error("[{}]: failed to set up book database: ", book.id.brief(), e)
+ this.taskRecorder.currentStepFailed(
+ message = "Could not set up the book database entry.",
+ errorCode = "Error",
+ exception = e
+ )
+ throw SelectTaskException.SelectTaskFailed()
+ }
+ }
+
+ private fun handleHTTPFailure(
+ status: LSHTTPResponseStatus.Failed
+ ) {
+ taskRecorder.currentStepFailed(
+ message = status.exception.message ?: "Exception raised during connection attempt.",
+ errorCode = "SelectingFailed",
+ exception = status.exception
+ )
+ throw SelectTaskException.SelectTaskFailed()
+ }
+
+ private fun handleHTTPError(
+ status: LSHTTPResponseStatus.Responded.Error
+ ) {
+ if (status.properties.status == 401) {
+ //Create an exception that is handled in AbstractBookTask and forwarded to Controller,
+ //From where the BookSelectTask was called
+ val message = String.format("bookSelect failed, bad credentials")
+ val exception = IOException(message)
+ //Fail the current step
+ this.taskRecorder.currentStepFailed(
+ message = message,
+ errorCode = "accessTokenExpired",
+ exception = exception
+ )
+ this.logger.debug("refresh credentials due to 401 server response")
+ //Failure is checked and handled in Controller, where the tokenRefresh is triggered
+ //Don't set as logged out, as can possibly be logged in with tokenRefresh
+ throw TaskFailedHandled(exception)
+ } else {
+ val report = status.properties.problemReport
+ if (report != null) {
+ taskRecorder.addAttributes(report.toMap())
+ }
+
+ taskRecorder.currentStepFailed(
+ message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}",
+ errorCode = "SelectedError",
+ exception = null
+ )
+
+ throw SelectTaskException.SelectTaskFailed()
+ }
+ }
+
+ private fun handleOKRequest(
+ account: AccountType,
+ uri: URI,
+ status: LSHTTPResponseStatus.Responded.OK,
+ book: Book
+ ) {
+ //Get the server response
+ val inputStream = status.bodyStream ?: ByteArrayInputStream(ByteArray(0))
+
+ // new entry created from ONLY response
+ val entry = this.parseOPDSFeedEntry(inputStream, uri)
+
+ //get database
+ val database = account.bookDatabase
+
+ //The version in database might be carrying loan information
+ //And just overwriting the existing entry with the new one would not carry that information over
+ if (database.books().contains(book.id)) {
+ //If book already in db, get the entry and insert the availability information already available
+ val dbEntry = database.entry(book.id)
+ //Build a new feed entry
+ val adjustedEntry = OPDSAcquisitionFeedEntry.newBuilderFrom(entry)
+ .setAvailability(dbEntry.book.entry.availability)
+ .build()
+ //Create the database entry
+ logger.debug("Book was in database with availability:")
+ logger.debug(dbEntry.book.entry.availability.toString())
+ database.createOrUpdate(book.id, adjustedEntry)
+ } else {
+ //otherwise just add the response entry
+ database.createOrUpdate(book.id, entry)
+ }
+
+ val updatedEntry = database.entry(book.id)
+
+ //If book has previous status, we want to add it to the status update so it can be reset
+ val oldBookStatus = this.bookRegistry.bookStatusOrNull(book.id)
+ logger.debug("OLD BOOK STATUS :{}",oldBookStatus)
+ //If successful, update the state of the book in database to selected, so other things happen
+ this.bookRegistry.update(
+ BookWithStatus(
+ book = updatedEntry.book,
+ status = BookStatus.Selected(updatedEntry.book.id, oldBookStatus)
+ )
+ )
+
+ taskRecorder.currentStepSucceeded("Book selected successfully")
+ }
+ private fun parseOPDSFeedEntry(
+ inputStream: InputStream,
+ uri: URI
+ ): OPDSAcquisitionFeedEntry {
+ taskRecorder.beginNewStep("Parsing the OPDS feed entry...")
+ val parser = OPDSAcquisitionFeedEntryParser.newParser()
+
+ return try {
+ inputStream.use {
+ parser.parseEntryStream(uri, it)
+ }
+ } catch (e: OPDSParseException) {
+ logger.error("OPDS feed parse error: ", e)
+ taskRecorder.currentStepFailed(
+ message = "Failed to parse the OPDS feed entry (${e.message}).",
+ errorCode = "Parse error",
+ exception = e
+ )
+ throw SelectTaskException.SelectTaskFailed()
+ }
+ }
+}
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt
index 99cf5f624..92117a417 100644
--- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt
@@ -1,5 +1,6 @@
package org.nypl.simplified.books.controller
+import com.io7m.jfunctional.None
import com.io7m.jfunctional.Some
import org.librarysimplified.http.api.LSHTTPClientType
import org.librarysimplified.http.api.LSHTTPResponseStatus
@@ -24,6 +25,7 @@ import org.nypl.simplified.books.book_registry.BookWithStatus
import org.nypl.simplified.books.controller.api.BooksControllerType
import org.nypl.simplified.feeds.api.FeedLoaderType
import org.nypl.simplified.feeds.api.FeedLoading
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
import org.nypl.simplified.opds.core.OPDSAvailabilityRevoked
import org.nypl.simplified.opds.core.OPDSFeedParserType
import org.nypl.simplified.opds.core.OPDSParseException
@@ -65,10 +67,12 @@ class BookSyncTask(
this.logger.debug("syncing account {}", account.id)
this.taskRecorder.beginNewStep("Syncing...")
+ //Handle keys
MDC.put(MDCKeys.ACCOUNT_INTERNAL_ID, account.id.uuid.toString())
MDC.put(MDCKeys.ACCOUNT_PROVIDER_NAME, account.provider.displayName)
MDC.put(MDCKeys.ACCOUNT_PROVIDER_ID, account.provider.id.toString())
+ //Update provider
val provider = this.updateAccountProvider(account)
val providerAuth = provider.authentication
if (providerAuth == AccountProviderAuthenticationDescription.Anonymous) {
@@ -76,6 +80,7 @@ class BookSyncTask(
return this.taskRecorder.finishSuccess(Unit)
}
+ //Get credentials to use for request authentication
val credentials = account.loginState.credentials
if (credentials == null) {
this.logger.debug("no credentials, aborting!")
@@ -87,28 +92,62 @@ class BookSyncTask(
credentials = credentials
)
- val loansURI = provider.loansURI
- if (loansURI == null) {
- this.logger.debug("no loans URI, aborting!")
- return this.taskRecorder.finishSuccess(Unit)
+ //Get the loans stream
+ //Continue execution only if successful
+ //Otherwise it's useless and can trigger multiple refresh
+ //requests in row, which makes the logout happen unnecessarily
+ val loansStream: InputStream = fetchFeed(
+ provider.loansURI,
+ credentials,
+ account
+ ) ?: return this.taskRecorder.finishSuccess(Unit)
+
+ //Get the selected stream
+ val selectedStream: InputStream? = fetchFeed(
+ provider.selectedURI,
+ credentials,
+ account
+ )
+ //If both fetches went fine, we combine the streams
+ //And update database and registry
+ if (selectedStream != null) {
+ this.onHTTPOKMultipleFeeds(
+ loansStream = loansStream,
+ selectedStream = selectedStream,
+ provider = provider,
+ account = account
+ )
}
+ return this.taskRecorder.finishSuccess(Unit)
+ }
- val request =
- this.http.newRequest(loansURI)
+ /**
+ * Fetch a feed from the URI provided.
+ */
+ private fun fetchFeed(
+ uri: URI?,
+ credentials: AccountAuthenticationCredentials,
+ account: AccountType
+ ) : InputStream? {
+ if (uri == null) {
+ this.logger.debug("no fetch URI, aborting!")
+ this.taskRecorder.finishSuccess(Unit)
+ return null
+ }
+ //Create the request
+ val feedRequest =
+ this.http.newRequest(uri)
.setAuthorization(AccountAuthenticatedHTTP.createAuthorization(credentials))
.addCredentialsToProperties(credentials)
.build()
- val response = request.execute()
- return when (val status = response.status) {
+ //Execute the fetch
+ val feedResponse = feedRequest.execute()
+ return when (val status = feedResponse.status) {
is LSHTTPResponseStatus.Responded.OK -> {
- this.onHTTPOK(
- stream = status.bodyStream ?: ByteArrayInputStream(ByteArray(0)),
- provider = provider,
- account = account,
- accessToken = status.getAccessToken()
- )
- this.taskRecorder.finishSuccess(Unit)
+ //If answer is okay
+ //Return the response stream
+ status.bodyStream ?: ByteArrayInputStream(ByteArray(0))
}
is LSHTTPResponseStatus.Responded.Error -> {
val recovered = this.onHTTPError(status, account)
@@ -116,7 +155,7 @@ class BookSyncTask(
if (recovered) {
this.taskRecorder.finishSuccess(Unit)
} else {
- val message = String.format("%s: %d: %s", provider.loansURI, status.properties.status, status.properties.message)
+ val message = String.format("%s: %d: %s", uri, status.properties.status, status.properties.message)
val exception = IOException(message)
this.taskRecorder.currentStepFailed(
message = message,
@@ -125,6 +164,7 @@ class BookSyncTask(
)
throw TaskFailedHandled(exception)
}
+ null
}
is LSHTTPResponseStatus.Failed ->
throw IOException(status.exception)
@@ -209,6 +249,10 @@ class BookSyncTask(
}
}
+ /**
+ * On successful HTTP request, update basicToken credentials
+ * and parse the given feed, saving the books into the book registry.
+ */
@Throws(IOException::class)
private fun onHTTPOK(
stream: InputStream,
@@ -297,6 +341,158 @@ class BookSyncTask(
}
}
+ /**
+ * Handle successful HTTP with multiple feeds
+ */
+ @Throws(IOException::class)
+ private fun onHTTPOKMultipleFeeds(
+ loansStream: InputStream,
+ selectedStream: InputStream,
+ provider: AccountProviderType,
+ account: AccountType
+ ) {
+ //Parse multiple streams into one
+ loansStream.use { loans ->
+ this.parseSelectedAndLoansFeeds(loans, selectedStream, provider, account)
+ }
+ }
+
+ /**
+ * Parse together two feeds and add the books into the database. Removes from the
+ * database entries that are not available in either of the feeds.
+ */
+ @Throws(OPDSParseException::class)
+ private fun parseSelectedAndLoansFeeds(
+ loansStream: InputStream,
+ selectedStream: InputStream,
+ provider: AccountProviderType,
+ account: AccountType
+ ) {
+ //Parse loans
+ val loansFeed = this.feedParser.parse(provider.selectedURI, loansStream)
+
+ //Parse selected
+ val selectedFeed = this.feedParser.parse(provider.selectedURI, selectedStream)
+
+ //Combine the feed entries from both feeds into one list, create new IDs for them
+ val loansMap = loansFeed.feedEntries.associateBy { BookIDs.newFromOPDSEntry(it) }
+ val selectedMap = selectedFeed.feedEntries.associateBy { BookIDs.newFromOPDSEntry(it) }
+
+ //Get all non-duplicate IDs
+ val allBookIDs = (loansMap.keys + selectedMap.keys)
+
+ //Initiate the list for the combined feed entries
+ val combinedFeedEntries = mutableListOf()
+
+ //Map values based on their id
+ allBookIDs.map { id ->
+ //Get the entries for id for both feeds
+ val loansEntry = loansMap[id]
+ val selectedEntry = selectedMap[id]
+
+ //If there is a loans entry, use it as a base
+ if (loansEntry != null) {
+ //If there is a selected entry, we want to copy its selected value to the loans entry
+ if (selectedEntry != null) {
+ //Create new entry based on the loans entry and replace the values you want to replace
+ //Which currently only selected info
+ val newEntry = OPDSAcquisitionFeedEntry.newBuilderFrom(loansEntry)
+ .setSelectedOption(selectedEntry.selected)
+ .build()
+ //Add the new entry
+ combinedFeedEntries.add(newEntry)
+ } else {
+ // If book is not selected, we can just add the loans entry
+ combinedFeedEntries.add(loansEntry)
+ }
+ } else {
+ //If there is no loans entry, we can just add the selected entry
+ combinedFeedEntries.add(selectedEntry!!)
+ }
+ }
+
+ /*
+ * Obtain the set of books that are on disk already. If any
+ * of these books are not selected and not loaned, then they have
+ * expired and/or are unselected and should be deleted.
+ */
+
+ val bookDatabase = account.bookDatabase
+ val existing = bookDatabase.books()
+
+ /*
+ * Handle each book in the combined feed by checking if matching book is in database
+ */
+ val receivedBooks = HashSet(64)
+ for (opdsEntry in combinedFeedEntries) {
+ // Create new id for the entry (will match the ID of the book in the registry, if any)
+ val bookId = BookIDs.newFromOPDSEntry(opdsEntry)
+ // Add to the received loans
+ receivedBooks.add(bookId)
+
+ this.logger.debug("[{}] updating", bookId.brief())
+
+ // Try to add the book to the database
+ // Update old entries or add new ones
+ try {
+ //Create a new database entry, or update old one
+ val databaseEntry = bookDatabase.createOrUpdate(bookId, opdsEntry)
+ //get the book we just added and refresh the registry
+ val book = databaseEntry.book
+ //Update the book state in the registry, create the status based on the book entry
+ this.bookRegistry.update(BookWithStatus(book, BookStatus.fromBook(book)))
+ } catch (e: BookDatabaseException) {
+ this.logger.error("[{}] unable to update database entry: ", bookId.brief(), e)
+ }
+ }
+
+ /*
+ * Now delete/revoke any book that previously existed, but is not in the
+ * received set of IDs we formed previously.
+ */
+
+ // Initiate the list into which we collect the book's IDs that need to be revoked
+ //Not just deleted
+ val revoking = HashSet(existing.size)
+ //Go through all id:s in database
+ for (existingId in existing) {
+ try {
+ this.logger.debug("[{}] checking for deletion", existingId.brief())
+
+ //If book not in loans or selected, handle it accordingly
+ if (!allBookIDs.contains(existingId)) {
+ val dbEntry = bookDatabase.entry(existingId)
+ val a = dbEntry.book.entry.availability
+ val b = dbEntry.book.entry.selected
+ // If the book availability is revoked, it should be revoked
+ if (a is OPDSAvailabilityRevoked) {
+ revoking.add(existingId)
+ } else {
+ //Otherwise just deleting will do
+ this.logger.debug("[{}] deleting", existingId.brief())
+ //Load the single entry and add the "neutral" version to the book
+ this.updateRegistryForBook(account, dbEntry)
+ //Delete entry from database
+ dbEntry.delete()
+ }
+ } else {
+ this.logger.debug("[{}] keeping", existingId.brief())
+ }
+ } catch (x: Throwable) {
+ this.logger.error("[{}]: unable to delete entry: ", existingId.value(), x)
+ }
+ }
+
+ /*
+ * Finish the revocation of any books that need it.
+ */
+
+ for (revoke_id in revoking) {
+ this.logger.debug("[{}] revoking", revoke_id.brief())
+ this.booksController.bookRevoke(account.id, revoke_id)
+ }
+ }
+
private fun updateRegistryForBook(
account: AccountType,
dbEntry: BookDatabaseEntryType
@@ -343,14 +539,22 @@ class BookSyncTask(
): Boolean {
when(account.loginState.credentials) {
is AccountAuthenticationCredentials.Ekirjasto -> {
+ //If the answer is 401, refresh needs to be triggered
if (result.properties.status == 401) {
+ //Create an exception that is handled in AbstractBookTask and forwarded to Controller,
+ //From where the BookSyncTask was called
+ val message = String.format("bookSync failed, bad credentials")
+ val exception = IOException(message)
+ //Fail the current step
+ this.taskRecorder.currentStepFailed(
+ message = message,
+ errorCode = "accessTokenExpired",
+ exception = exception
+ )
this.logger.debug("refresh credentials due to 401 server response")
- //Launch accessToken refresh
- booksController.executeProfileAccountAccessTokenRefresh(accountID)
- //Returns true, as it's not an actual error, so can continue normally
+ //Failure is checked and handled in Controller, where the tokenRefresh is triggered
//Don't set as logged out, as can possibly be logged in with tokenRefresh
- //If logout is needed, it is handled in another part of the code
- return true
+ throw TaskFailedHandled(exception)
}
}
else -> {
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookUnselectTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookUnselectTask.kt
new file mode 100644
index 000000000..8a00b2fbe
--- /dev/null
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookUnselectTask.kt
@@ -0,0 +1,239 @@
+package org.nypl.simplified.books.controller
+
+import com.io7m.jfunctional.None
+import com.io7m.jfunctional.OptionType
+import com.io7m.jfunctional.Some
+import one.irradia.mime.api.MIMECompatibility
+import org.librarysimplified.http.api.LSHTTPClientType
+import org.librarysimplified.http.api.LSHTTPRequestBuilderType
+import org.librarysimplified.http.api.LSHTTPResponseStatus
+import org.librarysimplified.mdc.MDCKeys
+import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP
+import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsToProperties
+import org.nypl.simplified.accounts.api.AccountID
+import org.nypl.simplified.accounts.database.api.AccountType
+import org.nypl.simplified.books.api.BookID
+import org.nypl.simplified.books.api.BookIDs
+import org.nypl.simplified.books.book_database.api.BookDatabaseEntryType
+import org.nypl.simplified.books.book_registry.BookRegistry
+import org.nypl.simplified.books.book_registry.BookRegistryType
+import org.nypl.simplified.books.book_registry.BookStatus
+import org.nypl.simplified.books.book_registry.BookWithStatus
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntryParser
+import org.nypl.simplified.opds.core.OPDSAvailabilityLoanable
+import org.nypl.simplified.opds.core.OPDSAvailabilityType
+import org.nypl.simplified.opds.core.OPDSParseException
+import org.nypl.simplified.opds.core.getOrNull
+import org.nypl.simplified.profiles.api.ProfileID
+import org.nypl.simplified.profiles.api.ProfilesDatabaseType
+import org.nypl.simplified.taskrecorder.api.TaskRecorder
+import org.nypl.simplified.taskrecorder.api.TaskRecorderType
+import org.nypl.simplified.taskrecorder.api.TaskResult
+import org.slf4j.LoggerFactory
+import org.slf4j.MDC
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.URI
+
+/**
+ * Task for unselecting a particular book, removing it from the favorites list.
+ * Extends the AbstractBookTask class.
+ */
+
+class BookUnselectTask (
+ private val accountID: AccountID,
+ profileID: ProfileID,
+ profiles: ProfilesDatabaseType,
+ private val HTTPClient: LSHTTPClientType,
+ private val feedEntry: OPDSAcquisitionFeedEntry,
+ private val bookRegistry: BookRegistryType
+) : AbstractBookTask(accountID, profileID, profiles) {
+
+ override val taskRecorder: TaskRecorderType =
+ TaskRecorder.create()
+ override val logger =
+ LoggerFactory.getLogger(BookUnselectTask::class.java)
+
+ override fun execute(account: AccountType): TaskResult.Success {
+ taskRecorder.beginNewStep("Creating an OPDS Unselect...")
+
+ try {
+ logger.debug("setting up book database entry")
+ val database = account.bookDatabase
+ val bookID = BookIDs.newFromOPDSEntry(feedEntry)
+ val databaseEntry = database.entry(bookID)
+
+ MDC.put(MDCKeys.BOOK_INTERNAL_ID, databaseEntry.book.id.value())
+ MDC.put(MDCKeys.BOOK_TITLE, databaseEntry.book.entry.title)
+ MDCKeys.put(MDCKeys.BOOK_PUBLISHER, databaseEntry.book.entry.publisher)
+
+ //Using the alternate link as the base for request
+ val baseURI = feedEntry.alternate.getOrNull()
+ if (baseURI == null) {
+ logger.debug("No link to form")
+ return taskRecorder.finishSuccess(Unit)
+ }
+ //Form the select URI by adding the desired path
+ val currentURI = URI.create(baseURI.toString().plus("/unselect_book"))
+
+ taskRecorder.beginNewStep("Using $currentURI to unselect a book...")
+ taskRecorder.addAttribute("Unselect URI", currentURI.toString())
+
+ //Use the credentials available on current profile
+ val credentials = account.loginState.credentials
+
+ //Create the authorization (bearer) based on current credentials
+ val auth =
+ AccountAuthenticatedHTTP.createAuthorizationIfPresent(credentials)
+
+ //Use the DELETE method to ask server to remove the book to selected with auth
+ val request =
+ HTTPClient.newRequest(currentURI!!)
+ .setMethod(
+ LSHTTPRequestBuilderType.Method.Delete(
+ ByteArray(0),
+ MIMECompatibility.applicationOctetStream
+ )
+ )
+ .setAuthorization(auth)
+ .addCredentialsToProperties(credentials)
+ .build()
+
+ request.execute().use { response ->
+ when (val status = response.status) {
+ is LSHTTPResponseStatus.Responded.OK -> {
+ //If successful, store returned value to bookRegistry
+ this.handleOKRequest(account, currentURI, status, databaseEntry, bookID)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ is LSHTTPResponseStatus.Responded.Error -> {
+ this.handleHTTPError(status)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ is LSHTTPResponseStatus.Failed -> {
+ this.handleHTTPFailure(status)
+ return taskRecorder.finishSuccess(Unit)
+ }
+ }
+ }
+ } catch (e: SelectTaskException.SelectAccessTokenExpired) {
+ //Catch a special error for access token expiration
+ throw e
+ }
+ }
+
+ override fun onFailure(result: TaskResult.Failure) {
+ //Do nothing
+ }
+
+ private fun handleHTTPFailure(
+ status: LSHTTPResponseStatus.Failed
+ ) {
+ taskRecorder.currentStepFailed(
+ message = status.exception.message ?: "Exception raised during connection attempt.",
+ errorCode = "SelectingFailed",
+ exception = status.exception
+ )
+ throw SelectTaskException.UnselectTaskFailed()
+ }
+
+ private fun handleHTTPError(
+ status: LSHTTPResponseStatus.Responded.Error
+ ) {
+
+ if (status.properties.status == 401) {
+ //Create an exception that is handled in AbstractBookTask and forwarded to Controller,
+ //From where the BookUnselectTask was called
+ val message = String.format("bookSelect failed, bad credentials")
+ val exception = IOException(message)
+ //Fail the current step
+ this.taskRecorder.currentStepFailed(
+ message = message,
+ errorCode = "accessTokenExpired",
+ exception = exception
+ )
+ this.logger.debug("refresh credentials due to 401 server response")
+ //Failure is checked and handled in Controller, where the tokenRefresh is triggered
+ //Don't set as logged out, as can possibly be logged in with tokenRefresh
+ throw TaskFailedHandled(exception)
+
+ } else {
+
+ val report = status.properties.problemReport
+ if (report != null) {
+ taskRecorder.addAttributes(report.toMap())
+ }
+
+ taskRecorder.currentStepFailed(
+ message = "HTTP request failed: ${status.properties.originalStatus} ${status.properties.message}",
+ errorCode = "UnselectError",
+ exception = null
+ )
+
+ throw SelectTaskException.UnselectTaskFailed()
+ }
+ }
+
+ /**
+ * Update the database and registry so that the selected info is removed
+ */
+ private fun handleOKRequest(
+ account: AccountType,
+ uri: URI,
+ status: LSHTTPResponseStatus.Responded.OK,
+ databaseEntry: BookDatabaseEntryType,
+ bookID: BookID
+ ) {
+ //If the book is on loan, the loan info should be retained, and the selected info removed
+
+ //Get the input stream from the delete
+ val inputStream = status.bodyStream ?: ByteArrayInputStream(ByteArray(0))
+
+ //Returned value,without the selected info
+ val entry = this.parseOPDSFeedEntry(inputStream, uri)
+
+ //Make a new value from response with old availability info from database
+ val newEntry = OPDSAcquisitionFeedEntry.newBuilderFrom(entry)
+ .setAvailability(databaseEntry.book.entry.availability)
+ .build()
+
+ //Update the database and take the new answer to be put into the registry
+ val newValue = account.bookDatabase.createOrUpdate(bookID, newEntry)
+
+ //Old status
+ val statusOld = this.bookRegistry.bookStatusOrNull(bookID)
+
+ //Shows the correct popup before possibly removing the entry
+ this.bookRegistry.update(
+ BookWithStatus(
+ newValue.book,
+ BookStatus.Unselected(bookID, statusOld))
+ )
+
+ taskRecorder.currentStepSucceeded("Book unselected successfully")
+ }
+
+ private fun parseOPDSFeedEntry(
+ inputStream: InputStream,
+ uri: URI
+ ): OPDSAcquisitionFeedEntry {
+ taskRecorder.beginNewStep("Parsing the OPDS feed entry...")
+ val parser = OPDSAcquisitionFeedEntryParser.newParser()
+
+ return try {
+ inputStream.use {
+ parser.parseEntryStream(uri, it)
+ }
+ } catch (e: OPDSParseException) {
+ logger.error("OPDS feed parse error: ", e)
+ taskRecorder.currentStepFailed(
+ message = "Failed to parse the OPDS feed entry (${e.message}).",
+ errorCode = "Parse error",
+ exception = e
+ )
+ throw SelectTaskException.UnselectTaskFailed()
+ }
+ }
+}
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt
index 4faa2f007..5618e3444 100644
--- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt
@@ -31,7 +31,9 @@ import org.nypl.simplified.accounts.database.api.AccountsDatabaseNonexistentExce
import org.nypl.simplified.accounts.registry.api.AccountProviderRegistryEvent
import org.nypl.simplified.accounts.registry.api.AccountProviderRegistryType
import org.nypl.simplified.analytics.api.AnalyticsType
+import org.nypl.simplified.books.api.Book
import org.nypl.simplified.books.api.BookID
+import org.nypl.simplified.books.api.BookIDs
import org.nypl.simplified.books.book_registry.BookPreviewRegistryType
import org.nypl.simplified.books.book_registry.BookRegistryType
import org.nypl.simplified.books.book_registry.BookStatus
@@ -41,6 +43,7 @@ import org.nypl.simplified.books.borrowing.BorrowRequirements
import org.nypl.simplified.books.borrowing.BorrowTask
import org.nypl.simplified.books.borrowing.BorrowTaskType
import org.nypl.simplified.books.borrowing.SAMLDownloadContext
+import org.nypl.simplified.books.borrowing.internal.BorrowErrorCodes
import org.nypl.simplified.books.controller.api.BookRevokeStringResourcesType
import org.nypl.simplified.books.controller.api.BooksControllerType
import org.nypl.simplified.books.controller.api.BooksPreviewControllerType
@@ -761,19 +764,57 @@ class Controller private constructor(
accountID: AccountID
): FluentFuture> {
return this.submitTask(
- BookSyncTask(
- accountID = accountID,
- profileID = this.profileCurrent().id,
- profiles = this.profiles,
- accountRegistry = this.accountProviders,
- bookRegistry = this.bookRegistry,
- booksController = this,
- feedParser = this.feedParser,
- feedLoader = this.feedLoader,
- patronParsers = this.patronUserProfileParsers,
- http = this.lsHttp
- )
- )
+ Callable> {
+ val syncTask = BookSyncTask(
+ accountID = accountID,
+ profileID = this.profileCurrent().id,
+ profiles = this.profiles,
+ accountRegistry = this.accountProviders,
+ bookRegistry = this.bookRegistry,
+ booksController = this,
+ feedParser = this.feedParser,
+ feedLoader = this.feedLoader,
+ patronParsers = this.patronUserProfileParsers,
+ http = this.lsHttp
+ )
+ syncTask.call()
+ }
+ ).transformAsync(AsyncFunction { taskResult ->
+ //Check if the result was a need to refresh the accessToken
+ //BookSyncTask does special error handling, and if the result is 401, it throws
+ //A special error, which can be caught with the error code of "accessTokenExpired"
+ if (taskResult is TaskResult.Failure) {
+ logger.debug("SYNC ERROR : {}", taskResult.lastErrorCode)
+ }
+ if (taskResult is TaskResult.Failure && taskResult.lastErrorCode == "accessTokenExpired" ) {
+ //In order to make the user not have to do anything
+ //Try to sync again if the accessToken refresh is successful
+ executeProfileAccountAccessTokenRefresh(accountID).transformAsync(AsyncFunction { tokenResult ->
+ if (tokenResult is TaskResult.Success) {
+ logger.debug("Attempt to execute bookSync again")
+ val syncTask = BookSyncTask(
+ accountID = accountID,
+ profileID = this.profileCurrent().id,
+ profiles = this.profiles,
+ accountRegistry = this.accountProviders,
+ bookRegistry = this.bookRegistry,
+ booksController = this,
+ feedParser = this.feedParser,
+ feedLoader = this.feedLoader,
+ patronParsers = this.patronUserProfileParsers,
+ http = this.lsHttp
+ )
+ submitTask(Callable { syncTask.call() })
+ } else {
+ //If accessToken refresh fails, return the result that should popup the login
+ Futures.immediateFuture(tokenResult)
+ }
+ }, MoreExecutors.directExecutor())
+ } else {
+ //In other errors return the normal bookSync answer
+ Futures.immediateFuture(taskResult)
+ }
+ }, MoreExecutors.directExecutor())
}
override fun bookRevoke(
@@ -799,11 +840,9 @@ class Controller private constructor(
}
).transformAsync(AsyncFunction { taskResult ->
//Check if the result was a need to refresh the accessToken
- //BookRevokeTask does special error handling, and if the result is 401, it throws
- //A special error, which can be caught with the error code of "accessTokenExpired"
+ //This can be caught with the error code of "accessTokenExpired"
if (taskResult is TaskResult.Failure && taskResult.lastErrorCode == "accessTokenExpired" ) {
- //In order to make the user not have to do anything
- //Try to return the book again if the accessToken refresh is successful
+ //Try to run revoke again if the accessToken refresh is successful
executeProfileAccountAccessTokenRefresh(accountID).transformAsync(AsyncFunction { tokenResult ->
if (tokenResult is TaskResult.Success) {
logger.debug("Attempt to execute return again")
@@ -895,6 +934,103 @@ class Controller private constructor(
)
}
+ override fun bookAddToSelected(
+ accountID: AccountID,
+ feedEntry: FeedEntry.FeedEntryOPDS
+
+ ) : FluentFuture> {
+ val profile = this.profileCurrent()
+
+ return this.submitTask(
+ Callable> {
+ val bookSelectTask = BookSelectTask(
+ accountID= accountID,
+ profileID = profile.id,
+ profiles = profiles,
+ HTTPClient = this.lsHttp,
+ feedEntry = feedEntry.feedEntry,
+ bookRegistry = bookRegistry
+ )
+ bookSelectTask.call()
+ }
+ ).transformAsync(AsyncFunction { taskResult ->
+ //Check if the result was a need to refresh the accessToken
+ //Can be caught with the error code of "accessTokenExpired"
+ if (taskResult is TaskResult.Failure && taskResult.lastErrorCode == "accessTokenExpired" ) {
+ //In order to make the user not have to do anything
+ //Try to return select the book again if the accessToken refresh is successful
+ executeProfileAccountAccessTokenRefresh(accountID).transformAsync(AsyncFunction { tokenResult ->
+ if (tokenResult is TaskResult.Success) {
+ logger.debug("Attempt to execute bookSelect again")
+ val bookSelectTask = BookSelectTask(
+ accountID= accountID,
+ profileID = profile.id,
+ profiles = profiles,
+ HTTPClient = this.lsHttp,
+ feedEntry = feedEntry.feedEntry,
+ bookRegistry = bookRegistry
+ )
+ submitTask(Callable { bookSelectTask.call() })
+ } else {
+ //If accessToken refresh fails, return the result that should popup the login
+ Futures.immediateFuture(tokenResult)
+ }
+ }, MoreExecutors.directExecutor())
+ } else {
+ //In other errors return the normal bookSync answer
+ Futures.immediateFuture(taskResult)
+ }
+ }, MoreExecutors.directExecutor())
+ }
+
+ override fun bookRemoveFromSelected(
+ accountID: AccountID,
+ feedEntry: FeedEntry.FeedEntryOPDS
+ ): FluentFuture> {
+ val profile = this.profileCurrent()
+
+ return this.submitTask(
+ Callable> {
+ val bookUnselectTask = BookUnselectTask(
+ accountID= accountID,
+ profileID = profile.id,
+ profiles = profiles,
+ HTTPClient = this.lsHttp,
+ feedEntry = feedEntry.feedEntry,
+ bookRegistry = bookRegistry
+ )
+ bookUnselectTask.call()
+ }
+ ).transformAsync(AsyncFunction { taskResult ->
+ //Check if the result was a need to refresh the accessToken
+ //Can be caught with the error code of "accessTokenExpired"
+ if (taskResult is TaskResult.Failure && taskResult.lastErrorCode == "accessTokenExpired" ) {
+ //In order to make the user not have to do anything
+ //Try to unselect the book again if the accessToken refresh is successful
+ executeProfileAccountAccessTokenRefresh(accountID).transformAsync(AsyncFunction { tokenResult ->
+ if (tokenResult is TaskResult.Success) {
+ logger.debug("Attempt to execute bookUnselect again")
+ val bookUnselectTask = BookUnselectTask(
+ accountID= accountID,
+ profileID = profile.id,
+ profiles = profiles,
+ HTTPClient = this.lsHttp,
+ feedEntry = feedEntry.feedEntry,
+ bookRegistry = bookRegistry
+ )
+ submitTask(Callable { bookUnselectTask.call() })
+ } else {
+ //If accessToken refresh fails, return the result that should popup the login
+ Futures.immediateFuture(tokenResult)
+ }
+ }, MoreExecutors.directExecutor())
+ } else {
+ //In other errors return the normal bookSync answer
+ Futures.immediateFuture(taskResult)
+ }
+ }, MoreExecutors.directExecutor())
+ }
+
override fun profileAnyIsCurrent(): Boolean =
this.profiles.currentProfile().isSome
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileFeedTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileFeedTask.kt
index 2f3cef231..15c358265 100644
--- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileFeedTask.kt
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileFeedTask.kt
@@ -1,5 +1,6 @@
package org.nypl.simplified.books.controller
+import com.io7m.jfunctional.None
import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.accounts.api.AccountLoginState
import org.nypl.simplified.books.book_registry.BookRegistryReadableType
@@ -13,6 +14,8 @@ import org.nypl.simplified.feeds.api.FeedFacet
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForAccount
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.Sorting
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.Sorting.SortBy
+import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForFeed
+import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForFeed.FilterBy
import org.nypl.simplified.feeds.api.FeedSearch
import org.nypl.simplified.profiles.controller.api.ProfileFeedRequest
import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
@@ -39,7 +42,9 @@ internal class ProfileFeedTask(
* Generate facets.
*/
- val facetGroups = this.makeFacets()
+ //Chose the tabs we want to show on the my books page
+ val doSelectFacets = request.feedSelection != FeedBooksSelection.BOOKS_FEED_SELECTED
+ val facetGroups = this.makeFacets(doSelectFacets)
val facets = facetGroups.values.flatten()
val feed =
@@ -57,14 +62,25 @@ internal class ProfileFeedTask(
val books = this.collectAllBooks(this.bookRegistry)
this.logger.debug("collected {} candidate books", books.size)
+ //Get the correct filter based on the bookStatus of the books in the registry
val filter = this.selectFeedFilter(this.request)
+ //Filter the books based on their bookStatus
this.filterBooks(filter, books)
this.logger.debug("after filtering, {} candidate books remain", books.size)
+ //If we look at selected feed, filter out the non selected books
+ //This is not done by the filtering, since the selection can not be seen from bookStatus
+ if(this.request.feedSelection == FeedBooksSelection.BOOKS_FEED_SELECTED) {
+ this.searchSelectedBooks(books)
+ this.logger.debug("after selecting, {} candidate books remain", books.size)
+ }
+ //If there is a search term, filter the unfitting books out
this.searchBooks(this.request.search, books)
this.logger.debug("after searching, {} candidate books remain", books.size)
+ //Sort the books in the way we want
this.sortBooks(this.request.sortBy, books)
this.logger.debug("after sorting, {} candidate books remain", books.size)
+ //Create feed entries for all the books
for (book in books) {
feed.entriesInOrder.add(
FeedEntry.FeedEntryOPDS(
@@ -80,13 +96,25 @@ internal class ProfileFeedTask(
}
}
- private fun makeFacets(): Map> {
+ /**
+ * Create the facets shown on the top of the feed. If fragment should handle
+ * multiple different feeds (or selections), create the feed selection facets.
+ * @param showSelectionFacets true, if fragment should have navigation facets for different feeds
+ */
+ private fun makeFacets(showSelectionFacets: Boolean): Map> {
+ //The facets for switching between multiple feeds
+ val selecting = this.makeSelectionFacets()
+ //The facets for sorting
val sorting = this.makeSortingFacets()
+ //The facets for filtering
val filtering = this.makeFilteringFacets()
val results = mutableMapOf>()
+ //If we want to show the feed facet, add it
+ if (showSelectionFacets) {
+ results[selecting.first] = selecting.second
+ }
results[sorting.first] = sorting.second
results[filtering.first] = filtering.second
- check(results.size == 2)
return results.toMap()
}
@@ -109,9 +137,42 @@ internal class ProfileFeedTask(
return Pair(this.request.facetTitleProvider.collection, facets)
}
+ private fun makeSelectionFacets(): Pair> {
+ val facets = mutableListOf()
+ //Create entries for all FilterBy options
+ val values = FilterBy.entries.toTypedArray()
+ //Create a facet for each of the bookFilters
+ for (filterFacet in values) {
+ //Set the activity of the facet based on the FilterBy of the request
+ val active = filterFacet == this.request.filterBy
+ //Get the translatable title of the tab
+ val title =
+ when (filterFacet) {
+ FilterBy.FILTER_BY_LOANS -> this.request.facetTitleProvider.showTabLoans
+ FilterBy.FILTER_BY_HOLDS -> this.request.facetTitleProvider.showTabHolds
+ FilterBy.FILTER_BY_SELECTED -> this.request.facetTitleProvider.showTabSelected
+ }
+ //Determine which feed should be shown, based on what we're filtering by
+ val selectedFeed =
+ when(filterFacet) {
+ FilterBy.FILTER_BY_LOANS -> FeedBooksSelection.BOOKS_FEED_LOANED
+ FilterBy.FILTER_BY_HOLDS -> FeedBooksSelection.BOOKS_FEED_HOLDS
+ FilterBy.FILTER_BY_SELECTED -> FeedBooksSelection.BOOKS_FEED_SELECTED
+ }
+ //Currently we don't want to show the selected feed in the books tab
+ //So we don't add it to the facets
+
+ if (selectedFeed != FeedBooksSelection.BOOKS_FEED_SELECTED) {
+ facets.add(FilteringForFeed(title, active, selectedFeed, filterFacet))
+ }
+ }
+ //The name is not shown anywhere, so have it just be something relevant
+ return Pair("FeedTabs", facets)
+ }
+
private fun makeSortingFacets(): Pair> {
val facets = mutableListOf()
- val values = SortBy.values()
+ val values = SortBy.entries.toTypedArray()
for (sortingFacet in values) {
val active = sortingFacet == this.request.sortBy
val title =
@@ -146,6 +207,22 @@ internal class ProfileFeedTask(
}
}
+ /**
+ * Removes books that should not be in the selected book list.
+ */
+ private fun searchSelectedBooks(
+ books: ArrayList
+ ) {
+ val iterator = books.iterator()
+ while (iterator.hasNext()) {
+ val book = iterator.next()
+ //If there is no selected info, remove from books
+ if (book.book.entry.selected is None) {
+ iterator.remove()
+ }
+ }
+ }
+
/**
* Split the given search string into a list of uppercase search terms.
*/
@@ -174,7 +251,7 @@ internal class ProfileFeedTask(
}
private fun sortBooksByTitle(books: ArrayList) {
- Collections.sort(books) { book0, book1 ->
+ books.sortWith { book0, book1 ->
val entry0 = book0.book.entry
val entry1 = book1.book.entry
entry0.title.compareTo(entry1.title)
@@ -182,7 +259,7 @@ internal class ProfileFeedTask(
}
private fun sortBooksByAuthor(books: ArrayList) {
- Collections.sort(books) { book0, book1 ->
+ books.sortWith { book0, book1 ->
val entry0 = book0.book.entry
val entry1 = book1.book.entry
val authors1 = entry0.authors
@@ -262,7 +339,7 @@ internal class ProfileFeedTask(
}
}
- private fun usableForBooksFeed(status: BookStatus): Boolean {
+ private fun usableForLoansFeed(status: BookStatus): Boolean {
return when (status) {
is BookStatus.Held,
is BookStatus.Holdable,
@@ -270,16 +347,17 @@ internal class ProfileFeedTask(
is BookStatus.ReachedLoanLimit,
is BookStatus.Revoked ->
false
-
is BookStatus.Downloading,
is BookStatus.DownloadWaitingForExternalAuthentication,
is BookStatus.DownloadExternalAuthenticationInProgress,
is BookStatus.FailedDownload,
is BookStatus.FailedLoan,
is BookStatus.FailedRevoke,
- is BookStatus.Loaned,
+ is BookStatus.Loaned -> true
is BookStatus.RequestingDownload,
is BookStatus.RequestingLoan,
+ is BookStatus.Selected -> false
+ is BookStatus.Unselected -> false
is BookStatus.RequestingRevoke ->
true
}
@@ -289,7 +367,6 @@ internal class ProfileFeedTask(
return when (status) {
is BookStatus.Held ->
true
-
is BookStatus.Downloading,
is BookStatus.DownloadWaitingForExternalAuthentication,
is BookStatus.DownloadExternalAuthenticationInProgress,
@@ -303,11 +380,24 @@ internal class ProfileFeedTask(
is BookStatus.RequestingDownload,
is BookStatus.RequestingLoan,
is BookStatus.RequestingRevoke,
+ is BookStatus.Selected -> false
+ is BookStatus.Unselected -> false
is BookStatus.Revoked ->
false
}
}
+ /**
+ * Return true if the book is usable for selected feed
+ */
+ private fun usableForSelectedFeed(status: BookStatus): Boolean {
+ //Allow the usage for any BookStatus, except a book when the book is just unselected
+ return when (status) {
+ is BookStatus.Unselected -> false
+ else -> true
+ }
+ }
+
/**
* @return `true` if any of the given search terms match the given book, or the list of
* search terms is empty
@@ -344,8 +434,9 @@ internal class ProfileFeedTask(
request: ProfileFeedRequest
): (BookStatus) -> Boolean {
return when (request.feedSelection) {
- FeedBooksSelection.BOOKS_FEED_LOANED -> ::usableForBooksFeed
+ FeedBooksSelection.BOOKS_FEED_LOANED -> ::usableForLoansFeed
FeedBooksSelection.BOOKS_FEED_HOLDS -> ::usableForHoldsFeed
+ FeedBooksSelection.BOOKS_FEED_SELECTED -> ::usableForSelectedFeed
}
}
}
diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/SelectTaskException.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/SelectTaskException.kt
new file mode 100644
index 000000000..19c352d9a
--- /dev/null
+++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/SelectTaskException.kt
@@ -0,0 +1,7 @@
+package org.nypl.simplified.books.controller
+
+sealed class SelectTaskException : Exception() {
+ class SelectTaskFailed : SelectTaskException()
+ class UnselectTaskFailed : SelectTaskException()
+ class SelectAccessTokenExpired : SelectTaskException()
+}
diff --git a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookRegistry.kt b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookRegistry.kt
index e5fab0ca7..b54e08a6d 100644
--- a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookRegistry.kt
+++ b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookRegistry.kt
@@ -79,12 +79,12 @@ class BookRegistry private constructor(
val updatePri = status.status.priority
if (currentPri.priority <= updatePri.priority) {
- this.logger.debug("current {} <= {}, updating", current, status)
+ this.logger.debug("current {} <= {}, updating", current.status, status.status)
this.update(status)
return
}
- this.logger.debug("current {} > {}, not updating", current, status)
+ this.logger.debug("current {} > {}, not updating", current.status, status.status)
} else {
this.update(status)
}
diff --git a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatus.kt b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatus.kt
index 17902ccee..4f3081f82 100644
--- a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatus.kt
+++ b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatus.kt
@@ -199,6 +199,32 @@ sealed class BookStatus {
get() = BookStatusPriorityOrdering.BOOK_STATUS_DOWNLOAD_FAILED
}
+ /**
+ * The book has just been added to the selected list. This status should be reset.
+ */
+ data class Selected(
+ override val id: BookID,
+
+ val previousStatus: BookStatus?
+ ) : BookStatus() {
+
+ override val priority: BookStatusPriorityOrdering
+ get() = BookStatusPriorityOrdering.BOOK_STATUS_SELECTED
+ }
+
+ /**
+ * The book has just been removed from the selected list. This status should be reset.
+ */
+ data class Unselected(
+ override val id: BookID,
+
+ val previousStatus: BookStatus?
+ ) : BookStatus() {
+
+ override val priority: BookStatusPriorityOrdering
+ get() = BookStatusPriorityOrdering.BOOK_STATUS_UNSELECTED
+ }
+
/**
* The given book not available for loan, but may be placed on hold.
*/
diff --git a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatusPriorityOrdering.java b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatusPriorityOrdering.java
index ac722b76d..e15b3374d 100644
--- a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatusPriorityOrdering.java
+++ b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookStatusPriorityOrdering.java
@@ -27,6 +27,16 @@ public enum BookStatusPriorityOrdering
BOOK_STATUS_DOWNLOAD_FAILED(90),
+ /**
+ * {@link BookStatus.Selected}
+ */
+ BOOK_STATUS_SELECTED(90),
+
+
+ /**
+ * {@link BookStatus.Unselected}
+ */
+ BOOK_STATUS_UNSELECTED(90),
/**
* {@link BookStatus.Loaned.Downloading.Downloading.DownloadExternalAuthenticationInProgress}
*/
diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedBooksSelection.java b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedBooksSelection.java
index 669f79631..460c526ac 100644
--- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedBooksSelection.java
+++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedBooksSelection.java
@@ -18,5 +18,10 @@ public enum FeedBooksSelection implements Serializable
* Generate a feed of books that are currently on hold.
*/
- BOOKS_FEED_HOLDS
+ BOOKS_FEED_HOLDS,
+
+ /**
+ * Generate a feed of books that are currently selected.
+ */
+ BOOKS_FEED_SELECTED
}
diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacet.kt b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacet.kt
index a71e24a5a..01f3e7f8d 100644
--- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacet.kt
+++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacet.kt
@@ -43,6 +43,36 @@ sealed class FeedFacet : Serializable {
val account: AccountID?
) : FeedFacetPseudo()
+ /**
+ * A feed select facet.
+ */
+ data class FilteringForFeed(
+ override val title: String,
+ override val isActive: Boolean,
+ val selectedFeed: FeedBooksSelection,
+ val filterBy: FilterBy
+ ) : FeedFacetPseudo() {
+ enum class FilterBy {
+
+ /**
+ * Filter loans.
+ */
+
+ FILTER_BY_LOANS,
+
+ /**
+ * Filter holds.
+ */
+
+ FILTER_BY_HOLDS,
+
+ /**
+ * Filter the selected.
+ */
+ FILTER_BY_SELECTED
+ }
+ }
+
/**
* A sorting facet.
*/
diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacetPseudoTitleProviderType.kt b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacetPseudoTitleProviderType.kt
index 81938a4e0..f6a590197 100644
--- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacetPseudoTitleProviderType.kt
+++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacetPseudoTitleProviderType.kt
@@ -17,4 +17,7 @@ interface FeedFacetPseudoTitleProviderType {
val show: String
val showAll: String
val showOnLoan: String
+ val showTabSelected: String
+ val showTabLoans: String
+ val showTabHolds: String
}
diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacets.kt b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacets.kt
index 4264d7e7a..ec55fdd81 100644
--- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacets.kt
+++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedFacets.kt
@@ -69,6 +69,8 @@ object FeedFacets {
}
/**
+ * Checks if a facet is an entry point. Returns true for opdsFacets with the
+ * correct group type, and for pseudofacets that are the kind of FilteringForFeed
* @return `true` if the given facet is "entry point" typed
*/
@@ -78,7 +80,14 @@ object FeedFacets {
is FeedFacet.FeedFacetOPDS ->
facet.opdsFacet.groupType == Option.some(ENTRYPOINT_FACET_GROUP_TYPE)
is FeedFacet.FeedFacetPseudo ->
- false
+ when (facet) {
+ is FeedFacet.FeedFacetPseudo.FilteringForFeed -> {
+ true
+ }
+ else -> {
+ false
+ }
+ }
}
}
}
diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt
index 06716c520..e4b3caa84 100644
--- a/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt
+++ b/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt
@@ -105,7 +105,7 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) {
when (it.itemId) {
org.librarysimplified.ui.tabs.R.id.tabCatalog -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabCatalog)
org.librarysimplified.ui.tabs.R.id.tabBooks -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabBooks)
- org.librarysimplified.ui.tabs.R.id.tabHolds -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabHolds)
+ org.librarysimplified.ui.tabs.R.id.tabSelected -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabSelected)
org.librarysimplified.ui.tabs.R.id.tabMagazines -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabMagazines)
org.librarysimplified.ui.tabs.R.id.tabSettings -> it.title = getString(org.librarysimplified.ui.tabs.R.string.tabSettings)
}
@@ -225,7 +225,7 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) {
this.logger.debug("DBGHOLDS viewModel.showHoldsTab=$showHolds")
val provider = viewModel.profilesController.profileCurrent().mostRecentAccount().provider
this.logger.debug("DBGHOLDS accountProvider id=${provider.id}, displayName=${provider.displayName}, supportReservation=${provider.supportsReservations}")
- val holdsItem = this.bottomView.menu.findItem(org.librarysimplified.ui.tabs.R.id.tabHolds)
+ val holdsItem = this.bottomView.menu.findItem(org.librarysimplified.ui.tabs.R.id.tabBooks)
holdsItem.isVisible = showHolds
holdsItem.isEnabled = showHolds
}
@@ -266,6 +266,8 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) {
is BookStatus.RequestingDownload,
is BookStatus.RequestingLoan,
is BookStatus.RequestingRevoke,
+ is BookStatus.Selected,
+ is BookStatus.Unselected,
is BookStatus.Revoked,
null -> {
// do nothing
@@ -290,26 +292,35 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) {
}
private fun onBookHoldsUpdateEvent(event: BookHoldsUpdateEvent) {
+ //Claimable holds
val numberOfHolds = event.numberOfHolds
+ //If we have holds that are claimable, we show a red dot on the bottom tab to indicate it
if (viewModel.showHoldsTab) {
+ //Get the item in the bottom nav we want to show the dot in
val bottomNavigationItem =
- this.bottomView.findViewById(org.librarysimplified.ui.tabs.R.id.tabHolds)
+ this.bottomView.findViewById(org.librarysimplified.ui.tabs.R.id.tabBooks)
+ //Get the red dot badge
var badgeView =
bottomNavigationItem.findViewById(org.librarysimplified.ui.tabs.R.id.badgeView)
+ //If there are showable holds and a badge to show
if (numberOfHolds > 0) {
if (badgeView == null) {
+ //Combine the dot and the item in the bottom tab into one view
badgeView = LayoutInflater.from(requireContext()).inflate(
org.librarysimplified.ui.tabs.R.layout.layout_menu_item_badge, bottomNavigationItem, false
)
+ //Add the new view to the spot of the original
bottomNavigationItem.addView(badgeView)
}
+ //Add the number in the dot that says how many are claimable
val badgeNumber = (badgeView as? ViewGroup)?.findViewById(
org.librarysimplified.ui.tabs.R.id.badgeNumber)
badgeNumber?.text = numberOfHolds.toString()
}
+ //Show the badge only if there are claimable holds
badgeView?.isVisible = numberOfHolds > 0
}
}
diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentListenerDelegate.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentListenerDelegate.kt
index 9b42e2fdd..434689735 100644
--- a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentListenerDelegate.kt
+++ b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentListenerDelegate.kt
@@ -247,10 +247,14 @@ internal class MainFragmentListenerDelegate(
state
}
- CatalogFeedEvent.GoUpwards -> {
+ is CatalogFeedEvent.GoUpwards -> {
this.goUpwards()
state
}
+ is CatalogFeedEvent.RefreshViews -> {
+ this.navigator.popToRoot()
+ state
+ }
}
}
@@ -377,7 +381,6 @@ internal class MainFragmentListenerDelegate(
this.navigator.popBackStack()
MainFragmentState.EmptyState
}
-
else -> {
state
}
diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt
index fb6d8693d..99d2e30e1 100644
--- a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt
+++ b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt
@@ -224,5 +224,13 @@ class MainFragmentViewModel(
get() = this.resources.getString(R.string.feedShowAll)
override val showOnLoan: String
get() = this.resources.getString(R.string.feedShowOnLoan)
+ override val showTabSelected: String
+ get() = this.resources.getString(R.string.feedFacetSelected)
+
+ override val showTabLoans: String
+ get() = this.resources.getString(R.string.feedFacetLoans)
+
+ override val showTabHolds: String
+ get() = this.resources.getString(R.string.feedFacetHolds)
}
}
diff --git a/simplified-opds-auth-document-api/src/main/java/org/nypl/simplified/opds/auth_document/api/AuthenticationDocument.kt b/simplified-opds-auth-document-api/src/main/java/org/nypl/simplified/opds/auth_document/api/AuthenticationDocument.kt
index 5df792f8d..0bf8b2437 100644
--- a/simplified-opds-auth-document-api/src/main/java/org/nypl/simplified/opds/auth_document/api/AuthenticationDocument.kt
+++ b/simplified-opds-auth-document-api/src/main/java/org/nypl/simplified/opds/auth_document/api/AuthenticationDocument.kt
@@ -72,6 +72,9 @@ data class AuthenticationDocument(
val loansURI: URI? =
this.links.find { link -> link.relation == "http://opds-spec.org/shelf" }?.hrefURI
+ val selectedURI: URI? =
+ this.links.find { link -> link.relation == "http://opds-spec.org/shelf/selected_books" }?.hrefURI
+
val cardCreatorURI: URI? =
this.links.find { link -> link.relation == "register" }?.hrefURI
diff --git a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntry.java b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntry.java
index 2a54e12b9..145d33656 100644
--- a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntry.java
+++ b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntry.java
@@ -37,6 +37,7 @@ public final class OPDSAcquisitionFeedEntry implements Serializable {
private final OptionType issues;
private final OptionType related;
private final OptionType published;
+ private final OptionType selected;
private final OptionType publisher;
private final OptionType language;
private final String distribution;
@@ -71,6 +72,7 @@ private OPDSAcquisitionFeedEntry(
final String in_summary,
final List in_narrators,
final OptionType in_published,
+ final OptionType in_selected,
final OptionType in_publisher,
final String in_distribution,
final List in_categories,
@@ -95,6 +97,7 @@ private OPDSAcquisitionFeedEntry(
this.title = NullCheck.notNull(in_title);
this.thumbnail = NullCheck.notNull(in_thumbnail);
this.timeTrackingUri = NullCheck.notNull(in_time_tracking_uri);
+ this.selected = NullCheck.notNull(in_selected);
this.updated = NullCheck.notNull(in_updated);
this.summary = NullCheck.notNull(in_summary);
this.narrators = NullCheck.notNull(in_narrators);
@@ -175,6 +178,7 @@ public static OPDSAcquisitionFeedEntryBuilderType newBuilderFrom(
b.setAlternateOption(e.getAlternate());
b.setAnalyticsOption(e.getAnalytics());
b.setLicensorOption(e.getLicensor());
+ b.setSelectedOption(e.getSelected());
{
final String summary = e.getSummary();
@@ -225,6 +229,7 @@ public boolean equals(
&& this.published.equals(other.published)
&& this.publisher.equals(other.publisher)
&& this.licensor.equals(other.licensor)
+ && this.selected.equals(other.selected)
&& this.distribution.equals(other.distribution);
}
@@ -414,6 +419,13 @@ public OptionType getLicensor() {
return this.licensor;
}
+ /**
+ * @return Selected
+ */
+ public OptionType getSelected() {
+ return this.selected;
+ }
+
/**
* @return The title
*/
@@ -480,6 +492,7 @@ public int hashCode() {
result = (prime * result) + this.published.hashCode();
result = (prime * result) + this.publisher.hashCode();
result = (prime * result) + this.distribution.hashCode();
+ result = (prime * result) + this.selected.hashCode();
result = (prime * result) + this.licensor.hashCode();
return result;
}
@@ -531,6 +544,8 @@ public String toString() {
b.append(this.updated);
b.append(", licensor=");
b.append(this.licensor);
+ b.append(", selected=");
+ b.append(this.selected);
b.append("]");
return NullCheck.notNull(b.toString());
}
@@ -564,6 +579,7 @@ private static final class Builder implements OPDSAcquisitionFeedEntryBuilderTyp
private OptionType thumbnail;
private OptionType timeTrackingUri;
private OptionType licensor;
+ private OptionType selected;
private OptionType duration;
private Builder(
@@ -578,6 +594,7 @@ private Builder(
this.title = NullCheck.notNull(in_title);
this.updated = NullCheck.notNull(in_updated);
this.availability = NullCheck.notNull(in_availability);
+ this.selected = Option.none();
this.summary = "";
this.narrators = new ArrayList();
this.copyright = Option.none();
@@ -659,6 +676,7 @@ public OPDSAcquisitionFeedEntry build() {
this.summary,
this.narrators,
this.published,
+ this.selected,
this.publisher,
this.distribution,
this.categories,
@@ -810,6 +828,13 @@ public OPDSAcquisitionFeedEntryBuilderType setLicensorOption(
return this;
}
+ @Override
+ public OPDSAcquisitionFeedEntryBuilderType setSelectedOption(
+ final OptionType sel) {
+ this.selected = NullCheck.notNull(sel);
+ return this;
+ }
+
@Override
public OPDSAcquisitionFeedEntryBuilderType setDurationOption(OptionType duration) {
this.duration = NullCheck.notNull(duration);
diff --git a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryBuilderType.java b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryBuilderType.java
index 46d0a6b62..cf51afa9c 100644
--- a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryBuilderType.java
+++ b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryBuilderType.java
@@ -106,6 +106,14 @@ OPDSAcquisitionFeedEntryBuilderType addPreviewAcquisition(
OPDSAcquisitionFeedEntryBuilderType setAnnotationsOption(
OptionType uri);
+ /**
+ * Set selected
+ *
+ * @param selected Selected DateTime or not
+ */
+ OPDSAcquisitionFeedEntryBuilderType setSelectedOption(
+ OptionType selected);
+
/**
* @param uri The alternate URI
*/
diff --git a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryParser.java b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryParser.java
index a486c13fe..7f97002fc 100644
--- a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryParser.java
+++ b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAcquisitionFeedEntryParser.java
@@ -249,6 +249,7 @@ private OPDSAcquisitionFeedEntry parseAcquisitionEntry(
entry_builder.setPublisherOption(findPublisher(element));
entry_builder.setDistribution(findDistribution(element));
entry_builder.setPublishedOption(OPDSAtom.findPublished(element));
+ entry_builder.setSelectedOption(OPDSAtom.findSelected(element));
entry_builder.setSummaryOption(
OPDSXML.getFirstChildElementTextWithNameOptional(element, ATOM_URI, "summary"));
entry_builder.setLanguageOption(findLanguage(element));
diff --git a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAtom.java b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAtom.java
index 64c85d676..a3b0fba9c 100644
--- a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAtom.java
+++ b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSAtom.java
@@ -60,4 +60,20 @@ static DateTime findUpdated(
OPDSXML.getFirstChildElementTextWithName(e, OPDSFeedConstants.ATOM_URI, "updated");
return OPDSDateParsers.dateTimeParser().parseDateTime(e_updated_raw);
}
+
+ static OptionType findSelected(
+ final Element e)
+ throws DOMException, ParseException
+ {
+ final OptionType e_opt =
+ OPDSXML.getFirstChildElementWithNameOptional(
+ e, OPDSFeedConstants.ATOM_URI, "selected");
+
+ return e_opt.mapPartial(
+ (PartialFunctionType) er -> {
+ final String text = er.getTextContent();
+ final String trimmed = text.trim();
+ return OPDSDateParsers.dateTimeParser().parseDateTime(trimmed);
+ });
+ }
}
diff --git a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSJSONParser.java b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSJSONParser.java
index b18287a02..a0bef8d75 100644
--- a/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSJSONParser.java
+++ b/simplified-opds-core/src/main/java/org/nypl/simplified/opds/core/OPDSJSONParser.java
@@ -513,6 +513,8 @@ public Unit call(
JSONParserUtilities.getString(s, "distribution"));
fb.setSummaryOption(
JSONParserUtilities.getStringOptional(s, "summary"));
+ fb.setSelectedOption(
+ JSONParserUtilities.getTimestampOptional(s, "selected"));
return fb.build();
} catch (final JSONParseException e) {
throw new OPDSParseException(e);
diff --git a/simplified-profiles-controller-api/src/main/java/org/nypl/simplified/profiles/controller/api/ProfileFeedRequest.kt b/simplified-profiles-controller-api/src/main/java/org/nypl/simplified/profiles/controller/api/ProfileFeedRequest.kt
index 6f8533443..0e12604f1 100644
--- a/simplified-profiles-controller-api/src/main/java/org/nypl/simplified/profiles/controller/api/ProfileFeedRequest.kt
+++ b/simplified-profiles-controller-api/src/main/java/org/nypl/simplified/profiles/controller/api/ProfileFeedRequest.kt
@@ -5,6 +5,7 @@ import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.feeds.api.FeedBooksSelection
import org.nypl.simplified.feeds.api.FeedBooksSelection.BOOKS_FEED_LOANED
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.Sorting.SortBy
+import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForFeed.FilterBy
import org.nypl.simplified.feeds.api.FeedFacetPseudoTitleProviderType
import java.net.URI
@@ -38,6 +39,12 @@ data class ProfileFeedRequest(
val title: String,
+ /**
+ * The current local feed facet.
+ */
+
+ val filterBy: FilterBy = FilterBy.FILTER_BY_LOANS,
+
/**
* The active sorting facet.
*/
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/TransformProviders.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/TransformProviders.kt
index c2b29b297..bbd2447d2 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/TransformProviders.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/TransformProviders.kt
@@ -82,6 +82,7 @@ class TransformProviders {
updated = DateTime.parse(entry.updated),
location = null,
alternateURI = null,
+ selectedURI = null
)
providers.add(provider)
}
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderSourceNYPLRegistryDescriptionTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderSourceNYPLRegistryDescriptionTest.kt
index dc3f5f231..2a1abd6f1 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderSourceNYPLRegistryDescriptionTest.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountProviderSourceNYPLRegistryDescriptionTest.kt
@@ -433,7 +433,8 @@ class AccountProviderSourceNYPLRegistryDescriptionTest {
supportsReservations = true,
updated = DateTime.parse("1970-01-01T00:00:00.000Z"),
location = null,
- alternateURI = URI.create("https://www.example.com/alternate")
+ alternateURI = URI.create("https://www.example.com/alternate"),
+ selectedURI = null
)
Assertions.assertEquals(provider, result.result)
@@ -596,7 +597,8 @@ class AccountProviderSourceNYPLRegistryDescriptionTest {
supportsReservations = true,
updated = DateTime.parse("1970-01-01T00:00:00.000Z"),
location = null,
- alternateURI = URI.create("https://www.example.com/alternate")
+ alternateURI = URI.create("https://www.example.com/alternate"),
+ selectedURI = null
)
Assertions.assertEquals(provider, result.result)
@@ -739,7 +741,8 @@ class AccountProviderSourceNYPLRegistryDescriptionTest {
supportsReservations = true,
updated = DateTime.parse("1970-01-01T00:00:00.000Z"),
location = null,
- alternateURI = URI.create("https://www.example.com/alternate")
+ alternateURI = URI.create("https://www.example.com/alternate"),
+ selectedURI = null
)
Assertions.assertEquals(provider, result.result)
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt
index 7aa9372b8..fdc77e064 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt
@@ -384,6 +384,14 @@ abstract class BooksControllerContract {
.setBody(this.simpleUserProfile())
)
+ //Loans response, should throw the error instantly without even reaching selected
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(400)
+ .setBody("")
+ )
+
+ //Selected response, should not reach here, but also should throw error
this.server.enqueue(
MockResponse()
.setResponseCode(400)
@@ -433,7 +441,13 @@ abstract class BooksControllerContract {
.setResponseCode(200)
.setBody(this.simpleUserProfile())
)
-
+ //401 in loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(401)
+ .setBody("")
+ )
+ //401 in selected
this.server.enqueue(
MockResponse()
.setResponseCode(401)
@@ -560,12 +574,20 @@ abstract class BooksControllerContract {
.setResponseCode(200)
.setBody(this.simpleUserProfile())
)
+ //For loans
this.server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("Unlikely!")
)
+ //For selected
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("Still unlikely!")
+ )
+
val result = controller.booksSync(account.id).get()
Assertions.assertTrue(result is TaskResult.Failure)
Assertions.assertEquals(OPDSParseException::class.java, (result as TaskResult.Failure).exception!!.javaClass)
@@ -612,6 +634,14 @@ abstract class BooksControllerContract {
.setBody(this.simpleUserProfile())
)
+ //Setup for loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksSyncNewEntries.xml")))
+ )
+
+ //Setup for selected
this.server.enqueue(
MockResponse()
.setResponseCode(200)
@@ -700,6 +730,14 @@ abstract class BooksControllerContract {
* Populate the database by syncing against a feed that contains books.
*/
+ //Loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksSyncNewEntries.xml")))
+ )
+
+ //Selected
this.server.enqueue(
MockResponse()
.setResponseCode(200)
@@ -729,11 +767,20 @@ abstract class BooksControllerContract {
.setResponseCode(200)
.setBody(this.simpleUserProfile())
)
+ //Loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksSyncRemoveEntries.xml")))
+ )
+
+ //Selected
this.server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(Buffer().readFrom(resource("testBooksSyncRemoveEntries.xml")))
)
+
this.server.enqueue(
MockResponse()
.setResponseCode(200)
@@ -819,6 +866,14 @@ abstract class BooksControllerContract {
.setBody(this.simpleUserProfile())
)
+ //First setup for loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksDelete.xml")))
+ )
+
+ //First setup for selected
this.server.enqueue(
MockResponse()
.setResponseCode(200)
@@ -953,7 +1008,14 @@ abstract class BooksControllerContract {
.setResponseCode(200)
.setBody(this.simpleUserProfile())
)
+ //Setup for loans
+ this.server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksSyncNewEntries.xml")))
+ )
+ //Setup for selected
this.server.enqueue(
MockResponse()
.setResponseCode(200)
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/ProfilesControllerContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/ProfilesControllerContract.kt
index c5fb76b09..6a31be1b1 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/ProfilesControllerContract.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/ProfilesControllerContract.kt
@@ -471,6 +471,12 @@ abstract class ProfilesControllerContract {
get() = "All"
override val showOnLoan: String
get() = "On Loan"
+ override val showTabSelected: String
+ get() = "Selected"
+ override val showTabLoans: String
+ get() = "Loans"
+ override val showTabHolds: String
+ get() = "Holds"
}
)
).get()
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/sync/SyncBookRefreshToken.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/sync/SyncBookRefreshToken.kt
index a129b4c2b..53ab8a6a4 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/sync/SyncBookRefreshToken.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/sync/SyncBookRefreshToken.kt
@@ -232,7 +232,14 @@ class SyncBookRefreshToken {
.setHeader(LSHTTPRequestConstants.PROPERTY_KEY_ACCESS_TOKEN, "ghij")
.setBody(this.simpleUserProfile())
)
+ //Loans feed
+ this.webServer.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody(Buffer().readFrom(resource("testBooksSyncNewEntries.xml")))
+ )
+ //Selected feed
this.webServer.enqueue(
MockResponse()
.setResponseCode(200)
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccessibilityStrings.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccessibilityStrings.kt
index f73961561..b3016c404 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccessibilityStrings.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccessibilityStrings.kt
@@ -11,4 +11,6 @@ class MockAccessibilityStrings : AccessibilityStringsType {
override fun bookFailedLoan(title: String): String = "bookFailedLoan $title"
override fun bookFailedDownload(title: String): String = "bookFailedDownload $title"
override fun bookLoanLimitReached(): String = "bookLoanLimitReached"
+ override fun bookSelected(title: String): String = "bookSelected $title"
+ override fun bookUnselected(title: String): String = "bookUnselected $title"
}
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccount.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccount.kt
index 522be02e3..f2282cc23 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccount.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccount.kt
@@ -81,7 +81,8 @@ class MockAccount(override val id: AccountID) : AccountType {
supportsReservations = false,
updated = DateTime(),
location = null,
- alternateURI = URI.create("https://www.example.com/alternate")
+ alternateURI = URI.create("https://www.example.com/alternate"),
+ selectedURI = null
)
}
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviders.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviders.kt
index 1cc93b4d2..c94fe43d2 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviders.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviders.kt
@@ -47,7 +47,8 @@ object MockAccountProviders {
supportsReservations = false,
updated = DateTime.parse("2000-01-01T00:00:00Z"),
location = null,
- alternateURI = URI.create("https://www.example.com/alternate")
+ alternateURI = URI.create("https://www.example.com/alternate"),
+ selectedURI = URI.create("http://$host:$port/accounts0/loans.xml"),
)
}
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookAvailabilityStrings.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookAvailabilityStrings.kt
index 502b9eb07..d0cf58f5a 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookAvailabilityStrings.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookAvailabilityStrings.kt
@@ -67,6 +67,10 @@ object CatalogBookAvailabilityStrings {
""
is BookStatus.ReachedLoanLimit ->
""
+ is BookStatus.Selected ->
+ ""
+ is BookStatus.Unselected ->
+ ""
}
}
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailFragment.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailFragment.kt
index b46ea81c2..d87f27cc7 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailFragment.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailFragment.kt
@@ -11,7 +11,9 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
@@ -29,6 +31,7 @@ import org.librarysimplified.services.api.Services
import org.nypl.simplified.android.ktx.supportActionBar
import org.nypl.simplified.books.api.Book
import org.nypl.simplified.books.api.BookFormat
+import org.nypl.simplified.books.api.BookID
import org.nypl.simplified.books.book_database.api.BookFormats
import org.nypl.simplified.books.book_registry.BookPreviewStatus
import org.nypl.simplified.books.book_registry.BookStatus
@@ -135,6 +138,7 @@ class CatalogBookDetailFragment : Fragment(R.layout.book_detail) {
private lateinit var summary: TextView
private lateinit var title: TextView
private lateinit var type: TextView
+ private lateinit var selected: ImageView
private lateinit var toolbar: PalaceToolbar
private val dateYearFormatter =
@@ -217,6 +221,8 @@ class CatalogBookDetailFragment : Fragment(R.layout.book_detail) {
view.findViewById(R.id.feedLoading)
this.report =
view.findViewById(R.id.bookDetailReport)
+ this.selected =
+ view.findViewById(R.id.bookDetailSelect)
this.debugStatus =
view.findViewById(R.id.bookDetailDebugStatus)
@@ -537,6 +543,31 @@ class CatalogBookDetailFragment : Fragment(R.layout.book_detail) {
private fun reconfigureUI(book: BookWithStatus, bookPreviewStatus: BookPreviewStatus) {
this.debugStatus.text = book.javaClass.simpleName
+ //If there is a selected date, the book is selected
+ if (book.book.entry.selected is Some) {
+ //Set the drawable as the "checked" version
+ this.selected.setImageDrawable(
+ ContextCompat.getDrawable(this.requireContext(), R.drawable.baseline_check_circle_24)
+ )
+ //Add the audio description
+ this.selected.contentDescription = getString(R.string.catalogAccessibilityBookUnselect)
+
+ this.selected.setOnClickListener {
+ //Set the button click to unselect the book
+ this.viewModel.unselectBook(this.parameters.feedEntry)
+ }
+ }else {
+ //Set the "unchecked" icon version
+ this.selected.setImageDrawable(
+ ContextCompat.getDrawable(this.requireContext(),R.drawable.round_add_circle_outline_24)
+ )
+ //Add audio description
+ this.selected.contentDescription = getString(R.string.catalogAccessibilityBookSelect)
+ this.selected.setOnClickListener {
+ //Add book to selected
+ this.viewModel.selectBook(this.parameters.feedEntry)
+ }
+ }
when (val status = book.status) {
is BookStatus.Held -> {
this.onBookStatusHeld(status, bookPreviewStatus)
@@ -583,6 +614,12 @@ class CatalogBookDetailFragment : Fragment(R.layout.book_detail) {
is BookStatus.DownloadExternalAuthenticationInProgress -> {
this.onBookStatusDownloadExternalAuthenticationInProgress()
}
+ is BookStatus.Selected -> {
+ this.onBookStatusBookSelected(book.book.id,book.book.entry.title, status)
+ }
+ is BookStatus.Unselected -> {
+ this.onBookStatusBookUnselected(book.book.id, book.book.entry.title,status)
+ }
}
}
@@ -794,6 +831,23 @@ class CatalogBookDetailFragment : Fragment(R.layout.book_detail) {
viewModel.resetInitialBookStatus(this.parameters.feedEntry)
}
+ /**
+ * Show a toast for successful select and trigger status reset
+ */
+ private fun onBookStatusBookSelected(bookID: BookID, title: String, status: BookStatus) {
+ Toast.makeText(this.requireContext(), getString(R.string.catalogBookSelect, title), Toast.LENGTH_SHORT).show()
+ viewModel.resetPreviousBookStatus(bookID, status, true)
+ logger.debug("BookStatusReset")
+ }
+
+ /**
+ * Show a toast for successful unselect and trigger status reset
+ */
+ private fun onBookStatusBookUnselected(bookID: BookID, title: String, status: BookStatus) {
+ Toast.makeText(this.requireContext(), getString(R.string.catalogBookUnselect, title), Toast.LENGTH_SHORT).show()
+ viewModel.resetPreviousBookStatus(bookID, status, false)
+ logger.debug("BookStatusResetToPrevious")
+ }
private fun onBookStatusHoldable(
bookStatus: BookStatus.Holdable,
bookPreviewStatus: BookPreviewStatus
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt
index 92b3bb58c..ff9786d66 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt
@@ -11,6 +11,7 @@ import io.reactivex.disposables.CompositeDisposable
import io.reactivex.subjects.PublishSubject
import net.jcip.annotations.GuardedBy
import org.nypl.simplified.accounts.api.AccountID
+import org.nypl.simplified.accounts.api.AccountLoginState
import org.nypl.simplified.accounts.api.AccountProviderAuthenticationDescription
import org.nypl.simplified.accounts.database.api.AccountType
import org.nypl.simplified.books.api.Book
@@ -21,6 +22,7 @@ import org.nypl.simplified.books.book_registry.BookRegistryType
import org.nypl.simplified.books.book_registry.BookStatus
import org.nypl.simplified.books.book_registry.BookStatusEvent
import org.nypl.simplified.books.book_registry.BookWithStatus
+import org.nypl.simplified.books.controller.api.BooksControllerType
import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType
import org.nypl.simplified.feeds.api.Feed
import org.nypl.simplified.feeds.api.FeedEntry
@@ -37,6 +39,7 @@ import org.nypl.simplified.opds.core.OPDSAvailabilityMatcherType
import org.nypl.simplified.opds.core.OPDSAvailabilityOpenAccess
import org.nypl.simplified.opds.core.OPDSAvailabilityRevoked
import org.nypl.simplified.profiles.api.ProfilePreferences
+import org.nypl.simplified.profiles.api.ProfileReadableType
import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
import org.nypl.simplified.taskrecorder.api.TaskResult
import org.nypl.simplified.ui.errorpage.ErrorPageParameters
@@ -50,6 +53,7 @@ class CatalogBookDetailViewModel(
private val bookRegistry: BookRegistryType,
private val buildConfiguration: BuildConfigurationServiceType,
private val borrowViewModel: CatalogBorrowViewModel,
+ private val booksController: BooksControllerType,
private val parameters: CatalogBookDetailFragmentParameters,
private val listener: FragmentListenerType
) : ViewModel(), CatalogPagedViewListener {
@@ -295,12 +299,65 @@ class CatalogBookDetailViewModel(
override fun cancelDownload(feedEntry: FeedEntry.FeedEntryOPDS) {
this.borrowViewModel.tryCancelDownload(feedEntry.accountID, feedEntry.bookID)
}
+
+ override fun selectBook(feedEntry: FeedEntry.FeedEntryOPDS) {
+ //Check if we are logged in, if not, show login
+ val account = this.profilesController.profileCurrent().mostRecentAccount()
+ if (account.loginState is AccountLoginState.AccountNotLoggedIn) {
+ openLoginDialog(account.id)
+ } else {
+ //If logged in, try adding book to selected
+ booksController.bookAddToSelected(
+ accountID = profilesController.profileCurrent().mostRecentAccount().id,
+ feedEntry = feedEntry
+ )
+ }
+ }
+
+ override fun unselectBook(feedEntry: FeedEntry.FeedEntryOPDS) {
+ //Check if we are logged in, if not, show login
+ val account = this.profilesController.profileCurrent().mostRecentAccount()
+ if (account.loginState is AccountLoginState.AccountNotLoggedIn) {
+ openLoginDialog(account.id)
+ } else {
+ //Attempt to unselect
+ booksController.bookRemoveFromSelected(
+ accountID = profilesController.profileCurrent().mostRecentAccount().id,
+ feedEntry = feedEntry
+ )
+ }
+ }
+
override fun resetInitialBookStatus(feedEntry: FeedEntry.FeedEntryOPDS) {
val initialBookStatus = synthesizeBookWithStatus(feedEntry)
this.bookRegistry.update(initialBookStatus)
this.bookWithStatusMutable.value = Pair(initialBookStatus, BookPreviewStatus.None)
}
+ /**
+ * Reset to the previous status provided in the current status, if not provided, generate it from the book.
+ */
+ override fun resetPreviousBookStatus(bookID: BookID, status: BookStatus, selected: Boolean) {
+ //Cast tho the correct status depending if select is true or not
+ val previousStatus: BookStatus? = if (selected) {
+ val currentStatus = status as BookStatus.Selected
+ currentStatus.previousStatus
+ } else {
+ val currentStatus = status as BookStatus.Unselected
+ currentStatus.previousStatus
+ }
+ // If there is a previous status, set that as the new status in book registry
+ if (previousStatus != null) {
+ val bookWithStatus = this.bookRegistry.bookOrNull(bookID)
+ this.bookRegistry.update(BookWithStatus(bookWithStatus!!.book, previousStatus))
+ } else {
+ // The book for certain has been added to the bookRegistry so if no status provided, create
+ //one from the bookRegistry entry
+ val bookWithStatus = this.bookRegistry.bookOrNull(bookID)
+ this.bookRegistry.update(BookWithStatus(bookWithStatus!!.book, BookStatus.fromBook(bookWithStatus.book)))
+ }
+ }
+
override fun borrowMaybeAuthenticated(book: Book) {
// do nothing
}
@@ -556,6 +613,15 @@ class CatalogBookDetailViewModel(
title = ""
)
}
+
+ is CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks -> {
+ CatalogFeedArguments.CatalogFeedArgumentsRemote(
+ feedURI = uri,
+ isSearchResults = false,
+ ownership = CatalogFeedOwnership.OwnedByAccount(accountID),
+ title = ""
+ )
+ }
}
}
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModelFactory.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModelFactory.kt
index c80ed3bc3..5d596cda9 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModelFactory.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModelFactory.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.librarysimplified.services.api.ServiceDirectoryType
import org.nypl.simplified.books.book_registry.BookRegistryType
+import org.nypl.simplified.books.controller.api.BooksControllerType
import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType
import org.nypl.simplified.feeds.api.FeedLoaderType
import org.nypl.simplified.listeners.api.FragmentListenerType
@@ -31,6 +32,8 @@ class CatalogBookDetailViewModelFactory(
services.requireService(ProfilesControllerType::class.java)
val bookRegistry =
services.requireService(BookRegistryType::class.java)
+ val booksController =
+ this.services.requireService(BooksControllerType::class.java)
val configurationService =
services.requireService(BuildConfigurationServiceType::class.java)
@@ -40,6 +43,7 @@ class CatalogBookDetailViewModelFactory(
bookRegistry,
configurationService,
this.borrowViewModel,
+ booksController,
parameters,
listener
) as T
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedArguments.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedArguments.kt
index 8ead95f9c..85d643fd5 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedArguments.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedArguments.kt
@@ -3,6 +3,7 @@ package org.librarysimplified.ui.catalog
import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.feeds.api.FeedBooksSelection
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.Sorting.SortBy
+import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForFeed.FilterBy
import java.io.Serializable
import java.net.URI
@@ -67,4 +68,21 @@ sealed class CatalogFeedArguments : Serializable {
override val isSearchResults: Boolean = false
override val isLocallyGenerated: Boolean = true
}
+
+ /**
+ * Arguments that define a multiple feed view.
+ */
+ data class CatalogFeedArgumentsAllLocalBooks(
+ override val title: String,
+ override val ownership: CatalogFeedOwnership,
+ val sortBy: SortBy = SortBy.SORT_BY_TITLE,
+ val searchTerms: String?,
+ val filterBy: FilterBy = FilterBy.FILTER_BY_LOANS,
+ val selection: FeedBooksSelection,
+ val filterAccount: AccountID?,
+ val updateHolds: Boolean
+ ) : CatalogFeedArguments() {
+ override val isSearchResults: Boolean = false
+ override val isLocallyGenerated: Boolean = true
+ }
}
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedEvent.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedEvent.kt
index 8f4526e2e..d44afb771 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedEvent.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedEvent.kt
@@ -31,4 +31,10 @@ sealed class CatalogFeedEvent {
val book: Book,
val format: BookFormat
) : CatalogFeedEvent()
+
+ /**
+ * Removes views not currently visible, so they are recreated with
+ * up to date information after login.
+ */
+ data object RefreshViews :CatalogFeedEvent()
}
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt
index ce8191077..9f7ef9043 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt
@@ -261,7 +261,7 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
/*
* Reconfigures the UI based on the log in status of the account.
- * This effects the texts shown on my books and loans pages.
+ * This effects the texts shown on my books and favorites pages.
* Triggered in onViewCreated and Start
*/
private fun reconfigureCatalogUI() {
@@ -279,18 +279,25 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
private fun showLoginText() {
if (parameters is CatalogFeedArguments.CatalogFeedArgumentsLocalBooks) {
this.feedEmptyMessage.setText(
- //Check if shown books are holds or not, set the message that is shown when
- //viewing loans and holds. When not logged in, this text is always shown
+ //If selection is the selected books, show a suitable
+ //Text, otherwise show default (should never happen in current shape)
if (
(parameters as CatalogFeedArguments.CatalogFeedArgumentsLocalBooks).selection ==
- FeedBooksSelection.BOOKS_FEED_HOLDS
+ FeedBooksSelection.BOOKS_FEED_SELECTED
) {
- R.string.emptyHoldsNotLoggedIn
+ R.string.emptySelectedNotLoggedIn
} else {
- R.string.emptyLoansNotLoggedIn
+ R.string.emptyBooksNotLoggedIn
}
)
}
+ //If user is viewing the multiple feed view, set the message to always be the same
+ //No matter the view
+ if (parameters is CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks) {
+ this.feedEmptyMessage.setText(
+ R.string.emptyBooksNotLoggedIn
+ )
+ }
}
//User is logged in, so we show text that informs about important things concerning loans and
@@ -298,18 +305,34 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
private fun showInfoWhenLoggedIn() {
if (parameters is CatalogFeedArguments.CatalogFeedArgumentsLocalBooks) {
this.feedEmptyMessage.setText(
- //Check if shown books are holds or not, set the message that is shown when there are no
- //books accordingly to loans and reserves when logged in
+ //Check if books shown are selected, and if the list is empty
+ //show an info of how to add books to selected
+ //Current layout should never show the default
if (
(parameters as CatalogFeedArguments.CatalogFeedArgumentsLocalBooks).selection ==
- FeedBooksSelection.BOOKS_FEED_HOLDS
+ FeedBooksSelection.BOOKS_FEED_SELECTED
) {
- R.string.feedWithGroupsEmptyHolds
+ R.string.feedWithGroupsEmptySelected
} else {
R.string.feedWithGroupsEmptyLoaned
}
)
}
+ //Set the feed empty messages for a multifeed view
+ if (parameters is CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks) {
+ this.feedEmptyMessage.setText(
+ //Add a special text to loans and holds
+ //Check the current selection from stateLive to show the correct message per view
+ if (
+ (viewModel.stateLive.value?.arguments as CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks).selection ==
+ FeedBooksSelection.BOOKS_FEED_LOANED
+ ) {
+ R.string.feedWithGroupsEmptyLoaned
+ } else {
+ R.string.feedWithGroupsEmptyHolds
+ }
+ )
+ }
}
private fun openLogoLink() {
@@ -357,6 +380,9 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
is CatalogFeedArguments.CatalogFeedArgumentsLocalBooks -> {
(parameters as CatalogFeedArguments.CatalogFeedArgumentsLocalBooks).searchTerms.orEmpty()
}
+ is CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks -> {
+ (parameters as CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks).searchTerms.orEmpty()
+ }
is CatalogFeedArguments.CatalogFeedArgumentsRemote -> {
val uri =
Uri.parse(
@@ -462,6 +488,17 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
this.feedLoading.visibility = View.INVISIBLE
this.feedNavigation.visibility = View.INVISIBLE
+ //If we are viewing multiple feed view, we want to show the top facets even if
+ //The feed is empty
+ if (feedState.arguments is CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks) {
+ if (feedState.facetsByGroup != null) {
+ // If there are some top level facets,configure them so they are shown on an empty view
+ this.configureFacetTabs(FeedFacets.findEntryPointFacetGroup(feedState.facetsByGroup), feedContentTabs)
+ //Update catalog UI to have the texts be up to date
+ this.reconfigureCatalogUI()
+ feedContentHeader.visibility = View.VISIBLE
+ }
+ }
this.configureLogoHeader(feedState)
this.configureToolbar()
}
@@ -721,9 +758,11 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele
val remainingGroups = facetsByGroup
.filter { entry ->
/*
- * SIMPLY-2923: Hide the 'Collection' Facet until approved by UX.
+ * Hide the 'Collection' Facet since we only have one library
*/
- entry.key != "Collection"
+ entry.key != "Collection" &&
+ entry.key != "Kokoelma" &&
+ entry.key != "Samling"
}
.filter { entry ->
!FeedFacets.facetGroupIsEntryPointTyped(entry.value)
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedState.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedState.kt
index 791549eee..3aef18ad7 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedState.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedState.kt
@@ -113,6 +113,7 @@ sealed class CatalogFeedState {
data class CatalogFeedEmpty(
override val arguments: CatalogFeedArguments,
+ val facetsByGroup: Map>? = null,
override val search: FeedSearch?,
override val title: String
) : CatalogFeedLoaded()
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt
index 5b56c6ac8..c0b31ac7e 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt
@@ -15,6 +15,7 @@ import org.joda.time.DateTime
import org.joda.time.LocalDateTime
import org.librarysimplified.mdc.MDCKeys
import org.librarysimplified.ui.catalog.CatalogFeedArguments.CatalogFeedArgumentsLocalBooks
+import org.librarysimplified.ui.catalog.CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks
import org.librarysimplified.ui.catalog.CatalogFeedArguments.CatalogFeedArgumentsRemote
import org.librarysimplified.ui.catalog.CatalogFeedState.CatalogFeedLoaded
import org.librarysimplified.ui.catalog.CatalogFeedState.CatalogFeedLoaded.CatalogFeedEmpty
@@ -46,6 +47,7 @@ import org.nypl.simplified.feeds.api.FeedEntry
import org.nypl.simplified.feeds.api.FeedFacet
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForAccount
+import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.FilteringForFeed
import org.nypl.simplified.feeds.api.FeedFacet.FeedFacetPseudo.Sorting
import org.nypl.simplified.feeds.api.FeedFacetPseudoTitleProviderType
import org.nypl.simplified.feeds.api.FeedLoaderResult
@@ -195,7 +197,9 @@ class CatalogFeedViewModel(
//We reload feed on login since in some login cases,
//(in cases where there are books stored on the device)
//the feed shows up empty despite there being loans due to not being updated on login
+ //Adding different types of feeds meant that there needs to be a backlog clear
logger.debug("reloading feed due to successful login")
+ this.listener.post(CatalogFeedEvent.RefreshViews)
this.reloadFeed()
}
}
@@ -266,7 +270,7 @@ class CatalogFeedViewModel(
if (event is BookStatusEvent.BookStatusEventRemoved && this.state.arguments.isLocallyGenerated) {
this.reloadFeed()
} else {
- when (event.statusNow) {
+ when (val status = event.statusNow) {
is BookStatus.Held,
is BookStatus.Loaned,
is BookStatus.Revoked -> {
@@ -280,12 +284,38 @@ class CatalogFeedViewModel(
is BookStatus.FailedDownload,
is BookStatus.FailedLoan,
is BookStatus.FailedRevoke,
- is BookStatus.Holdable,
- is BookStatus.Loanable,
+ is BookStatus.Holdable -> {
+ if (this.state.arguments.isLocallyGenerated) {
+ //Reload feed so dismissed failed holds are not shown in holds feed
+ this.reloadFeed()
+ }
+ }
+ is BookStatus.Loanable -> {
+ if (this.state.arguments.isLocallyGenerated) {
+ //Reload feed so no failed and dismissed loans show up
+ this.reloadFeed()
+ }
+ }
is BookStatus.ReachedLoanLimit,
is BookStatus.RequestingDownload,
is BookStatus.RequestingLoan,
is BookStatus.RequestingRevoke,
+ is BookStatus.Selected -> {
+ //No clue why this needs the status check, but it does, as otherwise it keeps
+ //triggering this reload every chance it gets
+ if (this.state.arguments.isLocallyGenerated && status is BookStatus.Selected) {
+ //Reload the feeds when book selected or unselected so
+ //What the user sees in the favorites feed is up to date
+ this.reloadFeed()
+ }
+ }
+ is BookStatus.Unselected -> {
+ if (this.state.arguments.isLocallyGenerated) {
+ //Reload the feeds when book selected or unselected so
+ //What the user sees in the favorites feed is up to date
+ this.reloadFeed()
+ }
+ }
null -> {
// do nothing
}
@@ -333,25 +363,30 @@ class CatalogFeedViewModel(
fun syncAccounts() {
when (val arguments = state.arguments) {
is CatalogFeedArgumentsLocalBooks -> {
- this.syncAccounts(arguments)
+ this.syncAccounts(arguments.filterAccount)
}
is CatalogFeedArgumentsRemote -> {
}
+ is CatalogFeedArgumentsAllLocalBooks ->
+ this.syncAccounts(arguments.filterAccount)
}
}
- private fun syncAccounts(arguments: CatalogFeedArgumentsLocalBooks) {
+ /**
+ * Sync the books in the book register, based on possible accountID.
+ */
+ private fun syncAccounts(filterAccountID: AccountID?) {
val profile =
this.profilesController.profileCurrent()
val accountsToSync =
- if (arguments.filterAccount == null) {
+ if (filterAccountID == null) {
// Sync all accounts
this.logger.debug("[{}]: syncing all accounts", this.instanceId)
profile.accounts()
} else {
// Sync the account we're filtering on
- this.logger.debug("[{}]: syncing account {}", this.instanceId, arguments.filterAccount)
- profile.accounts().filterKeys { it == arguments.filterAccount }
+ this.logger.debug("[{}]: syncing account {}", this.instanceId, filterAccountID)
+ profile.accounts().filterKeys { it == filterAccountID }
}
for (account in accountsToSync.keys) {
@@ -373,8 +408,67 @@ class CatalogFeedViewModel(
this.doLoadRemoteFeed(arguments)
is CatalogFeedArgumentsLocalBooks ->
this.doLoadLocalFeed(arguments)
+ is CatalogFeedArgumentsAllLocalBooks ->
+ this.doLoadLocalCombinationFeed(arguments)
}
}
+ /**
+ * Load a locally-generated feed that has multiple feeds combined.
+ */
+ private fun doLoadLocalCombinationFeed(
+ arguments: CatalogFeedArgumentsAllLocalBooks
+ ) {
+ this.logger.debug("[{}]: loading local feed {}", this.instanceId, arguments.filterBy.name)
+
+ MDC.remove(MDCKeys.FEED_URI)
+ MDC.remove(MDCKeys.ACCOUNT_INTERNAL_ID)
+ MDC.remove(MDCKeys.ACCOUNT_PROVIDER_ID)
+ MDC.remove(MDCKeys.ACCOUNT_PROVIDER_NAME)
+
+ val booksUri = URI.create("Books")
+ val request =
+ ProfileFeedRequest(
+ facetTitleProvider = CatalogFacetPseudoTitleProvider(this.resources),
+ feedSelection = arguments.selection,
+ filterByAccountID = arguments.filterAccount,
+ filterBy = arguments.filterBy,
+ search = arguments.searchTerms,
+ sortBy = arguments.sortBy,
+ title = arguments.title,
+ uri = booksUri
+ )
+
+ val future = this.profilesController.profileFeed(request)
+ .map { feed ->
+ if (arguments.filterBy == FilteringForFeed.FilterBy.FILTER_BY_LOANS) {
+ feed.entriesInOrder.removeAll { feedEntry ->
+ feedEntry is FeedEntry.FeedEntryOPDS &&
+ feedEntry.feedEntry.availability is OPDSAvailabilityLoaned &&
+ feedEntry.feedEntry.availability.endDate.getOrNull()?.isBeforeNow == true
+ }
+ }
+ if (arguments.updateHolds) {
+ bookRegistry.updateHolds(
+ numberOfHolds = feed.entriesInOrder.filter { feedEntry ->
+ feedEntry is FeedEntry.FeedEntryOPDS &&
+ feedEntry.feedEntry.availability is OPDSAvailabilityHeldReady
+ }.size
+ )
+ }
+ FeedLoaderResult.FeedLoaderSuccess(
+ feed = feed,
+ accessToken = null
+ ) as FeedLoaderResult
+ }
+ .onAnyError { ex -> FeedLoaderResult.wrapException(booksUri, ex) }
+
+ this.createNewStatus(
+ account = null,
+ arguments = arguments,
+ future = future
+ )
+ }
+
/**
* Load a locally-generated feed.
@@ -630,7 +724,8 @@ class CatalogFeedViewModel(
return CatalogFeedEmpty(
arguments = arguments,
search = feed.feedSearch,
- title = feed.feedTitle
+ title = feed.feedTitle,
+ facetsByGroup = feed.facetsByGroup
)
}
@@ -687,6 +782,14 @@ class CatalogFeedViewModel(
get() = this.resources.getString(R.string.feedShowAll)
override val showOnLoan: String
get() = this.resources.getString(R.string.feedShowOnLoan)
+ override val showTabSelected: String
+ get() = this.resources.getString(R.string.feedFacetSelected)
+
+ override val showTabLoans: String
+ get() = this.resources.getString(R.string.feedFacetLoans)
+
+ override val showTabHolds: String
+ get() = this.resources.getString(R.string.feedFacetHolds)
}
val accountProvider: AccountProviderType?
@@ -872,6 +975,44 @@ class CatalogFeedViewModel(
}
}
}
+
+ is CatalogFeedArgumentsAllLocalBooks -> {
+ when (search) {
+ is FeedSearch.FeedSearchLocal -> {
+ //Get the tab we are in from the stored values
+ //Otherwise search and filter always reset to loans
+ val oldValues = state.arguments as CatalogFeedArgumentsAllLocalBooks
+
+ return CatalogFeedArgumentsAllLocalBooks(
+ filterAccount = currentArguments.filterAccount,
+ ownership = currentArguments.ownership,
+ searchTerms = query,
+ selection = oldValues.selection,
+ sortBy = currentArguments.sortBy,
+ filterBy = oldValues.filterBy,
+ title = currentArguments.title,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
+ is FeedSearch.FeedSearchOpen1_1 -> {
+ //Get the tab we are in from the stored values
+ //Otherwise search and filter always reset to loans
+ val oldValues = state.arguments as CatalogFeedArgumentsAllLocalBooks
+
+ return CatalogFeedArgumentsAllLocalBooks(
+ filterAccount = currentArguments.filterAccount,
+ ownership = currentArguments.ownership,
+ searchTerms = query,
+ selection = oldValues.selection,
+ sortBy = currentArguments.sortBy,
+ filterBy = oldValues.filterBy,
+ title = currentArguments.title,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
+ }
+
+ }
}
}
@@ -910,6 +1051,11 @@ class CatalogFeedViewModel(
"Can't transition local to remote feed: ${this.feedArguments.title} -> $title"
)
}
+
+ is CatalogFeedArgumentsAllLocalBooks ->
+ throw IllegalStateException(
+ "Can't transition local to remote feed: ${this.feedArguments.title} -> $title"
+ )
}
}
@@ -970,6 +1116,72 @@ class CatalogFeedViewModel(
title = facet.title,
updateHolds = currentArguments.updateHolds
)
+
+ is FilteringForFeed ->
+ CatalogFeedArgumentsAllLocalBooks(
+ title = facet.title,
+ ownership = currentArguments.ownership,
+ sortBy = currentArguments.sortBy,
+ searchTerms = currentArguments.searchTerms,
+ selection = facet.selectedFeed,
+ filterBy = facet.filterBy,
+ filterAccount = currentArguments.filterAccount,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
+ }
+
+ is CatalogFeedArgumentsAllLocalBooks -> {
+ when (facet) {
+ is FeedFacet.FeedFacetOPDS ->
+ throw IllegalStateException("Cannot transition from a local feed to a remote feed.")
+ is FilteringForAccount -> {
+ //Get the tab we are in from the stored values
+ //Otherwise search and filter always reset to loans
+ val oldValues = state.arguments as CatalogFeedArgumentsAllLocalBooks
+
+ return CatalogFeedArgumentsAllLocalBooks(
+ title = currentArguments.title,
+ ownership = currentArguments.ownership,
+ sortBy = currentArguments.sortBy,
+ searchTerms = currentArguments.searchTerms,
+ selection = oldValues.selection,
+ filterBy = oldValues.filterBy,
+ filterAccount = facet.account,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
+ is FilteringForFeed -> {
+ // From the old state, use the information of which selection and filter we are currently in
+ //Otherwise they will reset to the assumed value of loans
+ val oldValues = state.arguments as CatalogFeedArgumentsAllLocalBooks
+
+ return CatalogFeedArgumentsAllLocalBooks(
+ title = facet.title,
+ ownership = currentArguments.ownership,
+ sortBy = oldValues.sortBy,
+ searchTerms = currentArguments.searchTerms,
+ selection = facet.selectedFeed,
+ filterBy = facet.filterBy,
+ filterAccount = currentArguments.filterAccount,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
+ is Sorting -> {
+ // From the old state, use the information of which selection and filter we are currently in
+ //Otherwise they will reset to the assumed value of loans
+ val oldValues = state.arguments as CatalogFeedArgumentsAllLocalBooks
+ return CatalogFeedArgumentsAllLocalBooks(
+ title = facet.title,
+ ownership = currentArguments.ownership,
+ sortBy = facet.sortBy,
+ searchTerms = currentArguments.searchTerms,
+ selection = oldValues.selection,
+ filterBy = oldValues.filterBy,
+ filterAccount = currentArguments.filterAccount,
+ updateHolds = currentArguments.updateHolds
+ )
+ }
}
}
}
@@ -981,6 +1193,35 @@ class CatalogFeedViewModel(
)
}
+ override fun selectBook(feedEntry: FeedEntry.FeedEntryOPDS) {
+ //Check if logged in
+ val account = this.profilesController.profileCurrent().mostRecentAccount()
+ if (account.loginState is AccountLoginState.AccountNotLoggedIn) {
+ //Show login page if not
+ openLoginDialog(account.id)
+ } else {
+ //Otherwise try selecting the book
+ booksController.bookAddToSelected(
+ accountID = profilesController.profileCurrent().mostRecentAccount().id,
+ feedEntry = feedEntry
+ )
+ }
+ }
+
+ override fun unselectBook(feedEntry: FeedEntry.FeedEntryOPDS) {
+ //Check if we are logged in, if not, show login
+ val account = this.profilesController.profileCurrent().mostRecentAccount()
+ if (account.loginState is AccountLoginState.AccountNotLoggedIn) {
+ openLoginDialog(account.id)
+ } else {
+ //Attempt to unselect
+ booksController.bookRemoveFromSelected(
+ accountID = profilesController.profileCurrent().mostRecentAccount().id,
+ feedEntry = feedEntry
+ )
+ }
+ }
+
override fun openBookPreview(feedEntry: FeedEntry.FeedEntryOPDS) {
// do nothing
}
@@ -1014,6 +1255,32 @@ class CatalogFeedViewModel(
this.bookRegistry.update(initialBookStatus)
}
+ /**
+ * Reset the book in registry to its previous status, or generate a new one from the available information
+ * if there isn't one
+ */
+ override fun resetPreviousBookStatus(bookID: BookID, status: BookStatus, selected: Boolean) {
+ logger.debug("Resetting status: {}", status)
+ //Cast tho the correct status depending if select is true or not
+ val previousStatus: BookStatus? = if (selected) {
+ val currentStatus = status as BookStatus.Selected
+ currentStatus.previousStatus
+ } else {
+ val currentStatus = status as BookStatus.Unselected
+ currentStatus.previousStatus
+ }
+ // If there is a previous status, set that as the new status in book registry
+ if (previousStatus != null) {
+ val bookWithStatus = this.bookRegistry.bookOrNull(bookID)
+ this.bookRegistry.update(BookWithStatus(bookWithStatus!!.book, previousStatus))
+ } else {
+ // The book for certain has been added to the bookRegistry so if no status provided, create
+ //one from the bookRegistry entry
+ val bookWithStatus = this.bookRegistry.bookOrNull(bookID)
+ this.bookRegistry.update(BookWithStatus(bookWithStatus!!.book, BookStatus.fromBook(bookWithStatus.book)))
+ }
+ }
+
override fun registerObserver(
feedEntry: FeedEntry.FeedEntryOPDS,
callback: (BookWithStatus) -> Unit
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewHolder.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewHolder.kt
index f97080c41..272840caf 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewHolder.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewHolder.kt
@@ -9,11 +9,14 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.recyclerview.widget.RecyclerView
import com.google.common.base.Preconditions
import com.google.common.util.concurrent.FluentFuture
+import com.io7m.jfunctional.Some
import org.joda.time.DateTime
import org.nypl.simplified.books.api.Book
import org.nypl.simplified.books.api.BookFormat
@@ -77,6 +80,8 @@ class CatalogPagedViewHolder(
this.idle.findViewById(R.id.bookCellIdleAuthor)!!
private val idleButtons =
this.idle.findViewById(R.id.bookCellIdleButtons)!!
+ private val idleSelectedButton =
+ this.idle.findViewById(R.id.bookCellIdleSelect)!!
private val progressProgress =
this.parent.findViewById(R.id.bookCellInProgressBar)!!
@@ -126,7 +131,7 @@ class CatalogPagedViewHolder(
}
}
- private fun onFeedEntryOPDS(item: FeedEntryOPDS) {
+ private fun onFeedEntryOPDS(item: FeedEntryOPDS, book: Book) {
this.setVisibilityIfNecessary(this.corrupt, View.GONE)
this.setVisibilityIfNecessary(this.error, View.GONE)
this.setVisibilityIfNecessary(this.idle, View.VISIBLE)
@@ -151,6 +156,30 @@ class CatalogPagedViewHolder(
context.getString(R.string.catalogBookFormatPDF)
null -> ""
}
+ //If there is a selected date, the book is selected
+ if (book.entry.selected is Some) {
+ //Set the drawable as the "checked" version
+ this.idleSelectedButton.setImageDrawable(
+ ContextCompat.getDrawable(context,R.drawable.baseline_check_circle_24)
+ )
+ //Set audio description to the button
+ this.idleSelectedButton.contentDescription = context.getString(R.string.catalogAccessibilityBookSelect)
+ this.idleSelectedButton.setOnClickListener{
+ //Remove book from selected
+ this.listener.unselectBook(item)
+ }
+ } else {
+ //Set the "unchecked" icon version
+ this.idleSelectedButton.setImageDrawable(
+ ContextCompat.getDrawable(context,R.drawable.round_add_circle_outline_24)
+ )
+ //Add audio description
+ this.idleSelectedButton.contentDescription = context.getString(R.string.catalogAccessibilityBookUnselect)
+ this.idleSelectedButton.setOnClickListener {
+ //Add book to selected
+ this.listener.selectBook(item)
+ }
+ }
val targetHeight =
this.parent.resources.getDimensionPixelSize(
@@ -170,7 +199,7 @@ class CatalogPagedViewHolder(
}
private fun onBookChanged(bookWithStatus: BookWithStatus) {
- this.onFeedEntryOPDS(this.feedEntry as FeedEntryOPDS)
+ this.onFeedEntryOPDS(this.feedEntry as FeedEntryOPDS, bookWithStatus.book)
this.onBookWithStatus(bookWithStatus)
this.checkSomethingIsVisible()
}
@@ -228,6 +257,8 @@ class CatalogPagedViewHolder(
this.onBookStatusDownloadWaitingForExternalAuthentication(book.book)
is BookStatus.DownloadExternalAuthenticationInProgress ->
this.onBookStatusDownloadExternalAuthenticationInProgress(book.book)
+ is BookStatus.Selected -> this.onBookStatusSelected(book)
+ is BookStatus.Unselected -> this.onBookStatusUnselected(book)
}
}
@@ -736,6 +767,24 @@ class CatalogPagedViewHolder(
this.progressProgress.isIndeterminate = true
}
+ /**
+ * Show toast informing user that the book has been added to selected books
+ * and reset bookStatus to what it was before selection
+ */
+ private fun onBookStatusSelected(book: BookWithStatus) {
+ Toast.makeText(this.context, context.getString(R.string.catalogBookSelect, book.book.entry.title), Toast.LENGTH_SHORT).show()
+ this.listener.resetPreviousBookStatus(book.book.id, book.status, true)
+ }
+
+ /**
+ * Show toast informing user that the book has been removed from selected books
+ * and reset bookStatus to what it was before
+ */
+ private fun onBookStatusUnselected(book: BookWithStatus) {
+ Toast.makeText(this.context, context.getString(R.string.catalogBookUnselect, book.book.entry.title), Toast.LENGTH_SHORT).show()
+ this.listener.resetPreviousBookStatus(book.book.id, book.status, false)
+ }
+
fun unbind() {
val currentFeedEntry = this.feedEntry
if (currentFeedEntry is FeedEntryOPDS) {
diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewListener.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewListener.kt
index 42173caf5..f6bafa247 100644
--- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewListener.kt
+++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogPagedViewListener.kt
@@ -3,6 +3,8 @@ package org.librarysimplified.ui.catalog
import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.books.api.Book
import org.nypl.simplified.books.api.BookFormat
+import org.nypl.simplified.books.api.BookID
+import org.nypl.simplified.books.book_registry.BookStatus
import org.nypl.simplified.books.book_registry.BookWithStatus
import org.nypl.simplified.feeds.api.FeedEntry
import org.nypl.simplified.taskrecorder.api.TaskResult
@@ -29,12 +31,18 @@ interface CatalogPagedViewListener {
fun cancelDownload(feedEntry: FeedEntry.FeedEntryOPDS)
+ fun selectBook(feedEntry: FeedEntry.FeedEntryOPDS)
+
+ fun unselectBook(feedEntry: FeedEntry.FeedEntryOPDS)
+
fun borrowMaybeAuthenticated(book: Book)
fun openLoginDialog(accountID: AccountID)
fun resetInitialBookStatus(feedEntry: FeedEntry.FeedEntryOPDS)
+ fun resetPreviousBookStatus(bookID: BookID, status: BookStatus, selected: Boolean)
+
fun reserveMaybeAuthenticated(book: Book)
fun revokeMaybeAuthenticated(book: Book)
diff --git a/simplified-ui-catalog/src/main/res/drawable/baseline_check_circle_24.xml b/simplified-ui-catalog/src/main/res/drawable/baseline_check_circle_24.xml
new file mode 100644
index 000000000..6a53520c4
--- /dev/null
+++ b/simplified-ui-catalog/src/main/res/drawable/baseline_check_circle_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/simplified-ui-catalog/src/main/res/drawable/round_add_circle_outline_24.xml b/simplified-ui-catalog/src/main/res/drawable/round_add_circle_outline_24.xml
new file mode 100644
index 000000000..0d9769fb3
--- /dev/null
+++ b/simplified-ui-catalog/src/main/res/drawable/round_add_circle_outline_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/simplified-ui-catalog/src/main/res/layout/book_cell_idle.xml b/simplified-ui-catalog/src/main/res/layout/book_cell_idle.xml
index 605841095..e7dcac642 100644
--- a/simplified-ui-catalog/src/main/res/layout/book_cell_idle.xml
+++ b/simplified-ui-catalog/src/main/res/layout/book_cell_idle.xml
@@ -43,18 +43,31 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
- android:layout_marginEnd="16dp"
+ android:layout_marginEnd="8dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="2"
android:textSize="16sp"
android:textStyle="bold"
- app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/bookCellIdleSelect"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/bookCellIdleCover"
app:layout_constraintTop_toTopOf="parent"
tools:text="The Modern Prometheus" />
+
+
+
Get
Delete
Download
+ Add this book to your favorites
+ Remove this book from your favorites
Cancel download
Details
Dismiss
@@ -91,7 +93,8 @@
Search
%1$s hours, %2$s minutes
Read more
-
+ %1$s added to favorites
+ %1$s removed from favorites
Session expired!
Your session has expired, please log in again.
Confirm
diff --git a/simplified-ui-catalog/src/main/res/values/stringsFeed.xml b/simplified-ui-catalog/src/main/res/values/stringsFeed.xml
index 453e50a60..ed988be79 100644
--- a/simplified-ui-catalog/src/main/res/values/stringsFeed.xml
+++ b/simplified-ui-catalog/src/main/res/values/stringsFeed.xml
@@ -18,10 +18,14 @@
Books
Browse Books
Holds
+ Favorites
+ Loans
+ Holds
When you reserve a book from the catalog, it will show up here. Look here from time to time to see if your book is available to download.\n Number of reservations is limited to 5.
Visit the Catalog to add books to My Books.\n Number of loans is limited to 5.\n Ensure good internet connection when downloading a book and make sure there is enough space on your device.
- Sign in to see your reservations.
- Sign in to see your books.
+ Visit the Catalog to add books to Favorites. Add a book by clicking the plus icon next to the book\'s name.
+ Sign in to see your favorites.
+ Sign in to see your books.
Please enter your birth year
Select Year
Age Verification
diff --git a/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt b/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt
index 7863559f6..730b1d8ba 100644
--- a/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt
+++ b/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt
@@ -66,7 +66,7 @@ object BottomNavigators {
)
},
R.id.tabBooks to {
- createBooksFragment(
+ createCombinationFragment(
context = context,
id = R.id.tabBooks,
profilesController = profilesController,
@@ -74,10 +74,10 @@ object BottomNavigators {
defaultProvider = accountProviders.defaultProvider
)
},
- R.id.tabHolds to {
- createHoldsFragment(
+ R.id.tabSelected to {
+ createSelectedFragment(
context = context,
- id = R.id.tabHolds,
+ id = R.id.tabSelected,
profilesController = profilesController,
settingsConfiguration = settingsConfiguration,
defaultProvider = accountProviders.defaultProvider
@@ -229,7 +229,10 @@ object BottomNavigators {
)
}
- private fun createBooksFragment(
+ /**
+ * Create a fragment for showing the loaned books.
+ */
+ private fun createLoansFragment(
context: Context,
id: Int,
profilesController: ProfilesControllerType,
@@ -269,6 +272,89 @@ object BottomNavigators {
)
}
+ /**
+ * Create a fragment for showing the selected books.
+ */
+ private fun createSelectedFragment(
+ context: Context,
+ id: Int,
+ profilesController: ProfilesControllerType,
+ settingsConfiguration: BuildConfigurationServiceType,
+ defaultProvider: AccountProviderType
+ ): Fragment {
+ logger.debug("[{}]: creating selected fragment", id)
+
+ //Choose the account we want to use, in our case, currently always null
+ val filterAccountId =
+ if (settingsConfiguration.showBooksFromAllAccounts) {
+ null
+ } else {
+ pickDefaultAccount(profilesController, defaultProvider).id
+ }
+
+ //Choose the feed owner, currently we always collect from all accounts
+ val ownership =
+ if (filterAccountId == null) {
+ CatalogFeedOwnership.CollectedFromAccounts
+ } else {
+ CatalogFeedOwnership.OwnedByAccount(filterAccountId)
+ }
+
+ //Create the fragment having the selection be the selected
+ return CatalogFeedFragment.create(
+ CatalogFeedArguments.CatalogFeedArgumentsLocalBooks(
+ filterAccount = filterAccountId,
+ ownership = ownership,
+ searchTerms = null,
+ selection = FeedBooksSelection.BOOKS_FEED_SELECTED,
+ sortBy = FeedFacet.FeedFacetPseudo.Sorting.SortBy.SORT_BY_TITLE,
+ title = context.getString(R.string.tabSelected),
+ updateHolds = false
+ )
+ )
+ }
+
+ /**
+ * Create a new fragment from the bookRegister with the option to switch between
+ * multiple different views, like loans and selected books.
+ */
+ private fun createCombinationFragment(
+ context: Context,
+ id: Int,
+ profilesController: ProfilesControllerType,
+ settingsConfiguration: BuildConfigurationServiceType,
+ defaultProvider: AccountProviderType
+ ): Fragment {
+ logger.debug("[{}]: creating combination fragment", id)
+
+ //Check if should show all books in the registry or the books of one account
+ val filterAccountId =
+ if (settingsConfiguration.showBooksFromAllAccounts) {
+ null
+ } else {
+ pickDefaultAccount(profilesController, defaultProvider).id
+ }
+ // Choose the owner, should be the ID of the account currently logged in
+ val ownership =
+ if (filterAccountId == null) {
+ CatalogFeedOwnership.CollectedFromAccounts
+ } else {
+ CatalogFeedOwnership.OwnedByAccount(filterAccountId)
+ }
+ //Create the fragment, enter from the loans
+ return CatalogFeedFragment.create(
+ CatalogFeedArguments.CatalogFeedArgumentsAllLocalBooks(
+ filterAccount = filterAccountId,
+ ownership = ownership,
+ searchTerms = null,
+ sortBy = FeedFacet.FeedFacetPseudo.Sorting.SortBy.SORT_BY_TITLE,
+ filterBy = FeedFacet.FeedFacetPseudo.FilteringForFeed.FilterBy.FILTER_BY_LOANS,
+ selection = FeedBooksSelection.BOOKS_FEED_LOANED,
+ title = context.getString(R.string.tabBooks),
+ updateHolds = false
+ )
+ )
+ }
private fun createCatalogFragment(
id: Int,
feedArguments: CatalogFeedArguments.CatalogFeedArgumentsRemote
diff --git a/simplified-ui-navigation-tabs/src/main/res/drawable/tab_holds.xml b/simplified-ui-navigation-tabs/src/main/res/drawable/tab_selected.xml
similarity index 100%
rename from simplified-ui-navigation-tabs/src/main/res/drawable/tab_holds.xml
rename to simplified-ui-navigation-tabs/src/main/res/drawable/tab_selected.xml
diff --git a/simplified-ui-navigation-tabs/src/main/res/menu/navigation_items.xml b/simplified-ui-navigation-tabs/src/main/res/menu/navigation_items.xml
index c693119b3..1b5fe3e54 100644
--- a/simplified-ui-navigation-tabs/src/main/res/menu/navigation_items.xml
+++ b/simplified-ui-navigation-tabs/src/main/res/menu/navigation_items.xml
@@ -13,9 +13,9 @@
android:title="@string/tabBooks" />
+ android:id="@+id/tabSelected"
+ android:icon="@drawable/tab_selected"
+ android:title="@string/tabSelected" />
- My Books
Browse Books
Reservations
+ Favorites
Magazines
Profile
Settings
diff --git a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt
index 4dc1a2953..1e9c95e2e 100644
--- a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt
+++ b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt
@@ -271,6 +271,7 @@ class SettingsDebugViewModel(application: Application) : AndroidViewModel(applic
isProduction = true,
license = URI.create("http://www.librarysimplified.org/iclicenses.html"),
loansURI = URI.create("https://qa-circulation.openebooks.us/USOEI/loans/"),
+ selectedURI = null,
logo = null,
mainColor = "teal",
patronSettingsURI = URI.create("https://qa-circulation.openebooks.us/USOEI/patrons/me/"),