Persist book status and add recent catalog section
This commit is contained in:
parent
6422f814d1
commit
44cc0bbaf3
@ -131,6 +131,13 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryIt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
||||||
|
appendLibraryLog("load recently added library items since=$sinceImportedAt limit=$limit")
|
||||||
|
openLibraryDatabase().useLibrary { db ->
|
||||||
|
db.files.listRecentlyAddedLibraryFiles(sinceImportedAt, limit).map { it.toLibraryItem() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
||||||
val prefixes = query.toSearchPrefixes()
|
val prefixes = query.toSearchPrefixes()
|
||||||
if (prefixes.isEmpty()) return@withContext emptyList()
|
if (prefixes.isEmpty()) return@withContext emptyList()
|
||||||
@ -140,6 +147,8 @@ actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryIt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun currentLibraryTimeMillis(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||||
|
|||||||
@ -96,8 +96,12 @@ expect suspend fun loadLibraryItems(): List<LibraryItem>
|
|||||||
|
|
||||||
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
|
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
|
||||||
|
|
||||||
|
expect suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int = 50): List<LibraryItem>
|
||||||
|
|
||||||
expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List<LibraryItem>
|
expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List<LibraryItem>
|
||||||
|
|
||||||
|
expect fun currentLibraryTimeMillis(): Long
|
||||||
|
|
||||||
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
|
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
|
||||||
|
|
||||||
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
|
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
|
||||||
|
|||||||
@ -86,6 +86,7 @@ internal fun LibraryScreen(
|
|||||||
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
|
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
|
||||||
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
||||||
var endReached by remember(state.items) { mutableStateOf(false) }
|
var endReached by remember(state.items) { mutableStateOf(false) }
|
||||||
|
var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||||
var wasScanning by remember { mutableStateOf(false) }
|
var wasScanning by remember { mutableStateOf(false) }
|
||||||
var settingsMenuOpen by remember { mutableStateOf(false) }
|
var settingsMenuOpen by remember { mutableStateOf(false) }
|
||||||
var autoScanDownloads by remember { mutableStateOf(true) }
|
var autoScanDownloads by remember { mutableStateOf(true) }
|
||||||
@ -94,6 +95,7 @@ internal fun LibraryScreen(
|
|||||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||||
var searching by remember { mutableStateOf(false) }
|
var searching by remember { mutableStateOf(false) }
|
||||||
var readingNowCollapsed by remember { mutableStateOf(false) }
|
var readingNowCollapsed by remember { mutableStateOf(false) }
|
||||||
|
var recentlyAddedCollapsed by remember { mutableStateOf(false) }
|
||||||
var myLibraryCollapsed by remember { mutableStateOf(false) }
|
var myLibraryCollapsed by remember { mutableStateOf(false) }
|
||||||
var notInterestedCollapsed by remember { mutableStateOf(false) }
|
var notInterestedCollapsed by remember { mutableStateOf(false) }
|
||||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||||
@ -133,6 +135,11 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun loadRecentlyAdded() {
|
||||||
|
val since = currentLibraryTimeMillis() - RecentlyAddedWindowMillis
|
||||||
|
recentlyAddedItems = loadRecentlyAddedLibraryItems(since, RecentlyAddedLimit)
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh(nextMessage: String? = message) {
|
fun refresh(nextMessage: String? = message) {
|
||||||
message = nextMessage
|
message = nextMessage
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -142,6 +149,7 @@ internal fun LibraryScreen(
|
|||||||
searching = false
|
searching = false
|
||||||
} else {
|
} else {
|
||||||
loadPage(reset = true)
|
loadPage(reset = true)
|
||||||
|
loadRecentlyAdded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,6 +170,7 @@ internal fun LibraryScreen(
|
|||||||
searching = false
|
searching = false
|
||||||
} else {
|
} else {
|
||||||
loadPage(reset = true)
|
loadPage(reset = true)
|
||||||
|
loadRecentlyAdded()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message = "Could not update ${item.title}."
|
message = "Could not update ${item.title}."
|
||||||
@ -193,7 +202,10 @@ internal fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(state.scanPath, state.message) {
|
LaunchedEffect(state.scanPath, state.message) {
|
||||||
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
if (items.isEmpty() && !endReached) {
|
||||||
|
loadPage(reset = true)
|
||||||
|
loadRecentlyAdded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(searchText) {
|
LaunchedEffect(searchText) {
|
||||||
@ -242,10 +254,12 @@ internal fun LibraryScreen(
|
|||||||
while (true) {
|
while (true) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
loadPage(reset = true)
|
loadPage(reset = true)
|
||||||
|
loadRecentlyAdded()
|
||||||
}
|
}
|
||||||
} else if (wasScanning) {
|
} else if (wasScanning) {
|
||||||
wasScanning = false
|
wasScanning = false
|
||||||
loadPage(reset = true)
|
loadPage(reset = true)
|
||||||
|
loadRecentlyAdded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,7 +454,13 @@ internal fun LibraryScreen(
|
|||||||
libraryRows("search", visibleItems)
|
libraryRows("search", visibleItems)
|
||||||
} else {
|
} else {
|
||||||
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
|
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
|
||||||
val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED }
|
val recentlyAdded = recentlyAddedItems.filter { it.readingStatus == BookReadingStatus.NEW }
|
||||||
|
val recentlyAddedIds = recentlyAdded.mapTo(mutableSetOf()) { it.fileId }
|
||||||
|
val myLibrary = visibleItems.filter {
|
||||||
|
it.fileId !in recentlyAddedIds &&
|
||||||
|
it.readingStatus != BookReadingStatus.READING &&
|
||||||
|
it.readingStatus != BookReadingStatus.NOT_INTERESTED
|
||||||
|
}
|
||||||
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
|
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
|
||||||
|
|
||||||
librarySection(
|
librarySection(
|
||||||
@ -453,6 +473,16 @@ internal fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
libraryRows("reading", readingNow)
|
libraryRows("reading", readingNow)
|
||||||
}
|
}
|
||||||
|
librarySection(
|
||||||
|
key = "recently-added",
|
||||||
|
title = "recently added",
|
||||||
|
itemCount = recentlyAdded.size,
|
||||||
|
displayCount = null,
|
||||||
|
collapsed = recentlyAddedCollapsed,
|
||||||
|
onCollapsedChange = { recentlyAddedCollapsed = it },
|
||||||
|
) {
|
||||||
|
libraryRows("recently-added", recentlyAdded)
|
||||||
|
}
|
||||||
librarySection(
|
librarySection(
|
||||||
key = "library",
|
key = "library",
|
||||||
title = "my library",
|
title = "my library",
|
||||||
@ -858,3 +888,5 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
|||||||
|
|
||||||
private const val LibraryPageSize: Int = 50
|
private const val LibraryPageSize: Int = 50
|
||||||
private const val SearchResultLimit: Int = 100
|
private const val SearchResultLimit: Int = 100
|
||||||
|
private const val RecentlyAddedLimit: Int = 50
|
||||||
|
private const val RecentlyAddedWindowMillis: Long = 30L * 60L * 60L * 1000L
|
||||||
|
|||||||
@ -94,6 +94,13 @@ actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryIt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
||||||
|
appendLibraryLog("load recently added library items since=$sinceImportedAt limit=$limit")
|
||||||
|
openLibraryDatabase().useLibrary { db ->
|
||||||
|
db.files.listRecentlyAddedLibraryFiles(sinceImportedAt, limit).map { it.toLibraryItem() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
|
||||||
val prefixes = query.toSearchPrefixes()
|
val prefixes = query.toSearchPrefixes()
|
||||||
if (prefixes.isEmpty()) return@withContext emptyList()
|
if (prefixes.isEmpty()) return@withContext emptyList()
|
||||||
@ -103,6 +110,8 @@ actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryIt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun currentLibraryTimeMillis(): Long = System.currentTimeMillis()
|
||||||
|
|
||||||
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
|
||||||
openLibraryDatabase().useLibrary { db ->
|
openLibraryDatabase().useLibrary { db ->
|
||||||
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
db.files.getLibraryFile(fileId)?.toLibraryItem()
|
||||||
|
|||||||
@ -24,8 +24,12 @@ actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
|
|||||||
|
|
||||||
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = emptyList()
|
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = emptyList()
|
||||||
|
|
||||||
|
actual suspend fun loadRecentlyAddedLibraryItems(sinceImportedAt: Long, limit: Int): List<LibraryItem> = emptyList()
|
||||||
|
|
||||||
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = emptyList()
|
actual suspend fun searchLibraryItems(query: String, limit: Int): List<LibraryItem> = emptyList()
|
||||||
|
|
||||||
|
actual fun currentLibraryTimeMillis(): Long = 0L
|
||||||
|
|
||||||
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null
|
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null
|
||||||
|
|
||||||
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null
|
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null
|
||||||
|
|||||||
@ -205,6 +205,7 @@ interface BookFileRepository {
|
|||||||
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
|
fun findPrimaryDuplicateTarget(bodyClusterId: String?, bodyId: String?, rawSha256: String): BookFileRecord?
|
||||||
fun markDuplicateFiles(): Int
|
fun markDuplicateFiles(): Int
|
||||||
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
fun listLibraryFiles(limit: Int = 100, offset: Int = 0): List<LibraryFileRecord>
|
||||||
|
fun listRecentlyAddedLibraryFiles(sinceImportedAt: Long, limit: Int = 50): List<LibraryFileRecord>
|
||||||
fun searchLibraryFiles(prefixes: List<String>, limit: Int = 100): List<LibraryFileRecord>
|
fun searchLibraryFiles(prefixes: List<String>, limit: Int = 100): List<LibraryFileRecord>
|
||||||
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
|
||||||
fun listForBook(bookId: String): List<BookFileRecord>
|
fun listForBook(bookId: String): List<BookFileRecord>
|
||||||
|
|||||||
@ -715,6 +715,40 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun listRecentlyAddedLibraryFiles(sinceImportedAt: Long, limit: Int): List<LibraryFileRecord> {
|
||||||
|
markDuplicateFiles()
|
||||||
|
return connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
f.id AS file_id,
|
||||||
|
f.book_id AS book_id,
|
||||||
|
b.title AS book_title,
|
||||||
|
b.authors AS book_authors,
|
||||||
|
b.language AS book_language,
|
||||||
|
b.published_date AS book_date,
|
||||||
|
f.format AS file_format,
|
||||||
|
f.size_bytes AS size_bytes,
|
||||||
|
f.original_filename AS original_filename,
|
||||||
|
f.storage_uri AS storage_uri,
|
||||||
|
f.last_seen_at AS last_seen_at,
|
||||||
|
f.reading_status AS reading_status,
|
||||||
|
f.last_read_at AS last_read_at,
|
||||||
|
f.imported_at AS imported_at
|
||||||
|
FROM book_files f
|
||||||
|
LEFT JOIN books b ON b.id = f.book_id
|
||||||
|
WHERE f.duplicate_of_file_id IS NULL
|
||||||
|
AND f.reading_status = 'NEW'
|
||||||
|
AND f.imported_at >= ?
|
||||||
|
ORDER BY f.imported_at DESC, f.id
|
||||||
|
LIMIT ?
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, sinceImportedAt)
|
||||||
|
statement.setInt(2, limit)
|
||||||
|
statement.executeQuery().use { resultSet -> resultSet.mapRows { it.toLibraryFileRecord() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun searchLibraryFiles(prefixes: List<String>, limit: Int): List<LibraryFileRecord> {
|
override fun searchLibraryFiles(prefixes: List<String>, limit: Int): List<LibraryFileRecord> {
|
||||||
val normalizedPrefixes = prefixes.mapNotNull { it.trim().lowercase().takeIf(String::isNotBlank) }.distinct()
|
val normalizedPrefixes = prefixes.mapNotNull { it.trim().lowercase().takeIf(String::isNotBlank) }.distinct()
|
||||||
if (normalizedPrefixes.isEmpty()) return emptyList()
|
if (normalizedPrefixes.isEmpty()) return emptyList()
|
||||||
|
|||||||
@ -201,6 +201,47 @@ class H2LibraryDatabaseTest {
|
|||||||
db.close()
|
db.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsRecentlyAddedNewLibraryFiles() {
|
||||||
|
val db = H2LibraryDatabase.openMemory("listsRecentlyAddedNewLibraryFiles")
|
||||||
|
val now = 1_700_000_000_000L
|
||||||
|
|
||||||
|
db.transaction {
|
||||||
|
listOf(
|
||||||
|
Triple("book-old", "Old", BookReadingStatus.NEW),
|
||||||
|
Triple("book-newer", "Newer", BookReadingStatus.NEW),
|
||||||
|
Triple("book-newest", "Newest", BookReadingStatus.NEW),
|
||||||
|
Triple("book-read", "Read", BookReadingStatus.READ),
|
||||||
|
).forEachIndexed { index, (bookId, title, status) ->
|
||||||
|
books.upsert(BookRecord(id = bookId, title = title, createdAt = now + index, updatedAt = now + index))
|
||||||
|
files.upsert(
|
||||||
|
BookFileRecord(
|
||||||
|
id = "file-$title",
|
||||||
|
bookId = bookId,
|
||||||
|
rawSha256 = "sha-$title",
|
||||||
|
originalFilename = "$title.fb2",
|
||||||
|
storageKind = BookFileStorageKind.EXTERNAL_URI,
|
||||||
|
readingStatus = status,
|
||||||
|
importedAt = when (title) {
|
||||||
|
"Old" -> now - 1_000
|
||||||
|
"Newer" -> now + 2_000
|
||||||
|
"Newest" -> now + 3_000
|
||||||
|
else -> now + 4_000
|
||||||
|
},
|
||||||
|
createdAt = now + index,
|
||||||
|
updatedAt = now + index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Newest", "Newer"),
|
||||||
|
db.files.listRecentlyAddedLibraryFiles(now, 50).map { it.title },
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun hidesDuplicateLibraryFilesAndPrefersZip() {
|
fun hidesDuplicateLibraryFilesAndPrefersZip() {
|
||||||
val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip")
|
val db = H2LibraryDatabase.openMemory("hidesDuplicateLibraryFilesAndPrefersZip")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user