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

refactor(R2.2): 高亮规则域 288 行摘出 ReadBookViewModel

按 AI 域同一形态摘第二个域。ReadBookViewModel 5453 → 5142 行。

- 新增 ReadHighlightRuleDelegate,自持 HighlightRuleConfigUiState(从
  ReadBookContract 移到 ReadHighlightRuleContract,同包所以 sheet 的
  import 零改动)。ReadBookUiState 不再挂 highlightRuleConfig。
- delegate 对外只剩两个 Host 回调:showToast、notifyRulesChanged
  (后者发原来的 UpdateReadViewConfig(UpdateChapterStyle+ReloadContent))。
- execute {} 是 BaseViewModel 的成员,delegate 里换成它的实现体
  Coroutine.async(scope, Dispatchers.IO)——start/executeContext/semaphore
  默认参数完全一致,语义不变。
- activeSheet 仍由 ReadBookUiState 单一持有,sheet 互斥与返回键行为不变;
  弹层关闭的中间态清理走 delegate.onSheetDismissed()。
- onIntent 里 8 个内联 _uiState.update{} 分支上提成 delegate 方法。

护栏:ReadAiDomainBoundaryTest 改写成表驱动的
ReadBookDomainSplitBoundaryTest,每摘一个域加一条即可。四条断言:
域状态不回流 ReadBookUiState、ReadAiUiState 字段完整、域状态类型不回流
ReadBookViewModel、各 delegate 不自带 DAO 直连(后者防止把 VM 棘轮上的
债洗进宽松的 legacyUiDaoAccessBaseline)。

已注入回归反证:三条断言各自变红后才回滚。

验证:compileAppDebugKotlin + verifyConfigArchitecture + 全量单测
(417 passed) + assembleAppDebug + lintAppDebug 全绿。
搬迁前后做了归一化多重集比对,无漏搬无多搬。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
6a4d6f7e9d

