Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc3fa85b9d | ||
|
|
457bbe43b6 | ||
|
|
f751ac0d36 | ||
|
|
910bc8313c |
@@ -95,15 +95,15 @@ The main search screen remains usable as long as at least one indexed dictionary
|
||||
## Tech Stack
|
||||
|
||||
- Kotlin 2.4.10
|
||||
- Jetpack Compose (BOM 2026.06.01)
|
||||
- Jetpack Compose (BOM 2026.08.00)
|
||||
- Material 3
|
||||
- Coroutines and Flow 1.11.0
|
||||
- DataStore Preferences 1.2.1
|
||||
- Paging 3.5.0
|
||||
- Paging 3.5.1
|
||||
- OkHttp 5.4.0
|
||||
- kotlinx.serialization 1.11.0
|
||||
- AboutLibraries 15.0.4 metadata generation
|
||||
- Android Gradle Plugin 9.3.0
|
||||
- Android Gradle Plugin 9.3.1
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ android {
|
||||
applicationId = "com.example.research"
|
||||
minSdk = project.property("minSdk").toString().toInt()
|
||||
targetSdk = project.property("targetSdk").toString().toInt()
|
||||
versionCode = 6
|
||||
versionName = "1.3.0"
|
||||
versionCode = 8
|
||||
versionName = "1.5.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.example.research.core.domain.usecase
|
||||
|
||||
import com.example.research.core.domain.model.Dictionary
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
import java.io.File
|
||||
|
||||
object DictionarySourceFileMatcher {
|
||||
fun matches(source: DictionarySource, fileName: String): Boolean =
|
||||
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
||||
|
||||
fun matches(source: DictionarySource, dictionary: Dictionary): Boolean =
|
||||
matches(source, File(dictionary.path).name)
|
||||
|
||||
fun installedSources(
|
||||
sources: List<DictionarySource>,
|
||||
dictionaries: List<Dictionary>,
|
||||
): List<DictionarySource> = sources.filter { source ->
|
||||
dictionaries.any { dictionary -> matches(source, dictionary) }
|
||||
}
|
||||
|
||||
fun installedSourcesForFileNames(
|
||||
sources: List<DictionarySource>,
|
||||
fileNames: Collection<String>,
|
||||
): List<DictionarySource> = sources.filter { source ->
|
||||
fileNames.any { fileName -> matches(source, fileName) }
|
||||
}
|
||||
}
|
||||
-8
@@ -43,12 +43,4 @@ class DictionarySourceValidator {
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
fun findMatchingSourceWithPrecomputedPrefixes(
|
||||
dictionaryPrefix: String,
|
||||
sourcesWithPrefixes: List<Pair<DictionarySource, String?>>
|
||||
): DictionarySource? {
|
||||
return sourcesWithPrefixes.find { (_, sourcePrefix) ->
|
||||
sourcePrefix != null && dictionaryPrefix.equals(sourcePrefix, ignoreCase = true)
|
||||
}?.first
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -30,17 +30,23 @@ class ManageDictionarySourcesUseCase(
|
||||
preferencesManager.removeDictionarySource(sourceId)
|
||||
}
|
||||
|
||||
suspend fun removeSourceForDictionary(dictionary: Dictionary) {
|
||||
val dictionaryPrefix = dictionary.name
|
||||
val sources = preferencesManager.dictionarySources.first()
|
||||
val sourcesWithPrefixes = sources.map { source ->
|
||||
source to DictionarySource.extractPrefix(source.urlTemplate)
|
||||
}
|
||||
suspend fun removeSourceForDictionary(
|
||||
dictionary: Dictionary,
|
||||
remainingDictionaryFileNames: Collection<String>?,
|
||||
) {
|
||||
if (remainingDictionaryFileNames == null) return
|
||||
|
||||
val matchingSource = validator.findMatchingSourceWithPrecomputedPrefixes(dictionaryPrefix, sourcesWithPrefixes)
|
||||
if (matchingSource != null) {
|
||||
preferencesManager.removeDictionarySource(matchingSource.id)
|
||||
}
|
||||
val sources = preferencesManager.dictionarySources.first()
|
||||
val sourceIdsToRemove = sources
|
||||
.filter { source ->
|
||||
DictionarySourceFileMatcher.matches(source, dictionary) &&
|
||||
remainingDictionaryFileNames.none { fileName ->
|
||||
DictionarySourceFileMatcher.matches(source, fileName)
|
||||
}
|
||||
}
|
||||
.map(DictionarySource::id)
|
||||
|
||||
preferencesManager.removeDictionarySources(sourceIdsToRemove)
|
||||
}
|
||||
|
||||
sealed class AddSourceResult {
|
||||
|
||||
@@ -24,10 +24,11 @@ object DslHeadwordParser {
|
||||
}
|
||||
|
||||
if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) {
|
||||
val unescaped = unescape(trimmed)
|
||||
return ParsedHeadword(
|
||||
simplified = trimmed,
|
||||
displayText = trimmed,
|
||||
searchableText = trimmed.lowercase()
|
||||
simplified = unescaped,
|
||||
displayText = unescaped,
|
||||
searchableText = unescaped.lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,9 +61,9 @@ object DslHeadwordParser {
|
||||
val searchableText = createSearchableText(trimmed)
|
||||
|
||||
return ParsedHeadword(
|
||||
simplified = simplified,
|
||||
displayText = displayText,
|
||||
searchableText = searchableText
|
||||
simplified = unescape(simplified),
|
||||
displayText = unescape(displayText),
|
||||
searchableText = unescape(searchableText)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,8 +130,17 @@ object DslHeadwordParser {
|
||||
if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value
|
||||
|
||||
return buildString(value.length) {
|
||||
value.forEach { char ->
|
||||
if (char != '{' && char != '}') append(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)
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +148,10 @@ object DslHeadwordParser {
|
||||
private fun firstFormattingTagIndex(value: String): Int {
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
if (value[index] == '\\' && index + 1 < value.length) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (value[index] == '[' && formattingTagEnd(value, index) > index) {
|
||||
return index
|
||||
}
|
||||
@@ -149,8 +163,12 @@ object DslHeadwordParser {
|
||||
private fun firstCurlyContent(value: String): String? {
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
if (value[index] == '\\' && index + 1 < value.length) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (value[index] == '{') {
|
||||
val end = value.indexOf('}', startIndex = index + 1)
|
||||
val end = matchingCurlyEnd(value, index + 1)
|
||||
if (end > index + 1) {
|
||||
return value.substring(index + 1, end)
|
||||
}
|
||||
@@ -161,7 +179,7 @@ object DslHeadwordParser {
|
||||
}
|
||||
|
||||
private fun substringBeforeFirstBracket(value: String): String {
|
||||
val bracketIndex = value.indexOf('[')
|
||||
val bracketIndex = indexOfUnescaped(value, '[')
|
||||
return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value
|
||||
}
|
||||
|
||||
@@ -171,8 +189,12 @@ object DslHeadwordParser {
|
||||
var index = 0
|
||||
|
||||
while (index < value.length) {
|
||||
if (value[index] == '\\' && index + 1 < value.length) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (value[index] == '{') {
|
||||
val end = value.indexOf('}', startIndex = index + 1)
|
||||
val end = matchingCurlyEnd(value, index + 1)
|
||||
if (end >= 0 && (removeEmpty || end > index + 1)) {
|
||||
if (builder == null) {
|
||||
builder = StringBuilder(value.length)
|
||||
@@ -197,6 +219,10 @@ object DslHeadwordParser {
|
||||
var index = 0
|
||||
|
||||
while (index < value.length) {
|
||||
if (value[index] == '\\' && index + 1 < value.length) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (value[index] == '[') {
|
||||
val tagEnd = formattingTagEnd(value, index)
|
||||
if (tagEnd > index) {
|
||||
@@ -217,6 +243,56 @@ object DslHeadwordParser {
|
||||
}?.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 {
|
||||
val tokenStart = openBracketIndex + 1
|
||||
if (tokenStart >= value.length) return -1
|
||||
|
||||
+72
-46
@@ -123,6 +123,24 @@ class LocalDictionaryRepository(
|
||||
|
||||
fun isIndexingInProgress(): Boolean = currentIndexingJob?.isActive == true
|
||||
|
||||
suspend fun listDictionaryPayloadFileNames(path: String): List<String>? =
|
||||
withContext(ioDispatcher) {
|
||||
try {
|
||||
val directory = File(path)
|
||||
when {
|
||||
!directory.exists() -> {
|
||||
if (directory.parentFile?.isDirectory == true) emptyList() else null
|
||||
}
|
||||
!directory.isDirectory -> null
|
||||
else -> directory.listFiles { file -> isDictionaryPayloadFile(file) }
|
||||
?.map(File::getName)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Failed to list dictionary files: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scanDirectory(pathOrUri: String): OperationResult<Int> = withContext(ioDispatcher) {
|
||||
if (isIndexingInProgress()) {
|
||||
return@withContext OperationResult.Error(
|
||||
@@ -371,9 +389,7 @@ class LocalDictionaryRepository(
|
||||
val dir = File(path)
|
||||
if (!dir.exists() || !dir.isDirectory) return emptyList()
|
||||
|
||||
return dir.listFiles { f ->
|
||||
f.isFile && (f.name.endsWith(".dsl") || f.name.endsWith(".dsl.dz") || f.name.endsWith(".dsl.gz"))
|
||||
}?.map { file ->
|
||||
return dir.listFiles { file -> isDictionaryPayloadFile(file) }?.map { file ->
|
||||
DiscoveredFile(
|
||||
name = file.name,
|
||||
localPath = file.absolutePath,
|
||||
@@ -382,6 +398,13 @@ class LocalDictionaryRepository(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun isDictionaryPayloadFile(file: File): Boolean =
|
||||
file.isFile && (
|
||||
file.name.endsWith(".dsl") ||
|
||||
file.name.endsWith(".dsl.dz") ||
|
||||
file.name.endsWith(".dsl.gz")
|
||||
)
|
||||
|
||||
suspend fun search(query: String): OperationResult<List<IndexEntry>> = withContext(defaultDispatcher) {
|
||||
if (query.isBlank()) return@withContext OperationResult.Success(emptyList())
|
||||
|
||||
@@ -460,53 +483,56 @@ class LocalDictionaryRepository(
|
||||
}
|
||||
|
||||
suspend fun deleteDictionary(dictionary: Dictionary): OperationResult<Unit> {
|
||||
try {
|
||||
dictionaryStateMutex.withLock { removeDictionaryFromState(dictionary.path) }
|
||||
|
||||
launch(ioDispatcher) {
|
||||
try {
|
||||
val deleteErrors = mutableListOf<String>()
|
||||
|
||||
val dictFile = File(dictionary.path)
|
||||
if (dictFile.exists()) {
|
||||
if (!dictFile.delete()) {
|
||||
deleteErrors.add("Failed to delete dictionary file: ${dictFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
||||
File(dictionary.indexPath)
|
||||
} else {
|
||||
File("${dictionary.indexPath}.idx")
|
||||
}
|
||||
|
||||
deleteIndexFiles(indexFile, deleteErrors)
|
||||
|
||||
if (!dictFile.exists()) {
|
||||
dictionaryStateMutex.withLock {
|
||||
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
||||
removeDictionaryFromState(dictionary.path)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
engine.getIndexSearcher().trimMemory(80)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
||||
}
|
||||
|
||||
if (deleteErrors.isNotEmpty()) {
|
||||
val message = deleteErrors.joinToString("; ")
|
||||
Log.w(TAG, "Dictionary deletion completed with errors: $message")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Background deletion failed: ${e.message}", e)
|
||||
return try {
|
||||
val (failure, cleanupErrors) = withContext(ioDispatcher) {
|
||||
val dictFile = File(dictionary.path)
|
||||
if (dictFile.exists() && !dictFile.delete()) {
|
||||
return@withContext Pair(
|
||||
"Failed to delete dictionary file: ${dictFile.absolutePath}",
|
||||
emptyList<String>(),
|
||||
)
|
||||
}
|
||||
|
||||
val deleteErrors = mutableListOf<String>()
|
||||
val indexFile = if (dictionary.indexPath.endsWith(".idx", ignoreCase = true)) {
|
||||
File(dictionary.indexPath)
|
||||
} else {
|
||||
File("${dictionary.indexPath}.idx")
|
||||
}
|
||||
|
||||
deleteIndexFiles(indexFile, deleteErrors)
|
||||
Pair<String?, List<String>>(null, deleteErrors)
|
||||
}
|
||||
|
||||
return OperationResult.Success(Unit)
|
||||
if (failure != null) {
|
||||
return OperationResult.Error(failure)
|
||||
}
|
||||
|
||||
dictionaryStateMutex.withLock {
|
||||
try {
|
||||
preferencesManager.removeDictionaryActiveState(dictionary.path)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to clear dictionary active state: ${e.message}")
|
||||
}
|
||||
removeDictionaryFromState(dictionary.path)
|
||||
}
|
||||
|
||||
try {
|
||||
engine.getIndexSearcher().trimMemory(80)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to trim memory after deletion: ${e.message}")
|
||||
}
|
||||
|
||||
if (cleanupErrors.isNotEmpty()) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Dictionary deletion completed with errors: ${cleanupErrors.joinToString("; ")}"
|
||||
)
|
||||
}
|
||||
|
||||
OperationResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
return OperationResult.Error("Failed to delete dictionary", e)
|
||||
OperationResult.Error("Failed to delete dictionary", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ object DslAnnotatedParser {
|
||||
"br", "p", "b", "i", "c", "t", "m", "m0", "m1", "m2", "m3", "m4", "m5",
|
||||
"ref", "ex", "e", "trn", "com", "lang", "sup", "'"
|
||||
)
|
||||
private val ESCAPED_CHARS = setOf('[', ']', '(', ')')
|
||||
|
||||
data class ColorScheme(
|
||||
val secondaryText: Color,
|
||||
@@ -101,15 +100,13 @@ object DslAnnotatedParser {
|
||||
'\\' -> {
|
||||
if (i + 1 < length) {
|
||||
val next = dsl[i + 1]
|
||||
if (next in ESCAPED_CHARS) {
|
||||
builder.append(next)
|
||||
if (refStack.isNotEmpty()) {
|
||||
refStack.last().second.append(next)
|
||||
}
|
||||
lastChar = next
|
||||
i += 2
|
||||
continue
|
||||
builder.append(next)
|
||||
if (refStack.isNotEmpty()) {
|
||||
refStack.last().second.append(next)
|
||||
}
|
||||
lastChar = next
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
builder.append(char)
|
||||
if (refStack.isNotEmpty()) {
|
||||
@@ -119,7 +116,7 @@ object DslAnnotatedParser {
|
||||
i++
|
||||
}
|
||||
'[' -> {
|
||||
val end = dsl.indexOf(']', i + 1)
|
||||
val end = tagCloseIndex(dsl, i + 1)
|
||||
if (end != -1) {
|
||||
val tagStart = i + 1
|
||||
var tagEnd = end
|
||||
@@ -228,6 +225,19 @@ object DslAnnotatedParser {
|
||||
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> {
|
||||
if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks
|
||||
return blocks.flatMap { block ->
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.example.research.core.domain.model.AppTheme
|
||||
import com.example.research.core.domain.model.Dictionary
|
||||
import com.example.research.core.domain.model.DictionarySource
|
||||
import com.example.research.core.domain.model.IndexingProgress
|
||||
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
|
||||
import com.example.research.core.domain.usecase.DictionarySourceValidator
|
||||
import com.example.research.core.domain.usecase.ManageDictionarySourcesUseCase
|
||||
import com.example.research.core.util.OperationResult
|
||||
@@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private data class DictionaryStateInputs(
|
||||
val theme: AppTheme,
|
||||
@@ -79,10 +80,12 @@ class SettingsViewModel(
|
||||
private val language = MutableStateFlow("system")
|
||||
private val hasCompletedStartupScan = MutableStateFlow(false)
|
||||
|
||||
private val pendingSourceUrls = mutableSetOf<String>()
|
||||
private val pendingSourceUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
private var isDownloadInProgress = false
|
||||
|
||||
private var cancelRefreshPending = false
|
||||
private var errorStateHandled = false
|
||||
private var statusBeforeDownload: DictionaryStatus = DictionaryStatus.Unknown
|
||||
|
||||
init {
|
||||
setupStateObservation()
|
||||
@@ -146,37 +149,37 @@ class SettingsViewModel(
|
||||
is DownloadState.Loading, is DownloadState.Extracting -> {
|
||||
isDownloadInProgress = true
|
||||
cancelRefreshPending = false
|
||||
errorStateHandled = false
|
||||
}
|
||||
is DownloadState.Success -> {
|
||||
pendingSourceUrls.clear()
|
||||
}
|
||||
is DownloadState.Error -> {
|
||||
isDownloadInProgress = false
|
||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
||||
DictionaryStatus.Empty
|
||||
} else {
|
||||
DictionaryStatus.UpToDate
|
||||
}
|
||||
if (pendingSourceUrls.isNotEmpty()) {
|
||||
|
||||
pendingSourceUrls.forEach { urlTemplate ->
|
||||
val source = dictionaryState.dictionarySources.find {
|
||||
it.urlTemplate == urlTemplate
|
||||
}
|
||||
if (source != null) {
|
||||
val hasDictionary = dictionaryState.dictionaries.any { dict ->
|
||||
DictionarySource.matchesDictionaryFile(
|
||||
urlTemplate,
|
||||
File(dict.path).name
|
||||
)
|
||||
if (!errorStateHandled) {
|
||||
errorStateHandled = true
|
||||
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) {
|
||||
DictionaryStatus.Empty
|
||||
} else when (statusBeforeDownload) {
|
||||
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate
|
||||
else -> statusBeforeDownload
|
||||
}
|
||||
if (pendingSourceUrls.isNotEmpty()) {
|
||||
pendingSourceUrls.forEach { urlTemplate ->
|
||||
val source = dictionaryState.dictionarySources.find {
|
||||
it.urlTemplate == urlTemplate
|
||||
}
|
||||
|
||||
if (!hasDictionary) {
|
||||
manageDictionarySourcesUseCase.removeSource(source.id)
|
||||
if (source != null) {
|
||||
val hasDictionary = dictionaryState.dictionaries.any { dictionary ->
|
||||
DictionarySourceFileMatcher.matches(source, dictionary)
|
||||
}
|
||||
if (!hasDictionary) {
|
||||
manageDictionarySourcesUseCase.removeSource(source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingSourceUrls.clear()
|
||||
}
|
||||
pendingSourceUrls.clear()
|
||||
}
|
||||
}
|
||||
is DownloadState.Cancelled -> {
|
||||
@@ -264,26 +267,35 @@ class SettingsViewModel(
|
||||
private suspend fun evaluateDictionaryStatus(): DictionaryStatus {
|
||||
return try {
|
||||
val actualDictionaries = localDictionaryRepository.dictionaries.first()
|
||||
val hasDictionaries = actualDictionaries.isNotEmpty()
|
||||
var sources = preferencesManager.dictionarySources.first()
|
||||
if (pendingSourceUrls.isEmpty() &&
|
||||
downloadManager.downloadState.value !is DownloadState.Loading &&
|
||||
downloadManager.downloadState.value !is DownloadState.Extracting
|
||||
) {
|
||||
val dictionaryFileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||
preferencesManager.dictionaryPath
|
||||
)
|
||||
if (dictionaryFileNames != null) {
|
||||
val installedSources = DictionarySourceFileMatcher.installedSourcesForFileNames(
|
||||
sources,
|
||||
dictionaryFileNames,
|
||||
)
|
||||
val installedSourceIds = installedSources
|
||||
.mapTo(mutableSetOf(), DictionarySource::id)
|
||||
val staleSourceIds = sources
|
||||
.filterNot { it.id in installedSourceIds }
|
||||
.map(DictionarySource::id)
|
||||
if (staleSourceIds.isNotEmpty()) {
|
||||
preferencesManager.removeDictionarySources(staleSourceIds)
|
||||
sources = installedSources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDictionaries) {
|
||||
if (actualDictionaries.isEmpty()) {
|
||||
return DictionaryStatus.Empty
|
||||
}
|
||||
|
||||
var sources = preferencesManager.dictionarySources.first()
|
||||
if (downloadManager.downloadState.value !is DownloadState.Loading &&
|
||||
downloadManager.downloadState.value !is DownloadState.Extracting
|
||||
) {
|
||||
val installedSources = installedSources(sources, actualDictionaries)
|
||||
val installedSourceIds = installedSources.mapTo(mutableSetOf(), DictionarySource::id)
|
||||
val staleSourceIds = sources
|
||||
.filterNot { it.id in installedSourceIds }
|
||||
.map(DictionarySource::id)
|
||||
if (staleSourceIds.isNotEmpty()) {
|
||||
preferencesManager.removeDictionarySources(staleSourceIds)
|
||||
sources = installedSources
|
||||
}
|
||||
}
|
||||
val enabledSources = installedEnabledSources(sources, actualDictionaries)
|
||||
|
||||
if (enabledSources.isEmpty()) {
|
||||
@@ -338,45 +350,62 @@ class SettingsViewModel(
|
||||
|
||||
private fun deleteDictionary(dictionary: Dictionary) {
|
||||
viewModelScope.launch {
|
||||
localDictionaryRepository.deleteDictionary(dictionary)
|
||||
|
||||
manageDictionarySourcesUseCase.removeSourceForDictionary(dictionary)
|
||||
|
||||
val remainingDictionaries = localDictionaryRepository.dictionaries.first()
|
||||
|
||||
if (remainingDictionaries.isEmpty()) {
|
||||
dictionaryStatus.value = DictionaryStatus.Empty
|
||||
when (localDictionaryRepository.deleteDictionary(dictionary)) {
|
||||
is OperationResult.Success -> {
|
||||
val remainingDictionaryFileNames =
|
||||
localDictionaryRepository.listDictionaryPayloadFileNames(
|
||||
preferencesManager.dictionaryPath
|
||||
)
|
||||
manageDictionarySourcesUseCase.removeSourceForDictionary(
|
||||
dictionary = dictionary,
|
||||
remainingDictionaryFileNames = remainingDictionaryFileNames,
|
||||
)
|
||||
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||
evaluateDictionaryStatus()
|
||||
}
|
||||
}
|
||||
is OperationResult.Error -> {
|
||||
effectChannel.send(
|
||||
getApplication<Application>().getString(R.string.error_delete_dictionary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDictionarySources(urlTemplates: List<String>) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val validUrls = mutableListOf<String>()
|
||||
val validUrls = mutableListOf<String>()
|
||||
|
||||
urlTemplates.forEach { urlTemplate ->
|
||||
val trimmed = urlTemplate.trim()
|
||||
if (trimmed.isEmpty() || trimmed.length > 2048) {
|
||||
return@forEach
|
||||
}
|
||||
urlTemplates.forEach { urlTemplate ->
|
||||
val trimmed = urlTemplate.trim()
|
||||
if (trimmed.isEmpty() || trimmed.length > 2048) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val normalizedUrl = DictionarySource.normalizeTemplate(trimmed)
|
||||
if (!pendingSourceUrls.add(normalizedUrl)) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
try {
|
||||
when (manageDictionarySourcesUseCase.addSource(trimmed)) {
|
||||
is ManageDictionarySourcesUseCase.AddSourceResult.Success -> {
|
||||
pendingSourceUrls.add(DictionarySource.normalizeTemplate(trimmed))
|
||||
validUrls.add(trimmed)
|
||||
}
|
||||
is ManageDictionarySourcesUseCase.AddSourceResult.ValidationFailed -> {
|
||||
pendingSourceUrls.remove(normalizedUrl)
|
||||
// Validation failed, skip this source
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pendingSourceUrls.remove(normalizedUrl)
|
||||
android.util.Log.e("SettingsViewModel", "Error adding dictionary source", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (validUrls.isNotEmpty()) {
|
||||
startDownloadForSources(validUrls)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("SettingsViewModel", "Error adding dictionary sources", e)
|
||||
if (validUrls.isNotEmpty()) {
|
||||
startDownloadForSources(validUrls)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,6 +419,7 @@ class SettingsViewModel(
|
||||
return@launch
|
||||
}
|
||||
|
||||
statusBeforeDownload = dictionaryStatus.value
|
||||
dictionaryStatus.value = DictionaryStatus.Checking
|
||||
isDownloadInProgress = true
|
||||
|
||||
@@ -413,6 +443,7 @@ class SettingsViewModel(
|
||||
return@launch
|
||||
}
|
||||
|
||||
statusBeforeDownload = dictionaryStatus.value
|
||||
dictionaryStatus.value = DictionaryStatus.Checking
|
||||
|
||||
val installedSources = installedEnabledSources(
|
||||
@@ -448,14 +479,8 @@ class SettingsViewModel(
|
||||
private fun installedSources(
|
||||
sources: List<DictionarySource>,
|
||||
dictionaries: List<Dictionary>
|
||||
): List<DictionarySource> {
|
||||
val installedFileNames = dictionaries.map { File(it.path).name }
|
||||
return sources.filter { source ->
|
||||
installedFileNames.any { fileName ->
|
||||
DictionarySource.matchesDictionaryFile(source.urlTemplate, fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
): List<DictionarySource> =
|
||||
DictionarySourceFileMatcher.installedSources(sources, dictionaries)
|
||||
|
||||
private fun cancelDownload() {
|
||||
// Cancel the ViewModel-side scan job so no further status recomputes
|
||||
@@ -501,17 +526,17 @@ class SettingsViewModel(
|
||||
is OperationResult.Success -> {
|
||||
val dictionaries = localDictionaryRepository.dictionaries.first()
|
||||
if (dictionaries.isEmpty()) {
|
||||
dictionaryStatus.value = DictionaryStatus.Empty
|
||||
dictionaryStatus.value = withContext(Dispatchers.IO) {
|
||||
evaluateDictionaryStatus()
|
||||
}
|
||||
isDownloadInProgress = false
|
||||
} else if (isDownloadInProgress) {
|
||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||
isDownloadInProgress = false
|
||||
} else {
|
||||
if (isDownloadInProgress) {
|
||||
dictionaryStatus.value = DictionaryStatus.UpToDate
|
||||
isDownloadInProgress = false
|
||||
} else {
|
||||
dictionaryStatus.value = DictionaryStatus.Checking
|
||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||
dictionaryStatus.value = status
|
||||
}
|
||||
dictionaryStatus.value = DictionaryStatus.Checking
|
||||
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
|
||||
dictionaryStatus.value = status
|
||||
}
|
||||
|
||||
if (result.data > 0) {
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
</plurals>
|
||||
<string name="dictionary_not_indexed">• Не индексирован</string>
|
||||
<string name="dictionary_delete">Удалить словарь</string>
|
||||
<string name="error_delete_dictionary">Не удалось удалить словарь</string>
|
||||
<string name="dictionaries_tap_to_update">Нажмите для обновления</string>
|
||||
<string name="notification_import_title">Импорт словарей</string>
|
||||
<string name="notification_import_success_title">Импорт завершён</string>
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
</plurals>
|
||||
<string name="dictionary_not_indexed">• Not indexed</string>
|
||||
<string name="dictionary_delete">Delete dictionary</string>
|
||||
<string name="error_delete_dictionary">Failed to delete dictionary</string>
|
||||
<string name="dictionaries_tap_to_update">Tap to update all dictionaries</string>
|
||||
<string name="notification_import_title">Importing dictionaries</string>
|
||||
<string name="notification_import_success_title">Import completed</string>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- Fixed matching source URLs to installed dictionary files, so deleted dictionaries can be downloaded again from the same URL
|
||||
@@ -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 @@
|
||||
- Исправлено сопоставление URL-источников с установленными файлами — удалённый словарь теперь можно повторно скачать по тому же URL
|
||||
@@ -0,0 +1,3 @@
|
||||
- Карточка обновления словарей теперь появляется снова после неудачной загрузки
|
||||
- Исправлено отображение заголовков и статей с экранированными символами
|
||||
- Исправлено медленное отображение статей с большим количеством скобок
|
||||
@@ -2,14 +2,14 @@
|
||||
aboutlibraries = "15.0.4"
|
||||
activity_compose = "1.13.0"
|
||||
agp = "9.3.1"
|
||||
compose_bom = "2026.06.01"
|
||||
compose_bom = "2026.08.00"
|
||||
core = "1.19.0"
|
||||
datastore_preferences = "1.2.1"
|
||||
documentfile = "1.1.0"
|
||||
kotlin = "2.4.10"
|
||||
lifecycle_runtime_ktx = "2.11.0"
|
||||
okhttp = "5.4.0"
|
||||
paging = "3.5.0"
|
||||
paging = "3.5.1"
|
||||
profileinstaller = "1.4.1"
|
||||
kotlinx_coroutines = "1.11.0"
|
||||
kotlinx_serialization = "1.11.0"
|
||||
|
||||
Reference in New Issue
Block a user