mirror of
https://github.com/gotson/komga.git
synced 2026-05-01 11:25:24 +02:00
feat(kobo): sync On Deck as a Kobo collection
This commit is contained in:
parent
e72ff784e8
commit
f07be065d2
10 changed files with 569 additions and 56 deletions
|
|
@ -0,0 +1,34 @@
|
|||
CREATE TABLE SYNC_POINT_READLIST
|
||||
(
|
||||
SYNC_POINT_ID varchar NOT NULL,
|
||||
READLIST_ID varchar NOT NULL,
|
||||
READLIST_NAME varchar NOT NULL,
|
||||
READLIST_CREATED_DATE datetime NOT NULL,
|
||||
READLIST_LAST_MODIFIED_DATE datetime NOT NULL,
|
||||
SYNCED boolean NOT NULL default false,
|
||||
PRIMARY KEY (SYNC_POINT_ID, READLIST_ID),
|
||||
FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID)
|
||||
);
|
||||
|
||||
create index if not exists idx__sync_point_readlist__sync_point_id
|
||||
on SYNC_POINT_READLIST (SYNC_POINT_ID);
|
||||
|
||||
CREATE TABLE SYNC_POINT_READLIST_BOOK
|
||||
(
|
||||
SYNC_POINT_ID varchar NOT NULL,
|
||||
READLIST_ID varchar NOT NULL,
|
||||
BOOK_ID varchar NOT NULL,
|
||||
PRIMARY KEY (SYNC_POINT_ID, READLIST_ID, BOOK_ID),
|
||||
FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID)
|
||||
);
|
||||
|
||||
create index if not exists idx__sync_point_readlist_book__sync_point_id_readlist_id
|
||||
on SYNC_POINT_READLIST_BOOK (SYNC_POINT_ID, READLIST_ID);
|
||||
|
||||
CREATE TABLE SYNC_POINT_READLIST_REMOVED_SYNCED
|
||||
(
|
||||
SYNC_POINT_ID varchar NOT NULL,
|
||||
READLIST_ID varchar NOT NULL,
|
||||
PRIMARY KEY (SYNC_POINT_ID, READLIST_ID),
|
||||
FOREIGN KEY (SYNC_POINT_ID) REFERENCES SYNC_POINT (ID)
|
||||
);
|
||||
|
|
@ -19,4 +19,23 @@ data class SyncPoint(
|
|||
val metadataLastModifiedDate: ZonedDateTime,
|
||||
val synced: Boolean,
|
||||
)
|
||||
|
||||
data class ReadList(
|
||||
val syncPointId: String,
|
||||
val readListId: String,
|
||||
val readListName: String,
|
||||
val createdDate: ZonedDateTime,
|
||||
val lastModifiedDate: ZonedDateTime,
|
||||
val synced: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
const val ON_DECK_ID = "KOMGA-ONDECK"
|
||||
}
|
||||
|
||||
data class Book(
|
||||
val syncPointId: String,
|
||||
val readListId: String,
|
||||
val bookId: String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ interface SyncPointRepository {
|
|||
search: BookSearch,
|
||||
): SyncPoint
|
||||
|
||||
fun addOnDeck(
|
||||
syncPointId: String,
|
||||
user: KomgaUser,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
)
|
||||
|
||||
fun findByIdOrNull(syncPointId: String): SyncPoint?
|
||||
|
||||
fun findBooksById(
|
||||
|
|
@ -49,12 +55,50 @@ interface SyncPointRepository {
|
|||
pageable: Pageable,
|
||||
): Page<SyncPoint.Book>
|
||||
|
||||
fun findReadListsById(
|
||||
syncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList>
|
||||
|
||||
fun findReadListsAdded(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList>
|
||||
|
||||
fun findReadListsChanged(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList>
|
||||
|
||||
fun findReadListsRemoved(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList>
|
||||
|
||||
fun findBookIdsByReadListIds(
|
||||
syncPointId: String,
|
||||
readListIds: Collection<String>,
|
||||
): List<SyncPoint.ReadList.Book>
|
||||
|
||||
fun markBooksSynced(
|
||||
syncPointId: String,
|
||||
forRemovedBooks: Boolean,
|
||||
bookIds: Collection<String>,
|
||||
)
|
||||
|
||||
fun markReadListsSynced(
|
||||
syncPointId: String,
|
||||
forRemovedReadLists: Boolean,
|
||||
readListIds: Collection<String>,
|
||||
)
|
||||
|
||||
fun deleteByUserId(userId: String)
|
||||
|
||||
fun deleteByUserIdAndApiKeyIds(
|
||||
|
|
|
|||
|
|
@ -18,17 +18,24 @@ class SyncPointLifecycle(
|
|||
user: KomgaUser,
|
||||
apiKeyId: String?,
|
||||
libraryIds: List<String>?,
|
||||
): SyncPoint =
|
||||
syncPointRepository.create(
|
||||
user,
|
||||
apiKeyId,
|
||||
BookSearch(
|
||||
libraryIds = user.getAuthorizedLibraryIds(libraryIds),
|
||||
mediaStatus = setOf(Media.Status.READY),
|
||||
mediaProfile = listOf(MediaProfile.EPUB),
|
||||
deleted = false,
|
||||
),
|
||||
)
|
||||
): SyncPoint {
|
||||
val authorizedLibraryIds = user.getAuthorizedLibraryIds(libraryIds)
|
||||
val syncPoint =
|
||||
syncPointRepository.create(
|
||||
user,
|
||||
apiKeyId,
|
||||
BookSearch(
|
||||
libraryIds = authorizedLibraryIds,
|
||||
mediaStatus = setOf(Media.Status.READY),
|
||||
mediaProfile = listOf(MediaProfile.EPUB),
|
||||
deleted = false,
|
||||
),
|
||||
)
|
||||
|
||||
syncPointRepository.addOnDeck(syncPoint.id, user, authorizedLibraryIds)
|
||||
|
||||
return syncPoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a page of un-synced books and mark them as synced.
|
||||
|
|
@ -83,4 +90,35 @@ class SyncPointLifecycle(
|
|||
): Page<SyncPoint.Book> =
|
||||
syncPointRepository.findBooksReadProgressChanged(fromSyncPointId, toSyncPointId, true, pageable)
|
||||
.also { page -> syncPointRepository.markBooksSynced(toSyncPointId, false, page.content.map { it.bookId }) }
|
||||
|
||||
fun takeReadLists(
|
||||
toSyncPointId: String,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> =
|
||||
syncPointRepository.findReadListsById(toSyncPointId, true, pageable)
|
||||
.also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) }
|
||||
|
||||
fun takeReadListsAdded(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> =
|
||||
syncPointRepository.findReadListsAdded(fromSyncPointId, toSyncPointId, true, pageable)
|
||||
.also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) }
|
||||
|
||||
fun takeReadListsChanged(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> =
|
||||
syncPointRepository.findReadListsChanged(fromSyncPointId, toSyncPointId, true, pageable)
|
||||
.also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, false, page.content.map { it.readListId }) }
|
||||
|
||||
fun takeReadListsRemoved(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> =
|
||||
syncPointRepository.findReadListsRemoved(fromSyncPointId, toSyncPointId, true, pageable)
|
||||
.also { page -> syncPointRepository.markReadListsSynced(toSyncPointId, true, page.content.map { it.readListId }) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import com.github.f4b6a3.tsid.TsidCreator
|
|||
import org.gotson.komga.domain.model.BookSearch
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.SyncPoint
|
||||
import org.gotson.komga.domain.model.SyncPoint.ReadList.Companion.ON_DECK_ID
|
||||
import org.gotson.komga.domain.persistence.SyncPointRepository
|
||||
import org.gotson.komga.infrastructure.jooq.toCondition
|
||||
import org.gotson.komga.jooq.main.Tables
|
||||
import org.gotson.komga.language.toZonedDateTime
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Field
|
||||
import org.jooq.Record1
|
||||
import org.jooq.SelectConditionStep
|
||||
import org.jooq.impl.DSL
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -24,6 +27,7 @@ import java.time.ZoneId
|
|||
@Component
|
||||
class SyncPointDao(
|
||||
private val dsl: DSLContext,
|
||||
private val bookCommonDao: BookCommonDao,
|
||||
) : SyncPointRepository {
|
||||
private val b = Tables.BOOK
|
||||
private val m = Tables.MEDIA
|
||||
|
|
@ -33,6 +37,9 @@ class SyncPointDao(
|
|||
private val sp = Tables.SYNC_POINT
|
||||
private val spb = Tables.SYNC_POINT_BOOK
|
||||
private val spbs = Tables.SYNC_POINT_BOOK_REMOVED_SYNCED
|
||||
private val sprl = Tables.SYNC_POINT_READLIST
|
||||
private val sprlb = Tables.SYNC_POINT_READLIST_BOOK
|
||||
private val sprls = Tables.SYNC_POINT_READLIST_REMOVED_SYNCED
|
||||
|
||||
@Transactional
|
||||
override fun create(
|
||||
|
|
@ -43,7 +50,7 @@ class SyncPointDao(
|
|||
val conditions = search.toCondition().and(user.restrictions.toCondition(dsl))
|
||||
|
||||
val syncPointId = TsidCreator.getTsid256().toString()
|
||||
val createdAt = LocalDateTime.now()
|
||||
val createdAt = LocalDateTime.now(ZoneId.of("Z"))
|
||||
|
||||
dsl.insertInto(
|
||||
sp,
|
||||
|
|
@ -91,6 +98,43 @@ class SyncPointDao(
|
|||
return findByIdOrNull(syncPointId)!!
|
||||
}
|
||||
|
||||
@Transactional
|
||||
override fun addOnDeck(
|
||||
syncPointId: String,
|
||||
user: KomgaUser,
|
||||
filterOnLibraryIds: Collection<String>?,
|
||||
) {
|
||||
val createdAt = LocalDateTime.now(ZoneId.of("Z"))
|
||||
val onDeckFields: Array<Field<*>> = arrayOf(DSL.`val`(syncPointId), DSL.`val`(ON_DECK_ID), b.ID)
|
||||
|
||||
val (query, _, queryMostRecentDate) = bookCommonDao.getBooksOnDeckQuery(user.id, user.restrictions, filterOnLibraryIds, onDeckFields)
|
||||
|
||||
val count =
|
||||
dsl.insertInto(sprlb)
|
||||
.select(query)
|
||||
.execute()
|
||||
|
||||
// only add the read list entry if some books were added
|
||||
if (count > 0) {
|
||||
val mostRecentDate = dsl.fetch(queryMostRecentDate).into(LocalDateTime::class.java).firstOrNull() ?: createdAt
|
||||
|
||||
dsl.insertInto(
|
||||
sprl,
|
||||
sprl.SYNC_POINT_ID,
|
||||
sprl.READLIST_ID,
|
||||
sprl.READLIST_NAME,
|
||||
sprl.READLIST_CREATED_DATE,
|
||||
sprl.READLIST_LAST_MODIFIED_DATE,
|
||||
).values(
|
||||
syncPointId,
|
||||
ON_DECK_ID,
|
||||
"On Deck",
|
||||
createdAt,
|
||||
mostRecentDate,
|
||||
).execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun findByIdOrNull(syncPointId: String): SyncPoint? =
|
||||
dsl.selectFrom(sp)
|
||||
.where(sp.ID.eq(syncPointId))
|
||||
|
|
@ -118,7 +162,7 @@ class SyncPointDao(
|
|||
}
|
||||
}
|
||||
|
||||
return queryToPage(query, pageable)
|
||||
return queryToPageBook(query, pageable)
|
||||
}
|
||||
|
||||
override fun findBooksAdded(
|
||||
|
|
@ -141,7 +185,7 @@ class SyncPointDao(
|
|||
),
|
||||
)
|
||||
|
||||
return queryToPage(query, pageable)
|
||||
return queryToPageBook(query, pageable)
|
||||
}
|
||||
|
||||
override fun findBooksRemoved(
|
||||
|
|
@ -167,7 +211,7 @@ class SyncPointDao(
|
|||
)
|
||||
}
|
||||
|
||||
return queryToPage(query, pageable)
|
||||
return queryToPageBook(query, pageable)
|
||||
}
|
||||
|
||||
override fun findBooksChanged(
|
||||
|
|
@ -195,7 +239,7 @@ class SyncPointDao(
|
|||
.or(spb.BOOK_METADATA_LAST_MODIFIED_DATE.ne(spbFrom.BOOK_METADATA_LAST_MODIFIED_DATE)),
|
||||
)
|
||||
|
||||
return queryToPage(query, pageable)
|
||||
return queryToPageBook(query, pageable)
|
||||
}
|
||||
|
||||
override fun findBooksReadProgressChanged(
|
||||
|
|
@ -230,9 +274,104 @@ class SyncPointDao(
|
|||
),
|
||||
)
|
||||
|
||||
return queryToPage(query, pageable)
|
||||
return queryToPageBook(query, pageable)
|
||||
}
|
||||
|
||||
override fun findReadListsById(
|
||||
syncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> {
|
||||
val query =
|
||||
dsl.selectFrom(sprl)
|
||||
.where(sprl.SYNC_POINT_ID.eq(syncPointId))
|
||||
.apply {
|
||||
if (onlyNotSynced) {
|
||||
and(sprl.SYNCED.isFalse)
|
||||
}
|
||||
}
|
||||
|
||||
return queryToPageReadList(query, pageable)
|
||||
}
|
||||
|
||||
override fun findReadListsAdded(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> {
|
||||
val to = sprl.`as`("to")
|
||||
val from = sprl.`as`("from")
|
||||
val query =
|
||||
dsl.select(*to.fields())
|
||||
.from(to)
|
||||
.leftOuterJoin(from).on(to.READLIST_ID.eq(from.READLIST_ID).and(from.SYNC_POINT_ID.eq(fromSyncPointId)))
|
||||
.where(to.SYNC_POINT_ID.eq(toSyncPointId))
|
||||
.apply { if (onlyNotSynced) and(to.SYNCED.isFalse) }
|
||||
.and(from.READLIST_ID.isNull)
|
||||
|
||||
return queryToPageReadList(query, pageable)
|
||||
}
|
||||
|
||||
override fun findReadListsChanged(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> {
|
||||
val from = sprl.`as`("from")
|
||||
val query =
|
||||
dsl.select(*sprl.fields())
|
||||
.from(sprl)
|
||||
.join(from).on(sprl.READLIST_ID.eq(from.READLIST_ID))
|
||||
.where(sprl.SYNC_POINT_ID.eq(toSyncPointId))
|
||||
.and(from.SYNC_POINT_ID.eq(fromSyncPointId))
|
||||
.apply { if (onlyNotSynced) and(sprl.SYNCED.isFalse) }
|
||||
.and(
|
||||
sprl.READLIST_LAST_MODIFIED_DATE.ne(from.READLIST_LAST_MODIFIED_DATE)
|
||||
.or(sprl.READLIST_NAME.ne(from.READLIST_NAME)),
|
||||
)
|
||||
|
||||
return queryToPageReadList(query, pageable)
|
||||
}
|
||||
|
||||
override fun findReadListsRemoved(
|
||||
fromSyncPointId: String,
|
||||
toSyncPointId: String,
|
||||
onlyNotSynced: Boolean,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> {
|
||||
val from = sprl.`as`("from")
|
||||
val to = sprl.`as`("to")
|
||||
val query =
|
||||
dsl.select(*from.fields())
|
||||
.from(from)
|
||||
.leftOuterJoin(to).on(from.READLIST_ID.eq(to.READLIST_ID).and(to.SYNC_POINT_ID.eq(toSyncPointId)))
|
||||
.where(from.SYNC_POINT_ID.eq(fromSyncPointId))
|
||||
.apply {
|
||||
if (onlyNotSynced)
|
||||
and(
|
||||
from.READLIST_ID.notIn(
|
||||
dsl.select(sprls.READLIST_ID).from(sprls).where(sprls.SYNC_POINT_ID.eq(toSyncPointId)),
|
||||
),
|
||||
)
|
||||
}
|
||||
.and(to.READLIST_ID.isNull)
|
||||
|
||||
return queryToPageReadList(query, pageable)
|
||||
}
|
||||
|
||||
override fun findBookIdsByReadListIds(
|
||||
syncPointId: String,
|
||||
readListIds: Collection<String>,
|
||||
): List<SyncPoint.ReadList.Book> =
|
||||
dsl.select(*sprlb.fields())
|
||||
.from(sprlb)
|
||||
.where(sprlb.SYNC_POINT_ID.eq(syncPointId))
|
||||
.and(sprlb.READLIST_ID.`in`(readListIds))
|
||||
.fetchInto(sprlb)
|
||||
.map { SyncPoint.ReadList.Book(it.syncPointId, it.readlistId, it.bookId) }
|
||||
|
||||
override fun markBooksSynced(
|
||||
syncPointId: String,
|
||||
forRemovedBooks: Boolean,
|
||||
|
|
@ -256,17 +395,31 @@ class SyncPointDao(
|
|||
}
|
||||
}
|
||||
|
||||
override fun markReadListsSynced(
|
||||
syncPointId: String,
|
||||
forRemovedReadLists: Boolean,
|
||||
readListIds: Collection<String>,
|
||||
) {
|
||||
// removed read lists are not present in the 'to' SyncPoint, only in the 'from' SyncPoint
|
||||
// we store status in a separate table
|
||||
if (readListIds.isNotEmpty()) {
|
||||
if (forRemovedReadLists)
|
||||
dsl.batch(
|
||||
dsl.insertInto(sprls, sprls.SYNC_POINT_ID, sprls.READLIST_ID).values(null as String?, null).onDuplicateKeyIgnore(),
|
||||
).also { step ->
|
||||
readListIds.map { step.bind(syncPointId, it) }
|
||||
}.execute()
|
||||
else
|
||||
dsl.update(sprl)
|
||||
.set(sprl.SYNCED, true)
|
||||
.where(sprl.SYNC_POINT_ID.eq(syncPointId))
|
||||
.and(sprl.READLIST_ID.`in`(readListIds))
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteByUserId(userId: String) {
|
||||
dsl.deleteFrom(spbs).where(
|
||||
spbs.SYNC_POINT_ID.`in`(
|
||||
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)),
|
||||
),
|
||||
).execute()
|
||||
dsl.deleteFrom(spb).where(
|
||||
spb.SYNC_POINT_ID.`in`(
|
||||
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)),
|
||||
),
|
||||
).execute()
|
||||
deleteSubEntities(dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId)))
|
||||
dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId)).execute()
|
||||
}
|
||||
|
||||
|
|
@ -274,32 +427,37 @@ class SyncPointDao(
|
|||
userId: String,
|
||||
apiKeyIds: Collection<String>,
|
||||
) {
|
||||
dsl.deleteFrom(spbs).where(
|
||||
spbs.SYNC_POINT_ID.`in`(
|
||||
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))),
|
||||
),
|
||||
).execute()
|
||||
dsl.deleteFrom(spb).where(
|
||||
spb.SYNC_POINT_ID.`in`(
|
||||
dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))),
|
||||
),
|
||||
).execute()
|
||||
deleteSubEntities(dsl.select(sp.ID).from(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))))
|
||||
dsl.deleteFrom(sp).where(sp.USER_ID.eq(userId).and(sp.API_KEY_ID.`in`(apiKeyIds))).execute()
|
||||
}
|
||||
|
||||
private fun deleteSubEntities(condition: SelectConditionStep<Record1<String>>) {
|
||||
dsl.deleteFrom(sprls).where(sprls.SYNC_POINT_ID.`in`(condition)).execute()
|
||||
dsl.deleteFrom(sprlb).where(sprlb.SYNC_POINT_ID.`in`(condition)).execute()
|
||||
dsl.deleteFrom(sprl).where(sprl.SYNC_POINT_ID.`in`(condition)).execute()
|
||||
dsl.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.`in`(condition)).execute()
|
||||
dsl.deleteFrom(spb).where(spb.SYNC_POINT_ID.`in`(condition)).execute()
|
||||
}
|
||||
|
||||
override fun deleteOne(syncPointId: String) {
|
||||
dsl.deleteFrom(sprls).where(sprls.SYNC_POINT_ID.eq(syncPointId)).execute()
|
||||
dsl.deleteFrom(sprlb).where(sprlb.SYNC_POINT_ID.eq(syncPointId)).execute()
|
||||
dsl.deleteFrom(sprl).where(sprl.SYNC_POINT_ID.eq(syncPointId)).execute()
|
||||
dsl.deleteFrom(spbs).where(spbs.SYNC_POINT_ID.eq(syncPointId)).execute()
|
||||
dsl.deleteFrom(spb).where(spb.SYNC_POINT_ID.eq(syncPointId)).execute()
|
||||
dsl.deleteFrom(sp).where(sp.ID.eq(syncPointId)).execute()
|
||||
}
|
||||
|
||||
override fun deleteAll() {
|
||||
dsl.deleteFrom(sprls).execute()
|
||||
dsl.deleteFrom(sprlb).execute()
|
||||
dsl.deleteFrom(sprl).execute()
|
||||
dsl.deleteFrom(spbs).execute()
|
||||
dsl.deleteFrom(spb).execute()
|
||||
dsl.deleteFrom(sp).execute()
|
||||
}
|
||||
|
||||
private fun queryToPage(
|
||||
private fun queryToPageBook(
|
||||
query: SelectConditionStep<*>,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.Book> {
|
||||
|
|
@ -332,4 +490,35 @@ class SyncPointDao(
|
|||
count.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun queryToPageReadList(
|
||||
query: SelectConditionStep<*>,
|
||||
pageable: Pageable,
|
||||
): Page<SyncPoint.ReadList> {
|
||||
val count = dsl.fetchCount(query)
|
||||
|
||||
val items =
|
||||
query
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
.fetchInto(sprl)
|
||||
.map {
|
||||
SyncPoint.ReadList(
|
||||
syncPointId = it.syncPointId,
|
||||
readListId = it.readlistId,
|
||||
readListName = it.readlistName,
|
||||
createdDate = it.readlistCreatedDate.atZone(ZoneId.of("Z")),
|
||||
lastModifiedDate = it.readlistLastModifiedDate.atZone(ZoneId.of("Z")),
|
||||
synced = it.synced,
|
||||
)
|
||||
}
|
||||
|
||||
return PageImpl(
|
||||
items,
|
||||
if (pageable.isPaged)
|
||||
PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.unsorted())
|
||||
else
|
||||
PageRequest.of(0, maxOf(count, 20), Sort.unsorted()),
|
||||
count.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ import org.gotson.komga.interfaces.api.kobo.dto.BookmarkDto
|
|||
import org.gotson.komga.interfaces.api.kobo.dto.ChangedEntitlementDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ChangedProductMetadataDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ChangedReadingStateDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ChangedTagDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.DeletedTagDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.KoboBookMetadataDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.NewEntitlementDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.NewTagDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateStateUpdateDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.ReadingStateUpdateResultDto
|
||||
|
|
@ -50,10 +53,12 @@ import org.gotson.komga.interfaces.api.kobo.dto.StatisticsDto
|
|||
import org.gotson.komga.interfaces.api.kobo.dto.StatusDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.StatusInfoDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.SyncResultDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.TagItemDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.TestsDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.WrappedReadingStateDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.toBookEntitlementDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.toDto
|
||||
import org.gotson.komga.interfaces.api.kobo.dto.toWrappedTagDto
|
||||
import org.gotson.komga.interfaces.api.kobo.persistence.KoboDtoRepository
|
||||
import org.gotson.komga.language.toUTCZoned
|
||||
import org.springframework.data.domain.Page
|
||||
|
|
@ -264,10 +269,38 @@ class KoboController(
|
|||
else
|
||||
Page.empty()
|
||||
|
||||
logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed, ${changedReadingState.numberOfElements} books with changed reading state" }
|
||||
val readListsAdded =
|
||||
if (changedReadingState.isLast && maxRemainingCount > 0)
|
||||
syncPointLifecycle.takeReadListsAdded(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
|
||||
maxRemainingCount -= it.numberOfElements
|
||||
shouldContinueSync = shouldContinueSync || it.hasNext()
|
||||
}
|
||||
else
|
||||
Page.empty()
|
||||
|
||||
val readListsChanged =
|
||||
if (readListsAdded.isLast && maxRemainingCount > 0)
|
||||
syncPointLifecycle.takeReadListsChanged(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
|
||||
maxRemainingCount -= it.numberOfElements
|
||||
shouldContinueSync = shouldContinueSync || it.hasNext()
|
||||
}
|
||||
else
|
||||
Page.empty()
|
||||
|
||||
val readListsRemoved =
|
||||
if (readListsChanged.isLast && maxRemainingCount > 0)
|
||||
syncPointLifecycle.takeReadListsRemoved(fromSyncPoint.id, toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
|
||||
maxRemainingCount -= it.numberOfElements
|
||||
shouldContinueSync = shouldContinueSync || it.hasNext()
|
||||
}
|
||||
else
|
||||
Page.empty()
|
||||
|
||||
logger.debug { "Library sync: ${booksAdded.numberOfElements} books added, ${booksChanged.numberOfElements} books changed, ${booksRemoved.numberOfElements} books removed, ${changedReadingState.numberOfElements} books with changed reading state, $readListsAdded readlists added, $readListsChanged readlists changed, $readListsRemoved removed" }
|
||||
|
||||
val metadata = koboDtoRepository.findBookMetadataByIds((booksAdded.content + booksChanged.content).map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
|
||||
val readProgress = readProgressRepository.findAllByBookIdsAndUserId((booksAdded.content + booksChanged.content + changedReadingState.content).map { it.bookId }, principal.user.id).associateBy { it.bookId }
|
||||
val readListsBooks = syncPointRepository.findBookIdsByReadListIds(toSyncPoint.id, (readListsAdded.content + readListsChanged.content).map { it.readListId }).groupBy { it.readListId }
|
||||
|
||||
buildList {
|
||||
addAll(
|
||||
|
|
@ -319,24 +352,63 @@ class KoboController(
|
|||
}
|
||||
},
|
||||
)
|
||||
addAll(
|
||||
readListsAdded.content.map {
|
||||
NewTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) }))
|
||||
},
|
||||
)
|
||||
addAll(
|
||||
readListsChanged.content.map {
|
||||
ChangedTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) }))
|
||||
},
|
||||
)
|
||||
addAll(
|
||||
readListsRemoved.content.map {
|
||||
DeletedTagDto(it.toWrappedTagDto())
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// no starting point, sync everything
|
||||
val books = syncPointLifecycle.takeBooks(toSyncPoint.id, Pageable.ofSize(komgaProperties.kobo.syncItemLimit))
|
||||
shouldContinueSync = books.hasNext()
|
||||
var maxRemainingCount = komgaProperties.kobo.syncItemLimit
|
||||
|
||||
logger.debug { "Library sync: ${books.numberOfElements} books" }
|
||||
val books =
|
||||
syncPointLifecycle.takeBooks(toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
|
||||
maxRemainingCount -= it.numberOfElements
|
||||
shouldContinueSync = it.hasNext()
|
||||
}
|
||||
|
||||
val readLists =
|
||||
if (books.isLast && maxRemainingCount > 0)
|
||||
syncPointLifecycle.takeReadLists(toSyncPoint.id, Pageable.ofSize(maxRemainingCount)).also {
|
||||
maxRemainingCount -= it.numberOfElements
|
||||
shouldContinueSync = shouldContinueSync || it.hasNext()
|
||||
}
|
||||
else
|
||||
Page.empty()
|
||||
|
||||
logger.debug { "Library sync: ${books.numberOfElements} books, ${readLists.numberOfElements} readlists" }
|
||||
|
||||
val metadata = koboDtoRepository.findBookMetadataByIds(books.content.map { it.bookId }, getDownloadUrlBuilder(authToken)).associateBy { it.entitlementId }
|
||||
val readProgress = readProgressRepository.findAllByBookIdsAndUserId(books.content.map { it.bookId }, principal.user.id).associateBy { it.bookId }
|
||||
val readListsBooks = syncPointRepository.findBookIdsByReadListIds(toSyncPoint.id, readLists.content.map { it.readListId }).groupBy { it.readListId }
|
||||
|
||||
books.content.map {
|
||||
NewEntitlementDto(
|
||||
BookEntitlementContainerDto(
|
||||
bookEntitlement = it.toBookEntitlementDto(false),
|
||||
bookMetadata = metadata[it.bookId]!!,
|
||||
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
|
||||
),
|
||||
buildList {
|
||||
addAll(
|
||||
books.content.map {
|
||||
NewEntitlementDto(
|
||||
BookEntitlementContainerDto(
|
||||
bookEntitlement = it.toBookEntitlementDto(false),
|
||||
bookMetadata = metadata[it.bookId]!!,
|
||||
readingState = readProgress[it.bookId]?.toDto() ?: getEmptyReadProgressForBook(it.bookId, it.createdDate),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
addAll(
|
||||
readLists.content.map {
|
||||
NewTagDto(it.toWrappedTagDto(readListsBooks[it.readListId]?.map { b -> TagItemDto(b.bookId) }))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,17 @@ data class ChangedProductMetadataDto(
|
|||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
data class NewTagDto(
|
||||
val newTag: TagDto,
|
||||
val newTag: WrappedTagDto,
|
||||
) : SyncResultDto
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
data class ChangedTagDto(
|
||||
val changedTag: TagDto,
|
||||
val changedTag: WrappedTagDto,
|
||||
) : SyncResultDto
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
data class DeletedTagDto(
|
||||
val deletedTag: WrappedTagDto,
|
||||
) : SyncResultDto
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package org.gotson.komga.interfaces.api.kobo.dto
|
|||
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
||||
import org.gotson.komga.domain.model.SyncPoint
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
|
|
@ -13,3 +14,20 @@ data class TagDto(
|
|||
val type: TagTypeDto,
|
||||
val items: List<TagItemDto>? = null,
|
||||
)
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
data class WrappedTagDto(
|
||||
val tag: TagDto,
|
||||
)
|
||||
|
||||
fun SyncPoint.ReadList.toWrappedTagDto(items: List<TagItemDto>? = null) =
|
||||
WrappedTagDto(
|
||||
TagDto(
|
||||
id = readListId,
|
||||
created = createdDate,
|
||||
lastModified = lastModifiedDate,
|
||||
name = readListName,
|
||||
type = TagTypeDto.USER_TAG,
|
||||
items = items,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
package org.gotson.komga.interfaces.api.kobo.dto
|
||||
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy::class)
|
||||
enum class TagTypeDto {
|
||||
@JsonProperty("SystemTag")
|
||||
SYSTEM_TAG,
|
||||
|
||||
@JsonProperty("UserTag")
|
||||
USER_TAG,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.ContentRestrictions
|
|||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
import org.gotson.komga.domain.model.SyncPoint.ReadList.Companion.ON_DECK_ID
|
||||
import org.gotson.komga.domain.model.makeBook
|
||||
import org.gotson.komga.domain.model.makeLibrary
|
||||
import org.gotson.komga.domain.model.makeSeries
|
||||
|
|
@ -29,6 +30,7 @@ import org.springframework.boot.test.context.SpringBootTest
|
|||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@SpringBootTest
|
||||
|
|
@ -39,13 +41,12 @@ class SyncPointLifecycleTest(
|
|||
@Autowired private val userRepository: KomgaUserRepository,
|
||||
@Autowired private val bookRepository: BookRepository,
|
||||
@Autowired private val bookMetadataRepository: BookMetadataRepository,
|
||||
@Autowired private val bookLifecycle: BookLifecycle,
|
||||
@Autowired private val seriesMetadataRepository: SeriesMetadataRepository,
|
||||
@Autowired private val seriesRepository: SeriesRepository,
|
||||
@Autowired private val seriesLifecycle: SeriesLifecycle,
|
||||
@Autowired private val mediaRepository: MediaRepository,
|
||||
) {
|
||||
@Autowired
|
||||
private lateinit var bookLifecycle: BookLifecycle
|
||||
private val library1 = makeLibrary()
|
||||
private val library2 = makeLibrary()
|
||||
private val library3 = makeLibrary()
|
||||
|
|
@ -269,4 +270,96 @@ class SyncPointLifecycleTest(
|
|||
.containsAnyElementsOf(listOf(book1.id, book2.id, book3.id))
|
||||
.doesNotContainAnyElementsOf((page1 + page2).map { it.bookId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given syncpoint when books are read then syncpoint diff contains on deck read list`() {
|
||||
// given
|
||||
val book1 = makeBook("book 1", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 1)
|
||||
val book2 = makeBook("book 2", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 2)
|
||||
val book3 = makeBook("book 3", libraryId = library1.id).copy(fileHash = "hash", fileSize = 12, fileLastModified = LocalDateTime.now(), number = 3)
|
||||
|
||||
makeSeries(name = "series1", libraryId = library1.id).let { series ->
|
||||
seriesLifecycle.createSeries(series).let { created ->
|
||||
seriesLifecycle.addBooks(created, listOf(book1, book2, book3))
|
||||
}
|
||||
}
|
||||
|
||||
bookRepository.findAll().forEach { mediaRepository.findById(it.id).let { media -> mediaRepository.update(media.copy(status = Media.Status.READY, mediaType = MediaType.EPUB.type)) } }
|
||||
|
||||
// first sync point
|
||||
val syncPoint1 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
|
||||
val syncPoint1ReadLists = syncPointRepository.findReadListsById(syncPoint1.id, false, Pageable.unpaged())
|
||||
|
||||
assertThat(syncPoint1ReadLists).isEmpty()
|
||||
|
||||
// book marked as read
|
||||
bookLifecycle.markReadProgressCompleted(book1.id, user1)
|
||||
val syncPoint2 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
|
||||
|
||||
// on deck is present and has 1 book
|
||||
val syncPoint2ReadLists = syncPointRepository.findReadListsById(syncPoint2.id, false, Pageable.unpaged())
|
||||
val rlAdded1to2 = syncPointRepository.findReadListsAdded(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
|
||||
val rlChanged1to2 = syncPointRepository.findReadListsChanged(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
|
||||
val rlRemoved1to2 = syncPointRepository.findReadListsRemoved(syncPoint1.id, syncPoint2.id, false, Pageable.unpaged())
|
||||
val syncPoint2Page1 = syncPointLifecycle.takeReadListsAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
|
||||
val syncPoint2Page2 = syncPointLifecycle.takeReadListsAdded(syncPoint1.id, syncPoint2.id, Pageable.ofSize(1))
|
||||
|
||||
assertThat(syncPoint2ReadLists).hasSize(1)
|
||||
assertThat(rlAdded1to2).containsExactlyInAnyOrderElementsOf(syncPoint2ReadLists)
|
||||
assertThat(rlChanged1to2).isEmpty()
|
||||
assertThat(rlRemoved1to2).isEmpty()
|
||||
with(syncPoint2ReadLists.first()) {
|
||||
assertThat(this.readListId).isEqualTo(ON_DECK_ID)
|
||||
assertThat(this.createdDate).isCloseTo(ZonedDateTime.now(), within(1, ChronoUnit.SECONDS))
|
||||
assertThat(this.lastModifiedDate).isCloseTo(ZonedDateTime.now(), within(1, ChronoUnit.SECONDS))
|
||||
}
|
||||
assertThat(syncPoint2Page1).containsExactlyInAnyOrderElementsOf(rlAdded1to2)
|
||||
assertThat(syncPoint2Page2).isEmpty()
|
||||
val syncPoint2OnDeckBooks = syncPointRepository.findBookIdsByReadListIds(syncPoint2.id, listOf(ON_DECK_ID))
|
||||
assertThat(syncPoint2OnDeckBooks.map { it.bookId })
|
||||
.hasSize(1)
|
||||
.containsExactlyInAnyOrder(book2.id)
|
||||
|
||||
// 2nd book marked as read, on deck is still present but has changed
|
||||
bookLifecycle.markReadProgressCompleted(book2.id, user1)
|
||||
val syncPoint3 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
|
||||
|
||||
val syncPoint3ReadLists = syncPointRepository.findReadListsById(syncPoint3.id, false, Pageable.unpaged())
|
||||
val rlAdded2to3 = syncPointRepository.findReadListsAdded(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged())
|
||||
val rlChanged2to3 = syncPointRepository.findReadListsChanged(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged())
|
||||
val rlRemoved2to3 = syncPointRepository.findReadListsRemoved(syncPoint2.id, syncPoint3.id, false, Pageable.unpaged())
|
||||
val syncPoint3Page1 = syncPointLifecycle.takeReadListsChanged(syncPoint2.id, syncPoint3.id, Pageable.ofSize(1))
|
||||
val syncPoint3Page2 = syncPointLifecycle.takeReadListsChanged(syncPoint2.id, syncPoint3.id, Pageable.ofSize(1))
|
||||
|
||||
assertThat(syncPoint3ReadLists.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID)
|
||||
assertThat(rlChanged2to3.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID)
|
||||
assertThat(rlAdded2to3).isEmpty()
|
||||
assertThat(rlRemoved2to3).isEmpty()
|
||||
assertThat(syncPoint3Page1).containsExactlyInAnyOrderElementsOf(rlChanged2to3)
|
||||
assertThat(syncPoint3Page2).isEmpty()
|
||||
|
||||
val syncPoint3OnDeckBooks = syncPointRepository.findBookIdsByReadListIds(syncPoint3.id, listOf(ON_DECK_ID))
|
||||
assertThat(syncPoint3OnDeckBooks.map { it.bookId })
|
||||
.hasSize(1)
|
||||
.containsExactlyInAnyOrder(book3.id)
|
||||
|
||||
// 3rd book marked as read, whole series is read now - on deck is not present anymore
|
||||
bookLifecycle.markReadProgressCompleted(book3.id, user1)
|
||||
val syncPoint4 = syncPointLifecycle.createSyncPoint(user1, null, listOf(library1.id))
|
||||
|
||||
val syncPoint4ReadLists = syncPointRepository.findReadListsById(syncPoint4.id, false, Pageable.unpaged())
|
||||
val rlAdded3to4 = syncPointRepository.findReadListsAdded(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged())
|
||||
val rlChanged3to4 = syncPointRepository.findReadListsChanged(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged())
|
||||
val rlRemoved3to4 = syncPointRepository.findReadListsRemoved(syncPoint3.id, syncPoint4.id, false, Pageable.unpaged())
|
||||
val syncPoint4Page1 = syncPointLifecycle.takeReadListsRemoved(syncPoint3.id, syncPoint4.id, Pageable.ofSize(1))
|
||||
val syncPoint4Page2 = syncPointLifecycle.takeReadListsRemoved(syncPoint3.id, syncPoint4.id, Pageable.ofSize(1))
|
||||
|
||||
assertThat(syncPoint4ReadLists).isEmpty()
|
||||
assertThat(rlAdded3to4).isEmpty()
|
||||
assertThat(rlChanged3to4).isEmpty()
|
||||
assertThat(rlRemoved3to4).hasSize(1)
|
||||
assertThat(rlRemoved3to4.map { it.readListId }).containsExactlyInAnyOrder(ON_DECK_ID)
|
||||
assertThat(syncPoint4Page1).containsExactlyInAnyOrderElementsOf(rlRemoved3to4)
|
||||
assertThat(syncPoint4Page2).isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue