diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5ff32b61..202aca121 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt index f6dd5c758..f8bd66e1a 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt @@ -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 { - return database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(mangaId) - .awaitAsList() - .toSet() + return mangaRepository.getExcludedScanlators(mangaId) } fun subscribe(mangaId: Long): Flow> { - return database.excluded_scanlatorsQueries - .getExcludedScanlatorsByMangaId(mangaId) - .subscribeToList() - .map { it.toSet() } + return mangaRepository.getExcludedScanlatorsAsFlow(mangaId) } } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt index 37b2778f2..d11a95bb2 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt @@ -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) { - 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) } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt index e715f2991..3c15bc17e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt @@ -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()) 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 -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index c8062a812..a01e92123 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -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::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(), ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt index 9afc12145..20b7d2f0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt @@ -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(), +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt index fe3256bac..428958a5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupManga.kt @@ -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(), ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt index 56108e68e..67dc92b57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt @@ -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, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt index a4d0ca231..5de4c5eb9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -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), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt index 16d747e8b..acbdbc94d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt index 7b895b159..845ee0f21 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/ExtensionStoreRestorer.kt @@ -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, + ), ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 0f891bfcc..085b49e8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -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): List { - 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, backupCategories: List, ) { - 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) { - 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) { - 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) { - 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, - categories: List, - backupCategories: List, - history: List, - tracks: List, - excludedScanlators: List, - ): 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, - backupCategories: List, - ) { - 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) { - 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) { - 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) { - 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) } - } } diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 219bec558..9274a6580 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -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 { diff --git a/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt b/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt index d7d17b164..1fc7998f3 100644 --- a/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt +++ b/core/common/src/main/kotlin/mihon/core/common/extensions/JsonObject.kt @@ -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(decodeToString()) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 632bfbf73..b575beb4f 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -42,5 +42,6 @@ dependencies { implementation(libs.kotlinx.datetime) - api(libs.bundles.sqldelight) + implementation(libs.androidx.sqlite.bundled) + implementation(libs.bundles.sqldelight) } diff --git a/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt b/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt index 25e50bbd5..4f63f4132 100644 --- a/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt +++ b/data/src/main/java/mihon/data/extension/repository/ExtensionStoreRepositoryImpl.kt @@ -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, diff --git a/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt b/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt index c45b8dccb..33b495fe6 100644 --- a/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt +++ b/data/src/main/java/tachiyomi/data/DatabaseAdapter.kt @@ -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 { @@ -31,11 +32,6 @@ object UpdateStrategyColumnAdapter : ColumnAdapter { } object MemoColumnAdapter : ColumnAdapter { - override fun decode(databaseValue: ByteArray): JsonObject { - return Json.decodeFromString(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() } diff --git a/data/src/main/java/tachiyomi/data/DatabaseBindings.kt b/data/src/main/java/tachiyomi/data/DatabaseBindings.kt new file mode 100644 index 000000000..d9151c5d2 --- /dev/null +++ b/data/src/main/java/tachiyomi/data/DatabaseBindings.kt @@ -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, + ), + ) + } +} diff --git a/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt new file mode 100644 index 000000000..a2c3342ad --- /dev/null +++ b/data/src/main/java/tachiyomi/data/backup/RestoreRepositoryImpl.kt @@ -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> { + return database.mangasQueries + .getAllMangaSourceAndUrl() + .awaitAsList() + .groupBy({ it.source }, { it.url }) + } + + override suspend fun restoreManga(entries: List, 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) { + 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) { + 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) { + 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) { + 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) } + } +} diff --git a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt index 90f2f75f0..729703d54 100644 --- a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt @@ -57,6 +57,12 @@ class CategoryRepositoryImpl( ) } + override suspend fun insertAll(categories: List) { + database.transaction { + categories.forEach { insert(it) } + } + } + override suspend fun updateName(categoryId: Long, name: String) { database.categoriesQueries.updateName(name = name, categoryId = categoryId) } diff --git a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt index fa2471884..4830bbf78 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryRepositoryImpl.kt @@ -74,6 +74,10 @@ class HistoryRepositoryImpl( } } + override suspend fun deleteResetHistory() { + database.historyQueries.removeResettedHistory() + } + override suspend fun upsertHistory(historyUpdate: HistoryUpdate) { try { database.historyQueries.upsert( diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index cd1a2972c..c2d107a50 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -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, keepReadManga: Boolean) { + database.mangasQueries.deleteNonLibraryManga(sourceIds, keepReadManga.toLong()) + } + override suspend fun setMangaCategories(mangaId: Long, categoryIds: List) { database.transaction { database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId) @@ -132,6 +138,33 @@ class MangaRepositoryImpl( } } + override suspend fun getExcludedScanlators(mangaId: Long): Set { + return database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(mangaId) + .awaitAsList() + .toSet() + } + + override fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow> { + return database.excluded_scanlatorsQueries + .getExcludedScanlatorsByMangaId(mangaId) + .subscribeToList() + .map { it.toSet() } + } + + override suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set) { + 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) diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 6325e8144..005fe14b0 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -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) diff --git a/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt b/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt index 3ab477081..eff7c8175 100644 --- a/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt +++ b/domain/src/main/java/mihon/domain/extension/repository/ExtensionStoreRepository.kt @@ -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 diff --git a/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt b/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt new file mode 100644 index 000000000..8e9cdac1e --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/backup/model/RestoredManga.kt @@ -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, + val categoryIds: List, + val history: List, + val tracks: List, + val excludedScanlators: List, +) + +data class RestoredHistory( + val chapterUrl: String, + val readAt: Date?, + val readDuration: Long, +) diff --git a/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt b/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt new file mode 100644 index 000000000..e442918b4 --- /dev/null +++ b/domain/src/main/java/tachiyomi/domain/backup/repository/RestoreRepository.kt @@ -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> + + /** + * 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, update: suspend (Manga) -> MangaUpdate) +} diff --git a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt index f2663d549..1ad636427 100644 --- a/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/category/repository/CategoryRepository.kt @@ -17,6 +17,8 @@ interface CategoryRepository { suspend fun insert(category: Category) + suspend fun insertAll(categories: List) + suspend fun updateName(categoryId: Long, name: String) suspend fun updateFlags(categoryId: Long, flags: Long) diff --git a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt index 410bfe951..c4cb27fc2 100644 --- a/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/history/repository/HistoryRepository.kt @@ -21,5 +21,7 @@ interface HistoryRepository { suspend fun deleteAllHistory(): Boolean + suspend fun deleteResetHistory() + suspend fun upsertHistory(historyUpdate: HistoryUpdate) } diff --git a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt index 14cbfd249..29be510d4 100644 --- a/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt +++ b/domain/src/main/java/tachiyomi/domain/manga/repository/MangaRepository.kt @@ -37,8 +37,16 @@ interface MangaRepository { suspend fun resetViewerFlags(): Boolean + suspend fun deleteNonLibraryManga(sourceIds: List, keepReadManga: Boolean) + suspend fun setMangaCategories(mangaId: Long, categoryIds: List) + suspend fun getExcludedScanlators(mangaId: Long): Set + + fun getExcludedScanlatorsAsFlow(mangaId: Long): Flow> + + suspend fun setExcludedScanlators(mangaId: Long, excludedScanlators: Set) + suspend fun update(update: MangaUpdate): Boolean suspend fun updateRemote(update: MangaRemoteUpdate): Boolean