Wave 10 (L-search): search own library + Open Library + Google Books, deduplicated
Committed by tasks/wave-chain.sh after its mechanical gate passed (assembleDebug, testDebugUnitTest = 308 tests, verifyPaparazziDebug, no build files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING. Prompt: tasks/L-search.txt. Worker: $10.452535, 157 turns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
No files matched your search
@@ -11,6 +11,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.diagnostics.CrashReporter
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
@@ -120,6 +121,9 @@ class AppContainer(private val context: Context) {
|
||||
|
||||
val metadataRepository by lazy { MetadataRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
|
||||
|
||||
/** Library screen's online title/author search — shares the same client/timeouts/key as [metadataRepository]. */
|
||||
val bookSearchRepository by lazy { BookSearchRepository(metadataHttpClient, json, BuildConfig.GOOGLE_BOOKS_API_KEY) }
|
||||
|
||||
val syncEngine by lazy {
|
||||
SyncEngine(
|
||||
apiProvider = apiProvider,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Entry point for the library screen's online title/author search — [MetadataRepository]'s
|
||||
* counterpart for search rather than by-ISBN lookup. Unlike [MetadataRepository.lookup],
|
||||
* this does NOT combine the two sources into one outcome: the library screen needs each
|
||||
* source's own [SearchSourceResult] so it can show one source's results alongside the
|
||||
* other's failure-with-retry (task: "per-source failure ... while still showing the
|
||||
* OTHER source's results"). [org.modg.bookshelf.data.metadata.SearchResultMerger] is
|
||||
* where the actual merge/dedupe happens, once local books are available to merge in too.
|
||||
*/
|
||||
class BookSearchRepository(
|
||||
private val openLibraryClient: OpenLibrarySearchClient,
|
||||
private val googleBooksClient: GoogleBooksSearchClient,
|
||||
) {
|
||||
constructor(httpClient: OkHttpClient, json: Json, googleBooksApiKey: String = "") : this(
|
||||
OpenLibrarySearchClient(httpClient, json),
|
||||
GoogleBooksSearchClient(httpClient, json, googleBooksApiKey),
|
||||
)
|
||||
|
||||
data class Results(val openLibrary: SearchSourceResult, val googleBooks: SearchSourceResult)
|
||||
|
||||
suspend fun search(query: String): Results = coroutineScope {
|
||||
val openLibrary = async { openLibraryClient.search(query) }
|
||||
val googleBooks = async { googleBooksClient.search(query) }
|
||||
Results(openLibrary.await(), googleBooks.await())
|
||||
}
|
||||
}
|
||||
@@ -130,9 +130,5 @@ class GoogleBooksClient(
|
||||
* will. Package-visible so the scrub itself is unit-testable without a socket.
|
||||
*/
|
||||
internal fun redact(result: SourceResult): SourceResult =
|
||||
if (result is SourceResult.Failed && apiKey.isNotBlank() && result.reason.contains(apiKey)) {
|
||||
result.copy(reason = result.reason.replace(apiKey, "[REDACTED]"))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
if (result is SourceResult.Failed) result.copy(reason = redactApiKey(result.reason, apiKey)) else result
|
||||
}
|
||||
@@ -52,8 +52,46 @@ fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
|
||||
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail),
|
||||
)
|
||||
|
||||
private fun normalizeCoverUrl(raw: String?): String? {
|
||||
/** Forces https + zoom=2, per SPEC's cover chain. Internal (not private) so [GoogleBooksSearchClient] reuses it too. */
|
||||
internal fun normalizeCoverUrl(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val https = raw.replaceFirst("http://", "https://")
|
||||
return if ("zoom=" in https) https.replace(Regex("zoom=\\d+"), "zoom=2") else "$https&zoom=2"
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one volume to a [SearchHit] for the library search screen. [isbn13s] collects
|
||||
* every industry identifier this volume reports, normalized to ISBN-13 regardless of
|
||||
* its declared type (ISBN-10 converted) — a volume usually carries 0 or 1, unlike an
|
||||
* Open Library work's dozens, which is exactly what [SearchResultMerger]'s ISBN rule
|
||||
* depends on to tell the two sources apart.
|
||||
*/
|
||||
fun GoogleBooksVolumeInfoDto.toSearchHit(): SearchHit = SearchHit(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authors = authors,
|
||||
year = plausibleYear(publishedDate),
|
||||
publisher = publisher,
|
||||
pageCount = pageCount,
|
||||
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail),
|
||||
isbn13s = industryIdentifiers.mapNotNull { it.identifier }.mapNotNull(IsbnUtils::toIsbn13).distinct(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A [publishedDate] prefix is only trustworthy as a year if it's exactly four digits —
|
||||
* real GB data has been observed to return garbage like "101-01-01" (METADATA-SOURCES.md),
|
||||
* which this rejects because its first four characters ("101-") aren't all digits.
|
||||
*/
|
||||
internal fun plausibleYear(publishedDate: String?): Int? {
|
||||
val prefix = publishedDate?.take(4) ?: return null
|
||||
if (prefix.length != 4 || !prefix.all(Char::isDigit)) return null
|
||||
return prefix.toIntOrNull()?.takeIf { it in 1000..2999 }
|
||||
}
|
||||
|
||||
/**
|
||||
* [SourceResult.Failed.reason]/[SearchSourceResult.Failed.reason] must never carry the
|
||||
* API key — shared by [GoogleBooksClient.redact] and [GoogleBooksSearchClient.redact]
|
||||
* so there is one scrub implementation, not two that could drift apart.
|
||||
*/
|
||||
internal fun redactApiKey(reason: String, apiKey: String): String =
|
||||
if (apiKey.isNotBlank() && reason.contains(apiKey)) reason.replace(apiKey, "[REDACTED]") else reason
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Google Books title/author search — `volumes?q=`, distinct from [GoogleBooksClient]'s
|
||||
* by-ISBN lookup but sharing its DTOs ([GoogleBooksResponseDto]/[GoogleBooksVolumeInfoDto]):
|
||||
* a search result and an ISBN lookup result are both a list of volumes, just a different
|
||||
* query. Same key handling, same redaction, same never-throws contract — see
|
||||
* [GoogleBooksClient]'s KDoc.
|
||||
*/
|
||||
class GoogleBooksSearchClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
json: Json,
|
||||
private val apiKey: String = "",
|
||||
) {
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun search(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
withSearchRetry(retryRateLimitedOnce = apiKey.isNotBlank()) { fetch(query) }
|
||||
}
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun searchOnce(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(query) }
|
||||
|
||||
private fun fetch(query: String): SearchSourceResult = redact(
|
||||
try {
|
||||
val request = Request.Builder().url(requestUrl(query)).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string(), response.header("Retry-After"))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SearchSourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
SearchSourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
},
|
||||
)
|
||||
|
||||
/** Package-visible pure function — no socket — the key is appended only when non-blank, same as the ISBN lookup. */
|
||||
internal fun requestUrl(query: String): String {
|
||||
val builder = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("www.googleapis.com")
|
||||
.addPathSegments("books/v1/volumes")
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("maxResults", "20")
|
||||
.addQueryParameter("printType", "books")
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.addQueryParameter("key", apiKey)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
/** Package-visible pure function — no socket — so it's exhaustively unit-testable offline. */
|
||||
internal fun classify(httpCode: Int, body: String?, retryAfterHeader: String? = null): SearchSourceResult {
|
||||
if (httpCode !in 200..299) return SearchSourceResult.fromHttpCode(httpCode, retryAfterHeader)
|
||||
if (body.isNullOrBlank()) return SearchSourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
|
||||
val hits = dto.items.mapNotNull { it.volumeInfo?.toSearchHit() }
|
||||
if (hits.isEmpty()) SearchSourceResult.NotFound else SearchSourceResult.Found(hits)
|
||||
} catch (e: SerializationException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||
internal fun parseResponse(body: String): List<SearchHit> =
|
||||
(classify(200, body) as? SearchSourceResult.Found)?.hits.orEmpty()
|
||||
|
||||
/** Same reasoning as [GoogleBooksClient.redact] — the key must never survive into a shown/logged reason. */
|
||||
internal fun redact(result: SearchSourceResult): SearchSourceResult =
|
||||
if (result is SearchSourceResult.Failed) result.copy(reason = redactApiKey(result.reason, apiKey)) else result
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
/** Which source(s) contributed to a merged [OnlineBook] — surfaced so the UI/enrichment can tell. */
|
||||
enum class SearchSource { OPEN_LIBRARY, GOOGLE_BOOKS }
|
||||
|
||||
/**
|
||||
* One deduplicated online search result, after [SearchResultMerger] has grouped
|
||||
* together every [SearchHit] (and any matching local [org.modg.bookshelf.data.local.BookEntity])
|
||||
* that identify the same book. [isbn13] is set only when [SearchResultMerger]'s ISBN
|
||||
* rule could resolve a single unambiguous edition — see its KDoc; otherwise null,
|
||||
* never guessed.
|
||||
*/
|
||||
data class OnlineBook(
|
||||
val title: String?,
|
||||
val subtitle: String? = null,
|
||||
val authors: List<String> = emptyList(),
|
||||
val year: Int? = null,
|
||||
val publisher: String? = null,
|
||||
val pageCount: Int? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isbn13: String? = null,
|
||||
val sources: Set<SearchSource> = emptySet(),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Open Library title/author search — `search.json`, distinct from [OpenLibraryClient]'s
|
||||
* by-ISBN `api/books` lookup. Same never-throws contract as [OpenLibraryClient] (see its
|
||||
* KDoc): every outcome is a [SearchSourceResult], [CancellationException] propagates,
|
||||
* anything else becomes [FailureKind.UNEXPECTED].
|
||||
*/
|
||||
class OpenLibrarySearchClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
json: Json,
|
||||
) {
|
||||
private val json = Json(from = json) { ignoreUnknownKeys = true }
|
||||
|
||||
/** Retries transient failures per [RetryPolicy] — same 13% TLS-reset story as the ISBN lookup. */
|
||||
suspend fun search(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { withSearchRetry { fetch(query) } }
|
||||
|
||||
/** Single un-retried attempt, for tests that need to count calls. */
|
||||
internal suspend fun searchOnce(query: String): SearchSourceResult =
|
||||
withContext(Dispatchers.IO) { fetch(query) }
|
||||
|
||||
private fun fetch(query: String): SearchSourceResult = try {
|
||||
val request = Request.Builder().url(requestUrl(query)).build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
classify(response.code, response.body.string())
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
SearchSourceResult.fromException(e)
|
||||
} catch (e: Throwable) {
|
||||
SearchSourceResult.Failed("unexpected: ${e.javaClass.simpleName}", FailureKind.UNEXPECTED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-visible pure function — no socket — for offline URL tests. `fields=`
|
||||
* is load-bearing: without it `isbn` is absent from every result (verified live,
|
||||
* see docs/METADATA-SOURCES.md). Built with [HttpUrl.Builder] so [query] is
|
||||
* correctly percent-encoded regardless of what punctuation it contains.
|
||||
*/
|
||||
internal fun requestUrl(query: String): String =
|
||||
HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("openlibrary.org")
|
||||
.addPathSegment("search.json")
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("limit", "20")
|
||||
.addQueryParameter(
|
||||
"fields",
|
||||
"key,title,subtitle,author_name,first_publish_year,edition_count,cover_i,isbn,publisher,number_of_pages_median",
|
||||
)
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* Package-visible pure function — no socket — so it's exhaustively unit-testable
|
||||
* offline. An empty `docs` list is [SearchSourceResult.NotFound] (OL answered
|
||||
* authoritatively), never conflated with a transport [SearchSourceResult.Failed].
|
||||
*/
|
||||
internal fun classify(httpCode: Int, body: String?): SearchSourceResult {
|
||||
if (httpCode !in 200..299) return SearchSourceResult.fromHttpCode(httpCode)
|
||||
if (body.isNullOrBlank()) return SearchSourceResult.Failed("empty body", FailureKind.MALFORMED)
|
||||
return try {
|
||||
val dto = json.decodeFromString(OpenLibrarySearchResponseDto.serializer(), body)
|
||||
if (dto.docs.isEmpty()) SearchSourceResult.NotFound else SearchSourceResult.Found(dto.docs.map { it.toSearchHit() })
|
||||
} catch (e: SerializationException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
|
||||
internal fun parseResponse(body: String): List<SearchHit> =
|
||||
(classify(200, body) as? SearchSourceResult.Found)?.hits.orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* https://openlibrary.org/search.json?q=<q>&limit=20&fields=... — a WORKS index
|
||||
* search, distinct from [OpenLibraryBookDto]'s by-ISBN edition lookup. `isbn` is
|
||||
* NOT in OL's default response fields; the caller must request it explicitly (see
|
||||
* [OpenLibrarySearchClient.requestUrl]) or every hit comes back with no ISBNs at all.
|
||||
*/
|
||||
@Serializable
|
||||
data class OpenLibrarySearchResponseDto(
|
||||
val docs: List<OpenLibrarySearchDocDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OpenLibrarySearchDocDto(
|
||||
val key: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
@SerialName("author_name") val authorName: List<String> = emptyList(),
|
||||
@SerialName("first_publish_year") val firstPublishYear: Int? = null,
|
||||
@SerialName("cover_i") val coverI: Int? = null,
|
||||
/** Flat list mixing ISBN-10 and ISBN-13 of ALL editions of this work — may be absent, may be dozens. */
|
||||
val isbn: List<String> = emptyList(),
|
||||
val publisher: List<String> = emptyList(),
|
||||
@SerialName("number_of_pages_median") val numberOfPagesMedian: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps one work to a [SearchHit]. [isbn13s] normalizes every reported ISBN to
|
||||
* ISBN-13 (ISBN-10 converted) and drops anything that doesn't pass a checksum —
|
||||
* [SearchResultMerger]'s ISBN rule counts DISTINCT values here to decide whether
|
||||
* this work resolves to a single unambiguous edition.
|
||||
*/
|
||||
fun OpenLibrarySearchDocDto.toSearchHit(): SearchHit = SearchHit(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authors = authorName,
|
||||
year = firstPublishYear,
|
||||
publisher = publisher.firstOrNull(),
|
||||
pageCount = numberOfPagesMedian,
|
||||
coverUrl = coverI?.let { "https://covers.openlibrary.org/b/id/$it-M.jpg" },
|
||||
isbn13s = isbn.mapNotNull(IsbnUtils::toIsbn13).distinct(),
|
||||
)
|
||||
@@ -130,22 +130,82 @@ suspend fun withRetry(
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
attempt: suspend () -> SourceResult,
|
||||
): SourceResult {
|
||||
): SourceResult = withRetryLoop(
|
||||
maxAttempts = maxAttempts,
|
||||
budgetMillis = budgetMillis,
|
||||
retryRateLimitedOnce = retryRateLimitedOnce,
|
||||
random = random,
|
||||
nowMillis = nowMillis,
|
||||
sleep = sleep,
|
||||
failureOf = { result -> (result as? SourceResult.Failed)?.let { it.kind to it.retryAfterMillis } },
|
||||
appendAttempts = { result, attempts ->
|
||||
(result as SourceResult.Failed).copy(reason = "${result.reason}, $attempts attempts")
|
||||
},
|
||||
attempt = attempt,
|
||||
)
|
||||
|
||||
/**
|
||||
* [OpenLibrarySearchClient]/[GoogleBooksSearchClient]'s counterpart to [withRetry] —
|
||||
* same policy, same loop, applied to [SearchSourceResult] instead of [SourceResult]
|
||||
* (search returns a list of hits, not one [BookMetadata], so it needs its own Found
|
||||
* shape, but the retry decision is identical). Both delegate to [withRetryLoop] so
|
||||
* there is exactly one loop implementation to keep correct.
|
||||
*/
|
||||
suspend fun withSearchRetry(
|
||||
maxAttempts: Int = RetryPolicy.MAX_ATTEMPTS,
|
||||
budgetMillis: Long = RetryPolicy.TOTAL_BUDGET_MILLIS,
|
||||
retryRateLimitedOnce: Boolean = false,
|
||||
random: Random = Random.Default,
|
||||
nowMillis: () -> Long = { System.currentTimeMillis() },
|
||||
sleep: suspend (Long) -> Unit = { delay(it) },
|
||||
attempt: suspend () -> SearchSourceResult,
|
||||
): SearchSourceResult = withRetryLoop(
|
||||
maxAttempts = maxAttempts,
|
||||
budgetMillis = budgetMillis,
|
||||
retryRateLimitedOnce = retryRateLimitedOnce,
|
||||
random = random,
|
||||
nowMillis = nowMillis,
|
||||
sleep = sleep,
|
||||
failureOf = { result -> (result as? SearchSourceResult.Failed)?.let { it.kind to it.retryAfterMillis } },
|
||||
appendAttempts = { result, attempts ->
|
||||
(result as SearchSourceResult.Failed).copy(reason = "${result.reason}, $attempts attempts")
|
||||
},
|
||||
attempt = attempt,
|
||||
)
|
||||
|
||||
/**
|
||||
* The actual retry loop, shared by [withRetry] and [withSearchRetry] so the policy
|
||||
* (attempt/budget caps, keyed-429 one-shot retry, backoff) can never drift between
|
||||
* ISBN lookup and title/author search. [failureOf] extracts (kind, retryAfterMillis)
|
||||
* from a failed [T], or null if [T] isn't a failure; [appendAttempts] stamps the final
|
||||
* attempt count into a surviving failure's reason, same diagnostic value as before.
|
||||
*/
|
||||
private suspend fun <T> withRetryLoop(
|
||||
maxAttempts: Int,
|
||||
budgetMillis: Long,
|
||||
retryRateLimitedOnce: Boolean,
|
||||
random: Random,
|
||||
nowMillis: () -> Long,
|
||||
sleep: suspend (Long) -> Unit,
|
||||
failureOf: (T) -> Pair<FailureKind, Long?>?,
|
||||
appendAttempts: (T, Int) -> T,
|
||||
attempt: suspend () -> T,
|
||||
): T {
|
||||
val started = nowMillis()
|
||||
var last: SourceResult = attempt()
|
||||
var last: T = attempt()
|
||||
var attemptsMade = 1
|
||||
var rateLimitedRetryUsed = false
|
||||
|
||||
while (attemptsMade < maxAttempts) {
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
val rateLimitedRetry = failure.kind == FailureKind.RATE_LIMITED &&
|
||||
retryRateLimitedOnce && !rateLimitedRetryUsed
|
||||
if (!RetryPolicy.isRetryable(failure.kind) && !rateLimitedRetry) break
|
||||
val failure = failureOf(last) ?: return last
|
||||
val (kind, retryAfterMillis) = failure
|
||||
val rateLimitedRetry = kind == FailureKind.RATE_LIMITED && retryRateLimitedOnce && !rateLimitedRetryUsed
|
||||
if (!RetryPolicy.isRetryable(kind) && !rateLimitedRetry) break
|
||||
if (nowMillis() - started >= budgetMillis) break
|
||||
|
||||
if (rateLimitedRetry) {
|
||||
rateLimitedRetryUsed = true
|
||||
sleep(failure.retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS)
|
||||
sleep(retryAfterMillis ?: RetryPolicy.RATE_LIMIT_RETRY_CAP_MILLIS)
|
||||
} else {
|
||||
sleep(RetryPolicy.backoffMillis(attemptsMade + 1, random))
|
||||
}
|
||||
@@ -153,6 +213,6 @@ suspend fun withRetry(
|
||||
attemptsMade++
|
||||
}
|
||||
|
||||
val failure = last as? SourceResult.Failed ?: return last
|
||||
return if (attemptsMade > 1) failure.copy(reason = "${failure.reason}, $attemptsMade attempts") else failure
|
||||
if (failureOf(last) == null) return last
|
||||
return if (attemptsMade > 1) appendAttempts(last, attemptsMade) else last
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
/**
|
||||
* One hit from a title/author search against Open Library or Google Books, before
|
||||
* [SearchResultMerger] groups and dedupes across sources. [isbn13s] carries EVERY
|
||||
* ISBN-13 this hit reports, already normalized (via [IsbnUtils.toIsbn13], ISBN-10
|
||||
* converted): for an Open Library work that can be dozens across all its editions;
|
||||
* for a Google Books volume it's the 0 or 1 ISBN-13 that volume itself carries. The
|
||||
* merger's ISBN rule (SPEC-adjacent, see its KDoc) reads this list directly rather
|
||||
* than a single [isbn13], so it can tell "one edition" from "many" per source.
|
||||
*/
|
||||
data class SearchHit(
|
||||
val title: String?,
|
||||
val subtitle: String? = null,
|
||||
val authors: List<String> = emptyList(),
|
||||
val year: Int? = null,
|
||||
val publisher: String? = null,
|
||||
val pageCount: Int? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isbn13s: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/**
|
||||
* Combines local books with the two search sources' hits into one deduplicated result
|
||||
* (task: library search screen, "Results are DEDUPLICATED across the three sources").
|
||||
* Pure, no Android/coroutines — a plain function over already-fetched data so it's
|
||||
* exhaustively unit-testable.
|
||||
*
|
||||
* [local] is whatever candidate set of owned books the caller wants checked for a tie
|
||||
* to an online hit — the library ViewModel passes the locally text-matched books plus
|
||||
* any owned book sharing an ISBN with one of the online hits, so a book reached ONLY
|
||||
* via an online hit (its own text didn't match the query, but an OL/GB record it
|
||||
* shares an ISBN or title+author with did) still surfaces "In your library" rather
|
||||
* than being duplicated as a new online result. See the class-level dedupe writeup in
|
||||
* the wave report for where a caller that passes too narrow a [local] set would miss
|
||||
* that case, and where too broad a one would falsely merge unrelated namesakes.
|
||||
*/
|
||||
object SearchResultMerger {
|
||||
|
||||
data class SearchResults(val inLibrary: List<BookEntity>, val online: List<OnlineBook>)
|
||||
|
||||
/**
|
||||
* Identity: two records are the same book if they share any ISBN-13 (after
|
||||
* normalizing everything, ISBN-10 included) OR their normalized title AND first
|
||||
* author's surname match — grouped transitively via union-find, not pairwise, so
|
||||
* A~B by ISBN and B~C by title puts all three in one group. Any group containing
|
||||
* a local book becomes an "In your library" entry (shown as the local book(s) in
|
||||
* it, since two owned copies/editions of the same work both belong there); every
|
||||
* other group becomes one [OnlineBook], ordered by the best (lowest) position
|
||||
* either source returned any of its members at, so relevance from both sources
|
||||
* is preserved and neither is buried under the other.
|
||||
*/
|
||||
fun merge(local: List<BookEntity>, openLibrary: List<SearchHit>, googleBooks: List<SearchHit>): SearchResults {
|
||||
val localCount = local.size
|
||||
val olCount = openLibrary.size
|
||||
val total = localCount + olCount + googleBooks.size
|
||||
if (total == 0) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val keys = buildList {
|
||||
local.forEach { book -> add(identityKeysOf(book)) }
|
||||
openLibrary.forEach { hit -> add(identityKeysOf(hit)) }
|
||||
googleBooks.forEach { hit -> add(identityKeysOf(hit)) }
|
||||
}
|
||||
|
||||
val unionFind = UnionFind(total)
|
||||
unionByIsbn(unionFind, keys)
|
||||
unionByTitleAuthor(unionFind, keys)
|
||||
|
||||
val groups = LinkedHashMap<Int, MutableList<Int>>()
|
||||
for (index in 0 until total) groups.getOrPut(unionFind.find(index)) { mutableListOf() }.add(index)
|
||||
|
||||
val inLibrary = mutableListOf<BookEntity>()
|
||||
val rankedOnline = mutableListOf<Pair<Int, OnlineBook>>()
|
||||
|
||||
for (members in groups.values) {
|
||||
val localMembers = members.filter { it < localCount }
|
||||
if (localMembers.isNotEmpty()) {
|
||||
inLibrary += localMembers.map { local[it] }
|
||||
continue
|
||||
}
|
||||
val olMembers = members.filter { it in localCount until localCount + olCount }.map { it - localCount }
|
||||
val gbMembers = members.filter { it >= localCount + olCount }.map { it - localCount - olCount }
|
||||
if (olMembers.isEmpty() && gbMembers.isEmpty()) continue
|
||||
|
||||
val olHits = olMembers.map { openLibrary[it] }
|
||||
val gbHits = gbMembers.map { googleBooks[it] }
|
||||
val bestRank = minOf(
|
||||
olMembers.minOrNull() ?: Int.MAX_VALUE,
|
||||
gbMembers.minOrNull() ?: Int.MAX_VALUE,
|
||||
)
|
||||
rankedOnline += bestRank to buildOnlineBook(olHits, gbHits)
|
||||
}
|
||||
|
||||
return SearchResults(inLibrary, rankedOnline.sortedBy { it.first }.map { it.second })
|
||||
}
|
||||
|
||||
private data class IdentityKeys(val isbns: List<String>, val titleKey: String, val surname: String)
|
||||
|
||||
private fun identityKeysOf(book: BookEntity): IdentityKeys {
|
||||
val isbns = listOfNotNull(
|
||||
book.isbn13?.let(IsbnUtils::toIsbn13),
|
||||
book.isbn10?.let(IsbnUtils::toIsbn13),
|
||||
).distinct()
|
||||
return IdentityKeys(isbns, TextNormalization.normalizedTitleKey(book.title), TextNormalization.authorSurnameKey(decodeAuthors(book.authorsJson)))
|
||||
}
|
||||
|
||||
private fun identityKeysOf(hit: SearchHit): IdentityKeys =
|
||||
IdentityKeys(hit.isbn13s, TextNormalization.normalizedTitleKey(hit.title), TextNormalization.authorSurnameKey(hit.authors))
|
||||
|
||||
private fun unionByIsbn(unionFind: UnionFind, keys: List<IdentityKeys>) {
|
||||
val firstSeenAt = HashMap<String, Int>()
|
||||
keys.forEachIndexed { index, key ->
|
||||
for (isbn in key.isbns) {
|
||||
val existing = firstSeenAt.putIfAbsent(isbn, index)
|
||||
if (existing != null) unionFind.union(existing, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unionByTitleAuthor(unionFind: UnionFind, keys: List<IdentityKeys>) {
|
||||
val firstSeenAt = HashMap<String, Int>()
|
||||
keys.forEachIndexed { index, key ->
|
||||
if (key.titleKey.isBlank() || key.surname.isBlank()) return@forEachIndexed
|
||||
val compositeKey = "${key.titleKey}|${key.surname}"
|
||||
val existing = firstSeenAt.putIfAbsent(compositeKey, index)
|
||||
if (existing != null) unionFind.union(existing, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SPEC-adjacent field fill, mirroring [MetadataMerger]'s "prefer whichever source
|
||||
* has a title, fill blanks from the other" — here per-field per the task's rule:
|
||||
* title/subtitle/authors from the OL hit with a title if there is one (work-level
|
||||
* data, cleaner than a single edition's), else the first GB hit with one; year,
|
||||
* publisher, page count and cover each independently fall through OL -> GB.
|
||||
*/
|
||||
private fun buildOnlineBook(olHits: List<SearchHit>, gbHits: List<SearchHit>): OnlineBook {
|
||||
val olPrimary = olHits.firstOrNull { !it.title.isNullOrBlank() }
|
||||
val gbPrimary = gbHits.firstOrNull { !it.title.isNullOrBlank() }
|
||||
val primary = olPrimary ?: gbPrimary
|
||||
val secondary = if (primary === olPrimary) gbPrimary else olPrimary
|
||||
|
||||
val sources = buildSet {
|
||||
if (olHits.isNotEmpty()) add(SearchSource.OPEN_LIBRARY)
|
||||
if (gbHits.isNotEmpty()) add(SearchSource.GOOGLE_BOOKS)
|
||||
}
|
||||
return OnlineBook(
|
||||
title = primary?.title,
|
||||
subtitle = primary?.subtitle ?: secondary?.subtitle,
|
||||
authors = primary?.authors?.ifEmpty { null } ?: secondary?.authors ?: emptyList(),
|
||||
year = primary?.year ?: secondary?.year,
|
||||
publisher = primary?.publisher ?: secondary?.publisher,
|
||||
pageCount = primary?.pageCount ?: secondary?.pageCount,
|
||||
coverUrl = primary?.coverUrl ?: secondary?.coverUrl,
|
||||
isbn13 = resolveIsbn(olHits, gbHits),
|
||||
sources = sources,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ISBN rule (decided by the user, implemented exactly): an OL work whose
|
||||
* ISBNs normalize to exactly one distinct ISBN-13 wins; else a GB volume set that
|
||||
* carries exactly one distinct ISBN-13 wins; otherwise no ISBN is recorded at all
|
||||
* rather than guessing among several editions.
|
||||
*/
|
||||
private fun resolveIsbn(olHits: List<SearchHit>, gbHits: List<SearchHit>): String? {
|
||||
for (hit in olHits) {
|
||||
val distinct = hit.isbn13s.distinct()
|
||||
if (distinct.size == 1) return distinct.single()
|
||||
}
|
||||
return gbHits.flatMap { it.isbn13s }.distinct().singleOrNull()
|
||||
}
|
||||
|
||||
/** Plain union-find over node indices `0 until n` — path-halved finds, union by attaching root to root. */
|
||||
private class UnionFind(n: Int) {
|
||||
private val parent = IntArray(n) { it }
|
||||
|
||||
fun find(x: Int): Int {
|
||||
var current = x
|
||||
while (parent[current] != current) {
|
||||
parent[current] = parent[parent[current]]
|
||||
current = parent[current]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun union(a: Int, b: Int) {
|
||||
val rootA = find(a)
|
||||
val rootB = find(b)
|
||||
if (rootA != rootB) parent[rootA] = rootB
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Per-source title/author search outcome — [SearchSourceResult]'s counterpart to
|
||||
* [SourceResult] for the library screen's online search (task: "Extend it to also
|
||||
* search Open Library and Google Books"). Kept as its own sealed type rather than
|
||||
* reusing [SourceResult] because a search's [Found] carries a list of [SearchHit],
|
||||
* not one [BookMetadata] — but it follows the exact same three-way rule (SPEC.md
|
||||
* "Book metadata lookup": a source that could not be reached must never be reported
|
||||
* as "no results") and the exact same [FailureKind] classification, via
|
||||
* [fromException]/[fromHttpCode] delegating straight to [SourceResult]'s so the two
|
||||
* can never disagree about what a given exception or HTTP code means.
|
||||
*/
|
||||
sealed interface SearchSourceResult {
|
||||
data class Found(val hits: List<SearchHit>) : SearchSourceResult
|
||||
|
||||
/** The source answered (2xx) and, in good faith, found nothing for this query. */
|
||||
data object NotFound : SearchSourceResult
|
||||
|
||||
data class Failed(
|
||||
val reason: String,
|
||||
val kind: FailureKind = FailureKind.TRANSPORT,
|
||||
val retryAfterMillis: Long? = null,
|
||||
) : SearchSourceResult
|
||||
|
||||
companion object {
|
||||
fun fromException(e: IOException): Failed =
|
||||
SourceResult.fromException(e).let { Failed(it.reason, it.kind, it.retryAfterMillis) }
|
||||
|
||||
fun fromHttpCode(code: Int, retryAfterHeader: String? = null): Failed =
|
||||
SourceResult.fromHttpCode(code, retryAfterHeader).let { Failed(it.reason, it.kind, it.retryAfterMillis) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* Shared text normalization for the two places that need to match books by loose
|
||||
* text rather than exact fields: [org.modg.bookshelf.ui.library.LocalBookMatcher]
|
||||
* (query tokens against an owned book) and [SearchResultMerger] (title+author
|
||||
* identity across sources). One implementation so "hobbit" ~ "Hobbit" and
|
||||
* "Brontë" ~ "bronte" mean the same thing in both places.
|
||||
*/
|
||||
object TextNormalization {
|
||||
private val leadingArticles = setOf("the", "a", "an")
|
||||
|
||||
/**
|
||||
* Lowercase, diacritics stripped (NFD decompose + drop combining marks), every
|
||||
* non-alphanumeric character turned into a space, whitespace collapsed. "J.R.R."
|
||||
* becomes "j r r" — a space-separated token run, not a single run-together word.
|
||||
*/
|
||||
fun normalizeForMatching(raw: String): String {
|
||||
val decomposed = Normalizer.normalize(raw, Normalizer.Form.NFD)
|
||||
val noDiacritics = decomposed.replace(Regex("\\p{Mn}+"), "")
|
||||
val spaced = noDiacritics.map { c -> if (c.isLetterOrDigit()) c else ' ' }.joinToString("")
|
||||
return spaced.lowercase().trim().replace(Regex("\\s+"), " ")
|
||||
}
|
||||
|
||||
fun tokens(raw: String): List<String> {
|
||||
val normalized = normalizeForMatching(raw)
|
||||
return if (normalized.isBlank()) emptyList() else normalized.split(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity title key for [SearchResultMerger]: subtitle (everything from the
|
||||
* first ':' on) dropped BEFORE normalizing (the colon itself would otherwise
|
||||
* become a space and the split point would be lost), then a leading "the/a/an"
|
||||
* dropped so "The Hobbit" and "Hobbit" key the same.
|
||||
*/
|
||||
fun normalizedTitleKey(title: String?): String {
|
||||
if (title.isNullOrBlank()) return ""
|
||||
val words = tokens(title.substringBefore(':'))
|
||||
val withoutArticle = if (words.firstOrNull() in leadingArticles) words.drop(1) else words
|
||||
return withoutArticle.joinToString(" ")
|
||||
}
|
||||
|
||||
/** The last alphabetic token of the first author — "Joanne S. Williamson" -> "williamson". */
|
||||
fun authorSurnameKey(authors: List<String>): String =
|
||||
authors.firstOrNull()?.let { tokens(it).lastOrNull() }.orEmpty()
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
|
||||
/**
|
||||
* The add-by-hand form's fields, independent of any particular entry point.
|
||||
* [org.modg.bookshelf.ui.nav.Routes.addBook] pre-fills [title]/[isbn] today —
|
||||
* from the scan screen's manual-ISBN dialog, or the library FAB with neither.
|
||||
* A follow-up wave (online search) is expected to pre-fill more fields from a
|
||||
* search hit; [fromRouteArgs] is the one place the route-args-to-draft mapping
|
||||
* lives, so extending it later is a small, localized change.
|
||||
* [org.modg.bookshelf.ui.nav.Routes.addBook] pre-fills whatever the entry point
|
||||
* knows — the scan screen's manual-ISBN dialog and library FAB pass just
|
||||
* title/isbn; wave 10's online-search save sheet's "Edit details" passes every
|
||||
* field. [fromRouteArgs] is the one place the route-args-to-draft mapping lives.
|
||||
* [coverSourceUrl] isn't shown anywhere in the form (no cover picker in this
|
||||
* screen) but rides along so a cover carried in from a search hit still gets
|
||||
* saved — see [AddBookViewModel.performSave].
|
||||
*/
|
||||
data class BookDraft(
|
||||
val title: String = "",
|
||||
@@ -19,10 +21,30 @@ data class BookDraft(
|
||||
val pages: String = "",
|
||||
val isbn: String = "",
|
||||
val description: String = "",
|
||||
val coverSourceUrl: String = "",
|
||||
) {
|
||||
companion object {
|
||||
fun fromRouteArgs(title: String?, isbn: String?): BookDraft =
|
||||
BookDraft(title = title.orEmpty(), isbn = isbn.orEmpty())
|
||||
fun fromRouteArgs(
|
||||
title: String? = null,
|
||||
isbn: String? = null,
|
||||
subtitle: String? = null,
|
||||
authors: String? = null,
|
||||
publisher: String? = null,
|
||||
publishedDate: String? = null,
|
||||
pages: String? = null,
|
||||
description: String? = null,
|
||||
coverSourceUrl: String? = null,
|
||||
): BookDraft = BookDraft(
|
||||
title = title.orEmpty(),
|
||||
subtitle = subtitle.orEmpty(),
|
||||
authors = authors.orEmpty(),
|
||||
publisher = publisher.orEmpty(),
|
||||
publishedDate = publishedDate.orEmpty(),
|
||||
pages = pages.orEmpty(),
|
||||
isbn = isbn.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
coverSourceUrl = coverSourceUrl.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ fun AddBookScreen(
|
||||
onBack: () -> Unit,
|
||||
onSaved: (bookId: String) -> Unit,
|
||||
container: AppContainer,
|
||||
initialSubtitle: String? = null,
|
||||
initialAuthors: String? = null,
|
||||
initialPublisher: String? = null,
|
||||
initialPublishedDate: String? = null,
|
||||
initialPages: String? = null,
|
||||
initialDescription: String? = null,
|
||||
initialCoverSourceUrl: String? = null,
|
||||
) {
|
||||
val viewModel: AddBookViewModel = viewModel(
|
||||
factory = viewModelFactory {
|
||||
@@ -64,7 +71,17 @@ fun AddBookScreen(
|
||||
bookRepository = container.bookRepository,
|
||||
locationRepository = container.locationRepository,
|
||||
settingsStore = container.settingsStore,
|
||||
initialDraft = BookDraft.fromRouteArgs(initialTitle, initialIsbn),
|
||||
initialDraft = BookDraft.fromRouteArgs(
|
||||
title = initialTitle,
|
||||
isbn = initialIsbn,
|
||||
subtitle = initialSubtitle,
|
||||
authors = initialAuthors,
|
||||
publisher = initialPublisher,
|
||||
publishedDate = initialPublishedDate,
|
||||
pages = initialPages,
|
||||
description = initialDescription,
|
||||
coverSourceUrl = initialCoverSourceUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -197,6 +197,7 @@ class AddBookViewModel(
|
||||
publishedDate = draft.publishedDate.trim().ifEmpty { null },
|
||||
pageCount = pageCount,
|
||||
description = draft.description.trim().ifEmpty { null },
|
||||
coverSourceUrl = draft.coverSourceUrl.trim().ifEmpty { null },
|
||||
shelfId = form.selectedShelfId,
|
||||
)
|
||||
rememberShelf(form.selectedShelfId)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/** SPEC.md library screen: "sort title/author/added". */
|
||||
@@ -52,3 +54,87 @@ object LibraryFilterLogic {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Online search state (task: library search "Extend it to also search Open Library
|
||||
* and Google Books"). Lives in [LibraryViewModel] so it survives rotation. Search
|
||||
* runs ONLY on submit (IME action / the explicit row), never per keystroke — decided
|
||||
* by the user, see the task brief on Google Books' 1,000/day quota.
|
||||
*/
|
||||
sealed interface OnlineSearchState {
|
||||
/** No search has run for the current query yet — the prompt row is what's shown. */
|
||||
data object NotSearched : OnlineSearchState
|
||||
|
||||
data class Searching(val query: String) : OnlineSearchState
|
||||
|
||||
/**
|
||||
* [inLibrary] is the FULL "In your library" set once a search has run — a superset
|
||||
* of [LocalBookMatcher]'s plain text match, since [org.modg.bookshelf.data.metadata.SearchResultMerger]
|
||||
* also pulls in an owned book reached only via matching one of the online hits (task:
|
||||
* "an online hit that is a different edition of an owned book belongs in the library
|
||||
* section"). [openLibraryFailure]/[googleBooksFailure] are that source's
|
||||
* [org.modg.bookshelf.data.metadata.SourceResult.Failed.reason]-equivalent, null when
|
||||
* that source answered (whether or not it had anything) — the UI keys off these to
|
||||
* show a per-source failure line with Retry while still showing the other source's
|
||||
* [online] results, and to tell "both failed" (retry affordance, never "no results")
|
||||
* from an authoritative miss (both answered, nothing found anywhere).
|
||||
*/
|
||||
data class Done(
|
||||
val query: String,
|
||||
val inLibrary: List<BookEntity>,
|
||||
val online: List<OnlineBook>,
|
||||
val openLibraryFailure: String?,
|
||||
val googleBooksFailure: String?,
|
||||
) : OnlineSearchState
|
||||
}
|
||||
|
||||
/** The bottom sheet shown when an online result is tapped (task: "same idea as the scan screen's Found sheet"). */
|
||||
sealed interface OnlineSaveSheetState {
|
||||
data object Hidden : OnlineSaveSheetState
|
||||
|
||||
/**
|
||||
* [enrichment] fills in once the background by-ISBN lookup (started the moment
|
||||
* the sheet opens, only when [book] has an isbn13) completes — Save reads
|
||||
* whatever's here at the moment it's tapped and never waits on it, per the task's
|
||||
* "Save must NEVER wait for this".
|
||||
*/
|
||||
data class Shown(
|
||||
val book: OnlineBook,
|
||||
val selectedShelfId: String?,
|
||||
val isSaving: Boolean = false,
|
||||
val saveError: String? = null,
|
||||
val enrichment: BookMetadata? = null,
|
||||
) : OnlineSaveSheetState
|
||||
}
|
||||
|
||||
/**
|
||||
* The same OL-then-GB-then-enrichment field-fill precedence, used by BOTH
|
||||
* [LibraryViewModel.performSaveOnlineResult] (what gets persisted) and
|
||||
* [org.modg.bookshelf.ui.nav.BookshelfNavHost]'s "Edit details" route builder
|
||||
* (what pre-fills the add-by-hand form) — one place so the two can't drift apart.
|
||||
*/
|
||||
data class OnlineBookDraftFields(
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val authors: List<String>,
|
||||
val publisher: String?,
|
||||
val publishedDate: String?,
|
||||
val pageCount: Int?,
|
||||
val isbn13: String?,
|
||||
val isbn10: String?,
|
||||
val description: String?,
|
||||
val coverUrl: String?,
|
||||
)
|
||||
|
||||
fun resolveOnlineBookFields(book: OnlineBook, enrichment: BookMetadata?): OnlineBookDraftFields = OnlineBookDraftFields(
|
||||
title = book.title?.ifBlank { null } ?: enrichment?.title?.ifBlank { null } ?: "Untitled",
|
||||
subtitle = book.subtitle ?: enrichment?.subtitle,
|
||||
authors = book.authors.ifEmpty { enrichment?.authors.orEmpty() },
|
||||
publisher = book.publisher ?: enrichment?.publisher,
|
||||
publishedDate = enrichment?.publishedDate ?: book.year?.toString(),
|
||||
pageCount = book.pageCount ?: enrichment?.pageCount,
|
||||
isbn13 = book.isbn13 ?: enrichment?.isbn13,
|
||||
isbn10 = enrichment?.isbn10,
|
||||
description = enrichment?.description,
|
||||
coverUrl = book.coverUrl ?: enrichment?.coverUrl,
|
||||
)
|
||||
@@ -1,18 +1,26 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.EditNote
|
||||
@@ -21,6 +29,7 @@ import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Sort
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -28,10 +37,13 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,6 +53,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -52,20 +65,30 @@ import org.modg.bookshelf.R
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.metadata.BookMetadata
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
import org.modg.bookshelf.ui.components.BookCover
|
||||
import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.GoldDivider
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||
import org.modg.bookshelf.ui.components.SyncStatus
|
||||
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.scan.ShelfPicker
|
||||
|
||||
/**
|
||||
* SPEC.md "library" screen: adaptive cover grid, search, filter, sort, empty
|
||||
* state, FAB to scan, sync status line. Covers carry the color; card chrome
|
||||
* stays minimal (cover + two lines of text) on purpose.
|
||||
*
|
||||
* Wave 10 (online search) extends the search bar: a blank query keeps the
|
||||
* plain cover grid, but any query switches this screen into a sectioned
|
||||
* search-results view — "In your library" (instant, local, as-you-type) above
|
||||
* "Online" (Open Library + Google Books, submit-only) — see
|
||||
* [LibrarySearchResultsContent].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -77,6 +100,8 @@ fun LibraryScreen(
|
||||
onLocationsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
container: AppContainer,
|
||||
onEnterByHandWithQuery: (String) -> Unit = { onAddByHandClick() },
|
||||
onEditOnlineResult: (OnlineBook, BookMetadata?) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val viewModel: LibraryViewModel = viewModel(
|
||||
// A fresh VM per distinct shelf filter, so tapping a different shelf
|
||||
@@ -89,6 +114,8 @@ fun LibraryScreen(
|
||||
locationRepository = container.locationRepository,
|
||||
syncEngine = container.syncEngine,
|
||||
settingsStore = container.settingsStore,
|
||||
bookSearchRepository = container.bookSearchRepository,
|
||||
metadataRepository = container.metadataRepository,
|
||||
initialShelfId = shelfIdFilter,
|
||||
)
|
||||
}
|
||||
@@ -99,6 +126,9 @@ fun LibraryScreen(
|
||||
val query by viewModel.query.collectAsState()
|
||||
val sortOption by viewModel.sortOption.collectAsState()
|
||||
val filter by viewModel.filter.collectAsState()
|
||||
val onlineSearch by viewModel.onlineSearch.collectAsState()
|
||||
val saveSheet by viewModel.saveSheet.collectAsState()
|
||||
val recentShelfId by viewModel.recentShelfId.collectAsState()
|
||||
|
||||
BookshelfScaffold(
|
||||
title = "Bookshelf",
|
||||
@@ -141,6 +171,7 @@ fun LibraryScreen(
|
||||
LibraryToolbar(
|
||||
query = query,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
onSearchSubmit = viewModel::submitOnlineSearch,
|
||||
sortOption = sortOption,
|
||||
onSortOptionChange = viewModel::onSortOptionChange,
|
||||
filter = filter,
|
||||
@@ -150,6 +181,16 @@ fun LibraryScreen(
|
||||
)
|
||||
|
||||
when {
|
||||
query.isNotBlank() -> LibrarySearchResultsContent(
|
||||
query = query,
|
||||
localMatches = uiState.books,
|
||||
onlineSearch = onlineSearch,
|
||||
onBookClick = onBookClick,
|
||||
onSubmitOnlineSearch = viewModel::submitOnlineSearch,
|
||||
onRetryOnlineSearch = viewModel::retryOnlineSearch,
|
||||
onOnlineResultClick = viewModel::onOnlineResultTapped,
|
||||
onEnterByHand = { onEnterByHandWithQuery(query.trim()) },
|
||||
)
|
||||
!uiState.hasAnyBooks -> EmptyState(
|
||||
title = "Your shelves are empty",
|
||||
message = "Scan a barcode to add your first book.",
|
||||
@@ -180,6 +221,25 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = saveSheet
|
||||
if (sheetState is OnlineSaveSheetState.Shown) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = viewModel::dismissSaveSheet,
|
||||
sheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
OnlineResultSaveSheet(
|
||||
state = sheetState,
|
||||
bookcases = uiState.bookcases,
|
||||
shelves = uiState.shelves,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = viewModel::onSaveSheetShelfSelected,
|
||||
onSave = viewModel::saveOnlineResult,
|
||||
onEditDetails = { onEditOnlineResult(sheetState.book, sheetState.enrichment) },
|
||||
onSkip = viewModel::dismissSaveSheet,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -192,6 +252,7 @@ internal fun LibraryToolbar(
|
||||
onFilterChange: (LibraryFilter) -> Unit,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
onSearchSubmit: () -> Unit = {},
|
||||
) {
|
||||
var filterMenuExpanded by remember { mutableStateOf(false) }
|
||||
var sortMenuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -217,6 +278,10 @@ internal fun LibraryToolbar(
|
||||
}
|
||||
}
|
||||
},
|
||||
// Wave 10: IME Search triggers the online search (task: "ONLY ON SUBMIT
|
||||
// (IME Search action, or an explicit button) — never per keystroke").
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearchSubmit() }),
|
||||
)
|
||||
|
||||
Column {
|
||||
@@ -288,8 +353,8 @@ internal fun LibraryToolbar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
BookCover(
|
||||
coverUrl = book.coverUrl ?: book.coverSourceUrl,
|
||||
contentDescription = book.title,
|
||||
@@ -321,3 +386,303 @@ internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wave 10: online search results ---
|
||||
|
||||
/**
|
||||
* The sectioned view shown whenever the search bar carries a non-blank query —
|
||||
* "In your library" (instant local matches, existing card style, horizontally
|
||||
* scrollable so it never fights the outer vertical scroll) above "Online"
|
||||
* (Open Library + Google Books, submit-only, as rows). Split out as its own
|
||||
* stateless composable — fed real state, not hand-rolled — so Paparazzi
|
||||
* renders the actual screen (see HANDOFF.md wave-6 "soft spots").
|
||||
*/
|
||||
@Composable
|
||||
internal fun LibrarySearchResultsContent(
|
||||
query: String,
|
||||
localMatches: List<BookEntity>,
|
||||
onlineSearch: OnlineSearchState,
|
||||
onBookClick: (String) -> Unit,
|
||||
onSubmitOnlineSearch: () -> Unit,
|
||||
onRetryOnlineSearch: () -> Unit,
|
||||
onOnlineResultClick: (OnlineBook) -> Unit,
|
||||
onEnterByHand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
SearchSectionHeader(text = "In your library")
|
||||
if (localMatches.isEmpty()) {
|
||||
// The header stays up even with nothing to show — task: the user must be
|
||||
// able to tell "not owned" from "didn't look".
|
||||
Text(
|
||||
text = "No matches in your library",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
localMatches.forEach { book ->
|
||||
LibraryBookCard(book = book, onClick = { onBookClick(book.id) }, modifier = Modifier.width(112.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoldDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
SearchSectionHeader(text = "Online")
|
||||
OnlineSearchSection(
|
||||
query = query,
|
||||
state = onlineSearch,
|
||||
onSubmit = onSubmitOnlineSearch,
|
||||
onRetry = onRetryOnlineSearch,
|
||||
onResultClick = onOnlineResultClick,
|
||||
)
|
||||
|
||||
TextButton(onClick = onEnterByHand, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text("Can't find it? Enter it by hand")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimum characters before the online-search prompt/submit is offered at all. */
|
||||
internal const val OnlineSearchMinQueryLength = 2
|
||||
|
||||
@Composable
|
||||
private fun OnlineSearchSection(
|
||||
query: String,
|
||||
state: OnlineSearchState,
|
||||
onSubmit: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onResultClick: (OnlineBook) -> Unit,
|
||||
) {
|
||||
val trimmed = query.trim()
|
||||
when (state) {
|
||||
is OnlineSearchState.NotSearched -> {
|
||||
if (trimmed.length >= OnlineSearchMinQueryLength) {
|
||||
SearchPromptRow(query = trimmed, onClick = onSubmit)
|
||||
} else {
|
||||
Text(
|
||||
text = "Keep typing to search Open Library and Google Books.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
is OnlineSearchState.Searching -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = "Searching Open Library and Google Books…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
is OnlineSearchState.Done -> OnlineSearchDoneSection(state = state, onRetry = onRetry, onResultClick = onResultClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnlineSearchDoneSection(state: OnlineSearchState.Done, onRetry: () -> Unit, onResultClick: (OnlineBook) -> Unit) {
|
||||
val bothFailed = state.openLibraryFailure != null && state.googleBooksFailure != null
|
||||
if (bothFailed) {
|
||||
// Task: "both failed -> a retry affordance that does NOT say 'no results'".
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = "Couldn't search online",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "This doesn't mean there are no results — neither source could be reached.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
PrimaryButton(text = "Retry", onClick = onRetry, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
state.openLibraryFailure?.let { FailureLine(message = "Open Library couldn't be reached — $it", onRetry = onRetry) }
|
||||
state.googleBooksFailure?.let { FailureLine(message = "Google Books couldn't be reached — $it", onRetry = onRetry) }
|
||||
|
||||
if (state.online.isEmpty()) {
|
||||
Text(
|
||||
text = "No online results for \"${state.query}\".",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
state.online.forEach { book -> OnlineResultRow(book = book, onClick = { onResultClick(book) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchPromptRow(query: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Outlined.Search, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = "Search Open Library & Google Books for \"$query\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureLine(message: String, onRetry: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OnlineResultRow(book: OnlineBook, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BookCover(coverUrl = book.coverUrl, contentDescription = book.title, modifier = Modifier.width(44.dp))
|
||||
Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text(
|
||||
text = book.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitleParts = listOfNotNull(
|
||||
book.authors.takeIf { it.isNotEmpty() }?.joinToString(", "),
|
||||
book.year?.toString(),
|
||||
)
|
||||
if (subtitleParts.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitleParts.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The save sheet for an online result — task: "same idea as the scan screen's
|
||||
* Found sheet: book summary + shelf picker + Save / Skip", plus "Edit details".
|
||||
*/
|
||||
@Composable
|
||||
internal fun OnlineResultSaveSheet(
|
||||
state: OnlineSaveSheetState.Shown,
|
||||
bookcases: List<BookcaseEntity>,
|
||||
shelves: List<ShelfEntity>,
|
||||
recentShelfId: String?,
|
||||
onShelfSelected: (String?) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onEditDetails: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
) {
|
||||
val book = state.book
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(width = 100.dp, height = 150.dp),
|
||||
) {
|
||||
BookCover(coverUrl = book.coverUrl, contentDescription = book.title, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
Text(
|
||||
text = book.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
if (book.authors.isNotEmpty()) {
|
||||
Text(text = book.authors.joinToString(", "), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
val meta = listOfNotNull(book.year?.toString(), book.isbn13)
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
text = meta.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
if (state.saveError != null) {
|
||||
Text(
|
||||
text = "Couldn't save: ${state.saveError}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
ShelfPicker(
|
||||
bookcases = bookcases,
|
||||
shelves = shelves,
|
||||
selectedShelfId = state.selectedShelfId,
|
||||
recentShelfId = recentShelfId,
|
||||
onShelfSelected = onShelfSelected,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SecondaryButton(text = "Skip", onClick = onSkip, modifier = Modifier.weight(1f))
|
||||
SecondaryButton(text = "Edit details", onClick = onEditDetails, modifier = Modifier.weight(1f))
|
||||
}
|
||||
PrimaryButton(
|
||||
text = if (state.isSaving) "Saving…" else "Save",
|
||||
enabled = !state.isSaving,
|
||||
onClick = onSave,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,29 @@ package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookcaseEntity
|
||||
import org.modg.bookshelf.data.local.ShelfEntity
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.metadata.IsbnUtils
|
||||
import org.modg.bookshelf.data.metadata.LookupResult
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.metadata.SearchHit
|
||||
import org.modg.bookshelf.data.metadata.SearchResultMerger
|
||||
import org.modg.bookshelf.data.metadata.SearchSourceResult
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
@@ -37,6 +47,8 @@ class LibraryViewModel(
|
||||
private val locationRepository: LocationRepository,
|
||||
private val syncEngine: SyncEngine,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val bookSearchRepository: BookSearchRepository,
|
||||
private val metadataRepository: MetadataRepository,
|
||||
initialShelfId: String?,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -65,12 +77,26 @@ class LibraryViewModel(
|
||||
.map { shelves -> shelves.groupBy({ it.bookcaseId }, { it.id }).mapValues { it.value.toSet() } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
private val hasAnyBooks: StateFlow<Boolean> = bookRepository.observeAll()
|
||||
/** Every non-deleted owned book — the base [allBooks] both the grid and the search/merge logic read from. */
|
||||
private val allBooks: StateFlow<List<BookEntity>> = bookRepository.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val hasAnyBooks: StateFlow<Boolean> = allBooks
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
|
||||
|
||||
/**
|
||||
* Instant, offline, as-you-type local match (task: "Own library filters AS THEY
|
||||
* TYPE"). [LocalBookMatcher] replaces [org.modg.bookshelf.data.local.BookDao.search]'s
|
||||
* single whole-string LIKE for this screen, which couldn't match "hobbit tolkien"
|
||||
* at all. A blank query means "browse everything", not "match nothing".
|
||||
*/
|
||||
private val localMatches: StateFlow<List<BookEntity>> = combine(_query, allBooks) { queryValue, books ->
|
||||
if (queryValue.isBlank()) books else LocalBookMatcher.match(books, queryValue)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val filteredSortedBooks: StateFlow<List<BookEntity>> = combine(
|
||||
_query.flatMapLatest { bookRepository.search(it) },
|
||||
localMatches,
|
||||
_filter,
|
||||
_sortOption,
|
||||
shelfIdsByBookcase,
|
||||
@@ -78,6 +104,62 @@ class LibraryViewModel(
|
||||
LibrarySort.sort(LibraryFilterLogic.apply(books, filterValue, shelfMap), sort)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
// --- online search (task: extend the search bar to also search Open Library/Google Books) ---
|
||||
|
||||
private sealed interface OnlineSearchPhase {
|
||||
data object NotSearched : OnlineSearchPhase
|
||||
data class Searching(val query: String) : OnlineSearchPhase
|
||||
data class Done(val hits: RawHits) : OnlineSearchPhase
|
||||
}
|
||||
|
||||
private data class RawHits(
|
||||
val query: String,
|
||||
val openLibrary: List<SearchHit>,
|
||||
val googleBooks: List<SearchHit>,
|
||||
val openLibraryFailure: String?,
|
||||
val googleBooksFailure: String?,
|
||||
)
|
||||
|
||||
private val _onlineSearchPhase = MutableStateFlow<OnlineSearchPhase>(OnlineSearchPhase.NotSearched)
|
||||
private var onlineSearchJob: Job? = null
|
||||
|
||||
/**
|
||||
* Recomputed from [allBooks] on every change, not frozen at search time — so a
|
||||
* book saved from the online section's save sheet (or from anywhere else, e.g.
|
||||
* sync) immediately migrates itself from "Online" into "In your library" via the
|
||||
* normal Room flow, with no hand-written "move it over" step.
|
||||
*/
|
||||
val onlineSearch: StateFlow<OnlineSearchState> = combine(_onlineSearchPhase, allBooks) { phase, books ->
|
||||
when (phase) {
|
||||
is OnlineSearchPhase.NotSearched -> OnlineSearchState.NotSearched
|
||||
is OnlineSearchPhase.Searching -> OnlineSearchState.Searching(phase.query)
|
||||
is OnlineSearchPhase.Done -> buildDoneState(phase.hits, books)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OnlineSearchState.NotSearched)
|
||||
|
||||
private fun buildDoneState(raw: RawHits, books: List<BookEntity>): OnlineSearchState.Done {
|
||||
val onlineIsbns = (raw.openLibrary.asSequence() + raw.googleBooks.asSequence())
|
||||
.flatMap { it.isbn13s }
|
||||
.toSet()
|
||||
val isbnLinked = books.filter { book -> bookIsbn13s(book).any { it in onlineIsbns } }
|
||||
val candidates = (LocalBookMatcher.match(books, raw.query) + isbnLinked).distinctBy { it.id }
|
||||
val merged = SearchResultMerger.merge(candidates, raw.openLibrary, raw.googleBooks)
|
||||
return OnlineSearchState.Done(raw.query, merged.inLibrary, merged.online, raw.openLibraryFailure, raw.googleBooksFailure)
|
||||
}
|
||||
|
||||
private fun bookIsbn13s(book: BookEntity): List<String> =
|
||||
listOfNotNull(book.isbn13?.let(IsbnUtils::toIsbn13), book.isbn10?.let(IsbnUtils::toIsbn13))
|
||||
|
||||
// --- online result save sheet ---
|
||||
|
||||
private val _saveSheet = MutableStateFlow<OnlineSaveSheetState>(OnlineSaveSheetState.Hidden)
|
||||
val saveSheet: StateFlow<OnlineSaveSheetState> = _saveSheet.asStateFlow()
|
||||
private var enrichmentJob: Job? = null
|
||||
|
||||
/** Surfaced as the save sheet's shelf picker default, same as the scan/add-by-hand sheets. */
|
||||
val recentShelfId: StateFlow<String?> = settingsStore.lastShelfId
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val syncBar: StateFlow<SyncBarState> = combine(
|
||||
settingsStore.lastSyncTime,
|
||||
_isSyncing,
|
||||
@@ -104,8 +186,16 @@ class LibraryViewModel(
|
||||
refreshSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the query after a submit clears the online section and cancels any
|
||||
* in-flight search (task: stale results for a different query are misleading).
|
||||
*/
|
||||
fun onQueryChange(value: String) {
|
||||
_query.value = value
|
||||
if (_onlineSearchPhase.value !is OnlineSearchPhase.NotSearched) {
|
||||
onlineSearchJob?.cancel()
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.NotSearched
|
||||
}
|
||||
}
|
||||
|
||||
fun onSortOptionChange(option: LibrarySortOption) {
|
||||
@@ -116,6 +206,125 @@ class LibraryViewModel(
|
||||
_filter.value = filter
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on submit (IME Search action / the explicit prompt row) ONLY — never per
|
||||
* keystroke, per the task's decision on Google Books' 1,000/day quota and Open
|
||||
* Library's latency. [org.modg.bookshelf.ui.library.LibraryScreen]'s minimum
|
||||
* query length (2) is enforced here too, so a stray programmatic call can't
|
||||
* burn quota on a near-empty query either.
|
||||
*/
|
||||
fun submitOnlineSearch() {
|
||||
val trimmed = _query.value.trim()
|
||||
if (trimmed.length < 2) return
|
||||
onlineSearchJob?.cancel()
|
||||
onlineSearchJob = viewModelScope.launch { performOnlineSearch(trimmed) }
|
||||
}
|
||||
|
||||
/** Retries both sources — used by the "both failed" retry affordance and each per-source failure line. */
|
||||
fun retryOnlineSearch() = submitOnlineSearch()
|
||||
|
||||
/**
|
||||
* Internal (not private) so tests can await it directly, same reasoning as
|
||||
* [org.modg.bookshelf.ui.scan.ScanViewModel.runLookup]. [BookSearchRepository]'s
|
||||
* clients already never throw (see their KDoc), but this guard is the same
|
||||
* belt-and-braces backstop that class documents.
|
||||
*/
|
||||
internal suspend fun performOnlineSearch(query: String) {
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.Searching(query)
|
||||
val raw = try {
|
||||
val results = bookSearchRepository.search(query)
|
||||
RawHits(
|
||||
query = query,
|
||||
openLibrary = (results.openLibrary as? SearchSourceResult.Found)?.hits.orEmpty(),
|
||||
googleBooks = (results.googleBooks as? SearchSourceResult.Found)?.hits.orEmpty(),
|
||||
openLibraryFailure = (results.openLibrary as? SearchSourceResult.Failed)?.reason,
|
||||
googleBooksFailure = (results.googleBooks as? SearchSourceResult.Failed)?.reason,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
val reason = "unexpected: ${e.javaClass.simpleName}"
|
||||
RawHits(query, emptyList(), emptyList(), reason, reason)
|
||||
}
|
||||
_onlineSearchPhase.value = OnlineSearchPhase.Done(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the save sheet and, when [book] carries an ISBN, kicks off the same
|
||||
* by-ISBN enrichment lookup the scan flow uses in the background — Save never
|
||||
* waits on it (see [performSaveOnlineResult]); it's cancelled when the sheet closes.
|
||||
*/
|
||||
fun onOnlineResultTapped(book: OnlineBook) {
|
||||
enrichmentJob?.cancel()
|
||||
_saveSheet.value = OnlineSaveSheetState.Shown(book = book, selectedShelfId = recentShelfId.value)
|
||||
val isbn13 = book.isbn13 ?: return
|
||||
enrichmentJob = viewModelScope.launch {
|
||||
val result = metadataRepository.lookup(isbn13)
|
||||
if (result is LookupResult.Found) {
|
||||
_saveSheet.update { state ->
|
||||
if (state is OnlineSaveSheetState.Shown && state.book == book) state.copy(enrichment = result.metadata) else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSaveSheetShelfSelected(shelfId: String?) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(selectedShelfId = shelfId) else it }
|
||||
}
|
||||
|
||||
fun dismissSaveSheet() {
|
||||
enrichmentJob?.cancel()
|
||||
_saveSheet.value = OnlineSaveSheetState.Hidden
|
||||
}
|
||||
|
||||
fun saveOnlineResult() {
|
||||
viewModelScope.launch { performSaveOnlineResult() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split out so tests can await it directly (same pattern as
|
||||
* [org.modg.bookshelf.ui.add.AddBookViewModel.performSave]). Re-entrancy guarded
|
||||
* here so a genuine double-tap can't create two books. Reads whatever
|
||||
* [OnlineSaveSheetState.Shown.enrichment] happens to hold at this instant — it
|
||||
* never awaits the enrichment lookup, per the task's "Save must NEVER wait for this".
|
||||
*/
|
||||
internal suspend fun performSaveOnlineResult(): String? {
|
||||
val state = _saveSheet.value as? OnlineSaveSheetState.Shown ?: return null
|
||||
if (state.isSaving) return null
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = true, saveError = null) else it }
|
||||
|
||||
val fields = resolveOnlineBookFields(state.book, state.enrichment)
|
||||
return try {
|
||||
val id = bookRepository.createBook(
|
||||
title = fields.title,
|
||||
subtitle = fields.subtitle,
|
||||
authors = fields.authors,
|
||||
isbn13 = fields.isbn13,
|
||||
isbn10 = fields.isbn10,
|
||||
publisher = fields.publisher,
|
||||
publishedDate = fields.publishedDate,
|
||||
pageCount = fields.pageCount,
|
||||
description = fields.description,
|
||||
coverSourceUrl = fields.coverUrl,
|
||||
shelfId = state.selectedShelfId,
|
||||
)
|
||||
rememberShelf(state.selectedShelfId)
|
||||
enrichmentJob?.cancel()
|
||||
_saveSheet.value = OnlineSaveSheetState.Hidden
|
||||
id
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_saveSheet.update { if (it is OnlineSaveSheetState.Shown) it.copy(isSaving = false, saveError = e.javaClass.simpleName) else it }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */
|
||||
private suspend fun rememberShelf(shelfId: String?) {
|
||||
if (shelfId != null) settingsStore.setLastShelfId(shelfId)
|
||||
}
|
||||
|
||||
/** Manual trigger — app start (see init) and pull-to-refresh, per SPEC "Sync design". */
|
||||
fun refreshSync() {
|
||||
if (_isSyncing.value) return
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.TextNormalization
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
|
||||
/**
|
||||
* The library search bar's instant, offline, as-you-type local filter (task: "Own
|
||||
* library filters AS THEY TYPE"). Replaces [org.modg.bookshelf.data.local.BookDao.search]'s
|
||||
* single whole-string LIKE for this screen — that matches nothing for "hobbit tolkien"
|
||||
* because neither column contains that exact substring. A home library is hundreds to
|
||||
* low thousands of books, so a pure Kotlin scan is fine and far more testable than SQL.
|
||||
*/
|
||||
object LocalBookMatcher {
|
||||
|
||||
/**
|
||||
* Normalizes [query] and every candidate field, splits the query on whitespace,
|
||||
* and requires EVERY token to appear in at least one of title/subtitle/authors/
|
||||
* isbn13/isbn10 (different tokens may match different fields). Matching also
|
||||
* checks a space-stripped form of each field so an initials-heavy name like
|
||||
* "J.R.R. Tolkien" (normalized to "j r r tolkien", per [TextNormalization]) is
|
||||
* still found by the squashed query token "jrr", not just "j" or "r".
|
||||
*/
|
||||
fun match(books: List<BookEntity>, query: String): List<BookEntity> {
|
||||
val queryTokens = TextNormalization.tokens(query)
|
||||
if (queryTokens.isEmpty()) return emptyList()
|
||||
return books.filter { book -> matches(book, queryTokens) }
|
||||
}
|
||||
|
||||
private fun matches(book: BookEntity, queryTokens: List<String>): Boolean {
|
||||
val haystacks = buildList {
|
||||
add(TextNormalization.normalizeForMatching(book.title))
|
||||
book.subtitle?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
decodeAuthors(book.authorsJson).forEach { add(TextNormalization.normalizeForMatching(it)) }
|
||||
book.isbn13?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
book.isbn10?.let { add(TextNormalization.normalizeForMatching(it)) }
|
||||
}
|
||||
val squashed = haystacks.map { it.replace(" ", "") }
|
||||
return queryTokens.all { token -> haystacks.any { it.contains(token) } || squashed.any { it.contains(token) } }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.modg.bookshelf.AppContainer
|
||||
import org.modg.bookshelf.ui.add.AddBookScreen
|
||||
import org.modg.bookshelf.ui.detail.DetailScreen
|
||||
import org.modg.bookshelf.ui.library.LibraryScreen
|
||||
import org.modg.bookshelf.ui.library.resolveOnlineBookFields
|
||||
import org.modg.bookshelf.ui.locations.LocationsScreen
|
||||
import org.modg.bookshelf.ui.scan.ScanScreen
|
||||
import org.modg.bookshelf.ui.settings.SettingsScreen
|
||||
@@ -57,6 +58,28 @@ fun BookshelfNavHost(
|
||||
onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
|
||||
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||
container = container,
|
||||
// Wave 10: "Can't find it? Enter it by hand" under the online results —
|
||||
// pre-fills the add-by-hand form's title with the search query.
|
||||
onEnterByHandWithQuery = { query -> navController.navigate(Routes.addBook(title = query)) },
|
||||
// Wave 10: the online-result save sheet's "Edit details" — carries the
|
||||
// whole resolved draft (see resolveOnlineBookFields) through to the
|
||||
// add-by-hand form via its per-field route args.
|
||||
onEditOnlineResult = { book, enrichment ->
|
||||
val fields = resolveOnlineBookFields(book, enrichment)
|
||||
navController.navigate(
|
||||
Routes.addBook(
|
||||
title = fields.title,
|
||||
subtitle = fields.subtitle,
|
||||
authors = fields.authors.joinToString(", "),
|
||||
publisher = fields.publisher,
|
||||
publishedDate = fields.publishedDate,
|
||||
pages = fields.pageCount?.toString(),
|
||||
isbn = fields.isbn13 ?: fields.isbn10,
|
||||
description = fields.description,
|
||||
coverSourceUrl = fields.coverUrl,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -79,21 +102,34 @@ fun BookshelfNavHost(
|
||||
composable(
|
||||
route = Routes.ADD_BOOK,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.ADD_BOOK_TITLE_ARG) {
|
||||
Routes.ADD_BOOK_TITLE_ARG,
|
||||
Routes.ADD_BOOK_ISBN_ARG,
|
||||
Routes.ADD_BOOK_SUBTITLE_ARG,
|
||||
Routes.ADD_BOOK_AUTHORS_ARG,
|
||||
Routes.ADD_BOOK_PUBLISHER_ARG,
|
||||
Routes.ADD_BOOK_PUBLISHED_DATE_ARG,
|
||||
Routes.ADD_BOOK_PAGES_ARG,
|
||||
Routes.ADD_BOOK_DESCRIPTION_ARG,
|
||||
Routes.ADD_BOOK_COVER_URL_ARG,
|
||||
).map { argName ->
|
||||
navArgument(argName) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Routes.ADD_BOOK_ISBN_ARG) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
) { backStackEntry ->
|
||||
fun arg(name: String) = backStackEntry.arguments?.getString(name)
|
||||
AddBookScreen(
|
||||
initialTitle = backStackEntry.arguments?.getString(Routes.ADD_BOOK_TITLE_ARG),
|
||||
initialIsbn = backStackEntry.arguments?.getString(Routes.ADD_BOOK_ISBN_ARG),
|
||||
initialTitle = arg(Routes.ADD_BOOK_TITLE_ARG),
|
||||
initialIsbn = arg(Routes.ADD_BOOK_ISBN_ARG),
|
||||
initialSubtitle = arg(Routes.ADD_BOOK_SUBTITLE_ARG),
|
||||
initialAuthors = arg(Routes.ADD_BOOK_AUTHORS_ARG),
|
||||
initialPublisher = arg(Routes.ADD_BOOK_PUBLISHER_ARG),
|
||||
initialPublishedDate = arg(Routes.ADD_BOOK_PUBLISHED_DATE_ARG),
|
||||
initialPages = arg(Routes.ADD_BOOK_PAGES_ARG),
|
||||
initialDescription = arg(Routes.ADD_BOOK_DESCRIPTION_ARG),
|
||||
initialCoverSourceUrl = arg(Routes.ADD_BOOK_COVER_URL_ARG),
|
||||
onBack = { navController.popBackStack() },
|
||||
onSaved = { bookId ->
|
||||
// Removes the add screen from the back stack, per the task's "Save"
|
||||
|
||||
@@ -20,21 +20,45 @@ object Routes {
|
||||
|
||||
const val ADD_BOOK_TITLE_ARG = "title"
|
||||
const val ADD_BOOK_ISBN_ARG = "isbn"
|
||||
|
||||
/**
|
||||
* Wave 10 (online search): "Edit details" on an online search hit needs to carry
|
||||
* the whole draft, not just title/isbn, to the add-by-hand screen. Extends the
|
||||
* wave-9 route with the same per-field nullable-query-arg pattern rather than a
|
||||
* new serialized-blob argument, so every arg keeps the identical encode/decode
|
||||
* story [ADD_BOOK_TITLE_ARG]/[ADD_BOOK_ISBN_ARG] already have (and RoutesTest
|
||||
* already covers).
|
||||
*/
|
||||
const val ADD_BOOK_SUBTITLE_ARG = "subtitle"
|
||||
const val ADD_BOOK_AUTHORS_ARG = "authors"
|
||||
const val ADD_BOOK_PUBLISHER_ARG = "publisher"
|
||||
const val ADD_BOOK_PUBLISHED_DATE_ARG = "publishedDate"
|
||||
const val ADD_BOOK_PAGES_ARG = "pages"
|
||||
const val ADD_BOOK_DESCRIPTION_ARG = "description"
|
||||
const val ADD_BOOK_COVER_URL_ARG = "coverUrl"
|
||||
private const val ADD_BOOK_BASE = "add"
|
||||
|
||||
/**
|
||||
* Both args are optional (wave 9: "add a book by hand" with nothing pre-filled)
|
||||
* Every arg is optional (wave 9: "add a book by hand" with nothing pre-filled)
|
||||
* — nullable/defaultValue-null args, same pattern [LIBRARY_WITH_SHELF] uses so
|
||||
* this one route also matches the bare "add" entry with neither supplied.
|
||||
* this one route also matches the bare "add" entry with none supplied.
|
||||
*/
|
||||
const val ADD_BOOK = "$ADD_BOOK_BASE?$ADD_BOOK_TITLE_ARG={$ADD_BOOK_TITLE_ARG}&$ADD_BOOK_ISBN_ARG={$ADD_BOOK_ISBN_ARG}"
|
||||
const val ADD_BOOK = "$ADD_BOOK_BASE?$ADD_BOOK_TITLE_ARG={$ADD_BOOK_TITLE_ARG}" +
|
||||
"&$ADD_BOOK_ISBN_ARG={$ADD_BOOK_ISBN_ARG}" +
|
||||
"&$ADD_BOOK_SUBTITLE_ARG={$ADD_BOOK_SUBTITLE_ARG}" +
|
||||
"&$ADD_BOOK_AUTHORS_ARG={$ADD_BOOK_AUTHORS_ARG}" +
|
||||
"&$ADD_BOOK_PUBLISHER_ARG={$ADD_BOOK_PUBLISHER_ARG}" +
|
||||
"&$ADD_BOOK_PUBLISHED_DATE_ARG={$ADD_BOOK_PUBLISHED_DATE_ARG}" +
|
||||
"&$ADD_BOOK_PAGES_ARG={$ADD_BOOK_PAGES_ARG}" +
|
||||
"&$ADD_BOOK_DESCRIPTION_ARG={$ADD_BOOK_DESCRIPTION_ARG}" +
|
||||
"&$ADD_BOOK_COVER_URL_ARG={$ADD_BOOK_COVER_URL_ARG}"
|
||||
|
||||
fun libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId"
|
||||
fun detail(bookId: String) = "detail/$bookId"
|
||||
|
||||
/**
|
||||
* Builds the add-by-hand route, percent-encoding [title]/[isbn] so values
|
||||
* containing `&`, `?`, `/`, `#` etc. (titles routinely do) survive as a single
|
||||
* Builds the add-by-hand route, percent-encoding every arg so values containing
|
||||
* `&`, `?`, `/`, `#` etc. (titles/descriptions routinely do) survive as a single
|
||||
* query value instead of corrupting the route string. [Uri.encode] rather than
|
||||
* [java.net.URLEncoder] because the latter encodes space as `+`, which
|
||||
* Navigation Compose's Uri-pattern argument matching does NOT decode back to
|
||||
@@ -44,10 +68,27 @@ object Routes {
|
||||
* nothing to pre-fill (e.g. the plain library FAB) gets exactly the bare
|
||||
* "add" route wave 9's [org.modg.bookshelf.ui.add.AddBookScreen] expects.
|
||||
*/
|
||||
fun addBook(title: String? = null, isbn: String? = null): String {
|
||||
fun addBook(
|
||||
title: String? = null,
|
||||
isbn: String? = null,
|
||||
subtitle: String? = null,
|
||||
authors: String? = null,
|
||||
publisher: String? = null,
|
||||
publishedDate: String? = null,
|
||||
pages: String? = null,
|
||||
description: String? = null,
|
||||
coverSourceUrl: String? = null,
|
||||
): String {
|
||||
val query = buildList {
|
||||
title?.let { add("$ADD_BOOK_TITLE_ARG=${Uri.encode(it)}") }
|
||||
isbn?.let { add("$ADD_BOOK_ISBN_ARG=${Uri.encode(it)}") }
|
||||
subtitle?.let { add("$ADD_BOOK_SUBTITLE_ARG=${Uri.encode(it)}") }
|
||||
authors?.let { add("$ADD_BOOK_AUTHORS_ARG=${Uri.encode(it)}") }
|
||||
publisher?.let { add("$ADD_BOOK_PUBLISHER_ARG=${Uri.encode(it)}") }
|
||||
publishedDate?.let { add("$ADD_BOOK_PUBLISHED_DATE_ARG=${Uri.encode(it)}") }
|
||||
pages?.let { add("$ADD_BOOK_PAGES_ARG=${Uri.encode(it)}") }
|
||||
description?.let { add("$ADD_BOOK_DESCRIPTION_ARG=${Uri.encode(it)}") }
|
||||
coverSourceUrl?.let { add("$ADD_BOOK_COVER_URL_ARG=${Uri.encode(it)}") }
|
||||
}
|
||||
return if (query.isEmpty()) ADD_BOOK_BASE else "$ADD_BOOK_BASE?" + query.joinToString("&")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses checked-in sample JSON fixtures — no network. Reuses the existing
|
||||
* googlebooks_*.json fixtures ([GoogleBooksClientTest] already established they're
|
||||
* shaped like a real `volumes?q=` response, which search and ISBN lookup share).
|
||||
*/
|
||||
class GoogleBooksSearchClientTest {
|
||||
|
||||
private val client = GoogleBooksSearchClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response into SearchHits with null industryIdentifiers and no imageLinks handled`() {
|
||||
val body = """
|
||||
{"items": [{"volumeInfo": {"title": "Some Title", "authors": ["Some Author"]}}]}
|
||||
""".trimIndent()
|
||||
val hits = client.parseResponse(body)
|
||||
|
||||
assertEquals(1, hits.size)
|
||||
assertEquals("Some Title", hits[0].title)
|
||||
assertEquals(listOf("Some Author"), hits[0].authors)
|
||||
assertTrue(hits[0].isbn13s.isEmpty())
|
||||
assertEquals(null, hits[0].coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses the real success fixture and forces https zoom=2 on the cover`() {
|
||||
val hits = client.parseResponse(fixture("googlebooks_success.json"))
|
||||
assertEquals(1, hits.size)
|
||||
assertEquals("Effective Java", hits[0].title)
|
||||
assertEquals(listOf("9780134685991"), hits[0].isbn13s)
|
||||
assertEquals(
|
||||
"https://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=2",
|
||||
hits[0].coverUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a garbage publishedDate does not become a year`() {
|
||||
val body = """
|
||||
{"items": [{"volumeInfo": {"title": "Old Book", "publishedDate": "101-01-01"}}]}
|
||||
""".trimIndent()
|
||||
val hits = client.parseResponse(body)
|
||||
assertEquals(null, hits[0].year)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns NotFound for no items, not an empty Found`() {
|
||||
assertEquals(SearchSourceResult.NotFound, client.classify(200, fixture("googlebooks_no_items.json")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a non-2xx status`() {
|
||||
assertEquals(SearchSourceResult.Failed("http 404", FailureKind.CLIENT_ERROR), client.classify(404, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for malformed json`() {
|
||||
assertEquals(SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED), client.classify(200, fixture("malformed.json")))
|
||||
}
|
||||
|
||||
// --- requestUrl(): key only when configured, query url-encoded ---
|
||||
|
||||
@Test
|
||||
fun `requestUrl omits the key when blank`() {
|
||||
val url = client.requestUrl("dune")
|
||||
assertTrue(url.contains("q=dune"))
|
||||
assertFalse(url.contains("key="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl carries the key only when configured`() {
|
||||
val keyed = GoogleBooksSearchClient(OkHttpClient(), Json, apiKey = "test-key-123")
|
||||
assertTrue(keyed.requestUrl("dune").contains("key=test-key-123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl percent-encodes a query with url-breaking characters`() {
|
||||
val url = client.requestUrl("a&b c")
|
||||
assertFalse(url.contains("q=a&b"))
|
||||
assertTrue(url.contains("q=a%26b"))
|
||||
}
|
||||
|
||||
// --- redact(): the key must never survive into a shown reason ---
|
||||
|
||||
@Test
|
||||
fun `redact scrubs the api key from a leaked reason`() {
|
||||
val key = "test-key-123"
|
||||
val keyed = GoogleBooksSearchClient(OkHttpClient(), Json, apiKey = key)
|
||||
val leakedUrl = keyed.requestUrl("dune")
|
||||
check(leakedUrl.contains(key))
|
||||
|
||||
val leaking = SearchSourceResult.Failed("network error: connect to $leakedUrl failed", FailureKind.TRANSPORT)
|
||||
val redacted = keyed.redact(leaking) as SearchSourceResult.Failed
|
||||
|
||||
assertFalse(redacted.reason.contains(key))
|
||||
assertTrue(redacted.reason.contains("[REDACTED]"))
|
||||
}
|
||||
|
||||
// --- fetch(): non-IOException -> Failed(UNEXPECTED); CancellationException propagates ---
|
||||
|
||||
@Test
|
||||
fun `searchOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom"))
|
||||
val result = client.searchOnce("dune")
|
||||
val failed = result as? SearchSourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
try {
|
||||
client.searchOnce("dune")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): GoogleBooksSearchClient {
|
||||
val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { throw t }).build()
|
||||
return GoogleBooksSearchClient(httpClient, Json)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network
|
||||
* involved, offline-safe. Mirrors [OpenLibraryClientTest]'s structure for the sibling
|
||||
* search endpoint.
|
||||
*/
|
||||
class OpenLibrarySearchClientTest {
|
||||
|
||||
private val client = OpenLibrarySearchClient(OkHttpClient(), Json)
|
||||
|
||||
@Test
|
||||
fun `parses a successful response, normalizing mixed isbn10-13 to one distinct isbn13`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
|
||||
assertEquals(2, hits.size)
|
||||
val hittite = hits[0]
|
||||
assertEquals("Hittite Warrior", hittite.title)
|
||||
assertEquals(listOf("Joanne S. Williamson"), hittite.authors)
|
||||
assertEquals(1960, hittite.year)
|
||||
assertEquals("Bethlehem Books", hittite.publisher)
|
||||
assertEquals(237, hittite.pageCount)
|
||||
assertEquals("https://covers.openlibrary.org/b/id/930599-M.jpg", hittite.coverUrl)
|
||||
// "1883937388" (isbn-10) and "9781883937386" (isbn-13) are the SAME edition --
|
||||
// must normalize to one distinct value, not two.
|
||||
assertEquals(listOf("9781883937386"), hittite.isbn13s)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doc with no isbn field parses with an empty isbn13s list, not a crash`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
val noIsbn = hits[1]
|
||||
assertEquals("A Book With No ISBN On File", noIsbn.title)
|
||||
assertTrue(noIsbn.isbn13s.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a doc with no cover_i has a null coverUrl, never a synthesized one`() {
|
||||
val hits = client.parseResponse(fixture("openlibrary_search_success.json"))
|
||||
assertNull(hits[1].coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fails soft on malformed json instead of throwing`() {
|
||||
assertTrue(client.parseResponse(fixture("malformed.json")).isEmpty())
|
||||
}
|
||||
|
||||
// --- classify(): the three-way per-source outcome ---
|
||||
|
||||
@Test
|
||||
fun `classify reports Found for docs with results`() {
|
||||
val result = client.classify(200, fixture("openlibrary_search_success.json"))
|
||||
val found = result as? SearchSourceResult.Found
|
||||
checkNotNull(found) { "expected Found, got $result" }
|
||||
assertEquals(2, found.hits.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports NotFound for an empty docs list, distinctly from a failure`() {
|
||||
val result = client.classify(200, fixture("openlibrary_search_no_docs.json"))
|
||||
assertEquals(SearchSourceResult.NotFound, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for a non-2xx status, not NotFound`() {
|
||||
assertEquals(SearchSourceResult.Failed("http 500 (server error)", FailureKind.SERVER_ERROR), client.classify(500, null))
|
||||
assertEquals(SearchSourceResult.Failed("http 429 (rate limited)", FailureKind.RATE_LIMITED), client.classify(429, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `classify reports Failed for malformed json even on a 2xx status`() {
|
||||
assertEquals(
|
||||
SearchSourceResult.Failed("malformed json", FailureKind.MALFORMED),
|
||||
client.classify(200, fixture("malformed.json")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- requestUrl(): fields= (with isbn) must be present, and the query URL-encoded ---
|
||||
|
||||
@Test
|
||||
fun `requestUrl includes fields= with isbn -- without it OL omits isbns entirely`() {
|
||||
val url = client.requestUrl("dune")
|
||||
assertTrue(url.contains("fields="))
|
||||
assertTrue(url.contains("isbn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestUrl percent-encodes a query with url-breaking characters`() {
|
||||
val url = client.requestUrl("a&b c")
|
||||
// A literal, un-encoded "&" would split the query into two bogus parameters.
|
||||
assertFalse(url.contains("q=a&b"))
|
||||
assertTrue(url.contains("q=a%26b"))
|
||||
}
|
||||
|
||||
// --- fetch(): non-IOException -> Failed(UNEXPECTED); CancellationException propagates ---
|
||||
|
||||
@Test
|
||||
fun `searchOnce reports Failed UNEXPECTED, not a crash, when the http call throws a non-IOException`() = runTest {
|
||||
val client = clientThrowing(IllegalStateException("boom"))
|
||||
val result = client.searchOnce("dune")
|
||||
val failed = result as? SearchSourceResult.Failed
|
||||
checkNotNull(failed) { "expected Failed, got $result" }
|
||||
assertEquals(FailureKind.UNEXPECTED, failed.kind)
|
||||
assertEquals("unexpected: IllegalStateException", failed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancellationException propagates out of the client rather than becoming a Failed`() = runTest {
|
||||
val client = clientThrowing(CancellationException("scope cancelled"))
|
||||
try {
|
||||
client.searchOnce("dune")
|
||||
fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientThrowing(t: Throwable): OpenLibrarySearchClient {
|
||||
val httpClient = OkHttpClient.Builder().addInterceptor(Interceptor { throw t }).build()
|
||||
return OpenLibrarySearchClient(httpClient, Json)
|
||||
}
|
||||
|
||||
private fun fixture(name: String): String =
|
||||
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package org.modg.bookshelf.data.metadata
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import org.modg.bookshelf.data.repo.encodeAuthors
|
||||
|
||||
class SearchResultMergerTest {
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String,
|
||||
authors: List<String>,
|
||||
isbn13: String? = null,
|
||||
isbn10: String? = null,
|
||||
) = BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
isbn10 = isbn10,
|
||||
createdAt = 1,
|
||||
updatedAt = 1,
|
||||
syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
private fun hit(
|
||||
title: String?,
|
||||
authors: List<String> = emptyList(),
|
||||
isbn13s: List<String> = emptyList(),
|
||||
year: Int? = null,
|
||||
coverUrl: String? = null,
|
||||
publisher: String? = null,
|
||||
pageCount: Int? = null,
|
||||
) = SearchHit(title, authors = authors, isbn13s = isbn13s, year = year, coverUrl = coverUrl, publisher = publisher, pageCount = pageCount)
|
||||
|
||||
// --- identity: ISBN ---
|
||||
|
||||
@Test
|
||||
fun `a GB edition joins an OL work by a shared isbn13`() {
|
||||
val ol = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593", "9780451128596"))
|
||||
val gb = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
assertEquals(setOf(SearchSource.OPEN_LIBRARY, SearchSource.GOOGLE_BOOKS), result.online[0].sources)
|
||||
}
|
||||
|
||||
// --- identity: title + author surname, near-duplicate author spellings ---
|
||||
|
||||
@Test
|
||||
fun `near-duplicate author spellings for the same work collapse into one entry`() {
|
||||
// Real example from the task brief: the same book returned as two OL works.
|
||||
val a = hit("Hittite Warrior", listOf("Joanne S. Williamson"))
|
||||
val b = hit("Hittite Warrior", listOf("Joanne Small Williamson"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a, b), emptyList())
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initials vs full name for the same author collapse via surname matching`() {
|
||||
val a = hit("The Hobbit", listOf("J.R.R. Tolkien"))
|
||||
val b = hit("The Hobbit", listOf("John Ronald Reuel Tolkien"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a), listOf(b))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `five GB editions of the same title plus one OL work collapse to ONE online entry`() {
|
||||
val ol = hit("The Hobbit", listOf("J.R.R. Tolkien"))
|
||||
val gbEditions = List(5) { hit("The Hobbit", listOf("J. R. R. Tolkien")) }
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(ol), gbEditions)
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distinct books with the same title but different authors stay separate`() {
|
||||
val a = hit("Circe", listOf("Madeline Miller"))
|
||||
val b = hit("Circe", listOf("Someone Else"))
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a, b), emptyList())
|
||||
|
||||
assertEquals(2, result.online.size)
|
||||
}
|
||||
|
||||
// --- transitivity ---
|
||||
|
||||
@Test
|
||||
fun `transitivity -- A tilde B by isbn, B tilde C by title, all three land in one group`() {
|
||||
// A and C share NEITHER an isbn NOR a title+author -- only B bridges them, so
|
||||
// this only passes if grouping is transitive (union-find) rather than pairwise.
|
||||
val a = hit("Title A", listOf("Author A"), isbn13s = listOf("9781111111111"))
|
||||
val b = hit("Title B", listOf("Author B"), isbn13s = listOf("9781111111111")) // shares A's isbn
|
||||
val c = hit("Title B", listOf("Author B")) // shares B's title+author, nothing in common with A
|
||||
|
||||
val result = SearchResultMerger.merge(emptyList(), listOf(a), listOf(b, c))
|
||||
|
||||
assertEquals(1, result.online.size)
|
||||
}
|
||||
|
||||
// --- local books: placement, and the "reached only via an online hit" case ---
|
||||
|
||||
@Test
|
||||
fun `an online hit of a different edition of an owned book lands in inLibrary, not online`() {
|
||||
val owned = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780451128596")
|
||||
val differentEditionOnline = hit("Dune", listOf("Frank Herbert"), isbn13s = listOf("9780441013593"))
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(owned), listOf(differentEditionOnline), emptyList())
|
||||
|
||||
assertEquals(listOf(owned), result.inLibrary)
|
||||
assertTrue("the online hit must not ALSO surface as a new online result", result.online.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an online hit unrelated to any local book stays in online`() {
|
||||
val owned = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780451128596")
|
||||
val unrelated = hit("Piranesi", listOf("Susanna Clarke"))
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(owned), listOf(unrelated), emptyList())
|
||||
|
||||
assertEquals(listOf(owned), result.inLibrary)
|
||||
assertEquals(1, result.online.size)
|
||||
assertEquals("Piranesi", result.online[0].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two local books sharing a group are both shown, not deduped away`() {
|
||||
val copy1 = book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780441013593")
|
||||
val copy2 = book("b2", "Dune", listOf("Frank Herbert"), isbn13 = "9780441013593")
|
||||
|
||||
val result = SearchResultMerger.merge(listOf(copy1, copy2), emptyList(), emptyList())
|
||||
|
||||
assertEquals(setOf("b1", "b2"), result.inLibrary.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
// --- ranking ---
|
||||
|
||||
@Test
|
||||
fun `online results are ordered by the best rank either source returned a member at`() {
|
||||
// The real hit is OL position 0 AND GB position 0 (rank 0); the noise trails
|
||||
// at OL position 1 with nothing in GB backing it (rank 1) -- it must sort after.
|
||||
val olRealHit = hit("The Real Book", listOf("Real Author"), isbn13s = listOf("9781111111111"))
|
||||
val olNoise = hit("Loosely Related Noise", listOf("Nobody"))
|
||||
val gbRealHit = hit("The Real Book", listOf("Real Author"), isbn13s = listOf("9781111111111"))
|
||||
|
||||
val result = SearchResultMerger.merge(
|
||||
local = emptyList(),
|
||||
openLibrary = listOf(olRealHit, olNoise),
|
||||
googleBooks = listOf(gbRealHit),
|
||||
)
|
||||
|
||||
assertEquals(2, result.online.size)
|
||||
assertEquals("The Real Book", result.online[0].title)
|
||||
assertEquals("Loosely Related Noise", result.online[1].title)
|
||||
}
|
||||
|
||||
// --- field fill ---
|
||||
|
||||
@Test
|
||||
fun `title and authors come from OL when OL has a title, year and cover fall through independently`() {
|
||||
// Tied together by a shared isbn13 so they're one group despite OL and GB
|
||||
// disagreeing on title/author string -- exactly the shape a real work-vs-
|
||||
// edition pairing takes, and what makes the primary/secondary choice visible.
|
||||
val ol = hit("Concrete Mathematics", listOf("Ronald L. Graham"), isbn13s = listOf("9780201558029"), year = null, coverUrl = null)
|
||||
val gb = hit(
|
||||
"A Different Title Entirely",
|
||||
listOf("GB Author"),
|
||||
isbn13s = listOf("9780201558029"),
|
||||
year = 1994,
|
||||
coverUrl = "https://example.com/cover.jpg",
|
||||
)
|
||||
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
|
||||
assertEquals("Concrete Mathematics", merged.title) // OL wins because it has a title
|
||||
assertEquals(listOf("Ronald L. Graham"), merged.authors)
|
||||
assertEquals(1994, merged.year) // OL's is null, falls through to GB
|
||||
assertEquals("https://example.com/cover.jpg", merged.coverUrl)
|
||||
}
|
||||
|
||||
// --- ISBN rule: each branch ---
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL isbn10 and isbn13 of the same edition normalize to one, and that one is used`() {
|
||||
val ol = hit("Hittite Warrior", listOf("Joanne S. Williamson"), isbn13s = listOf("9781883937386")) // already normalized by the client
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), emptyList()).online.single()
|
||||
assertEquals("9781883937386", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL has many isbns, GB has exactly one -- GB's isbn wins`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111", "9782222222222", "9783333333333"))
|
||||
val gb = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
assertEquals("9781111111111", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- several GB isbns and no single-edition OL work -- no isbn is recorded`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111", "9782222222222"))
|
||||
val gbA = hit("Popular Book", listOf("Author"), isbn13s = listOf("9784444444444"))
|
||||
val gbB = hit("Popular Book", listOf("Author"), isbn13s = listOf("9785555555555"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gbA, gbB)).online.single()
|
||||
assertNull(merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- OL has no isbn field -- falls through to GB`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = emptyList())
|
||||
val gb = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gb)).online.single()
|
||||
assertEquals("9781111111111", merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isbn rule -- neither source resolves to a single isbn -- null, never a guess`() {
|
||||
val ol = hit("Popular Book", listOf("Author"), isbn13s = emptyList())
|
||||
val gbA = hit("Popular Book", listOf("Author"), isbn13s = listOf("9781111111111"))
|
||||
val gbB = hit("Popular Book", listOf("Author"), isbn13s = listOf("9782222222222"))
|
||||
val merged = SearchResultMerger.merge(emptyList(), listOf(ol), listOf(gbA, gbB)).online.single()
|
||||
assertNull(merged.isbn13)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty inputs produce empty results without throwing`() {
|
||||
val result = SearchResultMerger.merge(emptyList(), emptyList(), emptyList())
|
||||
assertTrue(result.inLibrary.isEmpty())
|
||||
assertTrue(result.online.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.modg.bookshelf.data.local.BookDao
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.BookshelfDatabase
|
||||
import org.modg.bookshelf.data.metadata.BookSearchRepository
|
||||
import org.modg.bookshelf.data.metadata.MetadataRepository
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.prefs.SettingsStore
|
||||
import org.modg.bookshelf.data.repo.BookRepository
|
||||
import org.modg.bookshelf.data.repo.LocationRepository
|
||||
import org.modg.bookshelf.data.repo.SyncEngine
|
||||
import org.modg.bookshelf.data.repo.decodeAuthors
|
||||
import org.modg.bookshelf.data.remote.ApiProvider
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Wave 10 (online search). Covers the ViewModel-owned parts of the task's contract:
|
||||
* typing never hits the network, submit does, editing after a submit clears/cancels,
|
||||
* partial and total source failure, and the online-result save sheet's field mapping
|
||||
* and error handling. Parsing/merge logic itself is covered by
|
||||
* [org.modg.bookshelf.data.metadata.SearchResultMergerTest] and the client tests —
|
||||
* this file only exercises what's specific to owning that state in a ViewModel.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class LibraryViewModelTest {
|
||||
|
||||
private lateinit var db: BookshelfDatabase
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var locationRepository: LocationRepository
|
||||
private lateinit var bookRepository: BookRepository
|
||||
private lateinit var context: android.content.Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
settingsStore = SettingsStore(context)
|
||||
locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao())
|
||||
bookRepository = BookRepository(db.bookDao(), context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(searchHttpClient: OkHttpClient = OkHttpClient()): LibraryViewModel = LibraryViewModel(
|
||||
bookRepository = bookRepository,
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(searchHttpClient, Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
|
||||
// --- typing vs submit ---
|
||||
|
||||
@Test
|
||||
fun `typing does NOT trigger an online search`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onQueryChange("d")
|
||||
vm.onQueryChange("du")
|
||||
vm.onQueryChange("dune")
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit with fewer than 2 characters does nothing`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onQueryChange("d")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `submit runs the online search and populates online results`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olHit("Dune", "Frank Herbert"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, done.online.size)
|
||||
assertEquals("Dune", done.online[0].title)
|
||||
assertNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
// --- editing after a submit ---
|
||||
|
||||
@Test
|
||||
fun `editing the query after a submit clears the online section and cancels the in-flight search`() = runTest {
|
||||
val gate = CountDownLatch(1)
|
||||
val vm = viewModel(gatedFakeSearchClient(gate, olBody = olHit("Dune", "Frank Herbert"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
// .first(), not .value -- see the KDoc convention this codebase already uses
|
||||
// (e.g. AddBookViewModel.uiState): a bare .value read with no live collector
|
||||
// only ever sees the seed passed to stateIn. The fake call is blocked on the
|
||||
// gate, so the phase genuinely stays Searching until collection observes it.
|
||||
val searching = vm.onlineSearch.first { it !is OnlineSearchState.NotSearched }
|
||||
assertTrue("expected Searching, got $searching", searching is OnlineSearchState.Searching)
|
||||
|
||||
vm.onQueryChange("something else")
|
||||
|
||||
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
|
||||
gate.countDown() // release the blocked background call so its thread isn't leaked
|
||||
}
|
||||
|
||||
// --- partial and total source failure ---
|
||||
|
||||
@Test
|
||||
fun `one source failing still shows the other source's results, with a failure line naming it`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olCode = 404, olBody = "", gbBody = gbHit("Dune", "Frank Herbert")))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, done.online.size)
|
||||
assertEquals("Dune", done.online[0].title)
|
||||
checkNotNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both sources failing is reported as failures, never as an authoritative no-results`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olCode = 404, olBody = "", gbCode = 404, gbBody = ""))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertTrue(done.online.isEmpty())
|
||||
checkNotNull(done.openLibraryFailure)
|
||||
checkNotNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both sources answering with nothing is an authoritative empty result, no failures`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olEmpty(), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("zzzznonexistentzzzz")
|
||||
vm.submitOnlineSearch()
|
||||
|
||||
val done = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertTrue(done.online.isEmpty())
|
||||
assertNull(done.openLibraryFailure)
|
||||
assertNull(done.googleBooksFailure)
|
||||
}
|
||||
|
||||
// --- save sheet ---
|
||||
|
||||
@Test
|
||||
fun `save maps every online book field to createBook`() = runTest {
|
||||
val bookcaseId = locationRepository.createBookcase(name = "Living Room")
|
||||
val shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf")
|
||||
val vm = viewModel()
|
||||
val onlineBook = OnlineBook(
|
||||
title = "Dune",
|
||||
subtitle = "A novel",
|
||||
authors = listOf("Frank Herbert"),
|
||||
year = 1965,
|
||||
publisher = "Ace",
|
||||
pageCount = 412,
|
||||
coverUrl = "https://example.com/cover.jpg",
|
||||
isbn13 = "9780441013593",
|
||||
)
|
||||
vm.onOnlineResultTapped(onlineBook)
|
||||
vm.onSaveSheetShelfSelected(shelfId)
|
||||
|
||||
val bookId = vm.performSaveOnlineResult()
|
||||
checkNotNull(bookId)
|
||||
|
||||
val saved = bookRepository.getById(bookId)
|
||||
checkNotNull(saved)
|
||||
assertEquals("Dune", saved.title)
|
||||
assertEquals("A novel", saved.subtitle)
|
||||
assertEquals(listOf("Frank Herbert"), decodeAuthors(saved.authorsJson))
|
||||
assertEquals("Ace", saved.publisher)
|
||||
assertEquals("1965", saved.publishedDate)
|
||||
assertEquals(412, saved.pageCount)
|
||||
assertEquals("9780441013593", saved.isbn13)
|
||||
assertEquals("https://example.com/cover.jpg", saved.coverSourceUrl)
|
||||
assertEquals(shelfId, saved.shelfId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `after a save the sheet closes and the book appears in inLibrary via the Room flow`() = runTest {
|
||||
val vm = viewModel(fakeSearchClient(olBody = olHit("Dune", "Frank Herbert", isbn = "9780441013593"), gbBody = gbEmpty()))
|
||||
vm.onQueryChange("dune")
|
||||
vm.submitOnlineSearch()
|
||||
val beforeSave = vm.onlineSearch.first { it is OnlineSearchState.Done } as OnlineSearchState.Done
|
||||
assertEquals(1, beforeSave.online.size)
|
||||
assertTrue(beforeSave.inLibrary.isEmpty())
|
||||
|
||||
vm.onOnlineResultTapped(beforeSave.online[0])
|
||||
vm.performSaveOnlineResult()
|
||||
|
||||
assertEquals(OnlineSaveSheetState.Hidden, vm.saveSheet.first())
|
||||
val afterSave = vm.onlineSearch.first { (it as? OnlineSearchState.Done)?.online?.isEmpty() == true } as OnlineSearchState.Done
|
||||
assertEquals(1, afterSave.inLibrary.size)
|
||||
assertEquals("Dune", afterSave.inLibrary[0].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a throwing save keeps the sheet open with an error and does not throw`() = runTest {
|
||||
val throwingBookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), IllegalStateException("disk full")), context)
|
||||
val vm = LibraryViewModel(
|
||||
bookRepository = throwingBookRepository,
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
val result = vm.performSaveOnlineResult() // must not throw
|
||||
|
||||
assertNull(result)
|
||||
val shown = vm.saveSheet.first() as? OnlineSaveSheetState.Shown
|
||||
checkNotNull(shown)
|
||||
assertEquals("IllegalStateException", shown.saveError)
|
||||
assertTrue(!shown.isSaving)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CancellationException during save propagates rather than being swallowed`() = runTest {
|
||||
val vm = LibraryViewModel(
|
||||
bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), CancellationException("scope cancelled")), context),
|
||||
locationRepository = locationRepository,
|
||||
syncEngine = SyncEngine(ApiProvider { null }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
|
||||
settingsStore = settingsStore,
|
||||
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
|
||||
initialShelfId = null,
|
||||
)
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
try {
|
||||
vm.performSaveOnlineResult()
|
||||
org.junit.Assert.fail("expected CancellationException to propagate")
|
||||
} catch (e: CancellationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second save while one is in flight does not create a second book`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.onOnlineResultTapped(OnlineBook(title = "Dune"))
|
||||
|
||||
val first = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).launch { vm.performSaveOnlineResult() }
|
||||
val second = kotlinx.coroutines.CoroutineScope(Dispatchers.Unconfined).launch { vm.performSaveOnlineResult() }
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
assertEquals(1, bookRepository.observeAll().first().size)
|
||||
}
|
||||
|
||||
private class ThrowingUpsertBookDao(
|
||||
private val delegate: BookDao,
|
||||
private val toThrow: Throwable,
|
||||
) : BookDao by delegate {
|
||||
override suspend fun upsert(book: BookEntity) = throw toThrow
|
||||
}
|
||||
|
||||
// --- fake HTTP for BookSearchRepository ---
|
||||
|
||||
private fun fakeSearchClient(
|
||||
olCode: Int = 200,
|
||||
olBody: String,
|
||||
gbCode: Int = 200,
|
||||
gbBody: String,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain -> respond(chain.request(), olCode, olBody, gbCode, gbBody) }
|
||||
.build()
|
||||
|
||||
private fun gatedFakeSearchClient(
|
||||
gate: CountDownLatch,
|
||||
olBody: String,
|
||||
gbBody: String,
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain -> gate.await(); respond(chain.request(), 200, olBody, 200, gbBody) }
|
||||
.build()
|
||||
|
||||
private fun respond(request: Request, olCode: Int, olBody: String, gbCode: Int, gbBody: String): Response = when {
|
||||
request.url.host.contains("openlibrary") -> jsonResponse(request, olCode, olBody)
|
||||
request.url.host.contains("googleapis") -> jsonResponse(request, gbCode, gbBody)
|
||||
else -> error("unexpected host: ${request.url.host}")
|
||||
}
|
||||
|
||||
private fun jsonResponse(request: Request, code: Int, body: String): Response = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(body.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
private fun olHit(title: String, author: String, isbn: String? = null) = """
|
||||
{"docs": [{"title": "$title", "author_name": ["$author"]${isbn?.let { ", \"isbn\": [\"$it\"]" }.orEmpty()}}]}
|
||||
""".trimIndent()
|
||||
|
||||
private fun olEmpty() = """{"docs": []}"""
|
||||
|
||||
private fun gbHit(title: String, author: String) = """
|
||||
{"items": [{"volumeInfo": {"title": "$title", "authors": ["$author"]}}]}
|
||||
""".trimIndent()
|
||||
|
||||
private fun gbEmpty() = """{"items": []}"""
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.modg.bookshelf.ui.library
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.local.SyncState
|
||||
import org.modg.bookshelf.data.repo.encodeAuthors
|
||||
|
||||
class LocalBookMatcherTest {
|
||||
|
||||
private fun book(id: String, title: String, authors: List<String>, subtitle: String? = null, isbn13: String? = null) = BookEntity(
|
||||
id = id,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
authorsJson = encodeAuthors(authors),
|
||||
isbn13 = isbn13,
|
||||
createdAt = 1,
|
||||
updatedAt = 1,
|
||||
syncState = SyncState.SYNCED,
|
||||
)
|
||||
|
||||
private val hobbit = book("b1", "The Hobbit", listOf("J.R.R. Tolkien"), isbn13 = "9780547928227")
|
||||
private val bronte = book("b2", "Jane Eyre", listOf("Charlotte Brontë"))
|
||||
private val dune = book("b3", "Dune", listOf("Frank Herbert"))
|
||||
|
||||
@Test
|
||||
fun `title plus author across two fields matches`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "hobbit tolkien")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `squashed initials match a token with no spaces`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "jrr tolkien")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token order does not matter`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "tolkien hobbit")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diacritics are stripped for matching`() {
|
||||
val result = LocalBookMatcher.match(listOf(bronte, dune), "bronte")
|
||||
assertEquals(listOf(bronte), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a query token matching nothing excludes the book`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit), "hobbit nonexistentword")
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token can match the isbn`() {
|
||||
val result = LocalBookMatcher.match(listOf(hobbit, dune), "9780547928227")
|
||||
assertEquals(listOf(hobbit), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query matches nothing`() {
|
||||
assertTrue(LocalBookMatcher.match(listOf(hobbit, dune), " ").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subtitle token matches`() {
|
||||
val withSubtitle = book("b4", "The Way of Kings", listOf("Brandon Sanderson"), subtitle = "The Stormlight Archive")
|
||||
val result = LocalBookMatcher.match(listOf(withSubtitle, dune), "stormlight")
|
||||
assertEquals(listOf(withSubtitle), result)
|
||||
}
|
||||
}
|
||||
@@ -74,4 +74,39 @@ class RoutesTest {
|
||||
assertEquals("add", Routes.addBook().substringBefore("?"))
|
||||
assertEquals("add", Routes.ADD_BOOK.substringBefore("?"))
|
||||
}
|
||||
|
||||
// --- wave 10: the full draft (online search's "Edit details") round-trips too ---
|
||||
|
||||
@Test
|
||||
fun `round-trips every draft field an online search result can carry`() {
|
||||
val route = Routes.addBook(
|
||||
title = "The Hobbit",
|
||||
subtitle = "or There and Back Again",
|
||||
authors = "J.R.R. Tolkien, Someone Else",
|
||||
publisher = "Houghton Mifflin",
|
||||
publishedDate = "1937",
|
||||
pages = "310",
|
||||
isbn = "9780547928227",
|
||||
description = "A hobbit goes on an adventure & returns home.",
|
||||
coverSourceUrl = "https://covers.openlibrary.org/b/id/1-M.jpg?x=1&y=2",
|
||||
)
|
||||
|
||||
assertEquals("The Hobbit", decode(route, Routes.ADD_BOOK_TITLE_ARG))
|
||||
assertEquals("or There and Back Again", decode(route, Routes.ADD_BOOK_SUBTITLE_ARG))
|
||||
assertEquals("J.R.R. Tolkien, Someone Else", decode(route, Routes.ADD_BOOK_AUTHORS_ARG))
|
||||
assertEquals("Houghton Mifflin", decode(route, Routes.ADD_BOOK_PUBLISHER_ARG))
|
||||
assertEquals("1937", decode(route, Routes.ADD_BOOK_PUBLISHED_DATE_ARG))
|
||||
assertEquals("310", decode(route, Routes.ADD_BOOK_PAGES_ARG))
|
||||
assertEquals("9780547928227", decode(route, Routes.ADD_BOOK_ISBN_ARG))
|
||||
assertEquals("A hobbit goes on an adventure & returns home.", decode(route, Routes.ADD_BOOK_DESCRIPTION_ARG))
|
||||
assertEquals("https://covers.openlibrary.org/b/id/1-M.jpg?x=1&y=2", decode(route, Routes.ADD_BOOK_COVER_URL_ARG))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting the new draft fields leaves them out entirely, not as literal nulls`() {
|
||||
val route = Routes.addBook(title = "Dune")
|
||||
assertNull(decode(route, Routes.ADD_BOOK_SUBTITLE_ARG))
|
||||
assertNull(decode(route, Routes.ADD_BOOK_DESCRIPTION_ARG))
|
||||
assertNull(decode(route, Routes.ADD_BOOK_COVER_URL_ARG))
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -30,6 +31,8 @@ import app.cash.paparazzi.Paparazzi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.modg.bookshelf.data.local.BookEntity
|
||||
import org.modg.bookshelf.data.metadata.OnlineBook
|
||||
import org.modg.bookshelf.data.metadata.SearchSource
|
||||
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||
import org.modg.bookshelf.ui.components.EmptyState
|
||||
import org.modg.bookshelf.ui.components.PaperSurface
|
||||
@@ -40,8 +43,12 @@ import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||
import org.modg.bookshelf.ui.library.LibraryBookCard
|
||||
import org.modg.bookshelf.ui.library.LibraryFilter
|
||||
import org.modg.bookshelf.ui.library.LibraryScreen
|
||||
import org.modg.bookshelf.ui.library.LibrarySearchResultsContent
|
||||
import org.modg.bookshelf.ui.library.LibrarySortOption
|
||||
import org.modg.bookshelf.ui.library.LibraryToolbar
|
||||
import org.modg.bookshelf.ui.library.OnlineResultSaveSheet
|
||||
import org.modg.bookshelf.ui.library.OnlineSaveSheetState
|
||||
import org.modg.bookshelf.ui.library.OnlineSearchState
|
||||
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||
|
||||
/**
|
||||
@@ -63,12 +70,127 @@ class LibraryScreenPaparazziTest {
|
||||
@Test
|
||||
fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() }
|
||||
|
||||
@Test
|
||||
fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") {
|
||||
SearchShell(
|
||||
localMatches = listOf(ScreenFixtures.books[1]), // "The Hobbit" -- already owned
|
||||
onlineSearch = OnlineSearchState.Done(
|
||||
query = "hobbit",
|
||||
inLibrary = listOf(ScreenFixtures.books[1]),
|
||||
online = onlineResults,
|
||||
openLibraryFailure = null,
|
||||
googleBooksFailure = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun librarySearchLoadingLight() = snapshotBoth("library-search-loading") {
|
||||
SearchShell(localMatches = emptyList(), onlineSearch = OnlineSearchState.Searching(query = "hobbit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun librarySearchOneSourceFailedLight() = snapshotBoth("library-search-one-failed") {
|
||||
SearchShell(
|
||||
localMatches = emptyList(),
|
||||
onlineSearch = OnlineSearchState.Done(
|
||||
query = "hobbit",
|
||||
inLibrary = emptyList(),
|
||||
online = onlineResults,
|
||||
openLibraryFailure = "tls connection reset, 3 attempts",
|
||||
googleBooksFailure = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryOnlineSaveSheetLight() = snapshotBoth("library-online-save-sheet") {
|
||||
Surface {
|
||||
OnlineResultSaveSheet(
|
||||
state = OnlineSaveSheetState.Shown(
|
||||
book = onlineResults[0],
|
||||
selectedShelfId = ScreenFixtures.topShelf.id,
|
||||
),
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
recentShelfId = ScreenFixtures.topShelf.id,
|
||||
onShelfSelected = {},
|
||||
onSave = {},
|
||||
onEditDetails = {},
|
||||
onSkip = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Two online hits: one merged from both sources, one Open-Library-only -- the shape [SearchResultMerger] actually produces. */
|
||||
private val onlineResults = listOf(
|
||||
OnlineBook(
|
||||
title = "The Hobbit",
|
||||
authors = listOf("J.R.R. Tolkien"),
|
||||
year = 1937,
|
||||
publisher = "Houghton Mifflin",
|
||||
isbn13 = "9780547928227",
|
||||
sources = setOf(SearchSource.OPEN_LIBRARY, SearchSource.GOOGLE_BOOKS),
|
||||
),
|
||||
OnlineBook(
|
||||
title = "The Annotated Hobbit",
|
||||
authors = listOf("J.R.R. Tolkien", "Douglas A. Anderson"),
|
||||
year = 2002,
|
||||
sources = setOf(SearchSource.OPEN_LIBRARY),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Populated() = Shell(books = ScreenFixtures.books, syncStatus = SyncStatus.Synced, syncLabel = "Synced • 2m ago")
|
||||
|
||||
@Composable
|
||||
private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet")
|
||||
|
||||
/** Same chrome as [Shell] but with a non-blank query, so [LibraryScreen] shows the sectioned search-results view instead of the grid. */
|
||||
@Composable
|
||||
private fun SearchShell(localMatches: List<BookEntity>, onlineSearch: OnlineSearchState) {
|
||||
BookshelfScaffold(
|
||||
title = "Bookshelf",
|
||||
actions = {
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves") }
|
||||
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
|
||||
},
|
||||
floatingActionButton = {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
SmallFloatingActionButton(onClick = {}) { Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
|
||||
}
|
||||
},
|
||||
syncStatusBar = { SyncStatusBar(status = SyncStatus.Synced, label = "Synced • 2m ago") },
|
||||
) { innerPadding ->
|
||||
PaperSurface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LibraryToolbar(
|
||||
query = "hobbit",
|
||||
onQueryChange = {},
|
||||
sortOption = LibrarySortOption.TITLE,
|
||||
onSortOptionChange = {},
|
||||
filter = LibraryFilter.All,
|
||||
onFilterChange = {},
|
||||
bookcases = ScreenFixtures.bookcases,
|
||||
shelves = ScreenFixtures.shelves,
|
||||
)
|
||||
LibrarySearchResultsContent(
|
||||
query = "hobbit",
|
||||
localMatches = localMatches,
|
||||
onlineSearch = onlineSearch,
|
||||
onBookClick = {},
|
||||
onSubmitOnlineSearch = {},
|
||||
onRetryOnlineSearch = {},
|
||||
onOnlineResultClick = {},
|
||||
onEnterByHand = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shell(books: List<BookEntity>, syncStatus: SyncStatus, syncLabel: String) {
|
||||
BookshelfScaffold(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"numFound": 0,
|
||||
"start": 0,
|
||||
"docs": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"numFound": 2,
|
||||
"start": 0,
|
||||
"docs": [
|
||||
{
|
||||
"key": "/works/OL2028038W",
|
||||
"title": "Hittite Warrior",
|
||||
"author_name": ["Joanne S. Williamson"],
|
||||
"first_publish_year": 1960,
|
||||
"cover_i": 930599,
|
||||
"isbn": ["1883937388", "9781883937386"],
|
||||
"publisher": ["Bethlehem Books"],
|
||||
"number_of_pages_median": 237
|
||||
},
|
||||
{
|
||||
"key": "/works/OL9999999W",
|
||||
"title": "A Book With No ISBN On File",
|
||||
"author_name": ["Some Author"],
|
||||
"first_publish_year": 1901
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |