feat(local): Allow delete whole manga's downloaded folder & clear chapters list (#1016)

* feat(local): Allow delete local files

* Refresh chapters list after deleting

* Allow delete whole entry's downloaded folder & clear episodes/chapters list

Also hide `Open folder` for merged entries

* Don't delete local folder & refresh local's chapters list state after clear

* use forEachIndexed instead of forEach

* adding comment

* add empty line
This commit is contained in:
Cuong-Tran 2025-07-21 15:29:11 +07:00 committed by GitHub
parent 32d448d6ab
commit 3ec6aeaba2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 192 additions and 12 deletions

View file

@ -57,17 +57,14 @@ fun DeleteLibraryMangaDialog(
},
text = {
Column {
list.forEach { state ->
list.forEachIndexed { index, state ->
LabeledCheckbox(
label = stringResource(state.value),
checked = state.isChecked,
onCheckedChange = {
val index = list.indexOf(state)
if (index != -1) {
val mutableList = list.toMutableList()
mutableList[index] = state.next() as CheckboxState.State<StringResource>
list = mutableList.toList()
}
},
)
}

View file

@ -201,6 +201,7 @@ fun MangaScreen(
// KMK -->
getMangaState: @Composable (Manga) -> State<Manga>,
onClickSourceSettingsClicked: (() -> Unit)?,
onClearManga: () -> Unit,
onOpenMangaFolder: (() -> Unit)?,
onRelatedMangasScreenClick: () -> Unit,
onRelatedMangaClick: (Manga) -> Unit,
@ -269,6 +270,7 @@ fun MangaScreen(
// KMK -->
getMangaState = getMangaState,
onClickSourceSettingsClicked = onClickSourceSettingsClicked,
onClearManga = onClearManga,
onOpenMangaFolder = onOpenMangaFolder,
onRelatedMangasScreenClick = onRelatedMangasScreenClick,
onRelatedMangaClick = onRelatedMangaClick,
@ -330,6 +332,7 @@ fun MangaScreen(
// KMK -->
getMangaState = getMangaState,
onClickSourceSettingsClicked = onClickSourceSettingsClicked,
onClearManga = onClearManga,
onOpenMangaFolder = onOpenMangaFolder,
onRelatedMangasScreenClick = onRelatedMangasScreenClick,
onRelatedMangaClick = onRelatedMangaClick,
@ -408,6 +411,7 @@ private fun MangaScreenSmallImpl(
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onClickSourceSettingsClicked: (() -> Unit)?,
onClearManga: () -> Unit,
onOpenMangaFolder: (() -> Unit)?,
onRelatedMangasScreenClick: () -> Unit,
onRelatedMangaClick: (Manga) -> Unit,
@ -491,6 +495,7 @@ private fun MangaScreenSmallImpl(
onClickEditInfo = onEditInfoClicked.takeIf { state.manga.favorite },
// KMK -->
onClickSourceSettings = onClickSourceSettingsClicked,
onClearManga = onClearManga,
onOpenMangaFolder = onOpenMangaFolder,
onClickRelatedMangas = onRelatedMangasScreenClick.takeIf {
!expandRelatedMangas &&
@ -875,6 +880,7 @@ private fun MangaScreenLargeImpl(
// KMK -->
getMangaState: @Composable ((Manga) -> State<Manga>),
onClickSourceSettingsClicked: (() -> Unit)?,
onClearManga: () -> Unit,
onOpenMangaFolder: (() -> Unit)?,
onRelatedMangasScreenClick: () -> Unit,
onRelatedMangaClick: (Manga) -> Unit,
@ -950,6 +956,7 @@ private fun MangaScreenLargeImpl(
onClickEditInfo = onEditInfoClicked.takeIf { state.manga.favorite },
// KMK -->
onClickSourceSettings = onClickSourceSettingsClicked,
onClearManga = onClearManga,
onOpenMangaFolder = onOpenMangaFolder,
onClickRelatedMangas = onRelatedMangasScreenClick.takeIf {
!expandRelatedMangas &&

View file

@ -12,6 +12,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -19,10 +20,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import kotlinx.collections.immutable.toImmutableList
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.WheelTextPicker
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.pluralStringResource
@ -62,6 +67,65 @@ fun DeleteChaptersDialog(
)
}
// KMK -->
@Composable
fun ClearMangaDialog(
isMergedSource: Boolean,
onDismissRequest: () -> Unit,
onConfirm: (Boolean, Boolean) -> Unit,
) {
var list by remember {
mutableStateOf(
buildList<CheckboxState.State<StringResource>> {
add(CheckboxState.State.None(KMR.strings.downloaded_data))
add(CheckboxState.State.None(KMR.strings.chapters_from_database))
},
)
}
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
confirmButton = {
TextButton(
enabled = list.any { it.isChecked },
onClick = {
onDismissRequest()
onConfirm(
list[0].isChecked,
list[1].isChecked,
)
},
) {
Text(text = stringResource(MR.strings.action_ok))
}
},
title = {
Text(text = stringResource(MR.strings.action_remove))
},
text = {
Column {
list.forEachIndexed { index, state ->
LabeledCheckbox(
label = stringResource(state.value),
checked = state.isChecked,
enabled = index != 0 || !isMergedSource,
onCheckedChange = {
val mutableList = list.toMutableList()
mutableList[index] = state.next() as CheckboxState.State<StringResource>
list = mutableList.toList()
},
)
}
}
},
)
}
// KMK <--
@Composable
fun SetIntervalDialog(
interval: Int,

View file

@ -53,6 +53,7 @@ fun MangaToolbar(
// KMK -->
onClickRelatedMangas: (() -> Unit)?,
onClickSourceSettings: (() -> Unit)?,
onClearManga: () -> Unit,
onOpenMangaFolder: (() -> Unit)?,
// KMK <--
onClickRecommend: (() -> Unit)?,
@ -243,6 +244,12 @@ fun MangaToolbar(
),
)
}
add(
AppBar.OverflowAction(
title = stringResource(KMR.strings.action_clear_manga),
onClick = onClearManga,
),
)
if (onClickSourceSettings != null) {
add(
AppBar.OverflowAction(

View file

@ -27,6 +27,7 @@ import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -277,6 +278,10 @@ class DownloadManager(
provider.findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source)?.delete()
cache.removeManga(manga)
// KMK -->
if (source.isLocal()) return@launchIO
// KMK <--
// Delete source directory if empty
val sourceDir = provider.findSourceDir(source)
if (sourceDir?.listFiles()?.isEmpty() == true) {

View file

@ -12,6 +12,7 @@ import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
@ -74,7 +75,13 @@ class DownloadProvider(
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return downloadsDir?.findFile(getSourceDirName(source))
// KMK -->
return if (source.isLocal()) {
storageManager.getLocalSourceDirectory()
} else {
// KMK <--
downloadsDir?.findFile(getSourceDirName(source))
}
}
/**
@ -113,11 +120,24 @@ class DownloadProvider(
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> {
val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter ->
// KMK -->
if (source.isLocal()) {
val splitUrl = chapter.url.split('/', limit = 2)
if (splitUrl.size < 2) {
null
} else {
val (mangaDirName, chapterDirName) = splitUrl
mangaDir.findFile(chapterDirName)
?: storageManager.getLocalSourceDirectory()?.findFile(mangaDirName)?.findFile(chapterDirName)
}
} else {
// KMK <--
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence()
.mapNotNull { mangaDir.findFile(it) }
.firstOrNull()
}
}
}
// SY -->
/**

View file

@ -52,6 +52,7 @@ import eu.kanade.presentation.manga.ChapterSettingsDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.manga.components.ClearMangaDialog
import eu.kanade.presentation.manga.components.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.MangaCoverDialog
import eu.kanade.presentation.manga.components.ScanlatorFilterDialog
@ -65,6 +66,7 @@ import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreen
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
@ -418,8 +420,9 @@ class MangaScreen(
else -> {}
}
}.takeIf { isConfigurableSource },
onClearManga = { screenModel.showClearMangaDialog(successState.source is MergedSource) },
onOpenMangaFolder = { screenModel.openMangaFolder() }
.takeIf { successState.source !is StubSource },
.takeIf { successState.source !is StubSource && successState.source !is MergedSource },
onRelatedMangasScreenClick = {
if (successState.isRelatedMangasFetched == null) {
scope.launchIO { screenModel.fetchRelatedMangasFromSource(onDemand = true) }
@ -634,6 +637,15 @@ class MangaScreen(
)
}
// SY <--
// KMK -->
is MangaScreenModel.Dialog.ClearManga -> {
ClearMangaDialog(
isMergedSource = dialog.isMergedSource,
onDismissRequest = onDismissRequest,
onConfirm = screenModel::clearManga,
)
}
// KMK <--
}
if (showScanlatorsDialog) {

View file

@ -120,6 +120,7 @@ import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.category.interactor.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.DeleteChapters
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.UpdateChapter
@ -231,6 +232,7 @@ class MangaScreenModel(
private val deleteLibraryUpdateErrors: DeleteLibraryUpdateErrors = Injekt.get(),
private val insertLibraryUpdateErrors: InsertLibraryUpdateErrors = Injekt.get(),
private val insertLibraryUpdateErrorMessages: InsertLibraryUpdateErrorMessages = Injekt.get(),
private val deleteChaptersFromDb: DeleteChapters = Injekt.get(),
// KMK <--
) : StateScreenModel<MangaScreenModel.State>(State.Loading) {
@ -1524,6 +1526,12 @@ class MangaScreenModel(
ignoreCategoryExclusion = true,
// KMK <--
)
// KMK -->
if (source?.isLocal() == true) {
// Refresh chapters state for Local source
fetchChaptersFromSource()
}
// KMK <--
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
@ -1531,6 +1539,51 @@ class MangaScreenModel(
}
}
// KMK -->
fun clearManga(
deleteDownload: Boolean,
removeChapters: Boolean,
) {
if (deleteDownload) {
deleteDownloadedData()
}
if (removeChapters) {
removeChaptersDatabase()
}
}
private fun deleteDownloadedData() {
screenModelScope.launchNonCancellable {
try {
successState?.let { state ->
downloadManager.deleteManga(
manga = state.manga,
source = state.source,
removeQueued = true,
)
if (source?.isLocal() == true) {
// Refresh chapters state for Local source
fetchChaptersFromSource()
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
}
}
}
private fun removeChaptersDatabase() {
screenModelScope.launchNonCancellable {
try {
successState?.chapters?.map { it.id }
?.let { deleteChaptersFromDb.await(it) }
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e)
}
}
}
// KMK <--
private fun downloadNewChapters(chapters: List<Chapter>) {
screenModelScope.launchNonCancellable {
val manga = successState?.manga ?: return@launchNonCancellable
@ -1822,6 +1875,10 @@ class MangaScreenModel(
data class EditMergedSettings(val mergedData: MergedMangaData) : Dialog
// SY <--
// KMK -->
data class ClearManga(val isMergedSource: Boolean) : Dialog
// KMK <--
data object SettingsSheet : Dialog
data object TrackSheet : Dialog
data object FullCover : Dialog
@ -1883,6 +1940,12 @@ class MangaScreenModel(
}
// SY <--
// KMK -->
fun showClearMangaDialog(isMergedSource: Boolean) {
updateSuccessState { it.copy(dialog = Dialog.ClearManga(isMergedSource)) }
}
// KMK <--
sealed interface State {
@Immutable
data object Loading : State

View file

@ -15,7 +15,9 @@
<string name="action_show_hidden_categories">Show hidden categories</string>
<string name="action_panorama_cover">Panorama cover</string>
<string name="action_open_folder">Open folder</string>
<string name="action_clear_manga">Clear manga</string>
<string name="action_update">Update</string>
<!-- Reader -->
<!-- Reader Actions -->
<string name="action_copy_to_clipboard_first_page">Copy first page to clipboard</string>
@ -121,6 +123,9 @@
<string name="action_skip_duplicate_manga">Skip it</string>
<string name="action_allow_all_duplicate_manga">Allow all</string>
<string name="action_skip_all_duplicate_manga">Skip all</string>
<string name="downloaded_data">Downloaded data</string>
<string name="chapters_from_database">Chapters &amp; History from Database</string>
<!--UpdateCheck-->
<string name="update_check_try_now">Try now</string>
<string name="update_completed">Update completed</string>