|
@@ -0,0 +1,442 @@
|
|
|
+package eu.kanade.tachiyomi.data.backup.full
|
|
|
+
|
|
|
+import android.content.Context
|
|
|
+import android.net.Uri
|
|
|
+import com.hippo.unifile.UniFile
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
|
|
+import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.Backup
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupChapter
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupFull
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupManga
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupSource
|
|
|
+import eu.kanade.tachiyomi.data.backup.full.models.BackupTracking
|
|
|
+import eu.kanade.tachiyomi.data.backup.models.AbstractBackupManager
|
|
|
+import eu.kanade.tachiyomi.data.database.models.Chapter
|
|
|
+import eu.kanade.tachiyomi.data.database.models.History
|
|
|
+import eu.kanade.tachiyomi.data.database.models.Manga
|
|
|
+import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
|
|
+import eu.kanade.tachiyomi.data.database.models.Track
|
|
|
+import eu.kanade.tachiyomi.source.Source
|
|
|
+import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
|
|
+import kotlinx.serialization.ExperimentalSerializationApi
|
|
|
+import kotlinx.serialization.protobuf.ProtoBuf
|
|
|
+import okio.buffer
|
|
|
+import okio.gzip
|
|
|
+import okio.sink
|
|
|
+import rx.Observable
|
|
|
+import timber.log.Timber
|
|
|
+import kotlin.math.max
|
|
|
+
|
|
|
+@OptIn(ExperimentalSerializationApi::class)
|
|
|
+class FullBackupManager(context: Context) : AbstractBackupManager(context) {
|
|
|
+ /**
|
|
|
+ * Parser
|
|
|
+ */
|
|
|
+ val parser = ProtoBuf
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Create backup Json file from database
|
|
|
+ *
|
|
|
+ * @param uri path of Uri
|
|
|
+ * @param isJob backup called from job
|
|
|
+ */
|
|
|
+ override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? {
|
|
|
+ // Create root object
|
|
|
+ var backup: Backup? = null
|
|
|
+
|
|
|
+ databaseHelper.inTransaction {
|
|
|
+ // Get manga from database
|
|
|
+ val databaseManga = getDatabaseManga()
|
|
|
+
|
|
|
+ backup = Backup(
|
|
|
+ backupManga(databaseManga, flags),
|
|
|
+ backupCategories(),
|
|
|
+ backupExtensionInfo(databaseManga)
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // When BackupCreatorJob
|
|
|
+ if (isJob) {
|
|
|
+ // Get dir of file and create
|
|
|
+ var dir = UniFile.fromUri(context, uri)
|
|
|
+ dir = dir.createDirectory("automatic")
|
|
|
+
|
|
|
+ // Delete older backups
|
|
|
+ val numberOfBackups = numberOfBackups()
|
|
|
+ val backupRegex = Regex("""tachiyomi_full_\d+-\d+-\d+_\d+-\d+.proto.gz""")
|
|
|
+ dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
|
|
+ .orEmpty()
|
|
|
+ .sortedByDescending { it.name }
|
|
|
+ .drop(numberOfBackups - 1)
|
|
|
+ .forEach { it.delete() }
|
|
|
+
|
|
|
+ // Create new file to place backup
|
|
|
+ val newFile = dir.createFile(BackupFull.getDefaultFilename())
|
|
|
+ ?: throw Exception("Couldn't create backup file")
|
|
|
+
|
|
|
+ val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!)
|
|
|
+ newFile.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) }
|
|
|
+
|
|
|
+ return newFile.uri.toString()
|
|
|
+ } else {
|
|
|
+ val file = UniFile.fromUri(context, uri)
|
|
|
+ ?: throw Exception("Couldn't create backup file")
|
|
|
+ val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!)
|
|
|
+ file.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) }
|
|
|
+
|
|
|
+ return file.uri.toString()
|
|
|
+ }
|
|
|
+ } catch (e: Exception) {
|
|
|
+ Timber.e(e)
|
|
|
+ throw e
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun getDatabaseManga() = getFavoriteManga()
|
|
|
+
|
|
|
+ private fun backupManga(mangas: List<Manga>, flags: Int): List<BackupManga> {
|
|
|
+ return mangas.map {
|
|
|
+ backupMangaObject(it, flags)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
|
|
|
+ return mangas
|
|
|
+ .asSequence()
|
|
|
+ .map { it.source }
|
|
|
+ .distinct()
|
|
|
+ .map { sourceManager.getOrStub(it) }
|
|
|
+ .map { BackupSource.copyFrom(it) }
|
|
|
+ .toList()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Backup the categories of library
|
|
|
+ *
|
|
|
+ * @return list of [BackupCategory] to be backed up
|
|
|
+ */
|
|
|
+ private fun backupCategories(): List<BackupCategory> {
|
|
|
+ return databaseHelper.getCategories()
|
|
|
+ .executeAsBlocking()
|
|
|
+ .map { BackupCategory.copyFrom(it) }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Convert a manga to Json
|
|
|
+ *
|
|
|
+ * @param manga manga that gets converted
|
|
|
+ * @param options options for the backup
|
|
|
+ * @return [BackupManga] containing manga in a serializable form
|
|
|
+ */
|
|
|
+ private fun backupMangaObject(manga: Manga, options: Int): BackupManga {
|
|
|
+ // Entry for this manga
|
|
|
+ val mangaObject = BackupManga.copyFrom(manga)
|
|
|
+
|
|
|
+ // Check if user wants chapter information in backup
|
|
|
+ if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
|
|
|
+ // Backup all the chapters
|
|
|
+ val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
|
|
+ if (chapters.isNotEmpty()) {
|
|
|
+ mangaObject.chapters = chapters.map { BackupChapter.copyFrom(it) }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if user wants category information in backup
|
|
|
+ if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
|
|
+ // Backup categories for this manga
|
|
|
+ val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
|
|
|
+ if (categoriesForManga.isNotEmpty()) {
|
|
|
+ mangaObject.categories = categoriesForManga.mapNotNull { it.order }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if user wants track information in backup
|
|
|
+ if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
|
|
|
+ val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
|
|
+ if (tracks.isNotEmpty()) {
|
|
|
+ mangaObject.tracking = tracks.map { BackupTracking.copyFrom(it) }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if user wants history information in backup
|
|
|
+ if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
|
|
|
+ val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
|
|
+ if (historyForManga.isNotEmpty()) {
|
|
|
+ val history = historyForManga.mapNotNull { history ->
|
|
|
+ val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
|
|
|
+ url?.let { BackupHistory(url, history.last_read) }
|
|
|
+ }
|
|
|
+ if (history.isNotEmpty()) {
|
|
|
+ mangaObject.history = history
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return mangaObject
|
|
|
+ }
|
|
|
+
|
|
|
+ fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
|
|
|
+ manga.id = dbManga.id
|
|
|
+ manga.copyFrom(dbManga)
|
|
|
+ insertManga(manga)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * [Observable] that fetches manga information
|
|
|
+ *
|
|
|
+ * @param source source of manga
|
|
|
+ * @param manga manga that needs updating
|
|
|
+ * @return [Observable] that contains manga
|
|
|
+ */
|
|
|
+ fun restoreMangaFetchObservable(source: Source?, manga: Manga, online: Boolean): Observable<Manga> {
|
|
|
+ return if (online && source != null) {
|
|
|
+ source.fetchMangaDetails(manga)
|
|
|
+ .map { networkManga ->
|
|
|
+ manga.copyFrom(networkManga)
|
|
|
+ manga.favorite = manga.favorite
|
|
|
+ manga.initialized = true
|
|
|
+ manga.id = insertManga(manga)
|
|
|
+ manga
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Observable.just(manga)
|
|
|
+ .map {
|
|
|
+ it.initialized = it.description != null
|
|
|
+ it.id = insertManga(it)
|
|
|
+ it
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * [Observable] that fetches chapter information
|
|
|
+ *
|
|
|
+ * @param source source of manga
|
|
|
+ * @param manga manga that needs updating
|
|
|
+ * @param chapters list of chapters in the backup
|
|
|
+ * @return [Observable] that contains manga
|
|
|
+ */
|
|
|
+ fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
|
|
+ return source.fetchChapterList(manga)
|
|
|
+ .map { syncChaptersWithSource(databaseHelper, it, manga, source) }
|
|
|
+ .doOnNext { pair ->
|
|
|
+ if (pair.first.isNotEmpty()) {
|
|
|
+ chapters.forEach { it.manga_id = manga.id }
|
|
|
+ updateChapters(chapters)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Restore the categories from Json
|
|
|
+ *
|
|
|
+ * @param backupCategories list containing categories
|
|
|
+ */
|
|
|
+ internal fun restoreCategories(backupCategories: List<BackupCategory>) {
|
|
|
+ // Get categories from file and from db
|
|
|
+ val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
|
|
+
|
|
|
+ // Iterate over them
|
|
|
+ backupCategories.map { it.getCategoryImpl() }.forEach { category ->
|
|
|
+ // Used to know if the category is already in the db
|
|
|
+ var found = false
|
|
|
+ for (dbCategory in dbCategories) {
|
|
|
+ // If the category is already in the db, assign the id to the file's category
|
|
|
+ // and do nothing
|
|
|
+ if (category.name == dbCategory.name) {
|
|
|
+ category.id = dbCategory.id
|
|
|
+ found = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // If the category isn't in the db, remove the id and insert a new category
|
|
|
+ // Store the inserted id in the category
|
|
|
+ if (!found) {
|
|
|
+ // Let the db assign the id
|
|
|
+ category.id = null
|
|
|
+ val result = databaseHelper.insertCategory(category).executeAsBlocking()
|
|
|
+ category.id = result.insertedId()?.toInt()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Restores the categories a manga is in.
|
|
|
+ *
|
|
|
+ * @param manga the manga whose categories have to be restored.
|
|
|
+ * @param categories the categories to restore.
|
|
|
+ */
|
|
|
+ internal fun restoreCategoriesForManga(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) {
|
|
|
+ val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
|
|
+ val mangaCategoriesToUpdate = mutableListOf<MangaCategory>()
|
|
|
+ categories.forEach { backupCategoryOrder ->
|
|
|
+ backupCategories.firstOrNull {
|
|
|
+ it.order == backupCategoryOrder
|
|
|
+ }?.let { backupCategory ->
|
|
|
+ dbCategories.firstOrNull { dbCategory ->
|
|
|
+ dbCategory.name == backupCategory.name
|
|
|
+ }?.let { dbCategory ->
|
|
|
+ mangaCategoriesToUpdate += MangaCategory.create(manga, dbCategory)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Update database
|
|
|
+ if (mangaCategoriesToUpdate.isNotEmpty()) {
|
|
|
+ databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking()
|
|
|
+ databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Restore history from Json
|
|
|
+ *
|
|
|
+ * @param history list containing history to be restored
|
|
|
+ */
|
|
|
+ internal fun restoreHistoryForManga(history: List<BackupHistory>) {
|
|
|
+ // List containing history to be updated
|
|
|
+ val historyToBeUpdated = mutableListOf<History>()
|
|
|
+ for ((url, lastRead) in history) {
|
|
|
+ val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
|
|
+ // Check if history already in database and update
|
|
|
+ if (dbHistory != null) {
|
|
|
+ dbHistory.apply {
|
|
|
+ last_read = max(lastRead, dbHistory.last_read)
|
|
|
+ }
|
|
|
+ historyToBeUpdated.add(dbHistory)
|
|
|
+ } else {
|
|
|
+ // If not in database create
|
|
|
+ databaseHelper.getChapter(url).executeAsBlocking()?.let {
|
|
|
+ val historyToAdd = History.create(it).apply {
|
|
|
+ last_read = lastRead
|
|
|
+ }
|
|
|
+ historyToBeUpdated.add(historyToAdd)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Restores the sync of a manga.
|
|
|
+ *
|
|
|
+ * @param manga the manga whose sync have to be restored.
|
|
|
+ * @param tracks the track list to restore.
|
|
|
+ */
|
|
|
+ internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
|
|
+ // Fix foreign keys with the current manga id
|
|
|
+ tracks.map { it.manga_id = manga.id!! }
|
|
|
+
|
|
|
+ // Get tracks from database
|
|
|
+ val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
|
|
+ val trackToUpdate = mutableListOf<Track>()
|
|
|
+
|
|
|
+ tracks.forEach { track ->
|
|
|
+ val service = trackManager.getService(track.sync_id)
|
|
|
+ if (service != null && service.isLogged) {
|
|
|
+ var isInDatabase = false
|
|
|
+ for (dbTrack in dbTracks) {
|
|
|
+ if (track.sync_id == dbTrack.sync_id) {
|
|
|
+ // The sync is already in the db, only update its fields
|
|
|
+ if (track.media_id != dbTrack.media_id) {
|
|
|
+ dbTrack.media_id = track.media_id
|
|
|
+ }
|
|
|
+ if (track.library_id != dbTrack.library_id) {
|
|
|
+ dbTrack.library_id = track.library_id
|
|
|
+ }
|
|
|
+ dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
|
|
|
+ isInDatabase = true
|
|
|
+ trackToUpdate.add(dbTrack)
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!isInDatabase) {
|
|
|
+ // Insert new sync. Let the db assign the id
|
|
|
+ track.id = null
|
|
|
+ trackToUpdate.add(track)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Update database
|
|
|
+ if (trackToUpdate.isNotEmpty()) {
|
|
|
+ databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Restore the chapters for manga if chapters already in database
|
|
|
+ *
|
|
|
+ * @param manga manga of chapters
|
|
|
+ * @param chapters list containing chapters that get restored
|
|
|
+ * @return boolean answering if chapter fetch is not needed
|
|
|
+ */
|
|
|
+ internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
|
|
|
+ val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
|
|
+
|
|
|
+ // Return if fetch is needed
|
|
|
+ if (dbChapters.isEmpty() || dbChapters.size < chapters.size) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ chapters.forEach { chapter ->
|
|
|
+ val pos = dbChapters.indexOfFirst { it.url == chapter.url }
|
|
|
+ if (pos != -1) {
|
|
|
+ val dbChapter = dbChapters[pos]
|
|
|
+ chapter.id = dbChapter.id
|
|
|
+ chapter.copyFrom(dbChapter)
|
|
|
+ if (dbChapter.read && !chapter.read) {
|
|
|
+ chapter.read = dbChapter.read
|
|
|
+ chapter.last_page_read = dbChapter.last_page_read
|
|
|
+ } else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
|
|
|
+ chapter.last_page_read = dbChapter.last_page_read
|
|
|
+ }
|
|
|
+ if (!chapter.bookmark && dbChapter.bookmark) {
|
|
|
+ chapter.bookmark = dbChapter.bookmark
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Filter the chapters that couldn't be found.
|
|
|
+ chapters.filter { it.id != null }
|
|
|
+ chapters.map { it.manga_id = manga.id }
|
|
|
+
|
|
|
+ updateChapters(chapters)
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ internal fun restoreChaptersForMangaOffline(manga: Manga, chapters: List<Chapter>) {
|
|
|
+ val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
|
|
+
|
|
|
+ chapters.forEach { chapter ->
|
|
|
+ val pos = dbChapters.indexOfFirst { it.url == chapter.url }
|
|
|
+ if (pos != -1) {
|
|
|
+ val dbChapter = dbChapters[pos]
|
|
|
+ chapter.id = dbChapter.id
|
|
|
+ chapter.copyFrom(dbChapter)
|
|
|
+ if (dbChapter.read && !chapter.read) {
|
|
|
+ chapter.read = dbChapter.read
|
|
|
+ chapter.last_page_read = dbChapter.last_page_read
|
|
|
+ } else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) {
|
|
|
+ chapter.last_page_read = dbChapter.last_page_read
|
|
|
+ }
|
|
|
+ if (!chapter.bookmark && dbChapter.bookmark) {
|
|
|
+ chapter.bookmark = dbChapter.bookmark
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ chapters.map { it.manga_id = manga.id }
|
|
|
+
|
|
|
+ updateChapters(chapters.filter { it.id != null })
|
|
|
+ insertChapters(chapters.filter { it.id == null })
|
|
|
+ }
|
|
|
+}
|