+ 0 - 10
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -290,7 +290,6 @@ data class ReadBookUiState(
     val sheetConfig: ReadSheetConfigUiState = ReadSheetConfigUiState(),
     // Menu config (from ReadBookConfig via repository)
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
-    val highlightRuleConfig: HighlightRuleConfigUiState = HighlightRuleConfigUiState(),
     val contentProcessConfig: ContentProcessConfigUiState = ContentProcessConfigUiState(),
     // AI 域状态见 ReadAiUiState —— 由 ReadAiDelegate 独立持有
     val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
@@ -313,15 +312,6 @@ data class EyeProtectionUiState(
         get() = enabled || autoNight
 }
 
-@Stable
-data class HighlightRuleConfigUiState(
-    val rules: ImmutableList<HighlightRule> = persistentListOf(),
-    val editingRule: HighlightRule? = null,
-    val showNewRule: Boolean = false,
-    val deleteRule: HighlightRule? = null,
-    val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
-)
-
 @Stable
 data class ContentProcessConfigUiState(
     val isLoading: Boolean = false,

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -135,6 +135,7 @@ fun ReadBookRouteScreen(
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val aiState by viewModel.aiState.collectAsStateWithLifecycle()
+    val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
@@ -617,6 +618,7 @@ fun ReadBookRouteScreen(
             ReadBookScreen(
                 state = state,
                 aiState = aiState,
+                highlightRuleState = highlightRuleState,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -67,6 +67,7 @@ import io.legado.app.model.BookCover as BookCoverModel
 fun ReadBookScreen(
     state: ReadBookUiState,
     aiState: ReadAiUiState,
+    highlightRuleState: HighlightRuleConfigUiState,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
@@ -264,7 +265,7 @@ fun ReadBookScreen(
     )
     HighlightRuleConfigSheet(
         show = state.activeSheet is ReadBookSheet.HighlightRuleConfig,
-        state = state.highlightRuleConfig,
+        state = highlightRuleState,
         allConfigNames = state.sheetConfig.configNames,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,

+ 62 - 370
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -25,7 +25,6 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.Bookmark
-import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.BookSourceRepository
@@ -111,7 +110,6 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.read.page.provider.TextChapterLayout
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
@@ -280,6 +278,33 @@ class ReadBookViewModel(
 
     val aiState = aiDelegate.uiState
 
+    // --- 高亮规则域 ---
+
+    private val highlightRuleDelegate = ReadHighlightRuleDelegate(
+        context = context,
+        scope = viewModelScope,
+        host = object : ReadHighlightRuleDelegate.Host {
+            override fun showToast(message: String) {
+                _effects.tryEmit(ReadBookEffect.ShowToast(message))
+            }
+
+            override fun notifyRulesChanged() {
+                _effects.tryEmit(
+                    ReadBookEffect.UpdateReadViewConfig(
+                        setOf(
+                            ConfigUpdateAction.UpdateChapterStyle,
+                            ConfigUpdateAction.ReloadContent,
+                        )
+                    )
+                )
+            }
+        },
+        highlightRuleRepository = highlightRuleRepository,
+        uploadRepository = uploadRepository,
+    )
+
+    val highlightRuleState = highlightRuleDelegate.uiState
+
     private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
         val tts = TextToSpeech(context, null)
         val engines = tts.engines
@@ -646,7 +671,7 @@ class ReadBookViewModel(
 
             is ReadBookIntent.ShowSheet -> {
                 if (intent.sheet is ReadBookSheet.HighlightRuleConfig) {
-                    loadHighlightRules()
+                    highlightRuleDelegate.load()
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
                 } else if (intent.sheet is ReadBookSheet.ContentProcesses) {
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
@@ -659,6 +684,9 @@ class ReadBookViewModel(
             }
             is ReadBookIntent.DismissSheet -> {
                 aiDelegate.onSheetDismissed(_uiState.value.activeSheet)
+                if (_uiState.value.activeSheet is ReadBookSheet.HighlightRuleConfig) {
+                    highlightRuleDelegate.onSheetDismissed()
+                }
                 _uiState.update {
                     if (it.activeSheet is ReadBookSheet.ContentEdit) {
                         it.copy(
@@ -669,16 +697,6 @@ class ReadBookViewModel(
                             contentEditLoading = false,
                             contentEditSaveToSource = false,
                         )
-                    } else if (it.activeSheet is ReadBookSheet.HighlightRuleConfig) {
-                        it.copy(
-                            activeSheet = null,
-                            highlightRuleConfig = it.highlightRuleConfig.copy(
-                                editingRule = null,
-                                showNewRule = false,
-                                deleteRule = null,
-                                importState = BaseImportUiState.Idle,
-                            ),
-                        )
                     } else if (it.activeSheet is ReadBookSheet.ContentProcesses) {
                         it.copy(
                             activeSheet = null,
@@ -923,81 +941,43 @@ class ReadBookViewModel(
             is ReadBookIntent.UpdateConfig -> {
                 handleConfigUpdate(intent.update)
             }
-            is ReadBookIntent.AddHighlightRule -> _uiState.update {
-                it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(showNewRule = true))
-            }
-
-            is ReadBookIntent.EditHighlightRule -> _uiState.update {
-                it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(editingRule = intent.rule))
-            }
-
-            is ReadBookIntent.ToggleHighlightRule -> {
-                val rules = _uiState.value.highlightRuleConfig.rules.map {
-                    if (it.id == intent.rule.id) it.copy(enabled = intent.enabled) else it
-                }
-                saveHighlightRules(rules)
-            }
-
-            is ReadBookIntent.SaveHighlightRule -> {
-                val currentRules = _uiState.value.highlightRuleConfig.rules
-                val updatedRules = if (currentRules.any { it.id == intent.rule.id }) {
-                    currentRules.map { if (it.id == intent.rule.id) intent.rule else it }
-                } else {
-                    currentRules + intent.rule
-                }
-                saveHighlightRules(updatedRules)
-            }
-
-            is ReadBookIntent.DismissHighlightRuleEdit -> _uiState.update {
-                it.copy(
-                    highlightRuleConfig = it.highlightRuleConfig.copy(
-                        editingRule = null,
-                        showNewRule = false,
-                    ),
-                )
-            }
-
-            is ReadBookIntent.RequestDeleteHighlightRule -> _uiState.update {
-                it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(deleteRule = intent.rule))
-            }
-
-            is ReadBookIntent.ConfirmDeleteHighlightRule -> deletePendingHighlightRule()
-            is ReadBookIntent.DismissDeleteHighlightRule -> _uiState.update {
-                it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(deleteRule = null))
-            }
-            is ReadBookIntent.MoveHighlightRule -> moveHighlightRule(intent.from, intent.to)
-            is ReadBookIntent.SaveHighlightRuleOrder -> {
-                saveHighlightRules(_uiState.value.highlightRuleConfig.rules)
-            }
-            is ReadBookIntent.ImportHighlightRuleSource -> {
-                importHighlightRuleSource(intent.text)
-            }
+            is ReadBookIntent.AddHighlightRule -> highlightRuleDelegate.startAddRule()
+            is ReadBookIntent.EditHighlightRule ->
+                highlightRuleDelegate.startEditRule(intent.rule)
+            is ReadBookIntent.ToggleHighlightRule ->
+                highlightRuleDelegate.toggleRule(intent.rule, intent.enabled)
+            is ReadBookIntent.SaveHighlightRule -> highlightRuleDelegate.saveRule(intent.rule)
+            is ReadBookIntent.DismissHighlightRuleEdit -> highlightRuleDelegate.dismissRuleEdit()
+            is ReadBookIntent.RequestDeleteHighlightRule ->
+                highlightRuleDelegate.requestDeleteRule(intent.rule)
+            is ReadBookIntent.ConfirmDeleteHighlightRule ->
+                highlightRuleDelegate.deletePendingRule()
+            is ReadBookIntent.DismissDeleteHighlightRule ->
+                highlightRuleDelegate.dismissDeleteRule()
+            is ReadBookIntent.MoveHighlightRule ->
+                highlightRuleDelegate.moveRule(intent.from, intent.to)
+            is ReadBookIntent.SaveHighlightRuleOrder -> highlightRuleDelegate.saveRuleOrder()
+            is ReadBookIntent.ImportHighlightRuleSource ->
+                highlightRuleDelegate.importSource(intent.text)
             is ReadBookIntent.OpenHighlightRuleImportPicker -> {
                 _effects.tryEmit(ReadBookEffect.OpenHighlightRuleImportPicker)
             }
-            is ReadBookIntent.HighlightRuleImportFileSelected -> {
-                importHighlightRuleFile(intent.uri)
-            }
-            is ReadBookIntent.CancelHighlightRuleImport -> cancelHighlightRuleImport()
-            is ReadBookIntent.ToggleHighlightRuleImportSelection -> {
-                toggleHighlightRuleImportSelection(intent.index)
-            }
-            is ReadBookIntent.ToggleHighlightRuleImportAll -> {
-                toggleHighlightRuleImportAll(intent.isSelected)
-            }
-            is ReadBookIntent.UpdateHighlightRuleImportItem -> {
-                updateHighlightRuleImportItem(intent.index, intent.rule)
-            }
-            is ReadBookIntent.SaveImportedHighlightRules -> saveImportedHighlightRules()
+            is ReadBookIntent.HighlightRuleImportFileSelected ->
+                highlightRuleDelegate.importFile(intent.uri)
+            is ReadBookIntent.CancelHighlightRuleImport -> highlightRuleDelegate.cancelImport()
+            is ReadBookIntent.ToggleHighlightRuleImportSelection ->
+                highlightRuleDelegate.toggleImportSelection(intent.index)
+            is ReadBookIntent.ToggleHighlightRuleImportAll ->
+                highlightRuleDelegate.toggleImportAll(intent.isSelected)
+            is ReadBookIntent.UpdateHighlightRuleImportItem ->
+                highlightRuleDelegate.updateImportItem(intent.index, intent.rule)
+            is ReadBookIntent.SaveImportedHighlightRules -> highlightRuleDelegate.saveImported()
             is ReadBookIntent.ExportHighlightRules -> {
                 _effects.tryEmit(ReadBookEffect.OpenHighlightRuleExportPicker)
             }
-            is ReadBookIntent.ExportHighlightRulesAsUrl -> {
-                exportHighlightRulesAsUrl()
-            }
-            is ReadBookIntent.ExportHighlightRulesToFile -> {
-                exportHighlightRules(intent.uri)
-            }
+            is ReadBookIntent.ExportHighlightRulesAsUrl -> highlightRuleDelegate.exportAsUrl()
+            is ReadBookIntent.ExportHighlightRulesToFile ->
+                highlightRuleDelegate.exportToFile(intent.uri)
             is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri)
             is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri)
             is ReadBookIntent.OpenMenuCustomIconPicker -> {
@@ -4556,294 +4536,6 @@ class ReadBookViewModel(
     private fun colorMutation(key: ReadStyleColorKey, value: Int) =
         ReadStyleMutation.ColorValue(key, value)
 
-    private fun moveHighlightRule(from: Int, to: Int) {
-        val rules = _uiState.value.highlightRuleConfig.rules
-        if (from !in rules.indices || to !in rules.indices) return
-        val reordered = rules.toMutableList().apply {
-            add(to, removeAt(from))
-        }
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    rules = reordered.toImmutableList()
-                )
-            )
-        }
-    }
-
-    private fun importHighlightRuleSource(text: String) {
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    importState = BaseImportUiState.Loading
-                )
-            )
-        }
-        execute {
-            val importedRules = importHighlightRuleSourceAwait(text.trim())
-                .map(highlightRuleRepository::sanitizeRule)
-            if (importedRules.isEmpty()) {
-                throw NoStackTraceException(context.getString(R.string.wrong_format))
-            }
-            val oldRules = highlightRuleRepository.load(ReadBookConfig.durConfig.name)
-                .associateBy { it.id }
-            BaseImportUiState.Success(
-                source = text,
-                items = importedRules.map { rule ->
-                    val oldRule = oldRules[rule.id]
-                    val status = when {
-                        oldRule == null -> ImportStatus.New
-                        oldRule != rule -> ImportStatus.Update
-                        else -> ImportStatus.Existing
-                    }
-                    ImportItemWrapper(
-                        data = rule,
-                        oldData = oldRule,
-                        status = status,
-                        isSelected = status != ImportStatus.Existing,
-                    )
-                }
-            )
-        }.onSuccess { importState ->
-            _uiState.update {
-                it.copy(
-                    highlightRuleConfig = it.highlightRuleConfig.copy(
-                        importState = importState
-                    )
-                )
-            }
-        }.onError {
-            AppLog.put("导入高亮规则失败\n${it.localizedMessage}", it, true)
-            _uiState.update { state ->
-                state.copy(
-                    highlightRuleConfig = state.highlightRuleConfig.copy(
-                        importState = BaseImportUiState.Error(
-                            it.localizedMessage ?: context.getString(R.string.wrong_format)
-                        )
-                    )
-                )
-            }
-        }
-    }
-
-    private suspend fun importHighlightRuleSourceAwait(text: String): List<HighlightRule> {
-        return when {
-            text.isJsonArray() -> GSON.fromJsonArray<HighlightRule>(text).getOrThrow()
-            text.isJsonObject() -> listOf(
-                GSON.fromJsonObject<HighlightRule>(text).getOrThrow()
-            )
-            text.isAbsUrl() -> {
-                val body = okHttpClient.newCallResponseBody {
-                    if (text.endsWith("#requestWithoutUA")) {
-                        url(text.substringBeforeLast("#requestWithoutUA"))
-                        header(AppConst.UA_NAME, "null")
-                    } else {
-                        url(text)
-                    }
-                }.decompressed().text()
-                importHighlightRuleSourceAwait(body)
-            }
-            else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
-        }
-    }
-
-    private fun importHighlightRuleFile(uri: Uri) {
-        execute<String?> {
-            context.contentResolver.openInputStream(uri)?.use {
-                it.reader().readText()
-            }
-        }.onSuccess { text ->
-            if (text.isNullOrBlank()) {
-                _uiState.update { state ->
-                    state.copy(
-                        highlightRuleConfig = state.highlightRuleConfig.copy(
-                            importState = BaseImportUiState.Error(
-                                context.getString(R.string.wrong_format)
-                            )
-                        )
-                    )
-                }
-            } else {
-                importHighlightRuleSource(text)
-            }
-        }.onError {
-            _uiState.update { state ->
-                state.copy(
-                    highlightRuleConfig = state.highlightRuleConfig.copy(
-                        importState = BaseImportUiState.Error(
-                            it.localizedMessage ?: context.getString(R.string.wrong_format)
-                        )
-                    )
-                )
-            }
-        }
-    }
-
-    private fun cancelHighlightRuleImport() {
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    importState = BaseImportUiState.Idle
-                )
-            )
-        }
-    }
-
-    private fun toggleHighlightRuleImportSelection(index: Int) {
-        val importState = _uiState.value.highlightRuleConfig.importState
-            as? BaseImportUiState.Success<HighlightRule> ?: return
-        if (index !in importState.items.indices) return
-        val items = importState.items.toMutableList()
-        val item = items[index]
-        items[index] = item.copy(isSelected = !item.isSelected)
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    importState = importState.copy(items = items)
-                )
-            )
-        }
-    }
-
-    private fun toggleHighlightRuleImportAll(isSelected: Boolean) {
-        val importState = _uiState.value.highlightRuleConfig.importState
-            as? BaseImportUiState.Success<HighlightRule> ?: return
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    importState = importState.copy(
-                        items = importState.items.map { item ->
-                            item.copy(isSelected = isSelected)
-                        }
-                    )
-                )
-            )
-        }
-    }
-
-    private fun updateHighlightRuleImportItem(index: Int, rule: HighlightRule) {
-        val importState = _uiState.value.highlightRuleConfig.importState
-            as? BaseImportUiState.Success<HighlightRule> ?: return
-        if (index !in importState.items.indices) return
-        val items = importState.items.toMutableList()
-        items[index] = items[index].copy(data = rule)
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    importState = importState.copy(
-                        items = items,
-                        version = importState.version + 1,
-                    )
-                )
-            )
-        }
-    }
-
-    private fun saveImportedHighlightRules() {
-        val state = _uiState.value.highlightRuleConfig
-        val importState = state.importState
-            as? BaseImportUiState.Success<HighlightRule> ?: return
-        val importedRules = importState.items
-            .filter { it.isSelected }
-            .map { highlightRuleRepository.sanitizeRule(it.data) }
-        if (importedRules.isEmpty()) return
-        val importedById = importedRules.associateBy { it.id }
-        val mergedRules = state.rules.map { importedById[it.id] ?: it } +
-                importedRules.filter { imported -> state.rules.none { it.id == imported.id } }
-        saveHighlightRules(mergedRules)
-        cancelHighlightRuleImport()
-    }
-
-    private fun exportHighlightRules(uri: Uri) {
-        val rules = _uiState.value.highlightRuleConfig.rules
-        execute {
-            context.contentResolver.openOutputStream(uri)?.use { output ->
-                output.bufferedWriter().use { writer ->
-                    writer.write(GSON.toJson(rules))
-                }
-            } ?: throw NoStackTraceException(context.getString(R.string.error))
-        }.onSuccess {
-            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.export_success)))
-        }.onError {
-            _effects.tryEmit(
-                ReadBookEffect.ShowToast(
-                    it.localizedMessage ?: context.getString(R.string.error)
-                )
-            )
-        }
-    }
-
-    private fun exportHighlightRulesAsUrl() {
-        val rules = _uiState.value.highlightRuleConfig.rules
-        execute {
-            uploadRepository.upload(
-                fileName = HighlightRuleRepository.backupFileName,
-                file = GSON.toJson(rules),
-                contentType = "application/json",
-            )
-        }.onSuccess { url ->
-            context.sendToClip(url)
-            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.copy_url)))
-        }
-    }
-
-    private fun loadHighlightRules() {
-        val configName = ReadBookConfig.durConfig.name
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    rules = highlightRuleRepository.load(configName).toImmutableList(),
-                    editingRule = null,
-                    showNewRule = false,
-                    deleteRule = null,
-                    importState = BaseImportUiState.Idle,
-                ),
-            )
-        }
-    }
-
-    private fun saveHighlightRules(rules: List<HighlightRule>) {
-        val configName = ReadBookConfig.durConfig.name
-        val sanitizedRules = rules.map(highlightRuleRepository::sanitizeRule)
-        highlightRuleRepository.save(configName, sanitizedRules)
-        TextChapterLayout.invalidateRegexCache()
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    rules = highlightRuleRepository.load(configName).toImmutableList(),
-                    editingRule = null,
-                    showNewRule = false,
-                    deleteRule = null,
-                ),
-            )
-        }
-        _effects.tryEmit(
-            ReadBookEffect.UpdateReadViewConfig(
-                setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
-            )
-        )
-    }
-
-    private fun deletePendingHighlightRule() {
-        val rule = _uiState.value.highlightRuleConfig.deleteRule ?: return
-        val configName = ReadBookConfig.durConfig.name
-        highlightRuleRepository.delete(rule)
-        TextChapterLayout.invalidateRegexCache()
-        _uiState.update {
-            it.copy(
-                highlightRuleConfig = it.highlightRuleConfig.copy(
-                    rules = highlightRuleRepository.load(configName).toImmutableList(),
-                    deleteRule = null,
-                ),
-            )
-        }
-        _effects.tryEmit(
-            ReadBookEffect.UpdateReadViewConfig(
-                setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
-            )
-        )
-    }
-
     private fun saveMenuCustomIcon(id: String, uri: Uri) {
         execute {
             val iconFile = java.io.File(context.filesDir, "read_menu_icons/$id.png")

+ 20 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleContract.kt

@@ -0,0 +1,20 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * 高亮规则域的状态契约,由 [ReadHighlightRuleDelegate] 独立持有,
+ * 不再挂在 [ReadBookUiState] 上。sheet 的开合仍由 [ReadBookUiState.activeSheet] 单一持有。
+ */
+@Stable
+data class HighlightRuleConfigUiState(
+    val rules: ImmutableList<HighlightRule> = persistentListOf(),
+    val editingRule: HighlightRule? = null,
+    val showNewRule: Boolean = false,
+    val deleteRule: HighlightRule? = null,
+    val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
+)

+ 351 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt

@@ -0,0 +1,351 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import io.legado.app.R
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppLog
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.data.repository.HighlightRuleRepository
+import io.legado.app.data.repository.UploadRepository
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.http.decompressed
+import io.legado.app.help.http.newCallResponseBody
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.text
+import io.legado.app.ui.book.read.page.provider.TextChapterLayout
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
+import io.legado.app.ui.widget.components.importComponents.ImportStatus
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isJsonObject
+import io.legado.app.utils.sendToClip
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * 阅读页高亮规则域:增删改排序、导入(文本 / URL / 文件)、导出(文件 / 上传取链)。
+ *
+ * 自持 [HighlightRuleConfigUiState],只通过 [Host] 反向触达 Toast 与「规则变了,重排正文」。
+ *
+ * `execute {}` 是 `BaseViewModel` 的成员,delegate 里改用它的实现体
+ * `Coroutine.async(scope, Dispatchers.IO)`——默认参数(start / executeContext / semaphore)
+ * 与 `execute {}` 完全一致,语义不变。
+ */
+class ReadHighlightRuleDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val highlightRuleRepository: HighlightRuleRepository,
+    private val uploadRepository: UploadRepository,
+) {
+
+    interface Host {
+        fun showToast(message: String)
+
+        /** 规则集变了:让正文重新走高亮排版。 */
+        fun notifyRulesChanged()
+    }
+
+    private val _uiState = MutableStateFlow(HighlightRuleConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    fun load() {
+        val configName = ReadBookConfig.durConfig.name
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+                importState = BaseImportUiState.Idle,
+            )
+        }
+    }
+
+    /** 关闭弹层时清掉编辑/删除/导入的中间态,规则列表保留。 */
+    fun onSheetDismissed() {
+        _uiState.update {
+            it.copy(
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+                importState = BaseImportUiState.Idle,
+            )
+        }
+    }
+
+    // --- 增删改排序 ---
+
+    fun startAddRule() {
+        _uiState.update { it.copy(showNewRule = true) }
+    }
+
+    fun startEditRule(rule: HighlightRule) {
+        _uiState.update { it.copy(editingRule = rule) }
+    }
+
+    fun dismissRuleEdit() {
+        _uiState.update { it.copy(editingRule = null, showNewRule = false) }
+    }
+
+    fun toggleRule(rule: HighlightRule, enabled: Boolean) {
+        val rules = _uiState.value.rules.map {
+            if (it.id == rule.id) it.copy(enabled = enabled) else it
+        }
+        saveRules(rules)
+    }
+
+    fun saveRule(rule: HighlightRule) {
+        val currentRules = _uiState.value.rules
+        val updatedRules = if (currentRules.any { it.id == rule.id }) {
+            currentRules.map { if (it.id == rule.id) rule else it }
+        } else {
+            currentRules + rule
+        }
+        saveRules(updatedRules)
+    }
+
+    fun requestDeleteRule(rule: HighlightRule) {
+        _uiState.update { it.copy(deleteRule = rule) }
+    }
+
+    fun dismissDeleteRule() {
+        _uiState.update { it.copy(deleteRule = null) }
+    }
+
+    fun deletePendingRule() {
+        val rule = _uiState.value.deleteRule ?: return
+        val configName = ReadBookConfig.durConfig.name
+        highlightRuleRepository.delete(rule)
+        TextChapterLayout.invalidateRegexCache()
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                deleteRule = null,
+            )
+        }
+        host.notifyRulesChanged()
+    }
+
+    fun moveRule(from: Int, to: Int) {
+        val rules = _uiState.value.rules
+        if (from !in rules.indices || to !in rules.indices) return
+        val reordered = rules.toMutableList().apply {
+            add(to, removeAt(from))
+        }
+        _uiState.update { it.copy(rules = reordered.toImmutableList()) }
+    }
+
+    fun saveRuleOrder() {
+        saveRules(_uiState.value.rules)
+    }
+
+    private fun saveRules(rules: List<HighlightRule>) {
+        val configName = ReadBookConfig.durConfig.name
+        val sanitizedRules = rules.map(highlightRuleRepository::sanitizeRule)
+        highlightRuleRepository.save(configName, sanitizedRules)
+        TextChapterLayout.invalidateRegexCache()
+        _uiState.update {
+            it.copy(
+                rules = highlightRuleRepository.load(configName).toImmutableList(),
+                editingRule = null,
+                showNewRule = false,
+                deleteRule = null,
+            )
+        }
+        host.notifyRulesChanged()
+    }
+
+    // --- 导入 ---
+
+    fun importSource(text: String) {
+        _uiState.update { it.copy(importState = BaseImportUiState.Loading) }
+        Coroutine.async(scope, Dispatchers.IO) {
+            val importedRules = importSourceAwait(text.trim())
+                .map(highlightRuleRepository::sanitizeRule)
+            if (importedRules.isEmpty()) {
+                throw NoStackTraceException(context.getString(R.string.wrong_format))
+            }
+            val oldRules = highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+                .associateBy { it.id }
+            BaseImportUiState.Success(
+                source = text,
+                items = importedRules.map { rule ->
+                    val oldRule = oldRules[rule.id]
+                    val status = when {
+                        oldRule == null -> ImportStatus.New
+                        oldRule != rule -> ImportStatus.Update
+                        else -> ImportStatus.Existing
+                    }
+                    ImportItemWrapper(
+                        data = rule,
+                        oldData = oldRule,
+                        status = status,
+                        isSelected = status != ImportStatus.Existing,
+                    )
+                }
+            )
+        }.onSuccess { importState ->
+            _uiState.update { it.copy(importState = importState) }
+        }.onError {
+            AppLog.put("导入高亮规则失败\n${it.localizedMessage}", it, true)
+            _uiState.update { state ->
+                state.copy(
+                    importState = BaseImportUiState.Error(
+                        it.localizedMessage ?: context.getString(R.string.wrong_format)
+                    )
+                )
+            }
+        }
+    }
+
+    private suspend fun importSourceAwait(text: String): List<HighlightRule> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<HighlightRule>(text).getOrThrow()
+            text.isJsonObject() -> listOf(
+                GSON.fromJsonObject<HighlightRule>(text).getOrThrow()
+            )
+            text.isAbsUrl() -> {
+                val body = okHttpClient.newCallResponseBody {
+                    if (text.endsWith("#requestWithoutUA")) {
+                        url(text.substringBeforeLast("#requestWithoutUA"))
+                        header(AppConst.UA_NAME, "null")
+                    } else {
+                        url(text)
+                    }
+                }.decompressed().text()
+                importSourceAwait(body)
+            }
+            else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
+        }
+    }
+
+    fun importFile(uri: Uri) {
+        Coroutine.async<String?>(scope, Dispatchers.IO) {
+            context.contentResolver.openInputStream(uri)?.use {
+                it.reader().readText()
+            }
+        }.onSuccess { text ->
+            if (text.isNullOrBlank()) {
+                _uiState.update { state ->
+                    state.copy(
+                        importState = BaseImportUiState.Error(
+                            context.getString(R.string.wrong_format)
+                        )
+                    )
+                }
+            } else {
+                importSource(text)
+            }
+        }.onError {
+            _uiState.update { state ->
+                state.copy(
+                    importState = BaseImportUiState.Error(
+                        it.localizedMessage ?: context.getString(R.string.wrong_format)
+                    )
+                )
+            }
+        }
+    }
+
+    fun cancelImport() {
+        _uiState.update { it.copy(importState = BaseImportUiState.Idle) }
+    }
+
+    fun toggleImportSelection(index: Int) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        val item = items[index]
+        items[index] = item.copy(isSelected = !item.isSelected)
+        _uiState.update { it.copy(importState = importState.copy(items = items)) }
+    }
+
+    fun toggleImportAll(isSelected: Boolean) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        _uiState.update {
+            it.copy(
+                importState = importState.copy(
+                    items = importState.items.map { item ->
+                        item.copy(isSelected = isSelected)
+                    }
+                )
+            )
+        }
+    }
+
+    fun updateImportItem(index: Int, rule: HighlightRule) {
+        val importState = _uiState.value.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        items[index] = items[index].copy(data = rule)
+        _uiState.update {
+            it.copy(
+                importState = importState.copy(
+                    items = items,
+                    version = importState.version + 1,
+                )
+            )
+        }
+    }
+
+    fun saveImported() {
+        val state = _uiState.value
+        val importState = state.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        val importedRules = importState.items
+            .filter { it.isSelected }
+            .map { highlightRuleRepository.sanitizeRule(it.data) }
+        if (importedRules.isEmpty()) return
+        val importedById = importedRules.associateBy { it.id }
+        val mergedRules = state.rules.map { importedById[it.id] ?: it } +
+                importedRules.filter { imported -> state.rules.none { it.id == imported.id } }
+        saveRules(mergedRules)
+        cancelImport()
+    }
+
+    // --- 导出 ---
+
+    fun exportToFile(uri: Uri) {
+        val rules = _uiState.value.rules
+        Coroutine.async(scope, Dispatchers.IO) {
+            context.contentResolver.openOutputStream(uri)?.use { output ->
+                output.bufferedWriter().use { writer ->
+                    writer.write(GSON.toJson(rules))
+                }
+            } ?: throw NoStackTraceException(context.getString(R.string.error))
+        }.onSuccess {
+            host.showToast(context.getString(R.string.export_success))
+        }.onError {
+            host.showToast(it.localizedMessage ?: context.getString(R.string.error))
+        }
+    }
+
+    fun exportAsUrl() {
+        val rules = _uiState.value.rules
+        Coroutine.async(scope, Dispatchers.IO) {
+            uploadRepository.upload(
+                fileName = HighlightRuleRepository.backupFileName,
+                file = GSON.toJson(rules),
+                contentType = "application/json",
+            )
+        }.onSuccess { url ->
+            context.sendToClip(url)
+            host.showToast(context.getString(R.string.copy_url))
+        }
+    }
+}

