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,37 +39,21 @@ 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(
|
||||
url = url,
|
||||
name = name,
|
||||
chapterNumber = chapterNumber.toFloat(),
|
||||
scanlator = scanlator,
|
||||
read = read,
|
||||
bookmark = bookmark,
|
||||
lastPageRead = lastPageRead,
|
||||
dateFetch = dateFetch,
|
||||
dateUpload = dateUpload,
|
||||
sourceOrder = sourceOrder,
|
||||
memo = MemoColumnAdapter.encode(memo),
|
||||
)
|
||||
}
|
||||
fun Chapter.toBackupChapter() = BackupChapter(
|
||||
url = url,
|
||||
name = name,
|
||||
chapterNumber = chapterNumber.toFloat(),
|
||||
scanlator = scanlator,
|
||||
read = read,
|
||||
bookmark = bookmark,
|
||||
lastPageRead = lastPageRead,
|
||||
dateFetch = dateFetch,
|
||||
dateUpload = dateUpload,
|
||||
sourceOrder = sourceOrder,
|
||||
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,35 +54,18 @@ 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,
|
||||
// forced not null so its compatible with 1.x backup system
|
||||
libraryId = libraryId ?: 0,
|
||||
title = title,
|
||||
lastChapterRead = lastChapterRead.toFloat(),
|
||||
totalChapters = totalChapters.toInt(),
|
||||
score = score.toFloat(),
|
||||
status = status.toInt(),
|
||||
startedReadingDate = startDate,
|
||||
finishedReadingDate = finishDate,
|
||||
trackingUrl = remoteUrl,
|
||||
private = private,
|
||||
)
|
||||
}
|
||||
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,
|
||||
lastChapterRead = lastChapterRead.toFloat(),
|
||||
totalChapters = totalChapters.toInt(),
|
||||
score = score.toFloat(),
|
||||
status = status.toInt(),
|
||||
startedReadingDate = startDate,
|
||||
finishedReadingDate = finishDate,
|
||||
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)
|
||||
}
|
||||
}
|
||||
ensureActive()
|
||||
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,18 +239,16 @@ class BackupRestorer(
|
||||
backupExtensionStores
|
||||
.chunked(100)
|
||||
.forEach { chunk ->
|
||||
database.transaction {
|
||||
chunk.forEach {
|
||||
ensureActive()
|
||||
chunk.forEach {
|
||||
ensureActive()
|
||||
|
||||
try {
|
||||
extensionStoreRestorer(it)
|
||||
} catch (e: Exception) {
|
||||
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
|
||||
}
|
||||
|
||||
restoreProgress.incrementAndFetch()
|
||||
try {
|
||||
extensionStoreRestorer(it)
|
||||
} catch (e: Exception) {
|
||||
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
|
||||
}
|
||||
|
||||
restoreProgress.incrementAndFetch()
|
||||
}
|
||||
notifier.showRestoreProgress(
|
||||
context.stringResource(MR.strings.extensionStores),
|
||||
|
||||
+7
-14
@@ -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
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
val categories = backupCategories
|
||||
.sortedBy { it.order }
|
||||
.filter { it.name !in dbCategoriesByName }
|
||||
.map { it.toCategory(id = 0).copy(order = nextOrder++) }
|
||||
categoryRepository.insertAll(categories)
|
||||
|
||||
libraryPreferences.categorizedDisplaySettings.set(
|
||||
(dbCategories + categories)
|
||||
|
||||
+16
-11
@@ -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(
|
||||
indexUrl = backupStore.indexUrl,
|
||||
name = backupStore.name,
|
||||
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
|
||||
signingKey = backupStore.signingKey,
|
||||
contactWebsite = backupStore.contactWebsite,
|
||||
contactDiscord = backupStore.contactDiscord,
|
||||
isLegacy = backupStore.isLegacy ?: true,
|
||||
extensionListUrl = backupStore.extensionListUrl,
|
||||
extensionStoreRepository.upsert(
|
||||
ExtensionStore(
|
||||
indexUrl = backupStore.indexUrl,
|
||||
name = backupStore.name,
|
||||
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
|
||||
signingKey = backupStore.signingKey,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user