Jelajahi Sumber

feat(rule-edit): add regex test function for rule edit sheet

1. add test button to rule edit bottom sheet and show it optionally
2. add regex matching test logic with result display
3. add required string resources for test prompts
52fisher 4 bulan lalu
induk
melakukan
c1937ba06b

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

@@ -284,7 +284,8 @@ fun TxtRuleScreen(
                 rule = fields.rule1,
                 example = fields.rule2
             )
-        }
+        },
+        showTestButton = true,
     )
 
     RuleListScaffold(

+ 143 - 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.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 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.Check
 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.RunningWithErrors
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
@@ -24,16 +28,20 @@ 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.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 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.text.AppText
 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
+import java.util.regex.Pattern
+import java.util.regex.PatternSyntaxException
 
 /**
  * 通用编辑数据包装,用于适配不同的规则
@@ -45,6 +53,15 @@ data class RuleEditFields(
     val extra: String = ""
 )
 
+/**
+ * 测试结果:每一行的匹配状态
+ */
+data class TestLineResult(
+    val line: String,
+    val matched: Boolean,
+    val matchResult: String? = null,
+)
+
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun <T> RuleEditSheet(
@@ -58,7 +75,8 @@ fun <T> RuleEditSheet(
     onCopy: (T) -> Unit,
     onPaste: () -> T?,
     toFields: (T?) -> RuleEditFields,
-    fromFields: (RuleEditFields, T?) -> T
+    fromFields: (RuleEditFields, T?) -> T,
+    showTestButton: Boolean = false,
 ) {
     val scope = rememberCoroutineScope()
 
@@ -69,8 +87,60 @@ fun <T> RuleEditSheet(
 
     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) }
+
+    // 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 runTest() {
+        if (rule1.isBlank()) {
+            testError = regexIsEmptyStr
+            testResults = null
+            return
+        }
+        if (rule2.isBlank()) {
+            testError = exampleIsEmptyStr
+            testResults = null
+            return
+        }
+
+        val pattern: Pattern
+        try {
+            pattern = Pattern.compile(rule1, Pattern.MULTILINE)
+        } catch (e: PatternSyntaxException) {
+            testError = e.message ?: invalidRegexStr
+            testResults = null
+            return
+        }
+
+        testError = null
+        val lines = rule2.lines()
+        val fullText = "\n${rule2}"
+        val matcher = pattern.matcher(fullText)
+
+        // Collect all match ranges (character offsets in fullText)
+        val matchRanges = mutableListOf<IntRange>()
+        while (matcher.find()) {
+            matchRanges.add(matcher.start()..matcher.end())
+        }
+
+        // Map each line: check if any match overlaps with this line's range in fullText
+        var offset = 1 // first \n
+        testResults = lines.map { line ->
+            val lineStart = offset
+            val lineEnd = offset + line.length
+            val matched = matchRanges.any { it.first < lineEnd && it.last > lineStart }
+            offset = lineEnd + 1 // +1 for the \n that was between lines
+            TestLineResult(line = line, matched = matched, matchResult = if (matched) line else null)
+        }
+    }
+
     AppModalBottomSheet(
         title = title,
         startAction = {
@@ -148,16 +218,83 @@ fun <T> RuleEditSheet(
                     label = label2,
                     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
                     .align(Alignment.BottomEnd)
                     .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_or_invalid">剪贴板为空或格式不正确</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="sort_by_respondTime">响应时间排序</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="invalid_directory">無效的目錄路徑</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="is_blur_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_or_invalid">剪貼簿為空或格式無效</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="cannot_open_url">無法直接開啟此 URL</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_or_invalid">Clipboard is empty or has an 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="export_success">export success</string>
     <string name="path">path</string>