feat: 添加标签分组规则功能,支持按书籍标签自动匹配分组

This commit is contained in:
HapeLee
2026-06-27 02:27:42 +08:00
parent 4b65d6fc71
commit 64aeb33f91
8 changed files with 645 additions and 0 deletions
@@ -1,6 +1,7 @@
package io.legado.app.data.entities
import android.os.Parcelable
import io.legado.app.help.book.applyTagGroupRulesForBook
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
@@ -387,6 +388,7 @@ data class Book(
}
fun save() {
applyTagGroupRulesForBook(this)
if (appDb.bookDao.has(bookUrl)) {
appDb.bookDao.update(this)
} 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<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
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.dict.DictViewModel
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.MainViewModel
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -231,6 +233,8 @@ val appModule = module {
}
viewModelOf(::DictRuleViewModel)
viewModelOf(::HighlightTagRuleViewModel)
viewModelOf(::TagGroupRuleViewModel)
viewModelOf(::DictViewModel)
viewModelOf(::RssSourceViewModel)
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.Book
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.help.RuleBigDataHelp
import io.legado.app.help.config.AppConfig
@@ -345,6 +347,168 @@ fun Book.upKind() {
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) {
val curBook = appDb.bookDao.getBook(oldBook.bookUrl)!!
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)
}
}
}
}