Detect identical mangas when adding to library

Changes from upstream:
Option to migrate/copy from dialog (requires manga to be initialized first)
Dialog shows up on all places you can add manga (browse source and global)

Closes #1207

Co-Authored-By: Felix Kaiser <30923667+foxscore@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2022-04-22 04:30:27 -04:00
parent 54b7288acd
commit 0f50c30ad1
13 changed files with 286 additions and 59 deletions

View file

@ -44,6 +44,21 @@ interface MangaQueries : DbProvider {
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
.prepare()
fun getDuplicateLibraryManga(manga: Manga) = db.get()
.`object`(Manga::class.java)
.withQuery(
Query.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_FAVORITE} = 1 AND LOWER(${MangaTable.COL_TITLE}) = ? AND ${MangaTable.COL_SOURCE} != ?")
.whereArgs(
manga.title.lowercase(),
manga.source,
)
.limit(1)
.build(),
)
.prepare()
fun getFavoriteMangas() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(

View file

@ -1367,6 +1367,8 @@ class MangaDetailsController :
presenter.preferences,
view,
activity,
presenter.sourceManager,
this,
onMangaAdded = {
updateHeader()
showAddedSnack()

View file

@ -32,6 +32,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.SourceNotFoundException
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toSChapter
@ -82,6 +83,7 @@ class MangaDetailsPresenter(
private val customMangaManager: CustomMangaManager by injectLazy()
private val mangaShortcutManager: MangaShortcutManager by injectLazy()
val sourceManager: SourceManager by injectLazy()
private val chapterSort = ChapterSort(manga, chapterFilter, preferences)
val extension by lazy { (source as? HttpSource)?.getExtension() }

View file

@ -61,7 +61,8 @@ class MigrationProcessAdapter(
} && items.any { it.manga.migrationStatus == MigrationStatus.MANGA_FOUND }
)
fun mangasSkipped() = (items.count { it.manga.migrationStatus == MigrationStatus.MANGA_NOT_FOUND })
fun mangasSkipped() =
(items.count { it.manga.migrationStatus == MigrationStatus.MANGA_NOT_FOUND })
suspend fun performMigrations(copy: Boolean) {
withContext(Dispatchers.IO) {
@ -70,7 +71,8 @@ class MigrationProcessAdapter(
val manga = migratingManga.manga
if (manga.searchResult.initialized) {
val toMangaObj =
db.getManga(manga.searchResult.get() ?: return@forEach).executeAsBlocking()
db.getManga(manga.searchResult.get() ?: return@forEach)
.executeAsBlocking()
?: return@forEach
val prevManga = manga.manga() ?: return@forEach
val source = sourceManager.get(toMangaObj.source) ?: return@forEach
@ -128,63 +130,82 @@ class MigrationProcessAdapter(
) {
if (controller.config == null) return
val flags = preferences.migrateFlags().get()
// Update chapters read
if (MigrationFlags.hasChapters(flags)) {
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
val maxChapterRead =
prevMangaChapters.filter { it.read }.maxOfOrNull { it.chapter_number } ?: 0f
val dbChapters = db.getChapters(manga).executeAsBlocking()
val prevHistoryList = db.getHistoryByMangaId(prevManga.id!!).executeAsBlocking()
val historyList = mutableListOf<History>()
for (chapter in dbChapters) {
if (chapter.isRecognizedNumber) {
val prevChapter =
prevMangaChapters.find { it.isRecognizedNumber && it.chapter_number == chapter.chapter_number }
if (prevChapter != null) {
chapter.bookmark = prevChapter.bookmark
chapter.read = prevChapter.read
chapter.date_fetch = prevChapter.date_fetch
prevHistoryList.find { it.chapter_id == prevChapter.id }?.let { prevHistory ->
val history = History.create(chapter).apply { last_read = prevHistory.last_read }
historyList.add(history)
migrateMangaInternal(flags, db, enhancedServices, prevSource, source, prevManga, manga, replace)
}
companion object {
fun migrateMangaInternal(
flags: Int,
db: DatabaseHelper,
enhancedServices: List<EnhancedTrackService>,
prevSource: Source?,
source: Source,
prevManga: Manga,
manga: Manga,
replace: Boolean
) {
// Update chapters read
if (MigrationFlags.hasChapters(flags)) {
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
val maxChapterRead =
prevMangaChapters.filter { it.read }.maxOfOrNull { it.chapter_number } ?: 0f
val dbChapters = db.getChapters(manga).executeAsBlocking()
val prevHistoryList = db.getHistoryByMangaId(prevManga.id!!).executeAsBlocking()
val historyList = mutableListOf<History>()
for (chapter in dbChapters) {
if (chapter.isRecognizedNumber) {
val prevChapter =
prevMangaChapters.find { it.isRecognizedNumber && it.chapter_number == chapter.chapter_number }
if (prevChapter != null) {
chapter.bookmark = prevChapter.bookmark
chapter.read = prevChapter.read
chapter.date_fetch = prevChapter.date_fetch
prevHistoryList.find { it.chapter_id == prevChapter.id }
?.let { prevHistory ->
val history = History.create(chapter)
.apply { last_read = prevHistory.last_read }
historyList.add(history)
}
} else if (chapter.chapter_number <= maxChapterRead) {
chapter.read = true
}
} else if (chapter.chapter_number <= maxChapterRead) {
chapter.read = true
}
}
db.insertChapters(dbChapters).executeAsBlocking()
db.updateHistoryLastRead(historyList).executeAsBlocking()
}
db.insertChapters(dbChapters).executeAsBlocking()
db.updateHistoryLastRead(historyList).executeAsBlocking()
}
// Update categories
if (MigrationFlags.hasCategories(flags)) {
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mangaCategories, listOf(manga))
}
// Update track
if (MigrationFlags.hasTracks(flags)) {
val tracksToUpdate = db.getTracks(prevManga).executeAsBlocking().mapNotNull { track ->
track.id = null
track.manga_id = manga.id!!
// Update categories
if (MigrationFlags.hasCategories(flags)) {
val categories = db.getCategoriesForManga(prevManga).executeAsBlocking()
val mangaCategories = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mangaCategories, listOf(manga))
}
// Update track
if (MigrationFlags.hasTracks(flags)) {
val tracksToUpdate =
db.getTracks(prevManga).executeAsBlocking().mapNotNull { track ->
track.id = null
track.manga_id = manga.id!!
val service = enhancedServices
.firstOrNull { it.isTrackFrom(track, prevManga, prevSource) }
if (service != null) service.migrateTrack(track, manga, source)
else track
val service = enhancedServices
.firstOrNull { it.isTrackFrom(track, prevManga, prevSource) }
if (service != null) service.migrateTrack(track, manga, source)
else track
}
db.insertTracks(tracksToUpdate).executeAsBlocking()
}
db.insertTracks(tracksToUpdate).executeAsBlocking()
// Update favorite status
if (replace) {
prevManga.favorite = false
db.updateMangaFavorite(prevManga).executeAsBlocking()
}
manga.favorite = true
if (replace) manga.date_added = prevManga.date_added
else manga.date_added = Date().time
db.updateMangaFavorite(manga).executeAsBlocking()
db.updateMangaAdded(manga).executeAsBlocking()
db.updateMangaTitle(manga).executeAsBlocking()
}
// Update favorite status
if (replace) {
prevManga.favorite = false
db.updateMangaFavorite(prevManga).executeAsBlocking()
}
manga.favorite = true
if (replace) manga.date_added = prevManga.date_added
else manga.date_added = Date().time
db.updateMangaFavorite(manga).executeAsBlocking()
db.updateMangaAdded(manga).executeAsBlocking()
db.updateMangaTitle(manga).executeAsBlocking()
}
}

View file

@ -700,6 +700,8 @@ open class BrowseSourceController(bundle: Bundle) :
preferences,
view,
activity,
presenter.sourceManager,
this,
onMangaAdded = {
adapter?.notifyItemChanged(position)
snack = view.snack(R.string.added_to_library)

View file

@ -53,7 +53,7 @@ import uy.kohesive.injekt.api.get
open class BrowseSourcePresenter(
private val sourceId: Long,
searchQuery: String? = null,
private val sourceManager: SourceManager = Injekt.get(),
val sourceManager: SourceManager = Injekt.get(),
val db: DatabaseHelper = Injekt.get(),
val prefs: PreferencesHelper = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),

View file

@ -123,7 +123,21 @@ open class GlobalSearchController(
preferences,
view,
activity,
onMangaAdded = {
presenter.sourceManager,
this,
onMangaAdded = { migrationInfo ->
migrationInfo?.let { (source, stillFaved) ->
val index = this.adapter
?.currentItems?.indexOfFirst { it.source.id == source } ?: return@let
val item = this.adapter?.getItem(index) ?: return@let
val oldMangaIndex = item.results?.indexOfFirst {
it.manga.title.lowercase() == manga.title.lowercase()
} ?: return@let
val oldMangaItem = item.results.getOrNull(oldMangaIndex)
oldMangaItem?.manga?.favorite = stillFaved
val holder = binding.recycler.findViewHolderForAdapterPosition(index) as? GlobalSearchHolder
holder?.updateManga(oldMangaIndex)
}
adapter.notifyItemChanged(position)
snack = view.snack(R.string.added_to_library)
},

View file

@ -81,6 +81,8 @@ class GlobalSearchHolder(view: View, val adapter: GlobalSearchAdapter) :
}
}
fun updateManga(position: Int) = mangaAdapter.notifyItemChanged(position)
/**
* Called from the presenter when a manga is initialized.
*

View file

@ -1,7 +1,11 @@
package eu.kanade.tachiyomi.util
import android.app.Activity
import android.content.DialogInterface
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.bluelinelabs.conductor.Controller
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.R
@ -16,10 +20,18 @@ import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.category.addtolibrary.SetCategoriesSheet
import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
import eu.kanade.tachiyomi.ui.migration.MigrationFlags
import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcessAdapter
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
import eu.kanade.tachiyomi.util.lang.asButton
import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.materialAlertDialog
import eu.kanade.tachiyomi.util.system.setCustomTitleAndMessage
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.system.withUIContext
import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.TriStateCheckBox
import timber.log.Timber
import uy.kohesive.injekt.Injekt
@ -104,11 +116,46 @@ fun Manga.addOrRemoveToFavorites(
preferences: PreferencesHelper,
view: View,
activity: Activity,
onMangaAdded: () -> Unit,
sourceManager: SourceManager,
controller: Controller,
checkForDupes: Boolean = true,
onMangaAdded: (Pair<Long, Boolean>?) -> Unit,
onMangaMoved: () -> Unit,
onMangaDeleted: () -> Unit
onMangaDeleted: () -> Unit,
): Snackbar? {
if (!favorite) {
if (checkForDupes) {
val duplicateManga = db.getDuplicateLibraryManga(this).executeAsBlocking()
if (duplicateManga != null) {
showAddDuplicateDialog(
this,
duplicateManga,
activity,
db,
sourceManager,
controller,
addManga = {
addOrRemoveToFavorites(
db,
preferences,
view,
activity,
sourceManager,
controller,
false,
onMangaAdded,
onMangaMoved,
onMangaDeleted
)
},
migrateManga = { source, faved ->
onMangaAdded(source to faved)
}
)
return null
}
}
val categories = db.getCategories().executeAsBlocking()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
@ -155,7 +202,7 @@ fun Manga.addOrRemoveToFavorites(
ids,
true
) {
onMangaAdded()
onMangaAdded(null)
autoAddTrack(db, onMangaMoved)
}.show()
}
@ -188,6 +235,102 @@ fun Manga.addOrRemoveToFavorites(
return null
}
private fun showAddDuplicateDialog(
newManga: Manga,
libraryManga: Manga,
activity: Activity,
db: DatabaseHelper,
sourceManager: SourceManager,
controller: Controller,
addManga: () -> Unit,
migrateManga: (Long, Boolean) -> Unit,
) {
val source = sourceManager.getOrStub(libraryManga.source)
fun migrateManga(mDialog: DialogInterface, replace: Boolean) {
val listView = (mDialog as AlertDialog).listView
var flags = 0
if (listView.isItemChecked(0)) flags = flags or MigrationFlags.CHAPTERS
if (listView.isItemChecked(1)) flags = flags or MigrationFlags.CATEGORIES
if (listView.isItemChecked(2)) flags = flags or MigrationFlags.TRACK
val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
MigrationProcessAdapter.migrateMangaInternal(
flags,
db,
enhancedServices,
source,
sourceManager.getOrStub(newManga.source),
libraryManga,
newManga,
replace
)
migrateManga(libraryManga.source, !replace)
}
activity.materialAlertDialog().apply {
setCustomTitleAndMessage(0, activity.getString(R.string.confirm_manga_add_duplicate, source.name))
setItems(
arrayOf(
activity.getString(R.string.show_, libraryManga.seriesType(activity, sourceManager)).asButton(activity),
activity.getString(R.string.add_to_library).asButton(activity),
activity.getString(R.string.migrate).asButton(activity, !newManga.initialized),
)
) { dialog, i ->
when (i) {
0 -> controller.router.pushController(
MangaDetailsController(libraryManga)
.withFadeTransaction()
)
1 -> addManga()
2 -> {
if (!newManga.initialized) {
activity.toast(R.string.must_view_details_before_migration, Toast.LENGTH_LONG)
return@setItems
}
activity.materialAlertDialog().apply {
setTitle(R.string.migration)
setMultiChoiceItems(
arrayOf(
activity.getString(R.string.chapters),
activity.getString(R.string.categories),
activity.getString(R.string.tracking),
),
booleanArrayOf(true, true, true), null
)
setPositiveButton(R.string.migrate) { mDialog, _ ->
migrateManga(mDialog, true)
}
setNegativeButton(R.string.copy) { mDialog, _ ->
migrateManga(mDialog, false)
}
setNeutralButton(android.R.string.cancel, null)
setCancelable(true)
}.show()
}
else -> {}
}
dialog.dismiss()
}
setNegativeButton(activity.getString(android.R.string.cancel)) { _, _ -> }
setCancelable(true)
}.create().apply {
setOnShowListener {
if (!newManga.initialized) {
val listView = (it as AlertDialog).listView
val view = listView.getChildAt(2)
view?.setOnClickListener {
if (!newManga.initialized) {
activity.toast(
R.string.must_view_details_before_migration,
Toast.LENGTH_LONG
)
}
}
}
}
}.show()
}
fun Manga.autoAddTrack(db: DatabaseHelper, onMangaMoved: () -> Unit) {
val loggedServices = Injekt.get<TrackManager>().services.filter { it.isLogged }
val source = Injekt.get<SourceManager>().getOrStub(this.source)

View file

@ -6,13 +6,18 @@ import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.SpannedString
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.SuperscriptSpan
import android.text.style.TextAppearanceSpan
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.core.text.inSpans
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
@ -111,6 +116,19 @@ fun String.highlightText(highlight: String, @ColorInt color: Int): Spanned {
return wordToSpan
}
fun String.asButton(context: Context, disabled: Boolean = false): SpannedString {
return buildSpannedString {
val buttonSpan: SpannableStringBuilder.() -> Unit = {
inSpans(
TextAppearanceSpan(context, R.style.TextAppearance_Tachiyomi_Button)
) { append(this@asButton) }
}
if (disabled) {
color(context.getColor(R.color.material_on_surface_disabled), buttonSpan)
} else buttonSpan()
}
}
fun String.indexesOf(substr: String, ignoreCase: Boolean = true): List<Int> {
val list = mutableListOf<Int>()
if (substr.isBlank()) return list

View file

@ -63,7 +63,11 @@ fun AlertDialog.disableItems(items: Array<String>) {
fun MaterialAlertDialogBuilder.setCustomTitleAndMessage(title: Int, message: String): MaterialAlertDialogBuilder {
return setCustomTitle(
(CustomDialogTitleMessageBinding.inflate(LayoutInflater.from(context))).apply {
alertTitle.text = context.getString(title)
if (title != 0) {
alertTitle.text = context.getString(title)
} else {
alertTitle.isVisible = false
}
this.message.text = message
}.root
)

View file

@ -14,6 +14,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@android:id/message"
android:paddingTop="@dimen/abc_dialog_padding_top_material"
android:paddingLeft="?attr/dialogPreferredPadding"
android:paddingRight="?attr/dialogPreferredPadding" />

View file

@ -504,6 +504,9 @@
<string name="added_to_library">Added to library</string>
<string name="add_to_library">Add to Library</string>
<string name="removed_from_library">Removed from library</string>
<string name="show_">Show %1$s</string>
<string name="must_view_details_before_migration">This entry\'s details page must be viewed before being able to migrate</string>
<string name="confirm_manga_add_duplicate">You have an entry in your library with the same name but from a different source (%1$s).\n\nDo you still wish to continue?</string>
<string name="_copied_to_clipboard">%1$s copied to clipboard</string>
<string name="source_not_installed_">Source not installed: %1$s</string>
<string name="no_description">No description</string>