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 = { text = {
Column { Column {
list.forEach { state -> list.forEachIndexed { index, state ->
LabeledCheckbox( LabeledCheckbox(
label = stringResource(state.value), label = stringResource(state.value),
checked = state.isChecked, checked = state.isChecked,
onCheckedChange = { onCheckedChange = {
val index = list.indexOf(state) val mutableList = list.toMutableList()
if (index != -1) { mutableList[index] = state.next() as CheckboxState.State<StringResource>
val mutableList = list.toMutableList() list = mutableList.toList()
mutableList[index] = state.next() as CheckboxState.State<StringResource>
list = mutableList.toList()
}
}, },
) )
} }

View file

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

View file

@ -12,6 +12,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -19,10 +20,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.domain.manga.interactor.FetchInterval
import tachiyomi.i18n.MR 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.WheelTextPicker
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.pluralStringResource 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 @Composable
fun SetIntervalDialog( fun SetIntervalDialog(
interval: Int, interval: Int,

View file

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

View file

@ -27,6 +27,7 @@ import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -277,6 +278,10 @@ class DownloadManager(
provider.findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source)?.delete() provider.findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source)?.delete()
cache.removeManga(manga) cache.removeManga(manga)
// KMK -->
if (source.isLocal()) return@launchIO
// KMK <--
// Delete source directory if empty // Delete source directory if empty
val sourceDir = provider.findSourceDir(source) val sourceDir = provider.findSourceDir(source)
if (sourceDir?.listFiles()?.isEmpty() == true) { 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.manga.model.Manga
import tachiyomi.domain.storage.service.StorageManager import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.source.local.isLocal
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.IOException import java.io.IOException
@ -74,7 +75,13 @@ class DownloadProvider(
* @param source the source to query. * @param source the source to query.
*/ */
fun findSourceDir(source: Source): UniFile? { fun findSourceDir(source: Source): UniFile? {
return downloadsDir?.findFile(getSourceDirName(source)) // KMK -->
return if (source.isLocal()) {
storageManager.getLocalSourceDirectory()
} else {
// KMK <--
downloadsDir?.findFile(getSourceDirName(source))
}
} }
/** /**
@ -113,9 +120,22 @@ class DownloadProvider(
fun findChapterDirs(chapters: List<Chapter>, manga: Manga, source: Source): Pair<UniFile?, List<UniFile>> { 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() val mangaDir = findMangaDir(/* SY --> */ manga.ogTitle /* SY <-- */, source) ?: return null to emptyList()
return mangaDir to chapters.mapNotNull { chapter -> return mangaDir to chapters.mapNotNull { chapter ->
getValidChapterDirNames(chapter.name, chapter.scanlator).asSequence() // KMK -->
.mapNotNull { mangaDir.findFile(it) } if (source.isLocal()) {
.firstOrNull() 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()
}
} }
} }

View file

@ -52,6 +52,7 @@ import eu.kanade.presentation.manga.ChapterSettingsDialog
import eu.kanade.presentation.manga.DuplicateMangaDialog import eu.kanade.presentation.manga.DuplicateMangaDialog
import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.presentation.manga.MangaScreen 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.DeleteChaptersDialog
import eu.kanade.presentation.manga.components.MangaCoverDialog import eu.kanade.presentation.manga.components.MangaCoverDialog
import eu.kanade.presentation.manga.components.ScanlatorFilterDialog 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.Source
import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.source.isLocalOrStub
import eu.kanade.tachiyomi.source.online.HttpSource 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.BulkFavoriteScreenModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreen import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreen
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
@ -418,8 +420,9 @@ class MangaScreen(
else -> {} else -> {}
} }
}.takeIf { isConfigurableSource }, }.takeIf { isConfigurableSource },
onClearManga = { screenModel.showClearMangaDialog(successState.source is MergedSource) },
onOpenMangaFolder = { screenModel.openMangaFolder() } onOpenMangaFolder = { screenModel.openMangaFolder() }
.takeIf { successState.source !is StubSource }, .takeIf { successState.source !is StubSource && successState.source !is MergedSource },
onRelatedMangasScreenClick = { onRelatedMangasScreenClick = {
if (successState.isRelatedMangasFetched == null) { if (successState.isRelatedMangasFetched == null) {
scope.launchIO { screenModel.fetchRelatedMangasFromSource(onDemand = true) } scope.launchIO { screenModel.fetchRelatedMangasFromSource(onDemand = true) }
@ -634,6 +637,15 @@ class MangaScreen(
) )
} }
// SY <-- // SY <--
// KMK -->
is MangaScreenModel.Dialog.ClearManga -> {
ClearMangaDialog(
isMergedSource = dialog.isMergedSource,
onDismissRequest = onDismissRequest,
onConfirm = screenModel::clearManga,
)
}
// KMK <--
} }
if (showScanlatorsDialog) { 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.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.chapter.interactor.DeleteChapters
import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId import tachiyomi.domain.chapter.interactor.GetMergedChaptersByMangaId
import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.interactor.UpdateChapter
@ -231,6 +232,7 @@ class MangaScreenModel(
private val deleteLibraryUpdateErrors: DeleteLibraryUpdateErrors = Injekt.get(), private val deleteLibraryUpdateErrors: DeleteLibraryUpdateErrors = Injekt.get(),
private val insertLibraryUpdateErrors: InsertLibraryUpdateErrors = Injekt.get(), private val insertLibraryUpdateErrors: InsertLibraryUpdateErrors = Injekt.get(),
private val insertLibraryUpdateErrorMessages: InsertLibraryUpdateErrorMessages = Injekt.get(), private val insertLibraryUpdateErrorMessages: InsertLibraryUpdateErrorMessages = Injekt.get(),
private val deleteChaptersFromDb: DeleteChapters = Injekt.get(),
// KMK <-- // KMK <--
) : StateScreenModel<MangaScreenModel.State>(State.Loading) { ) : StateScreenModel<MangaScreenModel.State>(State.Loading) {
@ -1524,6 +1526,12 @@ class MangaScreenModel(
ignoreCategoryExclusion = true, ignoreCategoryExclusion = true,
// KMK <-- // KMK <--
) )
// KMK -->
if (source?.isLocal() == true) {
// Refresh chapters state for Local source
fetchChaptersFromSource()
}
// KMK <--
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) 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>) { private fun downloadNewChapters(chapters: List<Chapter>) {
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
val manga = successState?.manga ?: return@launchNonCancellable val manga = successState?.manga ?: return@launchNonCancellable
@ -1822,6 +1875,10 @@ class MangaScreenModel(
data class EditMergedSettings(val mergedData: MergedMangaData) : Dialog data class EditMergedSettings(val mergedData: MergedMangaData) : Dialog
// SY <-- // SY <--
// KMK -->
data class ClearManga(val isMergedSource: Boolean) : Dialog
// KMK <--
data object SettingsSheet : Dialog data object SettingsSheet : Dialog
data object TrackSheet : Dialog data object TrackSheet : Dialog
data object FullCover : Dialog data object FullCover : Dialog
@ -1883,6 +1940,12 @@ class MangaScreenModel(
} }
// SY <-- // SY <--
// KMK -->
fun showClearMangaDialog(isMergedSource: Boolean) {
updateSuccessState { it.copy(dialog = Dialog.ClearManga(isMergedSource)) }
}
// KMK <--
sealed interface State { sealed interface State {
@Immutable @Immutable
data object Loading : State data object Loading : State

View file

@ -15,7 +15,9 @@
<string name="action_show_hidden_categories">Show hidden categories</string> <string name="action_show_hidden_categories">Show hidden categories</string>
<string name="action_panorama_cover">Panorama cover</string> <string name="action_panorama_cover">Panorama cover</string>
<string name="action_open_folder">Open folder</string> <string name="action_open_folder">Open folder</string>
<string name="action_clear_manga">Clear manga</string>
<string name="action_update">Update</string> <string name="action_update">Update</string>
<!-- Reader --> <!-- Reader -->
<!-- Reader Actions --> <!-- Reader Actions -->
<string name="action_copy_to_clipboard_first_page">Copy first page to clipboard</string> <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_skip_duplicate_manga">Skip it</string>
<string name="action_allow_all_duplicate_manga">Allow all</string> <string name="action_allow_all_duplicate_manga">Allow all</string>
<string name="action_skip_all_duplicate_manga">Skip 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--> <!--UpdateCheck-->
<string name="update_check_try_now">Try now</string> <string name="update_check_try_now">Try now</string>
<string name="update_completed">Update completed</string> <string name="update_completed">Update completed</string>