+ 0 - 106
app/src/test/java/io/legado/app/ui/book/read/ReadAiDomainBoundaryTest.kt

@@ -1,106 +0,0 @@
-package io.legado.app.ui.book.read
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import java.io.File
-import kotlin.reflect.full.primaryConstructor
-
-/**
- * R2.2 —— AI 域摘除后的边界不变式。
- *
- * AI 域(章节摘要 / 划词净化 / 划词重写 / 重写预设)已从 `ReadBookViewModel` 摘成
- * [ReadAiDelegate],状态独立成 [ReadAiUiState]。三条会悄悄失效的边界:
- *
- * 1. AI 子状态被重新塞回 [ReadBookUiState]——流式输出又开始 copy 整个阅读态;
- * 2. AI 实现回流进 `ReadBookViewModel`——god object 重新长回来;
- * 3. `ReadAiDelegate` 自己拿 DAO——`legacyDaoInjectionBaseline` 只认文件名含 `ViewModel`
- *    的文件,delegate 里的 DAO 直连会掉进宽松的 `legacyUiDaoAccessBaseline`,
- *    等于把 VM 棘轮上的债洗白。章节读取必须继续走 `ReadAiDelegate.Host`。
- */
-class ReadAiDomainBoundaryTest {
-
-    @Test
-    fun `AI 子状态只挂在 ReadAiUiState 上`() {
-        assertEquals(
-            "ReadAiUiState 的字段与 AI 子状态不一致——新增/改名 AI 子状态时请同步本测试",
-            AI_STATE_FIELDS,
-            constructorParameterNames(ReadAiUiState::class),
-        )
-
-        val leaked = constructorParameterNames(ReadBookUiState::class)
-            .intersect(AI_STATE_FIELDS)
-        assertTrue(
-            "AI 子状态又挂回了 ReadBookUiState:${leaked.joinToString()}。\n" +
-                "AI 流式输出每秒刷新多次,挂在阅读态上会让整个 ReadBookUiState 反复 copy——" +
-                "请放进 ReadAiUiState,由 ReadAiDelegate 持有。",
-            leaked.isEmpty(),
-        )
-    }
-
-    @Test
-    fun `ReadBookViewModel 不再持有 AI 域实现`() {
-        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
-        val leaked = AI_STATE_TYPES.filter { it in source }
-        assertTrue(
-            "ReadBookViewModel 里又出现了 AI 域状态类型:${leaked.joinToString()}。\n" +
-                "AI 逻辑属于 ReadAiDelegate,VM 只做 `aiDelegate.xxx()` 转发和 Host 实现。",
-            leaked.isEmpty(),
-        )
-    }
-
-    @Test
-    fun `ReadAiDelegate 不自带 DAO 直连`() {
-        val source = mainSourceFile("io/legado/app/ui/book/read/ReadAiDelegate.kt").readText()
-        val violations = buildList {
-            if (APP_DB_DAO.containsMatchIn(source)) add("appDb.xxxDao 直连")
-            if (DAO_IMPORT.containsMatchIn(source)) add("import io.legado.app.data.dao.*")
-        }
-        assertTrue(
-            "ReadAiDelegate 出现了 ${violations.joinToString()}。\n" +
-                "build.gradle.kts 的 legacyDaoInjectionBaseline 只统计文件名含 `ViewModel` 的文件," +
-                "delegate 里的 DAO 直连会掉进宽松的 legacyUiDaoAccessBaseline,等于把 VM 棘轮上的债洗白。\n" +
-                "章节读取请继续走 ReadAiDelegate.Host,等 R2.1 随 ReaderSession 一起溶解。",
-            violations.isEmpty(),
-        )
-    }
-
-    private fun constructorParameterNames(type: kotlin.reflect.KClass<*>): Set<String> =
-        type.primaryConstructor?.parameters?.mapNotNull { it.name }?.toSet().orEmpty()
-
-    private companion object {
-        val AI_STATE_FIELDS = setOf(
-            "chapterSummary",
-            "aiTextClean",
-            "aiTextRewrite",
-            "aiRewritePresetConfig",
-        )
-
-        val AI_STATE_TYPES = listOf(
-            "ChapterSummaryUiState",
-            "AiTextCleanUiState",
-            "AiTextRewriteUiState",
-            "AiRewritePresetConfigUiState",
-            "AiRewritePresetUi",
-            "AiRewriteHistoryUi",
-        )
-
-        val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")
-        val DAO_IMPORT = Regex(
-            """^import io\.legado\.app\.data\.dao\.[A-Za-z0-9_*]+$""",
-            RegexOption.MULTILINE,
-        )
-
-        fun mainSourceFile(relativePath: String): File {
-            var directory: File? = File("").absoluteFile
-            while (directory != null) {
-                for (prefix in listOf("src/main/java", "app/src/main/java")) {
-                    val candidate = File(directory, "$prefix/$relativePath")
-                    if (candidate.isFile) return candidate
-                }
-                directory = directory.parentFile
-            }
-            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
-        }
-    }
-}

