feat: 添加标签分组规则功能,支持按书籍标签自动匹配分组
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package io.legado.app.data.entities
|
package io.legado.app.data.entities
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import io.legado.app.help.book.applyTagGroupRulesForBook
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Ignore
|
import androidx.room.Ignore
|
||||||
@@ -387,6 +388,7 @@ data class Book(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
|
applyTagGroupRulesForBook(this)
|
||||||
if (appDb.bookDao.has(bookUrl)) {
|
if (appDb.bookDao.has(bookUrl)) {
|
||||||
appDb.bookDao.update(this)
|
appDb.bookDao.update(this)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package io.legado.app.data.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import io.legado.app.ui.widget.components.list.SelectableItem
|
||||||
|
|
||||||
|
@Entity(tableName = "tag_group_rules")
|
||||||
|
data class TagGroupRule(
|
||||||
|
@PrimaryKey
|
||||||
|
var id: Long = System.currentTimeMillis(),
|
||||||
|
var pattern: String = "",
|
||||||
|
var groupName: String = "",
|
||||||
|
var order: Int = 0,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun hashCode(): Int = id.hashCode()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other is TagGroupRule) return id == other.id
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -39,4 +39,6 @@ val appDatabaseModule = module {
|
|||||||
factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
|
factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
|
||||||
factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
|
factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
|
||||||
factory<HighlightRuleDao> { get<AppDatabase>().highlightRuleDao }
|
factory<HighlightRuleDao> { get<AppDatabase>().highlightRuleDao }
|
||||||
|
factory<HighlightTagRuleDao> { get<AppDatabase>().highlightTagRuleDao }
|
||||||
|
factory<TagGroupRuleDao> { get<AppDatabase>().tagGroupRuleDao }
|
||||||
}
|
}
|
||||||
@@ -118,6 +118,8 @@ import io.legado.app.ui.config.readConfig.ReadConfigViewModel
|
|||||||
import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
|
import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
|
||||||
import io.legado.app.ui.dict.DictViewModel
|
import io.legado.app.ui.dict.DictViewModel
|
||||||
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
||||||
|
import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
|
||||||
|
import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
|
||||||
import io.legado.app.ui.main.MainRouteSearchContent
|
import io.legado.app.ui.main.MainRouteSearchContent
|
||||||
import io.legado.app.ui.main.MainViewModel
|
import io.legado.app.ui.main.MainViewModel
|
||||||
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
||||||
@@ -231,6 +233,8 @@ val appModule = module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelOf(::DictRuleViewModel)
|
viewModelOf(::DictRuleViewModel)
|
||||||
|
viewModelOf(::HighlightTagRuleViewModel)
|
||||||
|
viewModelOf(::TagGroupRuleViewModel)
|
||||||
viewModelOf(::DictViewModel)
|
viewModelOf(::DictViewModel)
|
||||||
viewModelOf(::RssSourceViewModel)
|
viewModelOf(::RssSourceViewModel)
|
||||||
viewModelOf(::RssSortViewModel)
|
viewModelOf(::RssSortViewModel)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import io.legado.app.data.appDb
|
|||||||
import io.legado.app.data.entities.BaseBook
|
import io.legado.app.data.entities.BaseBook
|
||||||
import io.legado.app.data.entities.Book
|
import io.legado.app.data.entities.Book
|
||||||
import io.legado.app.data.entities.BookSource
|
import io.legado.app.data.entities.BookSource
|
||||||
|
import io.legado.app.data.entities.HighlightTagRule
|
||||||
|
import io.legado.app.ui.book.info.HighlightedTag
|
||||||
import io.legado.app.exception.NoStackTraceException
|
import io.legado.app.exception.NoStackTraceException
|
||||||
import io.legado.app.help.RuleBigDataHelp
|
import io.legado.app.help.RuleBigDataHelp
|
||||||
import io.legado.app.help.config.AppConfig
|
import io.legado.app.help.config.AppConfig
|
||||||
@@ -345,6 +347,168 @@ fun Book.upKind() {
|
|||||||
kind = kinds.distinct().joinToString(",")
|
kind = kinds.distinct().joinToString(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseHighlightedTags(
|
||||||
|
kindLabels: List<String>,
|
||||||
|
rules: List<HighlightTagRule>,
|
||||||
|
): Pair<List<HighlightedTag>, List<String>> {
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
return emptyList<HighlightedTag>() to kindLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
val compiledRules = rules.mapNotNull { rule ->
|
||||||
|
val regex = try {
|
||||||
|
Regex(rule.pattern)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
rule to regex
|
||||||
|
}
|
||||||
|
if (compiledRules.isEmpty()) {
|
||||||
|
return emptyList<HighlightedTag>() to kindLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group matched labels by rule
|
||||||
|
val ruleToLabels = linkedMapOf<HighlightTagRule, MutableList<String>>()
|
||||||
|
val regular = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (tag in kindLabels) {
|
||||||
|
var matched = false
|
||||||
|
for ((rule, regex) in compiledRules) {
|
||||||
|
if (regex.containsMatchIn(tag)) {
|
||||||
|
matched = true
|
||||||
|
ruleToLabels.getOrPut(rule) { mutableListOf() }.add(tag)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
regular.add(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val highlighted = ruleToLabels.map { (rule, labels) ->
|
||||||
|
HighlightedTag(
|
||||||
|
matchedLabels = labels,
|
||||||
|
title = rule.title.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted to regular
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyTagGroupRules(
|
||||||
|
books: List<Book>,
|
||||||
|
rules: List<io.legado.app.data.entities.TagGroupRule>,
|
||||||
|
) {
|
||||||
|
if (rules.isEmpty()) return
|
||||||
|
|
||||||
|
val compiledRules = rules.mapNotNull { rule ->
|
||||||
|
val regex = try {
|
||||||
|
Regex(rule.pattern)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
rule to regex
|
||||||
|
}
|
||||||
|
if (compiledRules.isEmpty()) return
|
||||||
|
|
||||||
|
val groupDao = appDb.bookGroupDao
|
||||||
|
val bookDao = appDb.bookDao
|
||||||
|
|
||||||
|
// Resolve groupName -> groupId (find or create BookGroup)
|
||||||
|
val groupCache = mutableMapOf<String, Long>()
|
||||||
|
for ((rule, _) in compiledRules) {
|
||||||
|
if (rule.groupName !in groupCache) {
|
||||||
|
val existing = groupDao.getByName(rule.groupName)
|
||||||
|
val groupId = existing?.groupId ?: run {
|
||||||
|
val newId = groupDao.getUnusedId()
|
||||||
|
groupDao.insert(
|
||||||
|
io.legado.app.data.entities.BookGroup(
|
||||||
|
groupId = newId,
|
||||||
|
groupName = rule.groupName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
newId
|
||||||
|
}
|
||||||
|
groupCache[rule.groupName] = groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask of all group IDs managed by tag group rules
|
||||||
|
val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
|
||||||
|
|
||||||
|
val updatedBooks = mutableListOf<Book>()
|
||||||
|
for (book in books) {
|
||||||
|
val kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
|
||||||
|
var newGroupMask = 0L
|
||||||
|
for ((rule, regex) in compiledRules) {
|
||||||
|
if (kinds.any { regex.containsMatchIn(it) }) {
|
||||||
|
newGroupMask = newGroupMask or (groupCache[rule.groupName] ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clear old rule-managed bits, then set new ones
|
||||||
|
val clearedGroup = book.group and allRuleGroupMask.inv()
|
||||||
|
val finalGroup = clearedGroup or newGroupMask
|
||||||
|
if (book.group != finalGroup) {
|
||||||
|
book.group = finalGroup
|
||||||
|
updatedBooks.add(book)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedBooks.isNotEmpty()) {
|
||||||
|
appDb.runInTransaction {
|
||||||
|
bookDao.update(*updatedBooks.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply tag group rules to a single book. Called from Book.save().
|
||||||
|
* Lightweight: only processes the given book, not all books.
|
||||||
|
*/
|
||||||
|
fun applyTagGroupRulesForBook(book: Book) {
|
||||||
|
val rules = appDb.tagGroupRuleDao.getAll()
|
||||||
|
if (rules.isEmpty()) return
|
||||||
|
|
||||||
|
val compiledRules = rules.mapNotNull { rule ->
|
||||||
|
val regex = try { Regex(rule.pattern) } catch (_: Exception) { return@mapNotNull null }
|
||||||
|
rule to regex
|
||||||
|
}
|
||||||
|
if (compiledRules.isEmpty()) return
|
||||||
|
|
||||||
|
val groupDao = appDb.bookGroupDao
|
||||||
|
val groupCache = mutableMapOf<String, Long>()
|
||||||
|
for ((rule, _) in compiledRules) {
|
||||||
|
if (rule.groupName !in groupCache) {
|
||||||
|
val existing = groupDao.getByName(rule.groupName)
|
||||||
|
val groupId = existing?.groupId ?: run {
|
||||||
|
val newId = groupDao.getUnusedId()
|
||||||
|
groupDao.insert(
|
||||||
|
io.legado.app.data.entities.BookGroup(
|
||||||
|
groupId = newId,
|
||||||
|
groupName = rule.groupName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
newId
|
||||||
|
}
|
||||||
|
groupCache[rule.groupName] = groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
|
||||||
|
val kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
|
||||||
|
var newGroupMask = 0L
|
||||||
|
for ((rule, regex) in compiledRules) {
|
||||||
|
if (kinds.any { regex.containsMatchIn(it) }) {
|
||||||
|
newGroupMask = newGroupMask or (groupCache[rule.groupName] ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val clearedGroup = book.group and allRuleGroupMask.inv()
|
||||||
|
val finalGroup = clearedGroup or newGroupMask
|
||||||
|
if (book.group != finalGroup) {
|
||||||
|
book.group = finalGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun Book.sync(oldBook: Book) {
|
fun Book.sync(oldBook: Book) {
|
||||||
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
|
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
|
||||||
durChapterTime = curBook.durChapterTime
|
durChapterTime = curBook.durChapterTime
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package io.legado.app.ui.tagGroupRule
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import io.legado.app.data.entities.TagGroupRule
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
|
import io.legado.app.ui.widget.components.list.InteractionState
|
||||||
|
import io.legado.app.ui.widget.components.list.ListUiState
|
||||||
|
import io.legado.app.ui.widget.components.list.SelectableItem
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class TagGroupRuleItemUi(
|
||||||
|
override val id: Long,
|
||||||
|
val displayName: String,
|
||||||
|
val pattern: String,
|
||||||
|
val groupName: String,
|
||||||
|
val rule: TagGroupRule
|
||||||
|
) : SelectableItem<Long>
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class TagGroupRuleUiState(
|
||||||
|
override val items: ImmutableList<TagGroupRuleItemUi> = persistentListOf(),
|
||||||
|
override val selectedIds: ImmutableSet<Long> = persistentSetOf(),
|
||||||
|
override val searchKey: String = "",
|
||||||
|
val interaction: InteractionState = InteractionState()
|
||||||
|
) : ListUiState<TagGroupRuleItemUi> {
|
||||||
|
override val isSearch: Boolean get() = interaction.isSearchMode
|
||||||
|
override val isLoading: Boolean get() = interaction.isUploading
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface TagGroupRuleIntent {
|
||||||
|
data class SetSearchMode(val active: Boolean) : TagGroupRuleIntent
|
||||||
|
data class UpdateSearchQuery(val query: String) : TagGroupRuleIntent
|
||||||
|
data object ClearSelection : TagGroupRuleIntent
|
||||||
|
data object SelectAll : TagGroupRuleIntent
|
||||||
|
data object InvertSelection : TagGroupRuleIntent
|
||||||
|
data class SetSelection(val ids: Set<Long>) : TagGroupRuleIntent
|
||||||
|
data class ToggleSelection(val id: Long) : TagGroupRuleIntent
|
||||||
|
data object DeleteSelection : TagGroupRuleIntent
|
||||||
|
data object UploadSelection : TagGroupRuleIntent
|
||||||
|
data class ExportSelection(val uri: android.net.Uri) : TagGroupRuleIntent
|
||||||
|
data class MoveItem(val from: Int, val to: Int) : TagGroupRuleIntent
|
||||||
|
data object SaveSortOrder : TagGroupRuleIntent
|
||||||
|
data class SaveRule(val rule: TagGroupRule, val isNew: Boolean) : TagGroupRuleIntent
|
||||||
|
data class DeleteRule(val rule: TagGroupRule) : TagGroupRuleIntent
|
||||||
|
data class CopyRule(val rule: TagGroupRule) : TagGroupRuleIntent
|
||||||
|
data class ImportSource(val text: String) : TagGroupRuleIntent
|
||||||
|
data object CancelImport : TagGroupRuleIntent
|
||||||
|
data class ToggleImportSelection(val index: Int) : TagGroupRuleIntent
|
||||||
|
data class ToggleImportAll(val isSelected: Boolean) : TagGroupRuleIntent
|
||||||
|
data class UpdateImportItem(val index: Int, val rule: TagGroupRule) : TagGroupRuleIntent
|
||||||
|
data object SaveImportedRules : TagGroupRuleIntent
|
||||||
|
data object SyncGroups : TagGroupRuleIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface TagGroupRuleEffect
|
||||||
|
|
||||||
|
data class TagGroupRuleRenderState(
|
||||||
|
val uiState: TagGroupRuleUiState,
|
||||||
|
val importState: BaseImportUiState<TagGroupRule>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package io.legado.app.ui.tagGroupRule
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NoteAdd
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.data.entities.TagGroupRule
|
||||||
|
import io.legado.app.ui.theme.LegadoTheme
|
||||||
|
import io.legado.app.ui.widget.components.AppFloatingActionButton
|
||||||
|
import io.legado.app.ui.widget.components.AppTextField
|
||||||
|
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
|
||||||
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
|
||||||
|
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||||
|
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TagGroupRuleEditSheet(
|
||||||
|
show: Boolean,
|
||||||
|
rule: TagGroupRule?,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSave: (TagGroupRule) -> Unit,
|
||||||
|
onCopy: (TagGroupRule) -> Unit,
|
||||||
|
onPaste: () -> TagGroupRule?,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isNew = rule == null || rule.id == 0L
|
||||||
|
val initial = remember(rule) {
|
||||||
|
rule ?: TagGroupRule()
|
||||||
|
}
|
||||||
|
|
||||||
|
var pattern by remember(initial) { mutableStateOf(initial.pattern) }
|
||||||
|
var groupName by remember(initial) { mutableStateOf(initial.groupName) }
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun getCurrentRule(): TagGroupRule {
|
||||||
|
return initial.copy(
|
||||||
|
pattern = pattern,
|
||||||
|
groupName = groupName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppModalBottomSheet(
|
||||||
|
title = if (isNew) {
|
||||||
|
stringResource(R.string.tag_group_add_rule)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.tag_group_edit_rule)
|
||||||
|
},
|
||||||
|
startAction = {
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = onDismissRequest,
|
||||||
|
icon = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
endAction = {
|
||||||
|
Box {
|
||||||
|
MediumPlainButton(
|
||||||
|
onClick = { showMenu = true },
|
||||||
|
icon = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "More"
|
||||||
|
)
|
||||||
|
RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.copy_rule),
|
||||||
|
leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
|
||||||
|
onClick = {
|
||||||
|
onCopy(getCurrentRule())
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
RoundDropdownMenuItem(
|
||||||
|
text = stringResource(R.string.paste_rule),
|
||||||
|
leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
onPaste()?.let { pasted ->
|
||||||
|
pattern = pasted.pattern
|
||||||
|
groupName = pasted.groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
show = show,
|
||||||
|
onDismissRequest = onDismissRequest
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 120.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
AppTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = groupName,
|
||||||
|
onValueChange = { groupName = it },
|
||||||
|
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||||
|
label = stringResource(R.string.tag_group_name),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
AppTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
value = pattern,
|
||||||
|
onValueChange = { pattern = it },
|
||||||
|
backgroundColor = LegadoTheme.colorScheme.surface,
|
||||||
|
label = stringResource(R.string.tag_group_pattern),
|
||||||
|
minLines = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
AppFloatingActionButton(
|
||||||
|
onClick = { onSave(getCurrentRule()) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
icon = Icons.Default.Save
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package io.legado.app.ui.tagGroupRule
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import io.legado.app.R
|
||||||
|
import io.legado.app.base.BaseRuleViewModel
|
||||||
|
import io.legado.app.data.appDb
|
||||||
|
import io.legado.app.data.entities.TagGroupRule
|
||||||
|
import io.legado.app.data.repository.TagGroupRuleRepository
|
||||||
|
import io.legado.app.data.repository.UploadRepository
|
||||||
|
import io.legado.app.help.book.applyTagGroupRules
|
||||||
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
|
import io.legado.app.ui.widget.components.list.InteractionState
|
||||||
|
import io.legado.app.utils.GSON
|
||||||
|
import io.legado.app.utils.fromJsonArray
|
||||||
|
import io.legado.app.utils.fromJsonObject
|
||||||
|
import io.legado.app.utils.getClipText
|
||||||
|
import io.legado.app.utils.isJsonArray
|
||||||
|
import io.legado.app.utils.isJsonObject
|
||||||
|
import io.legado.app.utils.sendToClip
|
||||||
|
import io.legado.app.utils.toastOnUi
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class TagGroupRuleViewModel(
|
||||||
|
application: Application,
|
||||||
|
uploadRepository: UploadRepository
|
||||||
|
) : BaseRuleViewModel<TagGroupRuleItemUi, TagGroupRule, Long, TagGroupRuleUiState>(
|
||||||
|
application,
|
||||||
|
TagGroupRuleUiState(interaction = InteractionState(isLoading = true)),
|
||||||
|
uploadRepository
|
||||||
|
) {
|
||||||
|
private val repository = TagGroupRuleRepository()
|
||||||
|
|
||||||
|
override val rawDataFlow: Flow<List<TagGroupRule>> = repository.flowAll()
|
||||||
|
|
||||||
|
fun onIntent(intent: TagGroupRuleIntent) {
|
||||||
|
when (intent) {
|
||||||
|
is TagGroupRuleIntent.SetSearchMode -> setSearchMode(intent.active)
|
||||||
|
is TagGroupRuleIntent.UpdateSearchQuery -> setSearchKey(intent.query)
|
||||||
|
TagGroupRuleIntent.ClearSelection -> setSelection(emptySet())
|
||||||
|
TagGroupRuleIntent.SelectAll -> selectAll()
|
||||||
|
TagGroupRuleIntent.InvertSelection -> invertSelection()
|
||||||
|
is TagGroupRuleIntent.SetSelection -> setSelection(intent.ids)
|
||||||
|
is TagGroupRuleIntent.ToggleSelection -> toggleSelection(intent.id)
|
||||||
|
TagGroupRuleIntent.DeleteSelection -> {
|
||||||
|
delSelectionByIds(uiState.value.selectedIds)
|
||||||
|
setSelection(emptySet())
|
||||||
|
}
|
||||||
|
TagGroupRuleIntent.UploadSelection -> {
|
||||||
|
val state = uiState.value
|
||||||
|
uploadSelectedRules(state.selectedIds, state.items)
|
||||||
|
}
|
||||||
|
is TagGroupRuleIntent.ExportSelection -> {
|
||||||
|
val state = uiState.value
|
||||||
|
exportToUri(intent.uri, state.items, state.selectedIds)
|
||||||
|
}
|
||||||
|
is TagGroupRuleIntent.MoveItem -> moveItemInList(intent.from, intent.to)
|
||||||
|
TagGroupRuleIntent.SaveSortOrder -> saveSortOrder()
|
||||||
|
is TagGroupRuleIntent.SaveRule -> {
|
||||||
|
if (intent.isNew) {
|
||||||
|
insert(intent.rule)
|
||||||
|
} else {
|
||||||
|
update(intent.rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is TagGroupRuleIntent.DeleteRule -> delete(intent.rule)
|
||||||
|
is TagGroupRuleIntent.CopyRule -> copyRule(intent.rule)
|
||||||
|
is TagGroupRuleIntent.ImportSource -> importSource(intent.text)
|
||||||
|
TagGroupRuleIntent.CancelImport -> cancelImport()
|
||||||
|
is TagGroupRuleIntent.ToggleImportSelection -> toggleImportSelection(intent.index)
|
||||||
|
is TagGroupRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
|
||||||
|
is TagGroupRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
|
||||||
|
TagGroupRuleIntent.SaveImportedRules -> saveImportedRules()
|
||||||
|
TagGroupRuleIntent.SyncGroups -> syncGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun filterData(
|
||||||
|
data: List<TagGroupRule>,
|
||||||
|
searchKey: String,
|
||||||
|
groupFilter: String
|
||||||
|
): List<TagGroupRule> {
|
||||||
|
val key = groupFilter.ifEmpty { searchKey }
|
||||||
|
val filtered = if (key.isEmpty()) data else {
|
||||||
|
data.filter {
|
||||||
|
it.groupName.contains(key, ignoreCase = true) ||
|
||||||
|
it.pattern.contains(key, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered.sortedBy { it.order }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun composeUiState(
|
||||||
|
items: List<TagGroupRuleItemUi>,
|
||||||
|
selectedIds: Set<Long>,
|
||||||
|
isSearch: Boolean,
|
||||||
|
isUploading: Boolean,
|
||||||
|
importState: BaseImportUiState<TagGroupRule>
|
||||||
|
): TagGroupRuleUiState {
|
||||||
|
return TagGroupRuleUiState(
|
||||||
|
items = items.toImmutableList(),
|
||||||
|
selectedIds = selectedIds.toImmutableSet(),
|
||||||
|
searchKey = _searchKey.value,
|
||||||
|
interaction = InteractionState(
|
||||||
|
isSearchMode = isSearch,
|
||||||
|
isUploading = isUploading || (importState is BaseImportUiState.Loading),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun TagGroupRule.toUiItem() = TagGroupRuleItemUi(
|
||||||
|
id = id,
|
||||||
|
displayName = groupName.ifBlank { pattern },
|
||||||
|
pattern = pattern,
|
||||||
|
groupName = groupName,
|
||||||
|
rule = this
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun ruleItemToEntity(item: TagGroupRuleItemUi): TagGroupRule = item.rule
|
||||||
|
|
||||||
|
override suspend fun generateJson(entities: List<TagGroupRule>): String = GSON.toJson(entities)
|
||||||
|
|
||||||
|
override fun parseImportRules(text: String): List<TagGroupRule> {
|
||||||
|
return when {
|
||||||
|
text.isJsonArray() -> GSON.fromJsonArray<TagGroupRule>(text).getOrThrow()
|
||||||
|
text.isJsonObject() -> listOf(GSON.fromJsonObject<TagGroupRule>(text).getOrThrow())
|
||||||
|
else -> throw Exception("格式不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasChanged(newRule: TagGroupRule, oldRule: TagGroupRule): Boolean {
|
||||||
|
return newRule.groupName != oldRule.groupName
|
||||||
|
|| newRule.pattern != oldRule.pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findOldRule(newRule: TagGroupRule): TagGroupRule? {
|
||||||
|
return repository.findById(newRule.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveImportedRules() {
|
||||||
|
val state = _importState.value as? BaseImportUiState.Success<TagGroupRule> ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val rulesToSave = state.items
|
||||||
|
.filter { it.isSelected }
|
||||||
|
.map { it.data }
|
||||||
|
repository.insert(*rulesToSave.toTypedArray())
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_importState.value = BaseImportUiState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSortOrder() {
|
||||||
|
val currentLocal = _localItems.value ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.moveOrder(currentLocal.map { it.rule })
|
||||||
|
_localItems.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delSelectionByIds(ids: Set<Long>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteByIds(ids)
|
||||||
|
_selectedIds.update { it - ids }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectAll() {
|
||||||
|
setSelection(uiState.value.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invertSelection() {
|
||||||
|
val state = uiState.value
|
||||||
|
setSelection(state.items.map { it.id }.toSet() - state.selectedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(vararg rule: TagGroupRule) = viewModelScope.launch {
|
||||||
|
repository.update(*rule)
|
||||||
|
autoApplyRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insert(vararg rule: TagGroupRule) = viewModelScope.launch {
|
||||||
|
repository.insert(*rule)
|
||||||
|
autoApplyRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(vararg rule: TagGroupRule) = viewModelScope.launch {
|
||||||
|
repository.delete(*rule)
|
||||||
|
autoApplyRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun autoApplyRules() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val books = appDb.bookDao.getAll()
|
||||||
|
val rules = appDb.tagGroupRuleDao.getAll()
|
||||||
|
applyTagGroupRules(books, rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyRule(rule: TagGroupRule) {
|
||||||
|
context.sendToClip(GSON.toJson(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pasteRule(): TagGroupRule? {
|
||||||
|
val text = context.getClipText()
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
context.toastOnUi("剪贴板没有内容")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
GSON.fromJsonObject<TagGroupRule>(text).getOrThrow()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
context.toastOnUi("格式不对")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncGroups() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val books = appDb.bookDao.getAll()
|
||||||
|
val rules = appDb.tagGroupRuleDao.getAll()
|
||||||
|
applyTagGroupRules(books, rules)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
context.toastOnUi(R.string.tag_group_sync_complete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user