mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Restrict raw database access to the data module
app no longer touches the database directly. Backup restore's queries move into a new RestoreRepository in :data, which restores a batch of entries all or nothing, stopping between entries if cancelled, as before. The remaining direct calls (excluded scanlators, clear database, backup creation, store and category restore) go through their repositories, and the database bindings move from AppBindings into :data.
This commit is contained in:
@@ -233,14 +233,10 @@ dependencies {
|
||||
implementation(libs.androidx.paging.runtime)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
implementation(libs.bundles.kotlinx.coroutines)
|
||||
|
||||
implementation(libs.sqldelight.async)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
// AndroidX libraries
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
package eu.kanade.domain.manga.interactor
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.subscribeToList
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
|
||||
@Inject
|
||||
class GetExcludedScanlators(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
) {
|
||||
|
||||
suspend fun await(mangaId: Long): Set<String> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
return mangaRepository.getExcludedScanlators(mangaId)
|
||||
}
|
||||
|
||||
fun subscribe(mangaId: Long): Flow<Set<String>> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.subscribeToList()
|
||||
.map { it.toSet() }
|
||||
return mangaRepository.getExcludedScanlatorsAsFlow(mangaId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
package eu.kanade.domain.manga.interactor
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import dev.zacsweers.metro.Inject
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
|
||||
@Inject
|
||||
class SetExcludedScanlators(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
) {
|
||||
|
||||
suspend fun await(mangaId: Long, excludedScanlators: Set<String>) {
|
||||
database.transaction {
|
||||
val currentExcluded = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
val toAdd = excludedScanlators.minus(currentExcluded)
|
||||
for (scanlator in toAdd) {
|
||||
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
|
||||
}
|
||||
val toRemove = currentExcluded.minus(excludedScanlators)
|
||||
database.excluded_scanlatorsQueries.remove(mangaId, toRemove)
|
||||
}
|
||||
mangaRepository.setExcludedScanlators(mangaId, excludedScanlators)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -51,9 +51,9 @@ import mihon.icons.materialsymbols.rounded.FlipToBack
|
||||
import mihon.icons.materialsymbols.rounded.SelectAll
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.launchUI
|
||||
import tachiyomi.core.common.util.lang.toLong
|
||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.history.repository.HistoryRepository
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
|
||||
import tachiyomi.domain.source.model.Source
|
||||
import tachiyomi.domain.source.model.SourceWithCount
|
||||
@@ -229,7 +229,8 @@ class ClearDatabaseScreen : Screen() {
|
||||
@ViewModelKey
|
||||
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
|
||||
class ClearDatabaseViewModel(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val getSourcesWithNonLibraryManga: GetSourcesWithNonLibraryManga,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -253,8 +254,8 @@ class ClearDatabaseViewModel(
|
||||
|
||||
suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext {
|
||||
val state = state.value as? State.Ready ?: return@withNonCancellableContext
|
||||
database.mangasQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong())
|
||||
database.historyQueries.removeResettedHistory()
|
||||
mangaRepository.deleteNonLibraryManga(state.selection, keepReadManga)
|
||||
historyRepository.deleteResetHistory()
|
||||
}
|
||||
|
||||
fun toggleSelection(source: Source) = state.update { state ->
|
||||
|
||||
+16
-26
@@ -1,24 +1,25 @@
|
||||
package eu.kanade.tachiyomi.data.backup.create.creators
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOne
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
|
||||
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
|
||||
import eu.kanade.tachiyomi.data.backup.models.toBackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.toBackupTracking
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.MemoColumnAdapter
|
||||
import mihon.core.common.extensions.toByteArray
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.history.interactor.GetHistory
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.track.repository.TrackRepository
|
||||
|
||||
@Inject
|
||||
class MangaBackupCreator(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
private val trackRepository: TrackRepository,
|
||||
private val getCategories: GetCategories,
|
||||
private val getHistory: GetHistory,
|
||||
) {
|
||||
@@ -33,20 +34,13 @@ class MangaBackupCreator(
|
||||
// Entry for this manga
|
||||
val mangaObject = manga.toBackupManga()
|
||||
|
||||
mangaObject.excludedScanlators = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(manga.id)
|
||||
.awaitAsList()
|
||||
mangaObject.excludedScanlators = mangaRepository.getExcludedScanlators(manga.id).toList()
|
||||
|
||||
if (options.chapters) {
|
||||
// Backup all the chapters
|
||||
database.chaptersQueries
|
||||
.getChaptersByMangaId(
|
||||
mangaId = manga.id,
|
||||
applyScanlatorFilter = 0, // false
|
||||
mapper = backupChapterMapper,
|
||||
)
|
||||
.awaitAsList()
|
||||
.takeUnless(List<BackupChapter>::isEmpty)
|
||||
chapterRepository.getChapterByMangaId(manga.id, applyScanlatorFilter = false)
|
||||
.map { it.toBackupChapter() }
|
||||
.takeUnless { it.isEmpty() }
|
||||
?.let { mangaObject.chapters = it }
|
||||
}
|
||||
|
||||
@@ -59,9 +53,7 @@ class MangaBackupCreator(
|
||||
}
|
||||
|
||||
if (options.tracking) {
|
||||
val tracks = database.manga_syncQueries
|
||||
.getTracksByMangaId(manga.id, backupTrackMapper)
|
||||
.awaitAsList()
|
||||
val tracks = trackRepository.getTracksByMangaId(manga.id).map { it.toBackupTracking() }
|
||||
if (tracks.isNotEmpty()) {
|
||||
mangaObject.tracking = tracks
|
||||
}
|
||||
@@ -71,9 +63,7 @@ class MangaBackupCreator(
|
||||
val historyByMangaId = getHistory.await(manga.id)
|
||||
if (historyByMangaId.isNotEmpty()) {
|
||||
val history = historyByMangaId.map { history ->
|
||||
val chapter = database.chaptersQueries
|
||||
.getChapterById(history.chapterId)
|
||||
.awaitAsOne()
|
||||
val chapter = checkNotNull(chapterRepository.getChapterById(history.chapterId))
|
||||
BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration)
|
||||
}
|
||||
if (history.isNotEmpty()) {
|
||||
@@ -106,5 +96,5 @@ private fun Manga.toBackupManga() =
|
||||
favoriteModifiedAt = this.favoriteModifiedAt,
|
||||
notes = this.notes,
|
||||
initialized = this.initialized,
|
||||
memo = MemoColumnAdapter.encode(this.memo),
|
||||
memo = this.memo.toByteArray(),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import mihon.core.common.extensions.JsonObjectEmptyBytes
|
||||
import tachiyomi.data.MemoColumnAdapter
|
||||
import mihon.core.common.extensions.toByteArray
|
||||
import mihon.core.common.extensions.toJsonObject
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
|
||||
@Serializable
|
||||
@@ -39,27 +39,12 @@ class BackupChapter(
|
||||
dateFetch = this@BackupChapter.dateFetch,
|
||||
dateUpload = this@BackupChapter.dateUpload,
|
||||
sourceOrder = this@BackupChapter.sourceOrder,
|
||||
memo = MemoColumnAdapter.decode(this@BackupChapter.memo),
|
||||
memo = this@BackupChapter.memo.toJsonObject(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val backupChapterMapper = {
|
||||
_: Long,
|
||||
_: Long,
|
||||
url: String,
|
||||
name: String,
|
||||
scanlator: String?,
|
||||
read: Boolean,
|
||||
bookmark: Boolean,
|
||||
lastPageRead: Long,
|
||||
chapterNumber: Double,
|
||||
sourceOrder: Long,
|
||||
dateFetch: Long,
|
||||
dateUpload: Long,
|
||||
memo: JsonObject,
|
||||
->
|
||||
BackupChapter(
|
||||
fun Chapter.toBackupChapter() = BackupChapter(
|
||||
url = url,
|
||||
name = name,
|
||||
chapterNumber = chapterNumber.toFloat(),
|
||||
@@ -70,6 +55,5 @@ val backupChapterMapper = {
|
||||
dateFetch = dateFetch,
|
||||
dateUpload = dateUpload,
|
||||
sourceOrder = sourceOrder,
|
||||
memo = MemoColumnAdapter.encode(memo),
|
||||
memo = memo.toByteArray(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
import mihon.core.common.extensions.JsonObjectEmptyBytes
|
||||
import tachiyomi.data.MemoColumnAdapter
|
||||
import mihon.core.common.extensions.toJsonObject
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -66,7 +66,7 @@ class BackupManga(
|
||||
favoriteModifiedAt = this@BackupManga.favoriteModifiedAt,
|
||||
notes = this@BackupManga.notes,
|
||||
initialized = this@BackupManga.initialized,
|
||||
memo = MemoColumnAdapter.decode(this@BackupManga.memo),
|
||||
memo = this@BackupManga.memo.toJsonObject(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,25 +54,9 @@ data class BackupTracking(
|
||||
}
|
||||
}
|
||||
|
||||
val backupTrackMapper = {
|
||||
_: Long,
|
||||
_: Long,
|
||||
syncId: Long,
|
||||
mediaId: Long,
|
||||
libraryId: Long?,
|
||||
title: String,
|
||||
lastChapterRead: Double,
|
||||
totalChapters: Long,
|
||||
status: Long,
|
||||
score: Double,
|
||||
remoteUrl: String,
|
||||
startDate: Long,
|
||||
finishDate: Long,
|
||||
private: Boolean,
|
||||
->
|
||||
BackupTracking(
|
||||
syncId = syncId.toInt(),
|
||||
mediaId = mediaId,
|
||||
fun Track.toBackupTracking() = BackupTracking(
|
||||
syncId = trackerId.toInt(),
|
||||
mediaId = remoteId,
|
||||
// forced not null so its compatible with 1.x backup system
|
||||
libraryId = libraryId ?: 0,
|
||||
title = title,
|
||||
@@ -85,4 +69,3 @@ val backupTrackMapper = {
|
||||
trackingUrl = remoteUrl,
|
||||
private = private,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.i18n.MR
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -43,7 +42,6 @@ class BackupRestorer(
|
||||
@Assisted private val notifier: BackupNotifier,
|
||||
@Assisted private val isSync: Boolean,
|
||||
private val context: Context,
|
||||
private val database: Database,
|
||||
private val downloadCache: DownloadCache,
|
||||
private val categoriesRestorer: CategoriesRestorer,
|
||||
private val preferenceRestorer: PreferenceRestorer,
|
||||
@@ -170,12 +168,8 @@ class BackupRestorer(
|
||||
.chunked(100)
|
||||
.forEach { chunk ->
|
||||
val restoredAsBatch = try {
|
||||
database.transaction {
|
||||
chunk.forEach {
|
||||
ensureActive()
|
||||
mangaRestorer.restore(it, backupCategories)
|
||||
}
|
||||
}
|
||||
mangaRestorer.restore(chunk, backupCategories)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
ensureActive()
|
||||
@@ -190,7 +184,7 @@ class BackupRestorer(
|
||||
ensureActive()
|
||||
|
||||
try {
|
||||
mangaRestorer.restore(it, backupCategories)
|
||||
mangaRestorer.restore(listOf(it), backupCategories)
|
||||
} catch (e: Exception) {
|
||||
ensureActive()
|
||||
val sourceName = sourceMapping[it.source] ?: it.source.toString()
|
||||
@@ -245,7 +239,6 @@ class BackupRestorer(
|
||||
backupExtensionStores
|
||||
.chunked(100)
|
||||
.forEach { chunk ->
|
||||
database.transaction {
|
||||
chunk.forEach {
|
||||
ensureActive()
|
||||
|
||||
@@ -257,7 +250,6 @@ class BackupRestorer(
|
||||
|
||||
restoreProgress.incrementAndFetch()
|
||||
}
|
||||
}
|
||||
notifier.showRestoreProgress(
|
||||
context.stringResource(MR.strings.extensionStores),
|
||||
restoreProgress.load(),
|
||||
|
||||
+6
-13
@@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.repository.CategoryRepository
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
|
||||
@Inject
|
||||
class CategoriesRestorer(
|
||||
private val database: Database,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val getCategories: GetCategories,
|
||||
private val libraryPreferences: LibraryPreferences,
|
||||
) {
|
||||
@@ -19,18 +19,11 @@ class CategoriesRestorer(
|
||||
val dbCategoriesByName = dbCategories.associateBy { it.name }
|
||||
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
|
||||
|
||||
val categories = database.transactionWithResult {
|
||||
backupCategories
|
||||
val categories = backupCategories
|
||||
.sortedBy { it.order }
|
||||
.map {
|
||||
val dbCategory = dbCategoriesByName[it.name]
|
||||
if (dbCategory != null) return@map dbCategory
|
||||
val order = nextOrder++
|
||||
database.categoriesQueries
|
||||
.insert(it.name, order, it.flags)
|
||||
.let { id -> it.toCategory(id).copy(order = order) }
|
||||
}
|
||||
}
|
||||
.filter { it.name !in dbCategoriesByName }
|
||||
.map { it.toCategory(id = 0).copy(order = nextOrder++) }
|
||||
categoryRepository.insertAll(categories)
|
||||
|
||||
libraryPreferences.categorizedDisplaySettings.set(
|
||||
(dbCategories + categories)
|
||||
|
||||
+10
-5
@@ -2,25 +2,30 @@ package eu.kanade.tachiyomi.data.backup.restore.restorers
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
|
||||
import tachiyomi.data.Database
|
||||
import mihon.domain.extension.model.ExtensionStore
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
|
||||
@Inject
|
||||
class ExtensionStoreRestorer(
|
||||
private val database: Database,
|
||||
private val extensionStoreRepository: ExtensionStoreRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
backupStore: BackupExtensionStore,
|
||||
) {
|
||||
database.extension_storeQueries.upsert(
|
||||
extensionStoreRepository.upsert(
|
||||
ExtensionStore(
|
||||
indexUrl = backupStore.indexUrl,
|
||||
name = backupStore.name,
|
||||
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
|
||||
signingKey = backupStore.signingKey,
|
||||
contactWebsite = backupStore.contactWebsite,
|
||||
contactDiscord = backupStore.contactDiscord,
|
||||
contact = ExtensionStore.Contact(
|
||||
website = backupStore.contactWebsite,
|
||||
discord = backupStore.contactDiscord,
|
||||
),
|
||||
isLegacy = backupStore.isLegacy ?: true,
|
||||
extensionListUrl = backupStore.extensionListUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-376
@@ -1,42 +1,22 @@
|
||||
package eu.kanade.tachiyomi.data.backup.restore.restorers
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOne
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupHistory
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupManga
|
||||
import eu.kanade.tachiyomi.data.backup.models.BackupTracking
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.backup.model.RestoredHistory
|
||||
import tachiyomi.domain.backup.model.RestoredManga
|
||||
import tachiyomi.domain.backup.repository.RestoreRepository
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.manga.interactor.FetchInterval
|
||||
import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.domain.track.interactor.InsertTrack
|
||||
import tachiyomi.domain.track.model.Track
|
||||
import java.util.Date
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Clock
|
||||
|
||||
@Inject
|
||||
class MangaRestorer(
|
||||
private val database: Database,
|
||||
private val restoreRepository: RestoreRepository,
|
||||
private val getCategories: GetCategories,
|
||||
private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId,
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId,
|
||||
private val updateManga: UpdateManga,
|
||||
private val getTracks: GetTracks,
|
||||
private val insertTrack: InsertTrack,
|
||||
fetchInterval: FetchInterval,
|
||||
private val fetchInterval: FetchInterval,
|
||||
) {
|
||||
|
||||
private val timeZone = TimeZone.currentSystemDefault()
|
||||
@@ -44,370 +24,42 @@ class MangaRestorer(
|
||||
private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone)
|
||||
|
||||
suspend fun sortByNew(backupMangas: List<BackupManga>): List<BackupManga> {
|
||||
val urlsBySource = database.mangasQueries
|
||||
.getAllMangaSourceAndUrl()
|
||||
.awaitAsList()
|
||||
.groupBy({ it.source }, { it.url })
|
||||
val urlsBySource = restoreRepository.getMangaUrlsBySourceId()
|
||||
|
||||
return backupMangas
|
||||
.sortedBy { it.url in urlsBySource[it.source].orEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores [backupMangas] all together, so either every one of them is restored or none is.
|
||||
*/
|
||||
suspend fun restore(
|
||||
backupManga: BackupManga,
|
||||
backupMangas: List<BackupManga>,
|
||||
backupCategories: List<BackupCategory>,
|
||||
) {
|
||||
database.transaction {
|
||||
val dbManga = findExistingManga(backupManga)
|
||||
val manga = backupManga.getMangaImpl()
|
||||
val restoredManga = if (dbManga == null) {
|
||||
restoreNewManga(manga)
|
||||
} else {
|
||||
restoreExistingManga(manga, dbManga)
|
||||
}
|
||||
val dbCategoriesByName = getCategories.await().associateBy { it.name }
|
||||
val backupCategoriesByOrder = backupCategories.associateBy { it.order }
|
||||
|
||||
restoreMangaDetails(
|
||||
manga = restoredManga,
|
||||
chapters = backupManga.chapters,
|
||||
categories = backupManga.categories,
|
||||
backupCategories = backupCategories,
|
||||
history = backupManga.history,
|
||||
tracks = backupManga.tracking,
|
||||
val entries = backupMangas.map { backupManga ->
|
||||
RestoredManga(
|
||||
manga = backupManga.getMangaImpl(),
|
||||
chapters = backupManga.chapters.map { it.toChapterImpl() },
|
||||
categoryIds = backupManga.categories.mapNotNull { backupCategoryOrder ->
|
||||
backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory ->
|
||||
dbCategoriesByName[backupCategory.name]?.id
|
||||
}
|
||||
},
|
||||
history = backupManga.history.map {
|
||||
val history = it.getHistoryImpl()
|
||||
RestoredHistory(it.url, history.readAt, history.readDuration)
|
||||
},
|
||||
tracks = backupManga.tracking.map { it.getTrackImpl() },
|
||||
excludedScanlators = backupManga.excludedScanlators,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findExistingManga(backupManga: BackupManga): Manga? {
|
||||
return getMangaByUrlAndSourceId.await(backupManga.url, backupManga.source)
|
||||
}
|
||||
|
||||
private suspend fun restoreExistingManga(manga: Manga, dbManga: Manga): Manga {
|
||||
val details = if (dbManga.initialized || !manga.initialized) dbManga else manga
|
||||
return updateManga(
|
||||
dbManga.copy(
|
||||
favorite = dbManga.favorite || manga.favorite,
|
||||
dateAdded = listOfNotNull(
|
||||
dbManga.dateAdded.takeIf { it > 0 },
|
||||
manga.dateAdded.takeIf { it > 0 },
|
||||
).minOrNull() ?: 0L,
|
||||
title = details.title,
|
||||
artist = details.artist,
|
||||
author = details.author,
|
||||
description = details.description,
|
||||
genre = details.genre,
|
||||
status = details.status,
|
||||
thumbnailUrl = details.thumbnailUrl,
|
||||
updateStrategy = details.updateStrategy,
|
||||
initialized = dbManga.initialized || manga.initialized,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(manga.memo + dbManga.memo),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateManga(manga: Manga): Manga {
|
||||
database.mangasQueries.updateFromBackup(
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
initialized = manga.initialized,
|
||||
viewer = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
mangaId = manga.id,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
return manga
|
||||
}
|
||||
|
||||
private suspend fun restoreNewManga(
|
||||
manga: Manga,
|
||||
): Manga {
|
||||
return manga.copy(
|
||||
id = insertManga(manga),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun restoreChapters(manga: Manga, backupChapters: List<BackupChapter>) {
|
||||
val dbChaptersByUrl = getChaptersByMangaId.await(manga.id)
|
||||
.associateBy { it.url }
|
||||
|
||||
val (existingChapters, newChapters) = backupChapters
|
||||
.mapNotNull {
|
||||
val chapter = it.toChapterImpl().copy(mangaId = manga.id)
|
||||
|
||||
val dbChapter = dbChaptersByUrl[chapter.url]
|
||||
?: // New chapter
|
||||
return@mapNotNull chapter
|
||||
|
||||
if (chapter.forComparison() == dbChapter.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
chapter
|
||||
.copyFrom(dbChapter)
|
||||
.copy(
|
||||
id = dbChapter.id,
|
||||
read = chapter.read || dbChapter.read,
|
||||
bookmark = chapter.bookmark || dbChapter.bookmark,
|
||||
lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead),
|
||||
dateFetch = dbChapter.dateFetch,
|
||||
sourceOrder = dbChapter.sourceOrder,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(chapter.memo + dbChapter.memo),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
insertNewChapters(newChapters)
|
||||
updateExistingChapters(existingChapters)
|
||||
}
|
||||
|
||||
private fun Chapter.forComparison() =
|
||||
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L)
|
||||
|
||||
private suspend fun insertNewChapters(chapters: List<Chapter>) {
|
||||
database.transaction {
|
||||
chapters.forEach { chapter ->
|
||||
database.chaptersQueries.insert(
|
||||
chapter.mangaId,
|
||||
chapter.url,
|
||||
chapter.name,
|
||||
chapter.scanlator,
|
||||
chapter.read,
|
||||
chapter.bookmark,
|
||||
chapter.lastPageRead,
|
||||
chapter.chapterNumber,
|
||||
chapter.sourceOrder,
|
||||
chapter.dateFetch,
|
||||
chapter.dateUpload,
|
||||
chapter.memo,
|
||||
)
|
||||
restoreRepository.restoreManga(entries) {
|
||||
fetchInterval.toMangaUpdate(it, now, timeZone, currentFetchWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateExistingChapters(chapters: List<Chapter>) {
|
||||
database.transaction {
|
||||
chapters.forEach { chapter ->
|
||||
database.chaptersQueries.updateFromBackup(
|
||||
read = chapter.read,
|
||||
bookmark = chapter.bookmark,
|
||||
lastPageRead = chapter.lastPageRead,
|
||||
chapterId = chapter.id,
|
||||
memo = chapter.memo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts manga and returns id
|
||||
*
|
||||
* @return id of [Manga], null if not found
|
||||
*/
|
||||
private suspend fun insertManga(manga: Manga): Long {
|
||||
return database.mangasQueries.insertReturningId(
|
||||
source = manga.source,
|
||||
url = manga.url,
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
nextUpdate = 0L,
|
||||
calculateInterval = 0L,
|
||||
initialized = manga.initialized,
|
||||
viewerFlags = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
.awaitAsOne()
|
||||
}
|
||||
|
||||
private suspend fun restoreMangaDetails(
|
||||
manga: Manga,
|
||||
chapters: List<BackupChapter>,
|
||||
categories: List<Long>,
|
||||
backupCategories: List<BackupCategory>,
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<BackupTracking>,
|
||||
excludedScanlators: List<String>,
|
||||
): Manga {
|
||||
restoreCategories(manga, categories, backupCategories)
|
||||
restoreChapters(manga, chapters)
|
||||
restoreTracking(manga, tracks)
|
||||
restoreHistory(manga, history)
|
||||
restoreExcludedScanlators(manga, excludedScanlators)
|
||||
updateManga.awaitUpdateFetchInterval(manga, timeZone, now, currentFetchWindow)
|
||||
return manga
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the categories a manga is in.
|
||||
*
|
||||
* @param manga the manga whose categories have to be restored.
|
||||
* @param categories the categories to restore.
|
||||
*/
|
||||
private suspend fun restoreCategories(
|
||||
manga: Manga,
|
||||
categories: List<Long>,
|
||||
backupCategories: List<BackupCategory>,
|
||||
) {
|
||||
val dbCategories = getCategories.await()
|
||||
val dbCategoriesByName = dbCategories.associateBy { it.name }
|
||||
|
||||
val backupCategoriesByOrder = backupCategories.associateBy { it.order }
|
||||
|
||||
val mangaCategoriesToUpdate = categories.mapNotNull { backupCategoryOrder ->
|
||||
backupCategoriesByOrder[backupCategoryOrder]?.let { backupCategory ->
|
||||
dbCategoriesByName[backupCategory.name]?.let { dbCategory ->
|
||||
Pair(manga.id, dbCategory.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mangaCategoriesToUpdate.isNotEmpty()) {
|
||||
database.transaction {
|
||||
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id)
|
||||
mangaCategoriesToUpdate.forEach { (mangaId, categoryId) ->
|
||||
database.mangas_categoriesQueries.insert(mangaId, categoryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
|
||||
val toUpdate = backupHistory.mapNotNull { history ->
|
||||
val dbHistory = database.historyQueries
|
||||
.getHistoryByChapterUrlAndMangaId(history.url, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
val item = history.getHistoryImpl()
|
||||
|
||||
if (dbHistory == null) {
|
||||
val chapter = database.chaptersQueries
|
||||
.getChapterByUrlAndMangaId(history.url, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
return@mapNotNull if (chapter == null) {
|
||||
// Chapter doesn't exist; skip
|
||||
null
|
||||
} else {
|
||||
// New history entry
|
||||
item.copy(chapterId = chapter._id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update history entry
|
||||
item.copy(
|
||||
id = dbHistory._id,
|
||||
chapterId = dbHistory.chapter_id,
|
||||
readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
|
||||
.takeIf { it > 0L }
|
||||
?.let { Date(it) },
|
||||
readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read,
|
||||
)
|
||||
}
|
||||
|
||||
if (toUpdate.isEmpty()) return
|
||||
database.transaction {
|
||||
toUpdate.forEach {
|
||||
database.historyQueries.upsert(
|
||||
it.chapterId,
|
||||
it.readAt,
|
||||
it.readDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreTracking(manga: Manga, backupTracks: List<BackupTracking>) {
|
||||
val dbTrackByTrackerId = getTracks.await(manga.id).associateBy { it.trackerId }
|
||||
|
||||
val (existingTracks, newTracks) = backupTracks
|
||||
.mapNotNull {
|
||||
val track = it.getTrackImpl()
|
||||
val dbTrack = dbTrackByTrackerId[track.trackerId]
|
||||
?: // New track
|
||||
return@mapNotNull track.copy(
|
||||
id = 0, // Let DB assign new ID
|
||||
mangaId = manga.id,
|
||||
)
|
||||
|
||||
if (track.forComparison() == dbTrack.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// Update to an existing track
|
||||
dbTrack.copy(
|
||||
remoteId = track.remoteId,
|
||||
libraryId = track.libraryId,
|
||||
lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
if (newTracks.isNotEmpty()) {
|
||||
insertTrack.awaitAll(newTracks)
|
||||
}
|
||||
|
||||
if (existingTracks.isEmpty()) return
|
||||
database.transaction {
|
||||
existingTracks.forEach { track ->
|
||||
database.manga_syncQueries.update(
|
||||
track.mangaId,
|
||||
track.trackerId,
|
||||
track.remoteId,
|
||||
track.libraryId,
|
||||
track.title,
|
||||
track.lastChapterRead,
|
||||
track.totalChapters,
|
||||
track.status,
|
||||
track.score,
|
||||
track.remoteUrl,
|
||||
track.startDate,
|
||||
track.finishDate,
|
||||
track.private,
|
||||
track.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
|
||||
|
||||
/**
|
||||
* Restores the excluded scanlators for the manga.
|
||||
*
|
||||
* @param manga the manga whose excluded scanlators have to be restored.
|
||||
* @param excludedScanlators the excluded scanlators to restore.
|
||||
*/
|
||||
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
|
||||
if (excludedScanlators.isEmpty()) return
|
||||
val existingExcludedScanlators = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(manga.id)
|
||||
.awaitAsList()
|
||||
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
|
||||
if (toInsert.isEmpty()) return
|
||||
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
package mihon.app.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.FileProvider
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.Provides
|
||||
@@ -16,50 +9,10 @@ import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
||||
import nl.adaptivity.xmlutil.core.XmlVersion
|
||||
import nl.adaptivity.xmlutil.serialization.XML
|
||||
import tachiyomi.data.Chapters
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.DateColumnAdapter
|
||||
import tachiyomi.data.History
|
||||
import tachiyomi.data.Mangas
|
||||
import tachiyomi.data.MemoColumnAdapter
|
||||
import tachiyomi.data.StringListColumnAdapter
|
||||
import tachiyomi.data.UpdateStrategyColumnAdapter
|
||||
|
||||
@BindingContainer
|
||||
object AppBindings {
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesSqlDriver(context: Context): SqlDriver {
|
||||
return AndroidxSqliteDriver(
|
||||
driver = BundledSQLiteDriver(),
|
||||
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"),
|
||||
schema = Database.Schema,
|
||||
configuration = AndroidxSqliteConfiguration(
|
||||
isForeignKeyConstraintsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesDatabase(driver: SqlDriver): Database {
|
||||
return Database(
|
||||
driver = driver,
|
||||
historyAdapter = History.Adapter(
|
||||
last_readAdapter = DateColumnAdapter,
|
||||
),
|
||||
mangasAdapter = Mangas.Adapter(
|
||||
genreAdapter = StringListColumnAdapter,
|
||||
update_strategyAdapter = UpdateStrategyColumnAdapter,
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
chaptersAdapter = Chapters.Adapter(
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesJson(): Json = Json {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package mihon.core.common.extensions
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
val JsonObjectEmpty = JsonObject(emptyMap())
|
||||
@@ -8,3 +9,7 @@ val JsonObjectEmptyBytes = byteArrayOf(0x7B, 0x7D)
|
||||
|
||||
val JsonObject.Companion.EMPTY: JsonObject
|
||||
inline get() = JsonObjectEmpty
|
||||
|
||||
fun JsonObject.toByteArray(): ByteArray = toString().encodeToByteArray()
|
||||
|
||||
fun ByteArray.toJsonObject(): JsonObject = Json.decodeFromString<JsonObject>(decodeToString())
|
||||
|
||||
@@ -42,5 +42,6 @@ dependencies {
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
api(libs.bundles.sqldelight)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
implementation(libs.bundles.sqldelight)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class ExtensionStoreRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upsert(store: ExtensionStore) {
|
||||
override suspend fun upsert(store: ExtensionStore) {
|
||||
database.extension_storeQueries.upsert(
|
||||
indexUrl = store.indexUrl,
|
||||
name = store.name,
|
||||
|
||||
@@ -2,8 +2,9 @@ package tachiyomi.data
|
||||
|
||||
import app.cash.sqldelight.ColumnAdapter
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import mihon.core.common.extensions.toByteArray
|
||||
import mihon.core.common.extensions.toJsonObject
|
||||
import java.util.Date
|
||||
|
||||
object DateColumnAdapter : ColumnAdapter<Date, Long> {
|
||||
@@ -31,11 +32,6 @@ object UpdateStrategyColumnAdapter : ColumnAdapter<UpdateStrategy, Long> {
|
||||
}
|
||||
|
||||
object MemoColumnAdapter : ColumnAdapter<JsonObject, ByteArray> {
|
||||
override fun decode(databaseValue: ByteArray): JsonObject {
|
||||
return Json.decodeFromString<JsonObject>(databaseValue.decodeToString())
|
||||
}
|
||||
|
||||
override fun encode(value: JsonObject): ByteArray {
|
||||
return value.toString().encodeToByteArray()
|
||||
}
|
||||
override fun decode(databaseValue: ByteArray): JsonObject = databaseValue.toJsonObject()
|
||||
override fun encode(value: JsonObject): ByteArray = value.toByteArray()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package tachiyomi.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType
|
||||
import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver
|
||||
import com.eygraber.sqldelight.androidx.driver.FileProvider
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
@BindingContainer
|
||||
object DatabaseBindings {
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesSqlDriver(context: Context): SqlDriver {
|
||||
return AndroidxSqliteDriver(
|
||||
driver = BundledSQLiteDriver(),
|
||||
databaseType = AndroidxSqliteDatabaseType.FileProvider(context, "tachiyomi.db"),
|
||||
schema = Database.Schema,
|
||||
configuration = AndroidxSqliteConfiguration(
|
||||
isForeignKeyConstraintsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesDatabase(driver: SqlDriver): Database {
|
||||
return Database(
|
||||
driver = driver,
|
||||
historyAdapter = History.Adapter(
|
||||
last_readAdapter = DateColumnAdapter,
|
||||
),
|
||||
mangasAdapter = Mangas.Adapter(
|
||||
genreAdapter = StringListColumnAdapter,
|
||||
update_strategyAdapter = UpdateStrategyColumnAdapter,
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
chaptersAdapter = Chapters.Adapter(
|
||||
memoAdapter = MemoColumnAdapter,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package tachiyomi.data.backup
|
||||
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsList
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOne
|
||||
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.backup.model.RestoredHistory
|
||||
import tachiyomi.domain.backup.model.RestoredManga
|
||||
import tachiyomi.domain.backup.repository.RestoreRepository
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.chapter.repository.ChapterRepository
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.track.model.Track
|
||||
import tachiyomi.domain.track.repository.TrackRepository
|
||||
import java.util.Date
|
||||
import kotlin.math.max
|
||||
|
||||
@Inject
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class RestoreRepositoryImpl(
|
||||
private val database: Database,
|
||||
private val mangaRepository: MangaRepository,
|
||||
private val chapterRepository: ChapterRepository,
|
||||
private val trackRepository: TrackRepository,
|
||||
) : RestoreRepository {
|
||||
|
||||
override suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>> {
|
||||
return database.mangasQueries
|
||||
.getAllMangaSourceAndUrl()
|
||||
.awaitAsList()
|
||||
.groupBy({ it.source }, { it.url })
|
||||
}
|
||||
|
||||
override suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate) {
|
||||
database.transaction {
|
||||
entries.forEach { entry ->
|
||||
// A cancelled restore stops between entries and rolls the whole batch back
|
||||
currentCoroutineContext().ensureActive()
|
||||
mangaRepository.update(update(restore(entry)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restore(entry: RestoredManga): Manga {
|
||||
val dbManga = mangaRepository.getMangaByUrlAndSourceId(entry.manga.url, entry.manga.source)
|
||||
val manga = if (dbManga == null) {
|
||||
entry.manga.copy(id = insertManga(entry.manga))
|
||||
} else {
|
||||
updateManga(mergeManga(entry.manga, dbManga))
|
||||
}
|
||||
|
||||
if (entry.categoryIds.isNotEmpty()) {
|
||||
mangaRepository.setMangaCategories(manga.id, entry.categoryIds)
|
||||
}
|
||||
restoreChapters(manga, entry.chapters)
|
||||
restoreTracking(manga, entry.tracks)
|
||||
restoreHistory(manga, entry.history)
|
||||
restoreExcludedScanlators(manga, entry.excludedScanlators)
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun mergeManga(manga: Manga, dbManga: Manga): Manga {
|
||||
val details = if (dbManga.initialized || !manga.initialized) dbManga else manga
|
||||
return dbManga.copy(
|
||||
favorite = dbManga.favorite || manga.favorite,
|
||||
dateAdded = listOfNotNull(
|
||||
dbManga.dateAdded.takeIf { it > 0 },
|
||||
manga.dateAdded.takeIf { it > 0 },
|
||||
).minOrNull() ?: 0L,
|
||||
title = details.title,
|
||||
artist = details.artist,
|
||||
author = details.author,
|
||||
description = details.description,
|
||||
genre = details.genre,
|
||||
status = details.status,
|
||||
thumbnailUrl = details.thumbnailUrl,
|
||||
updateStrategy = details.updateStrategy,
|
||||
initialized = dbManga.initialized || manga.initialized,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(manga.memo + dbManga.memo),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateManga(manga: Manga): Manga {
|
||||
database.mangasQueries.updateFromBackup(
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
initialized = manga.initialized,
|
||||
viewer = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
mangaId = manga.id,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
return manga
|
||||
}
|
||||
|
||||
private suspend fun insertManga(manga: Manga): Long {
|
||||
return database.mangasQueries.insertReturningId(
|
||||
source = manga.source,
|
||||
url = manga.url,
|
||||
artist = manga.artist,
|
||||
author = manga.author,
|
||||
description = manga.description,
|
||||
genre = manga.genre,
|
||||
title = manga.title,
|
||||
status = manga.status,
|
||||
thumbnailUrl = manga.thumbnailUrl,
|
||||
favorite = manga.favorite,
|
||||
lastUpdate = manga.lastUpdate,
|
||||
nextUpdate = 0L,
|
||||
calculateInterval = 0L,
|
||||
initialized = manga.initialized,
|
||||
viewerFlags = manga.viewerFlags,
|
||||
chapterFlags = manga.chapterFlags,
|
||||
coverLastModified = manga.coverLastModified,
|
||||
dateAdded = manga.dateAdded,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
notes = manga.notes,
|
||||
memo = manga.memo,
|
||||
)
|
||||
.awaitAsOne()
|
||||
}
|
||||
|
||||
private suspend fun restoreChapters(manga: Manga, restoredChapters: List<Chapter>) {
|
||||
val dbChaptersByUrl = chapterRepository.getChapterByMangaId(manga.id)
|
||||
.associateBy { it.url }
|
||||
|
||||
val (existingChapters, newChapters) = restoredChapters
|
||||
.mapNotNull {
|
||||
val chapter = it.copy(mangaId = manga.id)
|
||||
|
||||
val dbChapter = dbChaptersByUrl[chapter.url]
|
||||
?: // New chapter
|
||||
return@mapNotNull chapter
|
||||
|
||||
if (chapter.forComparison() == dbChapter.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
chapter
|
||||
.copyFrom(dbChapter)
|
||||
.copy(
|
||||
id = dbChapter.id,
|
||||
read = chapter.read || dbChapter.read,
|
||||
bookmark = chapter.bookmark || dbChapter.bookmark,
|
||||
lastPageRead = max(chapter.lastPageRead, dbChapter.lastPageRead),
|
||||
dateFetch = dbChapter.dateFetch,
|
||||
sourceOrder = dbChapter.sourceOrder,
|
||||
// Merge both backup and local data with local winning
|
||||
memo = JsonObject(chapter.memo + dbChapter.memo),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
newChapters.forEach { chapter ->
|
||||
database.chaptersQueries.insert(
|
||||
chapter.mangaId,
|
||||
chapter.url,
|
||||
chapter.name,
|
||||
chapter.scanlator,
|
||||
chapter.read,
|
||||
chapter.bookmark,
|
||||
chapter.lastPageRead,
|
||||
chapter.chapterNumber,
|
||||
chapter.sourceOrder,
|
||||
chapter.dateFetch,
|
||||
chapter.dateUpload,
|
||||
chapter.memo,
|
||||
)
|
||||
}
|
||||
existingChapters.forEach { chapter ->
|
||||
database.chaptersQueries.updateFromBackup(
|
||||
read = chapter.read,
|
||||
bookmark = chapter.bookmark,
|
||||
lastPageRead = chapter.lastPageRead,
|
||||
chapterId = chapter.id,
|
||||
memo = chapter.memo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Chapter.forComparison() =
|
||||
this.copy(id = 0L, mangaId = 0L, dateFetch = 0L, dateUpload = 0L)
|
||||
|
||||
private suspend fun restoreHistory(manga: Manga, restoredHistory: List<RestoredHistory>) {
|
||||
val toUpdate = restoredHistory.mapNotNull { history ->
|
||||
val dbHistory = database.historyQueries
|
||||
.getHistoryByChapterUrlAndMangaId(history.chapterUrl, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
|
||||
if (dbHistory == null) {
|
||||
val chapter = database.chaptersQueries
|
||||
.getChapterByUrlAndMangaId(history.chapterUrl, manga.id)
|
||||
.awaitAsOneOrNull()
|
||||
// Chapter doesn't exist; skip
|
||||
?: return@mapNotNull null
|
||||
// New history entry
|
||||
return@mapNotNull Triple(chapter._id, history.readAt, history.readDuration)
|
||||
}
|
||||
|
||||
// Update history entry
|
||||
Triple(
|
||||
dbHistory.chapter_id,
|
||||
max(history.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L)
|
||||
.takeIf { it > 0L }
|
||||
?.let { Date(it) },
|
||||
max(history.readDuration, dbHistory.time_read) - dbHistory.time_read,
|
||||
)
|
||||
}
|
||||
|
||||
toUpdate.forEach { (chapterId, readAt, readDuration) ->
|
||||
database.historyQueries.upsert(chapterId, readAt, readDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restoreTracking(manga: Manga, restoredTracks: List<Track>) {
|
||||
val dbTrackByTrackerId = trackRepository.getTracksByMangaId(manga.id).associateBy { it.trackerId }
|
||||
|
||||
val (existingTracks, newTracks) = restoredTracks
|
||||
.mapNotNull { track ->
|
||||
val dbTrack = dbTrackByTrackerId[track.trackerId]
|
||||
?: // New track
|
||||
return@mapNotNull track.copy(
|
||||
id = 0, // Let DB assign new ID
|
||||
mangaId = manga.id,
|
||||
)
|
||||
|
||||
if (track.forComparison() == dbTrack.forComparison()) {
|
||||
// Same state; skip
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// Update to an existing track
|
||||
dbTrack.copy(
|
||||
remoteId = track.remoteId,
|
||||
libraryId = track.libraryId,
|
||||
lastChapterRead = max(dbTrack.lastChapterRead, track.lastChapterRead),
|
||||
)
|
||||
}
|
||||
.partition { it.id > 0 }
|
||||
|
||||
if (newTracks.isNotEmpty()) {
|
||||
trackRepository.insertAll(newTracks)
|
||||
}
|
||||
|
||||
existingTracks.forEach { track ->
|
||||
database.manga_syncQueries.update(
|
||||
track.mangaId,
|
||||
track.trackerId,
|
||||
track.remoteId,
|
||||
track.libraryId,
|
||||
track.title,
|
||||
track.lastChapterRead,
|
||||
track.totalChapters,
|
||||
track.status,
|
||||
track.score,
|
||||
track.remoteUrl,
|
||||
track.startDate,
|
||||
track.finishDate,
|
||||
track.private,
|
||||
track.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Track.forComparison() = this.copy(id = 0L, mangaId = 0L)
|
||||
|
||||
private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List<String>) {
|
||||
if (excludedScanlators.isEmpty()) return
|
||||
val existingExcludedScanlators = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(manga.id)
|
||||
.awaitAsList()
|
||||
val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators }
|
||||
toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) }
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ class CategoryRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun insertAll(categories: List<Category>) {
|
||||
database.transaction {
|
||||
categories.forEach { insert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateName(categoryId: Long, name: String) {
|
||||
database.categoriesQueries.updateName(name = name, categoryId = categoryId)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ class HistoryRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteResetHistory() {
|
||||
database.historyQueries.removeResettedHistory()
|
||||
}
|
||||
|
||||
override suspend fun upsertHistory(historyUpdate: HistoryUpdate) {
|
||||
try {
|
||||
database.historyQueries.upsert(
|
||||
|
||||
@@ -8,10 +8,12 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.lang.toLong
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.data.subscribeToList
|
||||
@@ -123,6 +125,10 @@ class MangaRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean) {
|
||||
database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong())
|
||||
}
|
||||
|
||||
override suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>) {
|
||||
database.transaction {
|
||||
database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId)
|
||||
@@ -132,6 +138,33 @@ class MangaRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getExcludedScanlators(mangaId: Long): Set<String> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>> {
|
||||
return database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.subscribeToList()
|
||||
.map { it.toSet() }
|
||||
}
|
||||
|
||||
override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>) {
|
||||
database.transaction {
|
||||
val current = database.excluded_scanlatorsQueries
|
||||
.getExcludedScanlatorsByMangaId(mangaId)
|
||||
.awaitAsList()
|
||||
.toSet()
|
||||
excludedScanlators.minus(current).forEach { scanlator ->
|
||||
database.excluded_scanlatorsQueries.insert(mangaId, scanlator)
|
||||
}
|
||||
database.excluded_scanlatorsQueries.remove(mangaId, current.minus(excludedScanlators))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun update(update: MangaUpdate): Boolean {
|
||||
return try {
|
||||
partialUpdate(update)
|
||||
|
||||
@@ -28,7 +28,7 @@ dependencies {
|
||||
|
||||
implementation(libs.unifile)
|
||||
|
||||
api(libs.sqldelight.androidxPaging)
|
||||
api(libs.androidx.paging.runtime)
|
||||
|
||||
compileOnly(platform(libs.androidx.compose.bom))
|
||||
compileOnly(libs.androidx.compose.runtimeAnnotation)
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ExtensionStoreRepository {
|
||||
|
||||
suspend fun insertFromPreference(indexUrl: String, name: String)
|
||||
|
||||
suspend fun upsert(store: ExtensionStore)
|
||||
|
||||
suspend fun refreshAll()
|
||||
|
||||
suspend fun fetchExtensions(): List<Extension.Available>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package tachiyomi.domain.backup.model
|
||||
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.track.model.Track
|
||||
import java.util.Date
|
||||
|
||||
data class RestoredManga(
|
||||
val manga: Manga,
|
||||
val chapters: List<Chapter>,
|
||||
val categoryIds: List<Long>,
|
||||
val history: List<RestoredHistory>,
|
||||
val tracks: List<Track>,
|
||||
val excludedScanlators: List<String>,
|
||||
)
|
||||
|
||||
data class RestoredHistory(
|
||||
val chapterUrl: String,
|
||||
val readAt: Date?,
|
||||
val readDuration: Long,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package tachiyomi.domain.backup.repository
|
||||
|
||||
import tachiyomi.domain.backup.model.RestoredManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
|
||||
interface RestoreRepository {
|
||||
|
||||
suspend fun getMangaUrlsBySourceId(): Map<Long, List<String>>
|
||||
|
||||
/**
|
||||
* Restores [entries] all together: either every one of them is restored or none is. [update] is what to change on each
|
||||
* entry once its chapters and history are in place, and is part of the same all-or-nothing restore.
|
||||
*/
|
||||
suspend fun restoreManga(entries: List<RestoredManga>, update: suspend (Manga) -> MangaUpdate)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ interface CategoryRepository {
|
||||
|
||||
suspend fun insert(category: Category)
|
||||
|
||||
suspend fun insertAll(categories: List<Category>)
|
||||
|
||||
suspend fun updateName(categoryId: Long, name: String)
|
||||
|
||||
suspend fun updateFlags(categoryId: Long, flags: Long)
|
||||
|
||||
@@ -21,5 +21,7 @@ interface HistoryRepository {
|
||||
|
||||
suspend fun deleteAllHistory(): Boolean
|
||||
|
||||
suspend fun deleteResetHistory()
|
||||
|
||||
suspend fun upsertHistory(historyUpdate: HistoryUpdate)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,16 @@ interface MangaRepository {
|
||||
|
||||
suspend fun resetViewerFlags(): Boolean
|
||||
|
||||
suspend fun deleteNonLibraryManga(sourceIds: List<Long>, keepReadManga: Boolean)
|
||||
|
||||
suspend fun setMangaCategories(mangaId: Long, categoryIds: List<Long>)
|
||||
|
||||
suspend fun getExcludedScanlators(mangaId: Long): Set<String>
|
||||
|
||||
fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow<Set<String>>
|
||||
|
||||
suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set<String>)
|
||||
|
||||
suspend fun update(update: MangaUpdate): Boolean
|
||||
|
||||
suspend fun updateRemote(update: MangaRemoteUpdate): Boolean
|
||||
|
||||
Reference in New Issue
Block a user