+ 140 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -0,0 +1,140 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import kotlin.reflect.KClass
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * R2.2 —— 从 `ReadBookViewModel` 摘出的各域的边界不变式。
+ *
+ * 每摘一个域,在 [DOMAINS] 里加一条即可。三类会悄悄失效的边界:
+ *
+ * 1. 域状态被重新塞回 [ReadBookUiState]——该域每次刷新又开始 copy 整个阅读态;
+ * 2. 域的实现回流进 `ReadBookViewModel`——god object 重新长回来;
+ * 3. delegate 自己拿 DAO——`build.gradle.kts` 的 `legacyDaoInjectionBaseline` 只认
+ *    **文件名含 `ViewModel`** 的文件,delegate 里的 DAO 直连会掉进宽松的
+ *    `legacyUiDaoAccessBaseline`,等于把 VM 棘轮上的债洗白。章节等数据读取必须继续
+ *    走各 delegate 的 `Host`,随 R2.1 的 `ReaderSession` 溶解一并清理。
+ */
+class ReadBookDomainSplitBoundaryTest {
+
+    @Test
+    fun `已摘出的域状态不再挂在 ReadBookUiState 上`() {
+        val readBookFields = constructorParameterNames(ReadBookUiState::class)
+        DOMAINS.forEach { domain ->
+            val leaked = readBookFields.intersect(domain.stateFields)
+            assertTrue(
+                "${domain.name}域的状态又挂回了 ReadBookUiState:${leaked.joinToString()}。\n" +
+                    "该域每次刷新都会让整个 ReadBookUiState 反复 copy——" +
+                    "请放进 ${domain.delegateSimpleName} 自持的 state。",
+                leaked.isEmpty(),
+            )
+        }
+    }
+
+    @Test
+    fun `ReadAiUiState 完整覆盖 AI 的四个子状态`() {
+        // AI 域是唯一有包装类型的域;这条保证下面 stateFields 的名单不会因改名而失真。
+        assertEquals(
+            "ReadAiUiState 的字段变了,请同步 DOMAINS 里 AI 域的 stateFields",
+            setOf("chapterSummary", "aiTextClean", "aiTextRewrite", "aiRewritePresetConfig"),
+            constructorParameterNames(ReadAiUiState::class),
+        )
+    }
+
+    @Test
+    fun `ReadBookViewModel 不再持有各域的实现`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+        DOMAINS.forEach { domain ->
+            val leaked = domain.stateTypes.filter { it in source }
+            assertTrue(
+                "ReadBookViewModel 里又出现了${domain.name}域的状态类型:${leaked.joinToString()}。\n" +
+                    "该域的逻辑属于 ${domain.delegateSimpleName}," +
+                    "VM 只做 `xxxDelegate.yyy()` 转发和 Host 实现。",
+                leaked.isEmpty(),
+            )
+        }
+    }
+
+    @Test
+    fun `各 delegate 不自带 DAO 直连`() {
+        DOMAINS.forEach { domain ->
+            val source = mainSourceFile(domain.delegateFile).readText()
+            val violations = buildList {
+                if (APP_DB_DAO.containsMatchIn(source)) add("appDb.xxxDao 直连")
+                if (DAO_IMPORT.containsMatchIn(source)) add("import io.legado.app.data.dao.*")
+            }
+            assertTrue(
+                "${domain.delegateSimpleName} 出现了 ${violations.joinToString()}。\n" +
+                    "legacyDaoInjectionBaseline 只统计文件名含 `ViewModel` 的文件," +
+                    "delegate 里的 DAO 直连会掉进宽松的 legacyUiDaoAccessBaseline," +
+                    "等于把 VM 棘轮上的债洗白。请改走该 delegate 的 Host。",
+                violations.isEmpty(),
+            )
+        }
+    }
+
+    private fun constructorParameterNames(type: KClass<*>): Set<String> =
+        type.primaryConstructor?.parameters?.mapNotNull { it.name }?.toSet().orEmpty()
+
+    private data class DomainSplit(
+        val name: String,
+        val delegateFile: String,
+        /** 不允许再出现在 ReadBookUiState 里的字段名。 */
+        val stateFields: Set<String>,
+        /** 不允许再出现在 ReadBookViewModel.kt 里的状态类型名。 */
+        val stateTypes: List<String>,
+    ) {
+        val delegateSimpleName: String get() = delegateFile.substringAfterLast('/').removeSuffix(".kt")
+    }
+
+    private companion object {
+        val DOMAINS = listOf(
+            DomainSplit(
+                name = "AI",
+                delegateFile = "io/legado/app/ui/book/read/ReadAiDelegate.kt",
+                stateFields = setOf(
+                    "chapterSummary",
+                    "aiTextClean",
+                    "aiTextRewrite",
+                    "aiRewritePresetConfig",
+                ),
+                stateTypes = listOf(
+                    "ChapterSummaryUiState",
+                    "AiTextCleanUiState",
+                    "AiTextRewriteUiState",
+                    "AiRewritePresetConfigUiState",
+                    "AiRewritePresetUi",
+                    "AiRewriteHistoryUi",
+                ),
+            ),
+            DomainSplit(
+                name = "高亮规则",
+                delegateFile = "io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt",
+                stateFields = setOf("highlightRuleConfig"),
+                stateTypes = listOf("HighlightRuleConfigUiState"),
+            ),
+        )
+
+        val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")
+        val DAO_IMPORT = Regex(
+            """^import io\.legado\.app\.data\.dao\.[A-Za-z0-9_*]+$""",
+            RegexOption.MULTILINE,
+        )
+
+        fun mainSourceFile(relativePath: String): File {
+            var directory: File? = File("").absoluteFile
+            while (directory != null) {
+                for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                    val candidate = File(directory, "$prefix/$relativePath")
+                    if (candidate.isFile) return candidate
+                }
+                directory = directory.parentFile
+            }
+            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+        }
+    }
+}