Add feature to clear database manga by source (#6241)

* Implement feature to selectively clear manga from database based on it's source

* Code cleanup and refactoring
This commit is contained in:
Hunter Nickel
2021-11-19 09:24:46 -07:00
committed by GitHub
parent 98822a39d9
commit 9fe1a7e2ae
11 changed files with 426 additions and 27 deletions

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.data.database.models
data class SourceIdMangaCount(val source: Long, val count: Int)

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.Queries
import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetListOfObjects
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
@ -7,6 +8,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.SourceIdMangaCount
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaCoverLastModifiedPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver
@ -14,6 +16,7 @@ import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaNextUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver
import eu.kanade.tachiyomi.data.database.resolvers.SourceIdMangaCountGetResolver
import eu.kanade.tachiyomi.data.database.tables.CategoryTable
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
@ -70,6 +73,17 @@ interface MangaQueries : DbProvider {
)
.prepare()
fun getSourceIdsWithNonLibraryManga() = db.get()
.listOfObjects(SourceIdMangaCount::class.java)
.withQuery(
RawQuery.builder()
.query(getSourceIdsWithNonLibraryMangaQuery())
.observesTables(MangaTable.TABLE)
.build()
)
.withGetResolver(SourceIdMangaCountGetResolver.INSTANCE)
.prepare()
fun insertManga(manga: Manga) = db.put().`object`(manga).prepare()
fun insertMangas(mangas: List<Manga>) = db.put().objects(mangas).prepare()
@ -123,12 +137,12 @@ interface MangaQueries : DbProvider {
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
fun deleteMangasNotInLibrary() = db.delete()
fun deleteMangasNotInLibraryBySourceIds(sourceIds: List<Long>) = db.delete()
.byQuery(
DeleteQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_FAVORITE} = ?")
.whereArgs(0)
.where("${MangaTable.COL_FAVORITE} = ? AND ${MangaTable.COL_SOURCE} IN (${Queries.placeholders(sourceIds.size)})")
.whereArgs(0, *sourceIds.toTypedArray())
.build()
)
.prepare()

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.database.queries
import eu.kanade.tachiyomi.data.database.resolvers.SourceIdMangaCountGetResolver
import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category
import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter
import eu.kanade.tachiyomi.data.database.tables.HistoryTable as History
@ -142,3 +143,14 @@ fun getCategoriesForMangaQuery() =
${MangaCategory.TABLE}.${MangaCategory.COL_CATEGORY_ID}
WHERE ${MangaCategory.COL_MANGA_ID} = ?
"""
/** Query to get the list of sources in the database that have
* non-library manga, and how many
*/
fun getSourceIdsWithNonLibraryMangaQuery() =
"""
SELECT ${Manga.COL_SOURCE}, COUNT(*) as ${SourceIdMangaCountGetResolver.COL_COUNT}
FROM ${Manga.TABLE}
WHERE ${Manga.COL_FAVORITE} = 0
GROUP BY ${Manga.COL_SOURCE}
"""

View File

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.annotation.SuppressLint
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import eu.kanade.tachiyomi.data.database.models.SourceIdMangaCount
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class SourceIdMangaCountGetResolver : DefaultGetResolver<SourceIdMangaCount>() {
companion object {
val INSTANCE = SourceIdMangaCountGetResolver()
const val COL_COUNT = "manga_count"
}
@SuppressLint("Range")
override fun mapFromCursor(cursor: Cursor): SourceIdMangaCount {
val sourceID = cursor.getLong(cursor.getColumnIndex(MangaTable.COL_SOURCE))
val count = cursor.getInt(cursor.getColumnIndex(COL_COUNT))
return SourceIdMangaCount(sourceID, count)
}
}

View File

@ -1,10 +1,8 @@
package eu.kanade.tachiyomi.ui.setting
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import androidx.core.net.toUri
import androidx.preference.PreferenceScreen
@ -20,8 +18,9 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PREF_DOH_ADGUARD
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.setting.database.ClearDatabaseController
import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext
@ -143,9 +142,7 @@ class SettingsAdvancedController : SettingsController() {
summaryRes = R.string.pref_clear_database_summary
onClick {
val ctrl = ClearDatabaseDialogController()
ctrl.targetController = this@SettingsAdvancedController
ctrl.showDialog(router)
router.pushController(ClearDatabaseController().withFadeTransaction())
}
}
}
@ -278,24 +275,6 @@ class SettingsAdvancedController : SettingsController() {
}
}
}
class ClearDatabaseDialogController : DialogController() {
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.clear_database_confirmation)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? SettingsAdvancedController)?.clearDatabase()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
}
private fun clearDatabase() {
db.deleteMangasNotInLibrary().executeAsBlocking()
db.deleteHistoryNoLastRead().executeAsBlocking()
activity?.toast(R.string.clear_database_completed)
}
}
private const val CLEAR_CACHE_KEY = "pref_clear_cache_key"

View File

@ -0,0 +1,172 @@
package eu.kanade.tachiyomi.ui.setting.database
import android.annotation.SuppressLint
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.forEach
import androidx.core.view.get
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.Payload
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ClearDatabaseControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.FabController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.util.system.toast
class ClearDatabaseController :
NucleusController<ClearDatabaseControllerBinding, ClearDatabasePresenter>(),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnUpdateListener,
FabController {
private var recycler: RecyclerView? = null
private var adapter: FlexibleAdapter<ClearDatabaseSourceItem>? = null
private var menu: Menu? = null
private var actionFab: ExtendedFloatingActionButton? = null
private var actionFabScrollListener: RecyclerView.OnScrollListener? = null
init {
setHasOptionsMenu(true)
}
override fun createBinding(inflater: LayoutInflater): ClearDatabaseControllerBinding {
return ClearDatabaseControllerBinding.inflate(inflater)
}
override fun createPresenter(): ClearDatabasePresenter {
return ClearDatabasePresenter()
}
override fun getTitle(): String? {
return activity?.getString(R.string.pref_clear_database)
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = FlexibleAdapter<ClearDatabaseSourceItem>(null, this, true)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(activity)
binding.recycler.setHasFixedSize(true)
adapter?.fastScroller = binding.fastScroller
recycler = binding.recycler
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.generic_selection, menu)
this.menu = menu
menu.forEach { menuItem -> menuItem.isVisible = (adapter?.itemCount ?: 0) > 0 }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val adapter = adapter ?: return false
when (item.itemId) {
R.id.action_select_all -> adapter.selectAll()
R.id.action_select_inverse -> {
val currentSelection = adapter.selectedPositionsAsSet
val invertedSelection = (0..adapter.itemCount)
.filterNot { currentSelection.contains(it) }
currentSelection.clear()
currentSelection.addAll(invertedSelection)
}
}
updateFab()
adapter.notifyItemRangeChanged(0, adapter.itemCount, Payload.SELECTION)
return super.onOptionsItemSelected(item)
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
binding.emptyView.hide()
} else {
binding.emptyView.show(activity!!.getString(R.string.database_clean))
}
menu?.forEach { menuItem -> menuItem.isVisible = size > 0 }
}
override fun onItemClick(view: View?, position: Int): Boolean {
val adapter = adapter ?: return false
adapter.toggleSelection(position)
adapter.notifyItemChanged(position, Payload.SELECTION)
updateFab()
return true
}
fun setItems(items: List<ClearDatabaseSourceItem>) {
adapter?.updateDataSet(items)
}
override fun configureFab(fab: ExtendedFloatingActionButton) {
fab.setIconResource(R.drawable.ic_delete_24dp)
fab.setText(R.string.action_delete)
fab.isVisible = false
fab.setOnClickListener {
val ctrl = ClearDatabaseSourcesDialog()
ctrl.targetController = this
ctrl.showDialog(router)
}
actionFab = fab
}
private fun updateFab() {
val adapter = adapter ?: return
actionFab?.isVisible = adapter.selectedItemCount > 0
}
override fun cleanupFab(fab: ExtendedFloatingActionButton) {
actionFab?.setOnClickListener(null)
actionFabScrollListener?.let { recycler?.removeOnScrollListener(it) }
actionFab = null
}
class ClearDatabaseSourcesDialog : DialogController() {
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.clear_database_confirmation)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? ClearDatabaseController)?.clearDatabaseForSelectedSources()
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun clearDatabaseForSelectedSources() {
val adapter = adapter ?: return
val selectedSourceIds = adapter.selectedPositions.mapNotNull { position ->
adapter.getItem(position)?.source?.id
}
presenter.clearDatabaseForSourceIds(selectedSourceIds)
actionFab!!.isVisible = false
adapter.clearSelection()
adapter.notifyDataSetChanged()
activity?.toast(R.string.clear_database_completed)
}
}

View File

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.ui.setting.database
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.Observable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ClearDatabasePresenter : BasePresenter<ClearDatabaseController>() {
private val db = Injekt.get<DatabaseHelper>()
private val sourceManager = Injekt.get<SourceManager>()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
getDatabaseSourcesObservable()
.subscribeOn(Schedulers.io())
.subscribeLatestCache(ClearDatabaseController::setItems)
}
fun clearDatabaseForSourceIds(sources: List<Long>) {
db.deleteMangasNotInLibraryBySourceIds(sources).executeAsBlocking()
db.deleteHistoryNoLastRead().executeAsBlocking()
}
private fun getDatabaseSourcesObservable(): Observable<List<ClearDatabaseSourceItem>> {
return db.getSourceIdsWithNonLibraryManga().asRxObservable()
.map { sourceCounts ->
sourceCounts.map {
val sourceObj = sourceManager.getOrStub(it.source)
ClearDatabaseSourceItem(sourceObj, it.count)
}.sortedBy { it.source.name }
}
}
}

View File

@ -0,0 +1,55 @@
package eu.kanade.tachiyomi.ui.setting.database
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.ClearDatabaseSourceItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.icon
data class ClearDatabaseSourceItem(val source: Source, private val mangaCount: Int) : AbstractFlexibleItem<ClearDatabaseSourceItem.Holder>() {
override fun getLayoutRes(): Int {
return R.layout.clear_database_source_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>?, holder: Holder?, position: Int, payloads: MutableList<Any>?) {
if (payloads.isNullOrEmpty()) {
holder?.bind(source, mangaCount)
} else {
holder?.updateCheckbox()
}
}
class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) {
private val binding = ClearDatabaseSourceItemBinding.bind(view)
fun bind(source: Source, count: Int) {
binding.title.text = source.toString()
binding.description.text = itemView.context.getString(R.string.clear_database_source_item_count, count)
itemView.post {
when {
source.id == LocalSource.ID -> binding.thumbnail.setImageResource(R.mipmap.ic_local_source)
source is SourceManager.StubSource -> binding.thumbnail.setImageDrawable(null)
source.icon() != null -> binding.thumbnail.setImageDrawable(source.icon())
}
}
}
fun updateCheckbox() {
binding.checkbox.isChecked = (bindingAdapter as FlexibleAdapter<*>).isSelected(bindingAdapterPosition)
}
}
}