From 64aeb33f91b8bd8df812a6ffb14b568dbfa36354 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:16:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=88=86=E7=BB=84=E8=A7=84=E5=88=99=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E4=B9=A6=E7=B1=8D=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/legado/app/data/entities/Book.kt | 2 + .../legado/app/data/entities/TagGroupRule.kt | 23 ++ .../io/legado/app/di/appDatabaseModule.kt | 2 + .../main/java/io/legado/app/di/appModule.kt | 4 + .../io/legado/app/help/book/BookExtensions.kt | 164 ++++++++++++ .../ui/tagGroupRule/TagGroupRuleContract.kt | 64 +++++ .../ui/tagGroupRule/TagGroupRuleEditSheet.kt | 151 +++++++++++ .../ui/tagGroupRule/TagGroupRuleViewModel.kt | 235 ++++++++++++++++++ 8 files changed, 645 insertions(+) create mode 100644 app/src/main/java/io/legado/app/data/entities/TagGroupRule.kt create mode 100644 app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleEditSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleViewModel.kt diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt index 2da0d8f04..4cb4728d6 100644 --- a/app/src/main/java/io/legado/app/data/entities/Book.kt +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -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 { diff --git a/app/src/main/java/io/legado/app/data/entities/TagGroupRule.kt b/app/src/main/java/io/legado/app/data/entities/TagGroupRule.kt new file mode 100644 index 000000000..efea08630 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt index 09fe510bf..2292c60b2 100644 --- a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt +++ b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt @@ -39,4 +39,6 @@ val appDatabaseModule = module { factory { get().homepageModuleDao } factory { get().homepageCustomSetDao } factory { get().highlightRuleDao } + factory { get().highlightTagRuleDao } + factory { get().tagGroupRuleDao } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index b9b2116de..fcc42f6c8 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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.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) diff --git a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt index 766a7d14f..b14eefef6 100644 --- a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt +++ b/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.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, + rules: List, +): Pair, List> { + if (rules.isEmpty()) { + return emptyList() 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() to kindLabels + } + + // Group matched labels by rule + val ruleToLabels = linkedMapOf>() + val regular = mutableListOf() + + 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, + rules: List, +) { + 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() + 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() + 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() + 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 diff --git a/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleContract.kt b/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleContract.kt new file mode 100644 index 000000000..0a6d5b260 --- /dev/null +++ b/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 + +@Stable +data class TagGroupRuleUiState( + override val items: ImmutableList = persistentListOf(), + override val selectedIds: ImmutableSet = persistentSetOf(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState() +) : ListUiState { + 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) : 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, +) diff --git a/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleEditSheet.kt new file mode 100644 index 000000000..ae959f5be --- /dev/null +++ b/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 + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/tagGroupRule/TagGroupRuleViewModel.kt new file mode 100644 index 000000000..d15370f57 --- /dev/null +++ b/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( + application, + TagGroupRuleUiState(interaction = InteractionState(isLoading = true)), + uploadRepository +) { + private val repository = TagGroupRuleRepository() + + override val rawDataFlow: Flow> = 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, + searchKey: String, + groupFilter: String + ): List { + 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, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): 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): String = GSON.toJson(entities) + + override fun parseImportRules(text: String): List { + return when { + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonObject() -> listOf(GSON.fromJsonObject(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 ?: 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) { + 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(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) + } + } + } +}