3 Commits
Author SHA1 Message Date
OneWay bc3fa85b9d Release 1.5.0
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
2026-08-18 00:16:27 +03:00
OneWay 457bbe43b6 Unescape any escaped character in article text
Apply the general DSL rule \X -> X instead of unescaping only brackets
and parentheses, so sequences like \! no longer show the backslash.
2026-08-18 00:15:53 +03:00
OneWay f751ac0d36 Harden DSL parsing against escapes and malformed tags
Fixed escape-aware headword parsing for \{, \}, \[ and trailing backslash.
Fixed O(n^2) tag scan and nested bracket handling in article rendering.
2026-08-16 11:13:46 +03:00
8 changed files with 147 additions and 46 deletions
+3 -3
View File
@@ -95,15 +95,15 @@ The main search screen remains usable as long as at least one indexed dictionary
## Tech Stack ## Tech Stack
- Kotlin 2.4.10 - Kotlin 2.4.10
- Jetpack Compose (BOM 2026.06.01) - Jetpack Compose (BOM 2026.08.00)
- Material 3 - Material 3
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.2.1
- Paging 3.5.0 - Paging 3.5.1
- OkHttp 5.4.0 - OkHttp 5.4.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.11.0
- AboutLibraries 15.0.4 metadata generation - AboutLibraries 15.0.4 metadata generation
- Android Gradle Plugin 9.3.0 - Android Gradle Plugin 9.3.1
## Build ## Build
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId = "com.example.research" applicationId = "com.example.research"
minSdk = project.property("minSdk").toString().toInt() minSdk = project.property("minSdk").toString().toInt()
targetSdk = project.property("targetSdk").toString().toInt() targetSdk = project.property("targetSdk").toString().toInt()
versionCode = 7 versionCode = 8
versionName = "1.4.0" versionName = "1.5.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -24,10 +24,11 @@ object DslHeadwordParser {
} }
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) { if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
val unescaped = unescape(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = trimmed, simplified = unescaped,
displayText = trimmed, displayText = unescaped,
searchableText = trimmed.lowercase() searchableText = unescaped.lowercase()
) )
} }
@@ -60,9 +61,9 @@ object DslHeadwordParser {
val searchableText = createSearchableText(trimmed) val searchableText = createSearchableText(trimmed)
return ParsedHeadword( return ParsedHeadword(
simplified = simplified, simplified = unescape(simplified),
displayText = displayText, displayText = unescape(displayText),
searchableText = searchableText searchableText = unescape(searchableText)
) )
} }
@@ -129,8 +130,17 @@ object DslHeadwordParser {
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
return buildString(value.length) { return buildString(value.length) {
value.forEach { char -> var index = 0
while (index < value.length) {
val char = value[index]
if (char == '\\' && index + 1 < value.length) {
append(char)
append(value[index + 1])
index += 2
} else {
if (char != '{' && char != '}') append(char) if (char != '{' && char != '}') append(char)
index++
}
} }
} }
} }
@@ -138,6 +148,10 @@ object DslHeadwordParser {
private fun firstFormattingTagIndex(value: String): Int { private fun firstFormattingTagIndex(value: String): Int {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[' && formattingTagEnd(value, index) > index) { if (value[index] == '[' && formattingTagEnd(value, index) > index) {
return index return index
} }
@@ -149,8 +163,12 @@ object DslHeadwordParser {
private fun firstCurlyContent(value: String): String? { private fun firstCurlyContent(value: String): String? {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end > index + 1) { if (end > index + 1) {
return value.substring(index + 1, end) return value.substring(index + 1, end)
} }
@@ -161,7 +179,7 @@ object DslHeadwordParser {
} }
private fun substringBeforeFirstBracket(value: String): String { private fun substringBeforeFirstBracket(value: String): String {
val bracketIndex = value.indexOf('[') val bracketIndex = indexOfUnescaped(value, '[')
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
} }
@@ -171,8 +189,12 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '{') { if (value[index] == '{') {
val end = value.indexOf('}', startIndex = index + 1) val end = matchingCurlyEnd(value, index + 1)
if (end >= 0 && (removeEmpty || end > index + 1)) { if (end >= 0 && (removeEmpty || end > index + 1)) {
if (builder == null) { if (builder == null) {
builder = StringBuilder(value.length) builder = StringBuilder(value.length)
@@ -197,6 +219,10 @@ object DslHeadwordParser {
var index = 0 var index = 0
while (index < value.length) { while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
continue
}
if (value[index] == '[') { if (value[index] == '[') {
val tagEnd = formattingTagEnd(value, index) val tagEnd = formattingTagEnd(value, index)
if (tagEnd > index) { if (tagEnd > index) {
@@ -217,6 +243,56 @@ object DslHeadwordParser {
}?.toString() ?: value }?.toString() ?: value
} }
private fun matchingCurlyEnd(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '{' -> return -1
value[index] == '}' -> return index
else -> index++
}
}
return -1
}
private fun indexOfUnescaped(
value: String,
target: Char,
startIndex: Int = 0,
): Int {
var index = startIndex.coerceAtLeast(0)
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
index += 2
} else if (value[index] == target) {
return index
} else {
index++
}
}
return -1
}
private fun unescape(value: String): String {
val firstEscape = value.indexOf('\\')
if (firstEscape < 0) return value
return buildString(value.length) {
append(value, 0, firstEscape)
var index = firstEscape
while (index < value.length) {
if (value[index] == '\\' && index + 1 < value.length) {
append(value[index + 1])
index += 2
} else {
append(value[index])
index++
}
}
}
}
private fun formattingTagEnd(value: String, openBracketIndex: Int): Int { private fun formattingTagEnd(value: String, openBracketIndex: Int): Int {
val tokenStart = openBracketIndex + 1 val tokenStart = openBracketIndex + 1
if (tokenStart >= value.length) return -1 if (tokenStart >= value.length) return -1
@@ -23,7 +23,6 @@ object DslAnnotatedParser {
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5", "br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
"ref", "ex", "e", "trn", "com", "lang", "sup", "'" "ref", "ex", "e", "trn", "com", "lang", "sup", "'"
) )
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
data class ColorScheme( data class ColorScheme(
val secondaryText: Color, val secondaryText: Color,
@@ -101,7 +100,6 @@ object DslAnnotatedParser {
'\\' -> { '\\' -> {
if (i + 1 < length) { if (i + 1 < length) {
val next = dsl[i + 1] val next = dsl[i + 1]
if (next in ESCAPED_CHARS) {
builder.append(next) builder.append(next)
if (refStack.isNotEmpty()) { if (refStack.isNotEmpty()) {
refStack.last().second.append(next) refStack.last().second.append(next)
@@ -110,7 +108,6 @@ object DslAnnotatedParser {
i += 2 i += 2
continue continue
} }
}
builder.append(char) builder.append(char)
if (refStack.isNotEmpty()) { if (refStack.isNotEmpty()) {
refStack.last().second.append(char) refStack.last().second.append(char)
@@ -119,7 +116,7 @@ object DslAnnotatedParser {
i++ i++
} }
'[' -> { '[' -> {
val end = dsl.indexOf(']', i + 1) val end = tagCloseIndex(dsl, i + 1)
if (end != -1) { if (end != -1) {
val tagStart = i + 1 val tagStart = i + 1
var tagEnd = end var tagEnd = end
@@ -228,6 +225,19 @@ object DslAnnotatedParser {
splitOversizedBlocks(result) splitOversizedBlocks(result)
} }
private fun tagCloseIndex(value: String, startIndex: Int): Int {
var index = startIndex
while (index < value.length) {
when {
value[index] == '\\' && index + 1 < value.length -> index += 2
value[index] == '[' -> return -1
value[index] == ']' -> return index
else -> index++
}
}
return -1
}
private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> { private fun splitOversizedBlocks(blocks: List<DslBlock>): List<DslBlock> {
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
return blocks.flatMap { block -> return blocks.flatMap { block ->
@@ -84,6 +84,8 @@ class SettingsViewModel(
private var isDownloadInProgress = false private var isDownloadInProgress = false
private var cancelRefreshPending = false private var cancelRefreshPending = false
private var errorStateHandled = false
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
init { init {
setupStateObservation() setupStateObservation()
@@ -147,16 +149,20 @@ class SettingsViewModel(
is DownloadState.Loading, is DownloadState.Extracting -> { is DownloadState.Loading, is DownloadState.Extracting -> {
isDownloadInProgress = true isDownloadInProgress = true
cancelRefreshPending = false cancelRefreshPending = false
errorStateHandled = false
} }
is DownloadState.Success -> { is DownloadState.Success -> {
pendingSourceUrls.clear() pendingSourceUrls.clear()
} }
is DownloadState.Error -> { is DownloadState.Error -> {
isDownloadInProgress = false isDownloadInProgress = false
if (!errorStateHandled) {
errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) { dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty DictionaryStatus.Empty
} else { } else when (statusBeforeDownload) {
DictionaryStatus.UpToDate DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
else -> statusBeforeDownload
} }
if (pendingSourceUrls.isNotEmpty()) { if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate -> pendingSourceUrls.forEach { urlTemplate ->
@@ -175,6 +181,7 @@ class SettingsViewModel(
pendingSourceUrls.clear() pendingSourceUrls.clear()
} }
} }
}
is DownloadState.Cancelled -> { is DownloadState.Cancelled -> {
isDownloadInProgress = false isDownloadInProgress = false
cancelRefreshPending = true cancelRefreshPending = true
@@ -412,6 +419,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
isDownloadInProgress = true isDownloadInProgress = true
@@ -435,6 +443,7 @@ class SettingsViewModel(
return@launch return@launch
} }
statusBeforeDownload = dictionaryStatus.value
dictionaryStatus.value = DictionaryStatus.Checking dictionaryStatus.value = DictionaryStatus.Checking
val installedSources = installedEnabledSources( val installedSources = installedEnabledSources(
@@ -0,0 +1,3 @@
- The dictionary update card now returns after a failed download
- Fixed headwords and articles with escaped characters being displayed incorrectly
- Fixed slow rendering of articles containing many bracket characters
@@ -0,0 +1,3 @@
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
- Исправлено отображение заголовков и статей с экранированными символами
- Исправлено медленное отображение статей с большим количеством скобок
+2 -2
View File
@@ -2,14 +2,14 @@
aboutlibraries = "15.0.4" aboutlibraries = "15.0.4"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.3.1" agp = "9.3.1"
compose_bom = "2026.06.01" compose_bom = "2026.08.00"
core = "1.19.0" core = "1.19.0"
datastore_preferences = "1.2.1" datastore_preferences = "1.2.1"
documentfile = "1.1.0" documentfile = "1.1.0"
kotlin = "2.4.10" kotlin = "2.4.10"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
okhttp = "5.4.0" okhttp = "5.4.0"
paging = "3.5.0" paging = "3.5.1"
profileinstaller = "1.4.1" profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0" kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0" kotlinx_serialization = "1.11.0"