Просмотр исходного кода

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

HapeLee 4 месяцев назад
Родитель
Сommit
64aeb33f91

+ 2 - 0
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -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 {

+ 23 - 0
app/src/main/java/io/legado/app/data/entities/TagGroupRule.kt

@@ -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
+    }
+
+}

+ 2 - 0
app/src/main/java/io/legado/app/di/appDatabaseModule.kt

@@ -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 }
 }
 }

+ 4 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -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)

+ 164 - 0
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -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

+ 64 - 0
app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleContract.kt

@@ -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>,
+)

+ 151 - 0
app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleEditSheet.kt

@@ -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
+            )
+        }
+    }
+}

+ 235 - 0
app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleViewModel.kt

@@ -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)
+            }
+        }
+    }
+}