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) .withGetResolver(LibraryMangaGetResolver.INSTANCE)
.prepare() .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() fun getFavoriteMangas() = db.get()
.listOfObjects(Manga::class.java) .listOfObjects(Manga::class.java)
.withQuery( .withQuery(

View file

@ -1367,6 +1367,8 @@ class MangaDetailsController :
presenter.preferences, presenter.preferences,
view, view,
activity, activity,
presenter.sourceManager,
this,
onMangaAdded = { onMangaAdded = {
updateHeader() updateHeader()
showAddedSnack() 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.data.track.TrackService
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.SourceNotFoundException import eu.kanade.tachiyomi.source.SourceNotFoundException
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toSChapter import eu.kanade.tachiyomi.source.model.toSChapter
@ -82,6 +83,7 @@ class MangaDetailsPresenter(
private val customMangaManager: CustomMangaManager by injectLazy() private val customMangaManager: CustomMangaManager by injectLazy()
private val mangaShortcutManager: MangaShortcutManager by injectLazy() private val mangaShortcutManager: MangaShortcutManager by injectLazy()
val sourceManager: SourceManager by injectLazy()
private val chapterSort = ChapterSort(manga, chapterFilter, preferences) private val chapterSort = ChapterSort(manga, chapterFilter, preferences)
val extension by lazy { (source as? HttpSource)?.getExtension() } val extension by lazy { (source as? HttpSource)?.getExtension() }

View file

@ -61,7 +61,8 @@ class MigrationProcessAdapter(
} && items.any { it.manga.migrationStatus == MigrationStatus.MANGA_FOUND } } && 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) { suspend fun performMigrations(copy: Boolean) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -70,7 +71,8 @@ class MigrationProcessAdapter(
val manga = migratingManga.manga val manga = migratingManga.manga
if (manga.searchResult.initialized) { if (manga.searchResult.initialized) {
val toMangaObj = val toMangaObj =
db.getManga(manga.searchResult.get() ?: return@forEach).executeAsBlocking() db.getManga(manga.searchResult.get() ?: return@forEach)
.executeAsBlocking()
?: return@forEach ?: return@forEach
val prevManga = manga.manga() ?: return@forEach val prevManga = manga.manga() ?: return@forEach
val source = sourceManager.get(toMangaObj.source) ?: return@forEach val source = sourceManager.get(toMangaObj.source) ?: return@forEach
@ -128,6 +130,21 @@ class MigrationProcessAdapter(
) { ) {
if (controller.config == null) return if (controller.config == null) return
val flags = preferences.migrateFlags().get() val flags = preferences.migrateFlags().get()
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 // Update chapters read
if (MigrationFlags.hasChapters(flags)) { if (MigrationFlags.hasChapters(flags)) {
val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking() val prevMangaChapters = db.getChapters(prevManga).executeAsBlocking()
@ -144,8 +161,10 @@ class MigrationProcessAdapter(
chapter.bookmark = prevChapter.bookmark chapter.bookmark = prevChapter.bookmark
chapter.read = prevChapter.read chapter.read = prevChapter.read
chapter.date_fetch = prevChapter.date_fetch chapter.date_fetch = prevChapter.date_fetch
prevHistoryList.find { it.chapter_id == prevChapter.id }?.let { prevHistory -> prevHistoryList.find { it.chapter_id == prevChapter.id }
val history = History.create(chapter).apply { last_read = prevHistory.last_read } ?.let { prevHistory ->
val history = History.create(chapter)
.apply { last_read = prevHistory.last_read }
historyList.add(history) historyList.add(history)
} }
} else if (chapter.chapter_number <= maxChapterRead) { } else if (chapter.chapter_number <= maxChapterRead) {
@ -164,7 +183,8 @@ class MigrationProcessAdapter(
} }
// Update track // Update track
if (MigrationFlags.hasTracks(flags)) { if (MigrationFlags.hasTracks(flags)) {
val tracksToUpdate = db.getTracks(prevManga).executeAsBlocking().mapNotNull { track -> val tracksToUpdate =
db.getTracks(prevManga).executeAsBlocking().mapNotNull { track ->
track.id = null track.id = null
track.manga_id = manga.id!! track.manga_id = manga.id!!
@ -187,4 +207,5 @@ class MigrationProcessAdapter(
db.updateMangaAdded(manga).executeAsBlocking() db.updateMangaAdded(manga).executeAsBlocking()
db.updateMangaTitle(manga).executeAsBlocking() db.updateMangaTitle(manga).executeAsBlocking()
} }
}
} }

View file

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

View file

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

View file

@ -123,7 +123,21 @@ open class GlobalSearchController(
preferences, preferences,
view, view,
activity, 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) adapter.notifyItemChanged(position)
snack = view.snack(R.string.added_to_library) 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. * Called from the presenter when a manga is initialized.
* *

View file

@ -1,7 +1,11 @@
package eu.kanade.tachiyomi.util package eu.kanade.tachiyomi.util
import android.app.Activity import android.app.Activity
import android.content.DialogInterface
import android.view.View 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.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.R 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.LocalSource
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.category.addtolibrary.SetCategoriesSheet 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.chapter.syncChaptersWithTrackServiceTwoWay
import eu.kanade.tachiyomi.util.lang.asButton
import eu.kanade.tachiyomi.util.system.launchIO 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.system.withUIContext
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.util.view.withFadeTransaction
import eu.kanade.tachiyomi.widget.TriStateCheckBox import eu.kanade.tachiyomi.widget.TriStateCheckBox
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -104,11 +116,46 @@ fun Manga.addOrRemoveToFavorites(
preferences: PreferencesHelper, preferences: PreferencesHelper,
view: View, view: View,
activity: Activity, activity: Activity,
onMangaAdded: () -> Unit, sourceManager: SourceManager,
controller: Controller,
checkForDupes: Boolean = true,
onMangaAdded: (Pair<Long, Boolean>?) -> Unit,
onMangaMoved: () -> Unit, onMangaMoved: () -> Unit,
onMangaDeleted: () -> Unit onMangaDeleted: () -> Unit,
): Snackbar? { ): Snackbar? {
if (!favorite) { 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 categories = db.getCategories().executeAsBlocking()
val defaultCategoryId = preferences.defaultCategory() val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId } val defaultCategory = categories.find { it.id == defaultCategoryId }
@ -155,7 +202,7 @@ fun Manga.addOrRemoveToFavorites(
ids, ids,
true true
) { ) {
onMangaAdded() onMangaAdded(null)
autoAddTrack(db, onMangaMoved) autoAddTrack(db, onMangaMoved)
}.show() }.show()
} }
@ -188,6 +235,102 @@ fun Manga.addOrRemoveToFavorites(
return null 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) { fun Manga.autoAddTrack(db: DatabaseHelper, onMangaMoved: () -> Unit) {
val loggedServices = Injekt.get<TrackManager>().services.filter { it.isLogged } val loggedServices = Injekt.get<TrackManager>().services.filter { it.isLogged }
val source = Injekt.get<SourceManager>().getOrStub(this.source) 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.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.SpannedString
import android.text.style.BackgroundColorSpan import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import android.text.style.TextAppearanceSpan
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.StringRes 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.R
import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.getResourceColor
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
@ -111,6 +116,19 @@ fun String.highlightText(highlight: String, @ColorInt color: Int): Spanned {
return wordToSpan 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> { fun String.indexesOf(substr: String, ignoreCase: Boolean = true): List<Int> {
val list = mutableListOf<Int>() val list = mutableListOf<Int>()
if (substr.isBlank()) return list 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 { fun MaterialAlertDialogBuilder.setCustomTitleAndMessage(title: Int, message: String): MaterialAlertDialogBuilder {
return setCustomTitle( return setCustomTitle(
(CustomDialogTitleMessageBinding.inflate(LayoutInflater.from(context))).apply { (CustomDialogTitleMessageBinding.inflate(LayoutInflater.from(context))).apply {
if (title != 0) {
alertTitle.text = context.getString(title) alertTitle.text = context.getString(title)
} else {
alertTitle.isVisible = false
}
this.message.text = message this.message.text = message
}.root }.root
) )

View file

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

View file

@ -504,6 +504,9 @@
<string name="added_to_library">Added to library</string> <string name="added_to_library">Added to library</string>
<string name="add_to_library">Add to Library</string> <string name="add_to_library">Add to Library</string>
<string name="removed_from_library">Removed from 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="_copied_to_clipboard">%1$s copied to clipboard</string>
<string name="source_not_installed_">Source not installed: %1$s</string> <string name="source_not_installed_">Source not installed: %1$s</string>
<string name="no_description">No description</string> <string name="no_description">No description</string>