Kaynağa Gözat

Merge pull request #1736 from 52fisher/main

feat(rule-edit): 目录规则编辑页面增加测试目录规则的功能
Kudomaga 4 ay önce
ebeveyn
işleme
c59ac99315

+ 33 - 1
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -49,10 +49,14 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleEditFields
 import io.legado.app.ui.widget.components.rules.RuleEditFields
 import io.legado.app.ui.widget.components.rules.RuleEditSheet
 import io.legado.app.ui.widget.components.rules.RuleEditSheet
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
+import io.legado.app.ui.widget.components.rules.TestLineResult
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
 import sh.calvin.reorderable.rememberReorderableLazyListState
+import java.util.regex.Pattern
 
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 @Composable
@@ -284,7 +288,35 @@ fun TxtRuleScreen(
                 rule = fields.rule1,
                 rule = fields.rule1,
                 example = fields.rule2
                 example = fields.rule2
             )
             )
-        }
+        },
+        showTestButton = true,
+        onTest = { rule, example ->
+            val pattern = Pattern.compile(rule, Pattern.MULTILINE)
+            withContext(Dispatchers.Default) {
+                val normalized = example.replace("\r\n", "\n").replace("\r", "\n")
+                val lines = normalized.lines()
+                val fullText = "\n$normalized"
+                val matcher = pattern.matcher(fullText)
+                val matchRanges = mutableListOf<IntRange>()
+                while (matcher.find()) {
+                    matchRanges.add(matcher.start()..matcher.end())
+                }
+                var offset = 1
+                lines.map { line ->
+                    val lineStart = offset
+                    val lineEnd = offset + line.length
+                    val matched = matchRanges.any { range ->
+                        if (range.first == range.last) {
+                            range.first in lineStart..lineEnd
+                        } else {
+                            range.first < lineEnd && range.last > lineStart
+                        }
+                    }
+                    offset = lineEnd + 1
+                    TestLineResult(line = line, matched = matched, matchResult = if (matched) line else null)
+                }
+            }
+        },
     )
     )
 
 
     RuleListScaffold(
     RuleListScaffold(

+ 141 - 6
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt

@@ -3,15 +3,19 @@ package io.legado.app.ui.widget.components.rules
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.NoteAdd
 import androidx.compose.material.icons.automirrored.filled.NoteAdd
+import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.ContentPaste
 import androidx.compose.material.icons.filled.ContentPaste
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.RunningWithErrors
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
@@ -24,15 +28,18 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 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.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 
 
 /**
 /**
@@ -45,6 +52,15 @@ data class RuleEditFields(
     val extra: String = ""
     val extra: String = ""
 )
 )
 
 
+/**
+ * 测试结果:每一行的匹配状态
+ */
+data class TestLineResult(
+    val line: String,
+    val matched: Boolean,
+    val matchResult: String? = null,
+)
+
 @OptIn(ExperimentalMaterial3Api::class)
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 @Composable
 fun <T> RuleEditSheet(
 fun <T> RuleEditSheet(
@@ -58,7 +74,9 @@ fun <T> RuleEditSheet(
     onCopy: (T) -> Unit,
     onCopy: (T) -> Unit,
     onPaste: () -> T?,
     onPaste: () -> T?,
     toFields: (T?) -> RuleEditFields,
     toFields: (T?) -> RuleEditFields,
-    fromFields: (RuleEditFields, T?) -> T
+    fromFields: (RuleEditFields, T?) -> T,
+    showTestButton: Boolean = false,
+    onTest: (suspend (rule: String, example: String) -> List<TestLineResult>?)? = null,
 ) {
 ) {
     val scope = rememberCoroutineScope()
     val scope = rememberCoroutineScope()
 
 
@@ -69,8 +87,58 @@ fun <T> RuleEditSheet(
 
 
     var showMenu by remember(show, rule) { mutableStateOf(false) }
     var showMenu by remember(show, rule) { mutableStateOf(false) }
 
 
+    // Test results state
+    var testResults by remember(show, rule) { mutableStateOf<List<TestLineResult>?>(null) }
+    var testError by remember(show, rule) { mutableStateOf<String?>(null) }
+    var testRunning by remember(show, rule) { mutableStateOf(false) }
+
+    // Pre-resolve string resources at composable level
+    val regexIsEmptyStr = stringResource(R.string.regex_is_empty)
+    val exampleIsEmptyStr = stringResource(R.string.example_is_empty)
+    val invalidRegexStr = stringResource(R.string.invalid_regex)
+
     fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
     fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
 
 
+    fun runTest() {
+        if (testRunning) return
+        if (rule1.isBlank()) {
+            testError = regexIsEmptyStr
+            testResults = null
+            return
+        }
+        if (rule2.isBlank()) {
+            testError = exampleIsEmptyStr
+            testResults = null
+            return
+        }
+
+        val testCallback = onTest
+        if (testCallback == null) {
+            testError = invalidRegexStr
+            testResults = null
+            return
+        }
+
+        // Capture inputs for background processing
+        val capturedRule1 = rule1
+        val capturedRule2 = rule2
+        testRunning = true
+        testError = null
+        testResults = null
+
+        scope.launch(Dispatchers.Default) {
+            try {
+                val results = testCallback(capturedRule1, capturedRule2)
+                testResults = results
+            } catch (_: Exception) {
+                testError = invalidRegexStr
+                testResults = null
+            } finally {
+                testRunning = false
+            }
+        }
+    }
+
     AppModalBottomSheet(
     AppModalBottomSheet(
         title = title,
         title = title,
         startAction = {
         startAction = {
@@ -148,16 +216,83 @@ fun <T> RuleEditSheet(
                     label = label2,
                     label = label2,
                     minLines = 3
                     minLines = 3
                 )
                 )
+
+                if (showTestButton) {
+                    // Test error
+                    testError?.let { error ->
+                        AppText(
+                            text = error,
+                            color = LegadoTheme.colorScheme.error,
+                            style = LegadoTheme.typography.bodySmall,
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                    }
+
+                    // Test results
+                    testResults?.let { results ->
+                        val matchedCount = results.count { it.matched }
+                        val totalCount = results.count { it.line.isNotBlank() }
+                        AppText(
+                            text = "$matchedCount / $totalCount",
+                            style = LegadoTheme.typography.labelMedium,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            modifier = Modifier.fillMaxWidth(),
+                        )
+                        results.forEach { result ->
+                            if (result.line.isBlank()) return@forEach
+                            Row(
+                                modifier = Modifier.fillMaxWidth(),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                            ) {
+                                Icon(
+                                    imageVector = if (result.matched) Icons.Default.Check else Icons.Default.Close,
+                                    contentDescription = null,
+                                    tint = if (result.matched) {
+                                        LegadoTheme.colorScheme.primary
+                                    } else {
+                                        LegadoTheme.colorScheme.error
+                                    },
+                                    modifier = Modifier.size(18.dp),
+                                )
+                                AppText(
+                                    text = result.line,
+                                    style = LegadoTheme.typography.bodySmall,
+                                    color = if (result.matched) {
+                                        LegadoTheme.colorScheme.onSurface
+                                    } else {
+                                        LegadoTheme.colorScheme.onSurfaceVariant
+                                    },
+                                    maxLines = 1,
+                                    overflow = TextOverflow.Ellipsis,
+                                    modifier = Modifier.weight(1f),
+                                )
+                            }
+                        }
+                    }
+                }
             }
             }
 
 
-            AppFloatingActionButton(
-                onClick = { onSave(getCurrentEntity()) },
+            Row(
                 modifier = Modifier
                 modifier = Modifier
                     .align(Alignment.BottomEnd)
                     .align(Alignment.BottomEnd)
                     .padding(16.dp),
                     .padding(16.dp),
-                tooltipText = stringResource(R.string.action_save),
-                icon = Icons.Default.Save
-            )
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                if (showTestButton) {
+                    AppFloatingActionButton(
+                        onClick = { runTest() },
+                        tooltipText = stringResource(R.string.test),
+                        icon = Icons.Default.RunningWithErrors,
+                        containerColor = LegadoTheme.colorScheme.secondaryContainer,
+                    )
+                }
+                AppFloatingActionButton(
+                    onClick = { onSave(getCurrentEntity()) },
+                    tooltipText = stringResource(R.string.action_save),
+                    icon = Icons.Default.Save
+                )
+            }
         }
         }
     }
     }
 }
 }

+ 3 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1056,6 +1056,9 @@
     <string name="clipboard_empty">剪贴板没有内容</string>
     <string name="clipboard_empty">剪贴板没有内容</string>
     <string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string>
     <string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string>
     <string name="invalid_format">格式不正确</string>
     <string name="invalid_format">格式不正确</string>
+    <string name="regex_is_empty">正则表达式为空</string>
+    <string name="example_is_empty">示例为空</string>
+    <string name="invalid_regex">正则表达式无效</string>
     <string name="complete_required_information">请填写完整信息</string>
     <string name="complete_required_information">请填写完整信息</string>
     <string name="sort_by_respondTime">响应时间排序</string>
     <string name="sort_by_respondTime">响应时间排序</string>
     <string name="respondTime">响应时间:%1$d ms</string>
     <string name="respondTime">响应时间:%1$d ms</string>

+ 3 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1744,6 +1744,9 @@
     <string name="input_url_or_json_hint">輸入 URL 或 JSON</string>
     <string name="input_url_or_json_hint">輸入 URL 或 JSON</string>
     <string name="invalid_directory">無效的目錄路徑</string>
     <string name="invalid_directory">無效的目錄路徑</string>
     <string name="invalid_format">格式無效</string>
     <string name="invalid_format">格式無效</string>
+    <string name="regex_is_empty">正則表達式為空</string>
+    <string name="example_is_empty">範例為空</string>
+    <string name="invalid_regex">正則表達式無效</string>
     <string name="invert_selection">反選</string>
     <string name="invert_selection">反選</string>
     <string name="is_blur_enable">啟用元素模糊</string>
     <string name="is_blur_enable">啟用元素模糊</string>
     <string name="is_blur_progressive_enable">啟用漸進式模糊</string>
     <string name="is_blur_progressive_enable">啟用漸進式模糊</string>

+ 3 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1539,6 +1539,9 @@
     <string name="clipboard_empty">剪貼簿為空</string>
     <string name="clipboard_empty">剪貼簿為空</string>
     <string name="clipboard_empty_or_invalid">剪貼簿為空或格式無效</string>
     <string name="clipboard_empty_or_invalid">剪貼簿為空或格式無效</string>
     <string name="invalid_format">無效格式</string>
     <string name="invalid_format">無效格式</string>
+    <string name="regex_is_empty">正則表達式為空</string>
+    <string name="example_is_empty">範例為空</string>
+    <string name="invalid_regex">正則表達式無效</string>
     <string name="complete_required_information">請填寫所有必填資訊</string>
     <string name="complete_required_information">請填寫所有必填資訊</string>
     <string name="cannot_open_url">無法直接開啟此 URL</string>
     <string name="cannot_open_url">無法直接開啟此 URL</string>
     <string name="open_by_browser">已切換到瀏覽器開啟</string>
     <string name="open_by_browser">已切換到瀏覽器開啟</string>

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -1088,6 +1088,9 @@
     <string name="clipboard_empty">Clipboard is empty</string>
     <string name="clipboard_empty">Clipboard is empty</string>
     <string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string>
     <string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string>
     <string name="invalid_format">Invalid format</string>
     <string name="invalid_format">Invalid format</string>
+    <string name="regex_is_empty">Regex is empty</string>
+    <string name="example_is_empty">Example is empty</string>
+    <string name="invalid_regex">Invalid regex</string>
     <string name="complete_required_information">Fill in all required information</string>
     <string name="complete_required_information">Fill in all required information</string>
     <string name="export_success">export success</string>
     <string name="export_success">export success</string>
     <string name="path">path</string>
     <string name="path">path</string>