|
|
@@ -2,7 +2,10 @@ package io.legado.app.ui.book.toc
|
|
|
|
|
|
import android.app.Application
|
|
|
import android.net.Uri
|
|
|
+import androidx.compose.runtime.Immutable
|
|
|
import androidx.compose.runtime.snapshotFlow
|
|
|
+import androidx.compose.ui.graphics.vector.ImageVector
|
|
|
+import androidx.lifecycle.SavedStateHandle
|
|
|
import androidx.lifecycle.viewModelScope
|
|
|
import io.legado.app.base.BaseRuleViewModel
|
|
|
import io.legado.app.data.appDb
|
|
|
@@ -11,6 +14,7 @@ import io.legado.app.data.entities.BookChapter
|
|
|
import io.legado.app.data.entities.Bookmark
|
|
|
import io.legado.app.help.book.BookHelp
|
|
|
import io.legado.app.help.book.ContentProcessor
|
|
|
+import io.legado.app.help.book.isLocal
|
|
|
import io.legado.app.help.bookmark.BookmarkExporter
|
|
|
import io.legado.app.model.CacheBook
|
|
|
import io.legado.app.model.ReadBook
|
|
|
@@ -25,156 +29,254 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
|
import kotlinx.coroutines.flow.Flow
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
|
+import kotlinx.coroutines.flow.StateFlow
|
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
|
-import kotlinx.coroutines.flow.catch
|
|
|
import kotlinx.coroutines.flow.combine
|
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
|
+import kotlinx.coroutines.flow.filter
|
|
|
import kotlinx.coroutines.flow.filterNotNull
|
|
|
import kotlinx.coroutines.flow.flatMapLatest
|
|
|
import kotlinx.coroutines.flow.flowOn
|
|
|
import kotlinx.coroutines.flow.map
|
|
|
-import kotlinx.coroutines.flow.shareIn
|
|
|
+import kotlinx.coroutines.flow.scan
|
|
|
+import kotlinx.coroutines.flow.stateIn
|
|
|
import kotlinx.coroutines.flow.update
|
|
|
import kotlinx.coroutines.launch
|
|
|
-
|
|
|
-data class TocUiItem(
|
|
|
- val chapter: BookChapter,
|
|
|
- val displayTitle: String,
|
|
|
- override val id: Int = chapter.index
|
|
|
+import kotlinx.coroutines.withContext
|
|
|
+
|
|
|
+@Immutable
|
|
|
+data class TocItemUi(
|
|
|
+ override val id: Int,
|
|
|
+ val title: String,
|
|
|
+ val tag: String?,
|
|
|
+ val isVolume: Boolean,
|
|
|
+ val isVip: Boolean,
|
|
|
+ val isPay: Boolean,
|
|
|
+ val isDur: Boolean,
|
|
|
+ val isSelected: Boolean,
|
|
|
+ val downloadState: DownloadState,
|
|
|
+ val wordCount: String?
|
|
|
) : SelectableItem<Int>
|
|
|
|
|
|
+@Immutable
|
|
|
+data class TocBookmarkItemUi(
|
|
|
+ val id: Long,
|
|
|
+ val chapterIndex: Int,
|
|
|
+ val chapterPos: Int,
|
|
|
+ val content: String,
|
|
|
+ val chapterName: String,
|
|
|
+ val isDur: Boolean,
|
|
|
+ val raw: Bookmark
|
|
|
+)
|
|
|
+
|
|
|
data class TocActionState(
|
|
|
- override val items: List<TocUiItem> = emptyList(),
|
|
|
+ override val items: List<TocItemUi> = emptyList(),
|
|
|
override val selectedIds: Set<Int> = emptySet(),
|
|
|
override val searchKey: String = "",
|
|
|
override val isSearch: Boolean = false,
|
|
|
override val isUploading: Boolean = false,
|
|
|
- val downloadingIndices: Set<Int> = emptySet(),
|
|
|
- val errorIndices: Set<Int> = emptySet(),
|
|
|
- val cachedFiles: Set<String> = emptySet(),
|
|
|
val downloadSummary: String = ""
|
|
|
-) : RuleActionState<TocUiItem>
|
|
|
+) : RuleActionState<TocItemUi>
|
|
|
|
|
|
-@OptIn(ExperimentalCoroutinesApi::class)
|
|
|
-class TocViewModel(application: Application) :
|
|
|
- BaseRuleViewModel<TocUiItem, Pair<BookChapter, String>, Int, TocActionState>(
|
|
|
- application,
|
|
|
- initialState = TocActionState()
|
|
|
- ) {
|
|
|
-
|
|
|
- private val _book = MutableStateFlow<Book?>(null)
|
|
|
- val bookState = _book.asStateFlow()
|
|
|
- val isSplitLongChapter: Boolean
|
|
|
- get() = _book.value?.getSplitLongChapter() ?: false
|
|
|
-
|
|
|
- private val _collapsedVolumes = MutableStateFlow<Set<String>>(emptySet())
|
|
|
- val collapsedVolumes = _collapsedVolumes.asStateFlow()
|
|
|
+data class TocDomainItem(
|
|
|
+ val chapter: BookChapter,
|
|
|
+ val displayTitle: String,
|
|
|
+ val downloadState: DownloadState
|
|
|
+)
|
|
|
|
|
|
- private val _cacheFileNames = MutableStateFlow<Set<String>>(emptySet())
|
|
|
- val cacheFileNames = _cacheFileNames.asStateFlow()
|
|
|
+private data class DownloadContext(
|
|
|
+ val downloadingPair: Pair<String, Set<Int>>,
|
|
|
+ val errorPair: Pair<String, Set<Int>>,
|
|
|
+ val cachedFiles: Set<String>
|
|
|
+)
|
|
|
|
|
|
- private val _isReverse = MutableStateFlow(false)
|
|
|
+private data class TocUiConfig(
|
|
|
+ val collapsedVolumes: Set<Int>,
|
|
|
+ val useReplace: Boolean,
|
|
|
+ val showWordCount: Boolean,
|
|
|
+ val isReverse: Boolean
|
|
|
+)
|
|
|
|
|
|
- private val _downloadingIndices = MutableStateFlow<Set<Int>>(emptySet())
|
|
|
- private val _downloadSummary = MutableStateFlow("")
|
|
|
+data class FabAction(val icon: ImageVector, val label: String, val action: () -> Unit)
|
|
|
|
|
|
- private val _errorIndices = MutableStateFlow<Set<Int>>(emptySet())
|
|
|
- val errorIndices = _errorIndices.asStateFlow()
|
|
|
+@OptIn(ExperimentalCoroutinesApi::class)
|
|
|
+class TocViewModel(
|
|
|
+ application: Application,
|
|
|
+ savedStateHandle: SavedStateHandle
|
|
|
+) : BaseRuleViewModel<TocItemUi, TocDomainItem, Int, TocActionState>(
|
|
|
+ application,
|
|
|
+ initialState = TocActionState()
|
|
|
+) {
|
|
|
+
|
|
|
+ private val bookUrlFlow = savedStateHandle.getStateFlow<String?>("bookUrl", null)
|
|
|
+ val bookState = bookUrlFlow
|
|
|
+ .filterNotNull()
|
|
|
+ .flatMapLatest { url ->
|
|
|
+ appDb.bookDao.flowGetBook(url)
|
|
|
+ }
|
|
|
+ .stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
|
|
|
|
|
- private val _bookmarks = MutableStateFlow<List<Bookmark>>(emptyList())
|
|
|
- val bookmarks = _bookmarks.asStateFlow()
|
|
|
+ val isSplitLongChapter: Boolean get() = bookState.value?.getSplitLongChapter() ?: false
|
|
|
|
|
|
- @OptIn(ExperimentalCoroutinesApi::class)
|
|
|
- private val dbChapterListFlow = _book
|
|
|
- .filterNotNull()
|
|
|
+ private val _collapsedVolumes = MutableStateFlow<Set<Int>>(emptySet())
|
|
|
+ val collapsedVolumes = _collapsedVolumes.asStateFlow()
|
|
|
+
|
|
|
+ val downloadSummary: StateFlow<String> =
|
|
|
+ CacheBook.downloadSummaryFlow
|
|
|
+ .stateIn(
|
|
|
+ viewModelScope,
|
|
|
+ SharingStarted.WhileSubscribed(5000),
|
|
|
+ ""
|
|
|
+ )
|
|
|
+
|
|
|
+ private val _cacheFileNames: StateFlow<Set<String>> = bookState.filterNotNull()
|
|
|
.map { it.bookUrl }
|
|
|
.distinctUntilChanged()
|
|
|
.flatMapLatest { url ->
|
|
|
- appDb.bookChapterDao.getChapterListFlow(url)
|
|
|
+ val initialFiles = withContext(Dispatchers.IO) {
|
|
|
+ BookHelp.getChapterFiles(bookState.value!!)
|
|
|
+ }.toSet()
|
|
|
+
|
|
|
+ CacheBook.cacheSuccessFlow
|
|
|
+ .filter { it.bookUrl == url }
|
|
|
+ .map { it.getFileName() }
|
|
|
+ .scan(initialFiles) { accumulator, newFileName ->
|
|
|
+ accumulator + newFileName
|
|
|
+ }
|
|
|
}
|
|
|
+ .flowOn(Dispatchers.IO)
|
|
|
+ .stateIn(
|
|
|
+ scope = viewModelScope,
|
|
|
+ started = SharingStarted.Eagerly,
|
|
|
+ initialValue = emptySet()
|
|
|
+ )
|
|
|
|
|
|
- @OptIn(ExperimentalCoroutinesApi::class)
|
|
|
- private val processedChaptersFlow: Flow<List<Pair<BookChapter, String>>> = combine(
|
|
|
- dbChapterListFlow,
|
|
|
- _isReverse,
|
|
|
- _book.filterNotNull(),
|
|
|
- snapshotFlow { ReadConfig.tocUiUseReplace },
|
|
|
- snapshotFlow { ReadConfig.tocCountWords }
|
|
|
- ) { originalList, isReverse, book, useReplace, showWordCount ->
|
|
|
-
|
|
|
- val processedList = if (isReverse) {
|
|
|
- originalList.fold(mutableListOf<MutableList<BookChapter>>()) { acc, chapter ->
|
|
|
- if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter))
|
|
|
- else acc.last().add(chapter)
|
|
|
- acc
|
|
|
- }.asReversed().flatMap { group ->
|
|
|
- if (group.firstOrNull()?.isVolume == true) {
|
|
|
- listOf(group.first()) + group.drop(1).asReversed()
|
|
|
- } else group.asReversed()
|
|
|
+ val bookmarkUiList: StateFlow<List<TocBookmarkItemUi>> =
|
|
|
+ combine(
|
|
|
+ bookState.filterNotNull(),
|
|
|
+ _searchKey
|
|
|
+ ) { book, query ->
|
|
|
+ book to query
|
|
|
+ }
|
|
|
+ .flatMapLatest { (book, query) ->
|
|
|
+ appDb.bookmarkDao
|
|
|
+ .flowByBook(book.name, book.author)
|
|
|
+ .map { list ->
|
|
|
+ list
|
|
|
+ .asSequence()
|
|
|
+ .filter {
|
|
|
+ query.isBlank() ||
|
|
|
+ it.content.contains(query, ignoreCase = true)
|
|
|
+ }
|
|
|
+ .map { bookmark ->
|
|
|
+ TocBookmarkItemUi(
|
|
|
+ id = bookmark.time,
|
|
|
+ chapterIndex = bookmark.chapterIndex,
|
|
|
+ chapterPos = bookmark.chapterPos,
|
|
|
+ content = bookmark.content,
|
|
|
+ chapterName = bookmark.chapterName,
|
|
|
+ isDur = bookmark.chapterIndex == book.durChapterIndex,
|
|
|
+ raw = bookmark
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .toList()
|
|
|
+ }
|
|
|
}
|
|
|
+ .stateIn(
|
|
|
+ scope = viewModelScope,
|
|
|
+ started = SharingStarted.WhileSubscribed(5000),
|
|
|
+ initialValue = emptyList()
|
|
|
+ )
|
|
|
+
|
|
|
+ private val reverseFlow =
|
|
|
+ bookState.map { it?.getReverseToc() ?: false }
|
|
|
+ .distinctUntilChanged()
|
|
|
+
|
|
|
+ private val downloadContextFlow = combine(
|
|
|
+ CacheBook.downloadingIndicesFlow,
|
|
|
+ CacheBook.downloadErrorFlow,
|
|
|
+ _cacheFileNames
|
|
|
+ ) { downloading, errors, cached ->
|
|
|
+ DownloadContext(downloading, errors, cached)
|
|
|
+ }
|
|
|
+
|
|
|
+ private val uiConfigFlow = combine(
|
|
|
+ _collapsedVolumes,
|
|
|
+ snapshotFlow { ReadConfig.tocUiUseReplace },
|
|
|
+ snapshotFlow { ReadConfig.tocCountWords },
|
|
|
+ reverseFlow
|
|
|
+ ) { collapsed, useReplace, showWordCount, isReverse ->
|
|
|
+ TocUiConfig(collapsed, useReplace, showWordCount, isReverse)
|
|
|
+ }
|
|
|
+
|
|
|
+ override val rawDataFlow: Flow<List<TocDomainItem>> = combine(
|
|
|
+ bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged()
|
|
|
+ .flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) },
|
|
|
+ downloadContextFlow,
|
|
|
+ uiConfigFlow
|
|
|
+ ) { originalChapters, downloadCtx, config ->
|
|
|
+ val book = bookState.value ?: return@combine emptyList()
|
|
|
+
|
|
|
+ val processedChapters = if (config.isReverse) {
|
|
|
+ originalChapters.groupAndReverseVolumes()
|
|
|
} else {
|
|
|
- originalList
|
|
|
+ originalChapters
|
|
|
}
|
|
|
|
|
|
- val replaceRules = if (useReplace && book.getUseReplaceRule()) {
|
|
|
+ val replaceRules = if (config.useReplace && book.getUseReplaceRule()) {
|
|
|
ContentProcessor.get(book.name, book.origin).getTitleReplaceRules()
|
|
|
} else emptyList()
|
|
|
|
|
|
- processedList.map { chapter ->
|
|
|
- chapter to chapter.getDisplayTitle(replaceRules, true)
|
|
|
+ if (book.isLocal) {
|
|
|
+ return@combine processedChapters.map { chapter ->
|
|
|
+ TocDomainItem(
|
|
|
+ chapter = chapter,
|
|
|
+ displayTitle = chapter.getDisplayTitle(replaceRules, true),
|
|
|
+ downloadState = DownloadState.LOCAL
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
- .flowOn(Dispatchers.IO)
|
|
|
- .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 1)
|
|
|
|
|
|
- private val downloadStateTrigger =
|
|
|
- combine(
|
|
|
- _downloadingIndices,
|
|
|
- _errorIndices,
|
|
|
- _cacheFileNames,
|
|
|
- _downloadSummary
|
|
|
- ) { _, _, _, _ -> Unit }
|
|
|
-
|
|
|
- override val rawDataFlow =
|
|
|
- combine(processedChaptersFlow, _collapsedVolumes, downloadStateTrigger) { list, _, _ ->
|
|
|
- list
|
|
|
+ val (downloadingPair, errorPair, cachedFiles) = downloadCtx
|
|
|
+ val downloadingIndices =
|
|
|
+ if (downloadingPair.first == book.bookUrl) downloadingPair.second else emptySet()
|
|
|
+ val errorIndices =
|
|
|
+ if (errorPair.first == book.bookUrl) errorPair.second else emptySet()
|
|
|
+
|
|
|
+ processedChapters.map { chapter ->
|
|
|
+ val downloadState = when {
|
|
|
+ chapter.index in downloadingIndices -> DownloadState.DOWNLOADING
|
|
|
+ chapter.index in errorIndices -> DownloadState.ERROR
|
|
|
+ chapter.getFileName() in cachedFiles -> DownloadState.SUCCESS
|
|
|
+ else -> DownloadState.NONE
|
|
|
+ }
|
|
|
+
|
|
|
+ TocDomainItem(
|
|
|
+ chapter,
|
|
|
+ chapter.getDisplayTitle(replaceRules, true),
|
|
|
+ downloadState
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
+ }.flowOn(Dispatchers.Default)
|
|
|
+
|
|
|
val useReplace get() = ReadConfig.tocUiUseReplace
|
|
|
val showWordCount get() = ReadConfig.tocCountWords
|
|
|
|
|
|
- fun toggleUseReplace() {
|
|
|
- ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace
|
|
|
- }
|
|
|
-
|
|
|
- fun toggleShowWordCount() {
|
|
|
- ReadConfig.tocCountWords = !ReadConfig.tocCountWords
|
|
|
- }
|
|
|
-
|
|
|
- override fun filterData(
|
|
|
- data: List<Pair<BookChapter, String>>,
|
|
|
- key: String
|
|
|
- ): List<Pair<BookChapter, String>> {
|
|
|
+ override fun filterData(data: List<TocDomainItem>, key: String): List<TocDomainItem> {
|
|
|
val collapsed = _collapsedVolumes.value
|
|
|
val isSearch = key.isNotBlank()
|
|
|
|
|
|
return buildList {
|
|
|
var isCurrentVolumeCollapsed = false
|
|
|
-
|
|
|
for (item in data) {
|
|
|
- val (chapter, displayTitle) = item
|
|
|
-
|
|
|
- if (chapter.isVolume) {
|
|
|
- isCurrentVolumeCollapsed = collapsed.contains(chapter.title)
|
|
|
+ if (item.chapter.isVolume) {
|
|
|
+ isCurrentVolumeCollapsed = collapsed.contains(item.chapter.index)
|
|
|
} else if (isCurrentVolumeCollapsed && !isSearch) {
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
- if (!isSearch || displayTitle.contains(
|
|
|
- key,
|
|
|
- ignoreCase = true
|
|
|
- ) || chapter.isVolume
|
|
|
- ) {
|
|
|
+ if (!isSearch || item.displayTitle.contains(key, true) || item.chapter.isVolume) {
|
|
|
add(item)
|
|
|
}
|
|
|
}
|
|
|
@@ -182,137 +284,138 @@ class TocViewModel(application: Application) :
|
|
|
}
|
|
|
|
|
|
override fun composeUiState(
|
|
|
- items: List<TocUiItem>,
|
|
|
+ items: List<TocItemUi>,
|
|
|
selectedIds: Set<Int>,
|
|
|
isSearch: Boolean,
|
|
|
isUploading: Boolean,
|
|
|
- importState: BaseImportUiState<Pair<BookChapter, String>>
|
|
|
+ importState: BaseImportUiState<TocDomainItem>
|
|
|
): TocActionState {
|
|
|
|
|
|
+ val durIndex = bookState.value?.durChapterIndex ?: -1
|
|
|
+
|
|
|
+ val updatedItems = items.map { uiItem ->
|
|
|
+ uiItem.copy(
|
|
|
+ isSelected = uiItem.id in selectedIds,
|
|
|
+ isDur = uiItem.id == durIndex
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
return TocActionState(
|
|
|
- items = items,
|
|
|
+ items = updatedItems,
|
|
|
selectedIds = selectedIds,
|
|
|
searchKey = _searchKey.value,
|
|
|
isSearch = isSearch,
|
|
|
isUploading = isUploading,
|
|
|
- downloadingIndices = _downloadingIndices.value,
|
|
|
- errorIndices = _errorIndices.value,
|
|
|
- cachedFiles = _cacheFileNames.value,
|
|
|
- downloadSummary = _downloadSummary.value
|
|
|
+ downloadSummary = downloadSummary.value
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- override fun Pair<BookChapter, String>.toUiItem() = TocUiItem(first, second)
|
|
|
+ override fun TocDomainItem.toUiItem(): TocItemUi {
|
|
|
+ val wordCountText = if (showWordCount) {
|
|
|
+ chapter.wordCount
|
|
|
+ } else {
|
|
|
+ null
|
|
|
+ }
|
|
|
|
|
|
- override fun ruleItemToEntity(item: TocUiItem) = item.chapter to item.displayTitle
|
|
|
+ return TocItemUi(
|
|
|
+ id = chapter.index,
|
|
|
+ title = displayTitle,
|
|
|
+ tag = chapter.tag,
|
|
|
+ isVolume = chapter.isVolume,
|
|
|
+ isVip = chapter.isVip,
|
|
|
+ isPay = chapter.isPay,
|
|
|
+ isDur = false,
|
|
|
+ isSelected = false,
|
|
|
+ downloadState = downloadState,
|
|
|
+ wordCount = wordCountText
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
- override suspend fun generateJson(entities: List<Pair<BookChapter, String>>) = ""
|
|
|
- override fun parseImportRules(text: String): List<Pair<BookChapter, String>> = emptyList()
|
|
|
- override fun hasChanged(
|
|
|
- newRule: Pair<BookChapter, String>,
|
|
|
- oldRule: Pair<BookChapter, String>
|
|
|
- ) = false
|
|
|
+ override fun ruleItemToEntity(item: TocItemUi): TocDomainItem {
|
|
|
+ throw NotImplementedError("TOC 不需要向后反转实体")
|
|
|
+ }
|
|
|
|
|
|
- override suspend fun findOldRule(newRule: Pair<BookChapter, String>) = null
|
|
|
+ override suspend fun generateJson(entities: List<TocDomainItem>) = ""
|
|
|
+ override fun parseImportRules(text: String): List<TocDomainItem> = emptyList()
|
|
|
+ override fun hasChanged(newRule: TocDomainItem, oldRule: TocDomainItem) = false
|
|
|
+ override suspend fun findOldRule(newRule: TocDomainItem) = null
|
|
|
override fun saveImportedRules() {}
|
|
|
|
|
|
- init {
|
|
|
-
|
|
|
- viewModelScope.launch {
|
|
|
- combine(
|
|
|
- _book.filterNotNull(),
|
|
|
- snapshotFlow { uiState.value.searchKey }
|
|
|
- ) { book, searchKey ->
|
|
|
- if (searchKey.isBlank()) {
|
|
|
- appDb.bookmarkDao.flowByBook(book.name, book.author)
|
|
|
- } else {
|
|
|
- appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey)
|
|
|
- }
|
|
|
- }
|
|
|
- .flatMapLatest { it }
|
|
|
- .catch { e ->
|
|
|
- // AppLog.put("目录界面获取书签数据失败\n${e.localizedMessage}", e)
|
|
|
- }
|
|
|
- .flowOn(Dispatchers.IO)
|
|
|
- .collect { list ->
|
|
|
- _bookmarks.value = list
|
|
|
- }
|
|
|
- }
|
|
|
+ fun reverseToc() = execute {
|
|
|
+ val currentBook = bookState.value ?: return@execute
|
|
|
+ val currentConfig = currentBook.readConfig ?: Book.ReadConfig()
|
|
|
+ val newConfig = currentConfig.copy(reverseToc = !currentConfig.reverseToc)
|
|
|
+ val newBook = currentBook.copy(readConfig = newConfig)
|
|
|
+ appDb.bookDao.update(newBook)
|
|
|
+ //bookState.value = newBook
|
|
|
+ }
|
|
|
|
|
|
- viewModelScope.launch {
|
|
|
+ fun toggleUseReplace() {
|
|
|
+ ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace
|
|
|
+ }
|
|
|
|
|
|
- launch {
|
|
|
- CacheBook.downloadingIndicesFlow.collect { (url, set) ->
|
|
|
- if (url == _book.value?.bookUrl) {
|
|
|
- _downloadingIndices.value = set
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ fun toggleShowWordCount() {
|
|
|
+ ReadConfig.tocCountWords = !ReadConfig.tocCountWords
|
|
|
+ }
|
|
|
|
|
|
- launch {
|
|
|
- CacheBook.downloadErrorFlow.collect { (url, set) ->
|
|
|
- if (url == _book.value?.bookUrl) {
|
|
|
- _errorIndices.value = set
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ fun toggleVolume(volumeIndex: Int) {
|
|
|
+ _collapsedVolumes.update { current ->
|
|
|
+ if (current.contains(volumeIndex)) current - volumeIndex else current + volumeIndex
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- launch {
|
|
|
- CacheBook.downloadSummaryFlow.collect {
|
|
|
- _downloadSummary.value = it
|
|
|
- }
|
|
|
- }
|
|
|
+ fun expandAllVolumes() {
|
|
|
+ _collapsedVolumes.value = emptySet()
|
|
|
+ }
|
|
|
|
|
|
- launch {
|
|
|
- CacheBook.cacheSuccessFlow.collect { chapter ->
|
|
|
- if (chapter.bookUrl == _book.value?.bookUrl) {
|
|
|
- _cacheFileNames.update {
|
|
|
- it + chapter.getFileName()
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ fun collapseAllVolumes() = execute {
|
|
|
+ val bookUrl = bookState.value?.bookUrl ?: return@execute
|
|
|
+ val volumes =
|
|
|
+ appDb.bookChapterDao.getChapterList(bookUrl).filter { it.isVolume }.map { it.index }
|
|
|
+ .toSet()
|
|
|
+ _collapsedVolumes.value = volumes
|
|
|
}
|
|
|
|
|
|
- fun initBook(bookUrl: String) = execute {
|
|
|
- appDb.bookDao.getBook(bookUrl)?.let { book ->
|
|
|
- _book.value = book
|
|
|
- _isReverse.value = book.getReverseToc()
|
|
|
+ fun selectAll() {
|
|
|
+ setSelection(uiState.value.items.map { it.id }.toSet())
|
|
|
+ }
|
|
|
|
|
|
- viewModelScope.launch(Dispatchers.IO) {
|
|
|
- val files = BookHelp.getChapterFiles(book).toSet()
|
|
|
- _cacheFileNames.value = files
|
|
|
- }
|
|
|
- }
|
|
|
+ fun invertSelection() {
|
|
|
+ val allIds = uiState.value.items.map { it.id }.toSet()
|
|
|
+ setSelection(allIds - _selectedIds.value)
|
|
|
+ }
|
|
|
+
|
|
|
+ fun clearSelection() {
|
|
|
+ setSelection(emptySet())
|
|
|
+ }
|
|
|
+
|
|
|
+ fun selectFromLast() {
|
|
|
+ val currentItems = uiState.value.items
|
|
|
+ val maxSelectedId = _selectedIds.value.maxOrNull() ?: return
|
|
|
+ val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId }
|
|
|
+ if (maxIndex == -1) return
|
|
|
+ setSelection(_selectedIds.value + currentItems.drop(maxIndex + 1).map { it.id })
|
|
|
}
|
|
|
|
|
|
fun saveTocRegex(newRegex: String) {
|
|
|
- val book = _book.value ?: return
|
|
|
+ val book = bookState.value ?: return
|
|
|
book.tocUrl = newRegex
|
|
|
upBookTocRule(book) { error ->
|
|
|
- if (error != null) {
|
|
|
- context.toastOnUi("更新目录规则失败: ${error.localizedMessage}")
|
|
|
- } else {
|
|
|
+ if (error != null) context.toastOnUi("更新目录规则失败: ${error.localizedMessage}")
|
|
|
+ else {
|
|
|
context.toastOnUi("目录规则已更新")
|
|
|
- if (ReadBook.book?.bookUrl == book.bookUrl) {
|
|
|
- ReadBook.upMsg(null)
|
|
|
- }
|
|
|
+ if (ReadBook.book?.bookUrl == book.bookUrl) ReadBook.upMsg(null)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun toggleSplitLongChapter() {
|
|
|
- val book = _book.value ?: return
|
|
|
+ val book = bookState.value ?: return
|
|
|
val newState = !isSplitLongChapter
|
|
|
book.setSplitLongChapter(newState)
|
|
|
-
|
|
|
upBookTocRule(book) { error ->
|
|
|
- if (error != null) {
|
|
|
- context.toastOnUi("设置失败: ${error.localizedMessage}")
|
|
|
- } else {
|
|
|
- context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分")
|
|
|
- }
|
|
|
+ if (error != null) context.toastOnUi("设置失败: ${error.localizedMessage}")
|
|
|
+ else context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -325,8 +428,7 @@ class TocViewModel(application: Application) :
|
|
|
appDb.bookChapterDao.insert(*chapters.toTypedArray())
|
|
|
appDb.bookDao.update(book)
|
|
|
ReadBook.onChapterListUpdated(book)
|
|
|
-
|
|
|
- _book.value = book
|
|
|
+ //bookState.value = book
|
|
|
}
|
|
|
}.onSuccess {
|
|
|
_isUploading.value = false
|
|
|
@@ -337,95 +439,32 @@ class TocViewModel(application: Application) :
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- fun reverseToc() = execute {
|
|
|
- val currentBook = _book.value ?: return@execute
|
|
|
-
|
|
|
- val newReverseState = !_isReverse.value
|
|
|
- _isReverse.value = newReverseState
|
|
|
-
|
|
|
- val newBook = currentBook.copy().apply {
|
|
|
- setReverseToc(newReverseState)
|
|
|
- }
|
|
|
- appDb.bookDao.update(newBook)
|
|
|
- _book.value = newBook
|
|
|
- }
|
|
|
-
|
|
|
- fun toggleVolume(volumeName: String) {
|
|
|
- _collapsedVolumes.update { current ->
|
|
|
- if (current.contains(volumeName)) current - volumeName else current + volumeName
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- fun expandAllVolumes() {
|
|
|
- _collapsedVolumes.value = emptySet()
|
|
|
- }
|
|
|
-
|
|
|
- fun collapseAllVolumes() = execute {
|
|
|
- val bookUrl = _book.value?.bookUrl ?: return@execute
|
|
|
- val volumes = appDb.bookChapterDao.getChapterList(bookUrl)
|
|
|
- .filter { it.isVolume }
|
|
|
- .map { it.title }
|
|
|
- .toSet()
|
|
|
- _collapsedVolumes.value = volumes
|
|
|
- }
|
|
|
-
|
|
|
- fun selectAll() {
|
|
|
- setSelection(uiState.value.items.map { it.id }.toSet())
|
|
|
- }
|
|
|
-
|
|
|
- fun invertSelection() {
|
|
|
- val allIds = uiState.value.items.map { it.id }.toSet()
|
|
|
- setSelection(allIds - _selectedIds.value)
|
|
|
- }
|
|
|
-
|
|
|
- fun clearSelection() {
|
|
|
- setSelection(emptySet())
|
|
|
- }
|
|
|
-
|
|
|
- fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) {
|
|
|
- viewModelScope.launch {
|
|
|
- try {
|
|
|
- val book = _book.value ?: return@launch
|
|
|
- val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author)
|
|
|
-
|
|
|
- if (bookmarks.isEmpty()) {
|
|
|
- context.toastOnUi("没有可导出的书签")
|
|
|
- return@launch
|
|
|
- }
|
|
|
-
|
|
|
- BookmarkExporter.exportToUri(
|
|
|
- context = getApplication(),
|
|
|
- fileUri = fileUri,
|
|
|
- bookmarks = bookmarks,
|
|
|
- isMd = isMd,
|
|
|
- bookName = book.name,
|
|
|
- author = book.author
|
|
|
- )
|
|
|
-
|
|
|
- context.toastOnUi("保存成功")
|
|
|
- } catch (e: Exception) {
|
|
|
- context.toastOnUi("保存失败: ${e.message}")
|
|
|
+ fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) = viewModelScope.launch {
|
|
|
+ try {
|
|
|
+ val book = bookState.value ?: return@launch
|
|
|
+ val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author)
|
|
|
+ if (bookmarks.isEmpty()) {
|
|
|
+ context.toastOnUi("没有可导出的书签")
|
|
|
+ return@launch
|
|
|
}
|
|
|
+ BookmarkExporter.exportToUri(
|
|
|
+ context = getApplication(), fileUri = fileUri, bookmarks = bookmarks,
|
|
|
+ isMd = isMd, bookName = book.name, author = book.author
|
|
|
+ )
|
|
|
+ context.toastOnUi("保存成功")
|
|
|
+ } catch (e: Exception) {
|
|
|
+ context.toastOnUi("保存失败: ${e.message}")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- fun updateBookmark(bookmark: Bookmark) {
|
|
|
- viewModelScope.launch(Dispatchers.IO) {
|
|
|
- appDb.bookmarkDao.insert(bookmark)
|
|
|
- }
|
|
|
- }
|
|
|
+ fun updateBookmark(bookmark: Bookmark) =
|
|
|
+ viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.insert(bookmark) }
|
|
|
|
|
|
- fun deleteBookmark(bookmark: Bookmark) {
|
|
|
- viewModelScope.launch(Dispatchers.IO) {
|
|
|
- appDb.bookmarkDao.delete(bookmark)
|
|
|
- }
|
|
|
- }
|
|
|
+ fun deleteBookmark(bookmark: Bookmark) =
|
|
|
+ viewModelScope.launch(Dispatchers.IO) { appDb.bookmarkDao.delete(bookmark) }
|
|
|
|
|
|
- /**
|
|
|
- * 下载选中的章节
|
|
|
- */
|
|
|
fun downloadSelected() {
|
|
|
- val book = _book.value ?: return
|
|
|
+ val book = bookState.value ?: return
|
|
|
val indices = uiState.value.selectedIds.toList()
|
|
|
if (indices.isEmpty()) return
|
|
|
CacheBook.start(getApplication(), book, indices)
|
|
|
@@ -433,24 +472,16 @@ class TocViewModel(application: Application) :
|
|
|
clearSelection()
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 下载单个章节
|
|
|
- */
|
|
|
fun downloadChapter(index: Int) {
|
|
|
- val book = _book.value ?: return
|
|
|
+ val book = bookState.value ?: return
|
|
|
CacheBook.start(getApplication(), book, listOf(index))
|
|
|
getApplication<Application>().toastOnUi("开始下载章节")
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 下载所有章节
|
|
|
- */
|
|
|
fun downloadAll() {
|
|
|
- val book = _book.value ?: return
|
|
|
- val cachedFiles = _cacheFileNames.value
|
|
|
-
|
|
|
+ val book = bookState.value ?: return
|
|
|
val targetIndices = uiState.value.items
|
|
|
- .filter { !it.chapter.isVolume && it.chapter.getFileName() !in cachedFiles }
|
|
|
+ .filter { !it.isVolume && it.downloadState != DownloadState.SUCCESS }
|
|
|
.map { it.id }
|
|
|
|
|
|
if (targetIndices.isEmpty()) {
|
|
|
@@ -462,14 +493,17 @@ class TocViewModel(application: Application) :
|
|
|
getApplication<Application>().toastOnUi("开始下载剩余 ${targetIndices.size} 个章节")
|
|
|
}
|
|
|
|
|
|
- fun selectFromLast() {
|
|
|
- val currentItems = uiState.value.items
|
|
|
- val maxSelectedId = _selectedIds.value.maxOrNull() ?: return
|
|
|
-
|
|
|
- val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId }
|
|
|
- if (maxIndex == -1) return
|
|
|
-
|
|
|
- val idsToAppend = currentItems.drop(maxIndex + 1).map { it.id }
|
|
|
- setSelection(_selectedIds.value + idsToAppend)
|
|
|
+ private fun List<BookChapter>.groupAndReverseVolumes(): List<BookChapter> {
|
|
|
+ return this.fold(mutableListOf<MutableList<BookChapter>>()) { acc, chapter ->
|
|
|
+ if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter))
|
|
|
+ else acc.last().add(chapter)
|
|
|
+ acc
|
|
|
+ }.asReversed().flatMap { group ->
|
|
|
+ if (group.firstOrNull()?.isVolume == true) {
|
|
|
+ listOf(group.first()) + group.drop(1).asReversed()
|
|
|
+ } else {
|
|
|
+ group.asReversed()
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|