refactor: centralize subscription editing

This commit is contained in:
二刺螈
2026-08-21 20:56:18 +08:00
parent 12254246c1
commit cbbf97acf3
11 changed files with 870 additions and 281 deletions

View File

@@ -0,0 +1,279 @@
package li.songe.gkd.data
class SubscriptionEditor(subscription: RawSubscription) {
private val subscriptionId = subscription.id
private var current = subscription
fun update(transform: RawSubscription.() -> RawSubscription) {
setCurrent(current.transform())
}
fun updateApp(
appId: String,
transform: RawSubscription.RawApp.() -> RawSubscription.RawApp,
): Boolean {
val newApps = current.apps.updateFirstOrNull(
predicate = { it.id == appId },
transform = { it.transform() },
) ?: return false
if (newApps !== current.apps) {
setCurrent(current.copy(apps = newApps))
}
return true
}
private fun putApp(app: RawSubscription.RawApp): RawSubscription.RawApp? {
val previous = current.apps.find { it.id == app.id }
if (previous == null) {
setCurrent(current.copy(apps = current.apps + app))
} else {
updateApp(app.id) { app }
}
return previous
}
fun mergeApp(app: RawSubscription.RawApp): RawSubscription.RawApp {
val currentApp = current.apps.find { it.id == app.id }
if (currentApp == null) {
requireGroupNamesAvailable(emptyList(), app.groups)
return app.copy(
groups = normalizeAppGroupKeys(emptyList(), app.groups),
).also(::putApp)
}
return appendAppGroups(app, app.groups)
}
fun appendAppGroups(
targetApp: RawSubscription.RawApp,
groups: List<RawSubscription.RawAppGroup>,
): RawSubscription.RawApp {
val app = current.apps.find { it.id == targetApp.id } ?: targetApp
requireGroupNamesAvailable(app.groups, groups)
val normalizedGroups = normalizeAppGroupKeys(app.groups, groups)
return app.copy(groups = app.groups + normalizedGroups).also(::putApp)
}
private fun removeApp(appId: String): RawSubscription.RawApp? {
val app = current.apps.find { it.id == appId } ?: return null
setCurrent(current.copy(apps = current.apps.filterNot { it.id == appId }))
return app
}
fun updateCategory(
categoryKey: Int,
transform: RawSubscription.RawCategory.() -> RawSubscription.RawCategory,
): Boolean {
val newCategories = current.categories.updateFirstOrNull(
predicate = { it.key == categoryKey },
transform = { it.transform() },
) ?: return false
if (newCategories !== current.categories) {
setCurrent(current.copy(categories = newCategories))
}
return true
}
fun putCategory(category: RawSubscription.RawCategory): RawSubscription.RawCategory? {
val previous = current.categories.find { it.key == category.key }
if (previous == null) {
setCurrent(current.copy(categories = current.categories + category))
} else {
updateCategory(category.key) { category }
}
return previous
}
fun removeCategory(categoryKey: Int): RawSubscription.RawCategory? {
val category = current.categories.find { it.key == categoryKey } ?: return null
setCurrent(
current.copy(categories = current.categories.filterNot { it.key == categoryKey }),
)
return category
}
private fun updateGlobalGroup(
groupKey: Int,
transform: RawSubscription.RawGlobalGroup.() -> RawSubscription.RawGlobalGroup,
): Boolean {
val newGroups = current.globalGroups.updateFirstOrNull(
predicate = { it.key == groupKey },
transform = { it.transform() },
) ?: return false
if (newGroups !== current.globalGroups) {
setCurrent(current.copy(globalGroups = newGroups))
}
return true
}
private fun putGlobalGroup(
group: RawSubscription.RawGlobalGroup,
): RawSubscription.RawGlobalGroup? {
val previous = current.globalGroups.find { it.key == group.key }
if (previous == null) {
setCurrent(current.copy(globalGroups = current.globalGroups + group))
} else {
updateGlobalGroup(group.key) { group }
}
return previous
}
fun appendGlobalGroup(
group: RawSubscription.RawGlobalGroup,
): RawSubscription.RawGlobalGroup {
requireGroupNamesAvailable(current.globalGroups, listOf(group))
val normalizedGroup = normalizeGlobalGroupKey(current.globalGroups, group)
putGlobalGroup(normalizedGroup)
return normalizedGroup
}
fun removeGlobalGroups(
predicate: (RawSubscription.RawGlobalGroup) -> Boolean,
): List<RawSubscription.RawGlobalGroup> {
val (removed, remaining) = current.globalGroups.partition(predicate)
if (removed.isNotEmpty()) {
setCurrent(current.copy(globalGroups = remaining))
}
return removed
}
private fun updateAppGroup(
appId: String,
groupKey: Int,
transform: RawSubscription.RawAppGroup.() -> RawSubscription.RawAppGroup,
): Boolean {
var groupFound = false
updateApp(appId) {
val newGroups = groups.updateFirstOrNull(
predicate = { it.key == groupKey },
transform = { group -> group.transform() },
) ?: return@updateApp this
groupFound = true
if (newGroups === groups) {
this
} else {
copy(groups = newGroups)
}
}
return groupFound
}
fun replaceAppGroup(
targetApp: RawSubscription.RawApp,
groupKey: Int,
expectedGroup: RawSubscription.RawAppGroup,
newGroup: RawSubscription.RawAppGroup,
) {
require(newGroup.key == groupKey) { "规则key与当前规则不一致" }
val currentApp = current.apps.find { it.id == targetApp.id }
if (currentApp == null) {
putApp(targetApp.copy(groups = listOf(newGroup)))
return
}
val updated = updateAppGroup(targetApp.id, groupKey) {
if (this != expectedGroup) error("规则已发生变化,请重新编辑")
requireGroupNamesAvailable(
currentApp.groups.filterNot { it.key == groupKey },
listOf(newGroup),
)
newGroup
}
if (!updated) error("规则已不存在")
}
fun replaceGlobalGroup(
groupKey: Int,
expectedGroup: RawSubscription.RawGlobalGroup,
newGroup: RawSubscription.RawGlobalGroup,
) {
require(newGroup.key == groupKey) { "规则key与当前规则不一致" }
val updated = updateGlobalGroup(groupKey) {
if (this != expectedGroup) error("规则已发生变化,请重新编辑")
requireGroupNamesAvailable(
current.globalGroups.filterNot { it.key == groupKey },
listOf(newGroup),
)
newGroup
}
if (!updated) error("规则已不存在")
}
fun removeAppGroups(
appId: String,
removeAppIfEmpty: Boolean = false,
predicate: (RawSubscription.RawAppGroup) -> Boolean,
): List<RawSubscription.RawAppGroup> {
val app = current.apps.find { it.id == appId } ?: return emptyList()
val (removed, remaining) = app.groups.partition(predicate)
if (removed.isEmpty()) return emptyList()
if (removeAppIfEmpty && remaining.isEmpty()) {
removeApp(appId)
} else {
updateApp(appId) { copy(groups = remaining) }
}
return removed
}
fun build(): RawSubscription = current
private fun setCurrent(subscription: RawSubscription) {
require(subscription.id == subscriptionId) {
"订阅id不可修改: $subscriptionId -> ${subscription.id}"
}
current = subscription
}
private fun requireGroupNamesAvailable(
existingGroups: List<RawSubscription.RawGroupProps>,
newGroups: List<RawSubscription.RawGroupProps>,
) {
val usedNames = existingGroups.mapTo(mutableSetOf()) { it.name }
newGroups.forEach { group ->
if (!usedNames.add(group.name)) {
error("已存在同名「${group.name}」规则")
}
}
}
private fun normalizeAppGroupKeys(
existingGroups: List<RawSubscription.RawAppGroup>,
newGroups: List<RawSubscription.RawAppGroup>,
): List<RawSubscription.RawAppGroup> {
val usedKeys = existingGroups.mapTo(mutableSetOf()) { it.key }
return newGroups.map { group ->
if (usedKeys.add(group.key)) {
group
} else {
val newKey = requireNotNull(usedKeys.maxOrNull()) + 1
usedKeys.add(newKey)
group.copy(key = newKey)
}
}
}
private fun normalizeGlobalGroupKey(
existingGroups: List<RawSubscription.RawGlobalGroup>,
group: RawSubscription.RawGlobalGroup,
): RawSubscription.RawGlobalGroup {
if (existingGroups.none { it.key == group.key }) return group
return group.copy(key = existingGroups.maxOf { it.key } + 1)
}
}
fun RawSubscription.edit(
block: SubscriptionEditor.() -> Unit,
): RawSubscription = SubscriptionEditor(this).apply(block).build()
private inline fun <T> List<T>.updateFirstOrNull(
predicate: (T) -> Boolean,
transform: (T) -> T,
): List<T>? {
val index = indexOfFirst(predicate)
if (index < 0) return null
val current = this[index]
val updated = transform(current)
return if (updated == current) {
this
} else {
toMutableList().apply { set(index, updated) }
}
}

View File

@@ -0,0 +1,128 @@
package li.songe.gkd.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import li.songe.json5.Json5
class SubscriptionInputParser private constructor(
val jsonObject: JsonObject,
) {
fun parseApp(): RawSubscription.RawApp {
val app = parseAppObject()
app.groups.forEach(::requireValid)
return app
}
fun parseAppGroups(expectedAppId: String): List<RawSubscription.RawAppGroup> {
val groups = if (isAppObject()) {
parseAppObject(expectedAppId).groups
} else {
listOf(parseRule { RawSubscription.parseAppGroup(jsonObject) })
}
groups.forEach(::requireValid)
return groups
}
fun parseAppGroup(expectedAppId: String): RawSubscription.RawAppGroup {
val group = if (isAppObject()) {
parseAppObject(expectedAppId).groups.first()
} else {
parseRule { RawSubscription.parseAppGroup(jsonObject) }
}
requireValid(group)
return group
}
fun parseGlobalGroup(): RawSubscription.RawGlobalGroup {
val group = parseRule { RawSubscription.parseGlobalGroup(jsonObject) }
requireValid(group)
return group
}
private fun isAppObject(): Boolean = jsonObject["groups"] is JsonArray
private fun parseAppObject(
expectedAppId: String? = null,
): RawSubscription.RawApp = parseRule {
if (expectedAppId != null) requireExpectedAppId(expectedAppId)
RawSubscription.parseApp(jsonObject).requireGroups()
}
private fun requireExpectedAppId(expectedAppId: String) {
val id = jsonObject["id"] ?: error("缺少id")
if (id !is JsonPrimitive || !id.isString || id.content != expectedAppId) {
error("id与当前应用不一致")
}
}
private fun RawSubscription.RawApp.requireGroups(): RawSubscription.RawApp {
if (groups.isEmpty()) error("至少输入一个规则")
return this
}
private fun <T : RawSubscription.RawGroupProps> requireValid(group: T): T {
group.errorDesc?.let(::error)
return group
}
private fun <T> parseRule(block: () -> T): T = try {
block()
} catch (e: Exception) {
error("非法规则\n${e.message}")
}
companion object {
fun parse(
source: String,
defaultGroupKey: Int = 0,
): SubscriptionInputParser {
val element = try {
Json5.parseToJson5Element(source)
} catch (e: Exception) {
error("非法格式\n${e.message}")
}
if (element !is JsonObject) error("规则应为对象格式")
return SubscriptionInputParser(element.fillGroupKeys(defaultGroupKey))
}
private fun JsonObject.fillGroupKeys(defaultGroupKey: Int): JsonObject {
val groups = this["groups"]
if (groups is JsonArray) {
val usedKeys = groups.mapNotNull { group ->
(group as? JsonObject)?.get("key")?.groupKeyOrNull()
}.toMutableSet()
var nextKey = defaultGroupKey
var changed = false
val newGroups = groups.map { group ->
if (group is JsonObject && group["name"] != null && group["key"] == null) {
while (nextKey in usedKeys) nextKey += 1
changed = true
JsonObject(group + ("key" to JsonPrimitive(nextKey))).also {
usedKeys.add(nextKey)
nextKey += 1
}
} else {
group
}
}
return if (changed) {
JsonObject(this + ("groups" to JsonArray(newGroups)))
} else {
this
}
}
return if (this["name"] != null && this["key"] == null) {
JsonObject(this + ("key" to JsonPrimitive(defaultGroupKey)))
} else {
this
}
}
private fun JsonElement.groupKeyOrNull(): Int? {
val primitive = this as? JsonPrimitive ?: return null
return primitive.content.toIntOrNull()
}
}
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.withContext
import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.edit
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.batchUpdateGroupEnable
import li.songe.gkd.ui.component.toGroupState
@@ -110,26 +111,11 @@ class SubsAppGroupListVm(val route: SubsAppGroupListRoute) : BaseViewModel() {
suspend fun deleteSelectedGroups(selectedKeys: Set<Int>): Int {
var deletedSize = 0
subscription.update { current ->
val app = current.apps.find { it.id == route.appId }
?: return@update current
val currentKeys = app.groups.mapTo(mutableSetOf()) { it.key }
val keysToDelete = selectedKeys intersect currentKeys
deletedSize = keysToDelete.size
if (keysToDelete.isEmpty()) {
current
} else if (keysToDelete == currentKeys) {
current.copy(
apps = current.apps.filter { it.id != route.appId },
)
} else {
current.copy(
apps = current.apps.toMutableList().apply {
set(
indexOfFirst { it.id == route.appId },
app.copy(groups = app.groups.filterNot { it.key in keysToDelete }),
)
},
)
current.edit {
deletedSize = removeAppGroups(
appId = route.appId,
removeAppIfEmpty = true,
) { it.key in selectedKeys }.size
}
}
return deletedSize

View File

@@ -9,6 +9,7 @@ import li.songe.gkd.MainViewModel
import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.edit
import li.songe.gkd.db.DbSet
import li.songe.gkd.store.storeFlow
import li.songe.gkd.ui.component.updateRuleGroupEnable
@@ -181,14 +182,12 @@ class SubsCategoryGroupVm(
if (category.name == name && (category.desc ?: "") == description) {
current
} else {
current.copy(
categories = current.categories.toMutableList().apply {
set(
indexOfFirst { it.key == category.key },
category.copy(name = name, desc = description),
)
},
)
current.edit {
val updated = updateCategory(category.key) {
copy(name = name, desc = description)
}
if (!updated) error("类别已不存在")
}
}
}
return if (changed) "更新成功" else "未修改"
@@ -196,11 +195,7 @@ class SubsCategoryGroupVm(
suspend fun deleteCategory() {
subscription.update { current ->
current.copy(
categories = current.categories.filterNot {
it.key == route.categoryKey
},
)
current.edit { removeCategory(route.categoryKey) }
}
DbSet.categoryConfigDao.deleteByCategoryKey(
route.subsId,

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.edit
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.share.BaseViewModel
import li.songe.gkd.ui.share.Loadable
@@ -75,18 +76,16 @@ class SubsCategoryVm(val route: SubsCategoryRoute) : BaseViewModel() {
if (current.categories.any { category -> category.name == name }) {
error("不可添加同名类别")
}
current.copy(
categories = current.categories.toMutableList().apply {
add(
RawSubscription.RawCategory(
key = (current.categories.maxOfOrNull { it.key } ?: -1) + 1,
enable = null,
name = name,
desc = description,
),
)
},
)
current.edit {
putCategory(
RawSubscription.RawCategory(
key = (current.categories.maxOfOrNull { it.key } ?: -1) + 1,
enable = null,
name = name,
desc = description,
),
)
}
}
return "添加成功"
}

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.edit
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.batchUpdateGroupEnable
import li.songe.gkd.ui.component.toGroupState
@@ -76,9 +77,7 @@ class SubsGlobalGroupListVm(val route: SubsGlobalGroupListRoute) : BaseViewModel
suspend fun deleteSelectedGroups(selectedKeys: Set<Int>) {
subscription.update { current ->
current.copy(globalGroups = current.globalGroups.filterNot {
it.key in selectedKeys
})
current.edit { removeGlobalGroups { it.key in selectedKeys } }
}
DbSet.subsConfigDao.batchDeleteGlobalGroupConfig(route.subsItemId, selectedKeys.toList())
}

View File

@@ -82,7 +82,7 @@ fun UpsertRuleGroupPage(route: UpsertRuleGroupRoute) {
})
val onClickSave = throttle(vm.scope.launchAsFn(Dispatchers.Main) {
withContext(Dispatchers.Default) { vm.saveRule() }
val addedAppId = withContext(Dispatchers.Default) { vm.saveRule() }
context.imeController.hideAndAwait()
if (forward) {
if (appId == null) {
@@ -94,7 +94,7 @@ fun UpsertRuleGroupPage(route: UpsertRuleGroupRoute) {
mainVm.navigatePage(
SubsAppGroupListRoute(
subsItemId = subsId,
appId = vm.addAppId ?: appId,
appId = addedAppId ?: appId,
),
replaced = true,
)

View File

@@ -3,15 +3,12 @@ package li.songe.gkd.ui
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubscriptionInputParser
import li.songe.gkd.data.edit
import li.songe.gkd.ui.share.BaseViewModel
import li.songe.gkd.ui.style.clearJson5TransformationCache
import li.songe.gkd.util.LogUtils
import li.songe.gkd.util.toast
import li.songe.json5.Json5
data class UpsertRuleGroupUiState(
val initialGroup: RawSubscription.RawGroupProps?,
@@ -71,13 +68,12 @@ class UpsertRuleGroupVm(val route: UpsertRuleGroupRoute) : BaseViewModel() {
if (!isEdit) return !text.isBlank()
if (state.initialText == text) return false
return state.initialGroup?.cacheJsonObject !=
runCatching { Json5.parseToJson5Element(text) }.getOrNull()
runCatching {
SubscriptionInputParser.parse(text, groupKey ?: 0).jsonObject
}.getOrNull()
}
var addAppId: String? = null
suspend fun saveRule() {
suspend fun saveRule(): String? {
val state = requireUiState()
val initialGroup = state.initialGroup
val text = textFlow.value ?: state.initialText
@@ -86,221 +82,72 @@ class UpsertRuleGroupVm(val route: UpsertRuleGroupRoute) : BaseViewModel() {
}
if (text == state.initialText) {
toast("规则无变动")
return
return null
}
var jsonObject = runCatching { Json5.parseToJson5Element(text) }.run {
if (isFailure) {
error("非法格式\n${exceptionOrNull()?.message}")
}
getOrThrow()
}
if (jsonObject !is JsonObject) {
error("规则应为对象格式")
}
// 自动填充 key
if (jsonObject["name"] != null && jsonObject["key"] == null) {
jsonObject = JsonObject(jsonObject + mapOf("key" to JsonPrimitive(groupKey ?: 0)))
}
if (jsonObject["id"] is JsonPrimitive && jsonObject["groups"] is JsonArray) {
val groups = jsonObject["groups"] as JsonArray
val newGroups = groups.map {
if (it is JsonObject && it["name"] != null && it["key"] == null) {
JsonObject(it + mapOf("key" to JsonPrimitive(groupKey ?: 0)))
} else {
it
}
}
jsonObject = JsonObject(mapOf("groups" to JsonArray(newGroups)) + jsonObject)
}
if (jsonObject == initialGroup?.cacheJsonObject) {
val input = SubscriptionInputParser.parse(text, groupKey ?: 0)
if (input.jsonObject == initialGroup?.cacheJsonObject) {
toast("规则无变动")
return
return null
}
var addedAppId: String? = null
if (groupKey != null) {
var newGroup = try {
if (appId != null) {
if (jsonObject["groups"] is JsonArray) {
val id = jsonObject["id"] ?: error("缺少id")
if (!(id is JsonPrimitive && id.isString && id.content == appId)) {
error("id与当前应用不一致")
}
RawSubscription.parseApp(jsonObject).let { newApp ->
if (newApp.groups.isEmpty()) {
error("至少输入一个规则")
}
newApp.groups.first()
}
} else {
null
} ?: RawSubscription.parseAppGroup(jsonObject)
} else {
RawSubscription.parseGlobalGroup(jsonObject)
if (appId != null) {
val newGroup = input.parseAppGroup(appId).copy(key = groupKey)
if (newGroup == initialGroup) {
toast("规则无变动")
return null
}
} catch (e: Exception) {
LogUtils.d(e)
error("非法规则\n${e.message}")
}
newGroup.errorDesc?.let(::error)
if (newGroup.key != groupKey) {
// 自动修正 key 与原来一致
newGroup = when (newGroup) {
is RawSubscription.RawAppGroup -> newGroup.copy(key = groupKey)
is RawSubscription.RawGlobalGroup -> newGroup.copy(key = groupKey)
val originalGroup = requireNotNull(
editBaseGroup ?: initialGroup,
) as RawSubscription.RawAppGroup
requiredSubscription.update { subscription ->
subscription.edit {
replaceAppGroup(
targetApp = subscription.getApp(appId),
groupKey = groupKey,
expectedGroup = originalGroup,
newGroup = newGroup,
)
}
}
}
if (newGroup == initialGroup) {
toast("规则无变动")
return
}
val originalGroup = requireNotNull(editBaseGroup ?: initialGroup)
requiredSubscription.update { subscription ->
if (appId != null) {
newGroup as RawSubscription.RawAppGroup
val appIndex = subscription.apps.indexOfFirst { it.id == appId }
if (appIndex < 0) error("应用不存在")
val app = subscription.apps[appIndex]
val groupIndex = app.groups.indexOfFirst { it.key == groupKey }
if (groupIndex < 0) error("规则已不存在")
if (app.groups[groupIndex] != originalGroup) {
error("规则已发生变化,请重新编辑")
} else {
val newGroup = input.parseGlobalGroup().copy(key = groupKey)
if (newGroup == initialGroup) {
toast("规则无变动")
return null
}
val originalGroup = requireNotNull(
editBaseGroup ?: initialGroup,
) as RawSubscription.RawGlobalGroup
requiredSubscription.update { subscription ->
subscription.edit {
replaceGlobalGroup(groupKey, originalGroup, newGroup)
}
subscription.copy(
apps = subscription.apps.toMutableList().apply {
set(
appIndex,
app.copy(
groups = app.groups.toMutableList().apply {
set(groupIndex, newGroup)
},
),
)
},
)
} else {
newGroup as RawSubscription.RawGlobalGroup
val groupIndex = subscription.globalGroups.indexOfFirst {
it.key == groupKey
}
if (groupIndex < 0) error("规则已不存在")
if (subscription.globalGroups[groupIndex] != originalGroup) {
error("规则已发生变化,请重新编辑")
}
subscription.copy(
globalGroups = subscription.globalGroups.toMutableList().apply {
set(groupIndex, newGroup)
},
)
}
}
} else {
if (isAddAnyApp) {
val newApp = try {
RawSubscription.parseApp(jsonObject).apply {
if (groups.isEmpty()) {
error("至少输入一个规则")
}
}
} catch (e: Exception) {
LogUtils.d(e)
error("非法规则\n${e.message}")
}
val newApp = input.parseApp()
requiredSubscription.update { subscription ->
val appIndex = subscription.apps.indexOfFirst { it.id == newApp.id }
if (appIndex < 0) {
subscription.copy(apps = subscription.apps + newApp)
} else {
val oldApp = subscription.apps[appIndex]
newApp.groups.forEach { group ->
checkGroupKeyName(oldApp.groups, group)
}
val usedKeys = oldApp.groups.mapTo(mutableSetOf()) { it.key }
val newGroups = newApp.groups.map { group ->
if (group.key in usedKeys) {
val newKey = requireNotNull(usedKeys.maxOrNull()) + 1
group.copy(key = newKey).also { usedKeys.add(newKey) }
} else {
group.also { usedKeys.add(it.key) }
}
}
subscription.copy(
apps = subscription.apps.toMutableList().apply {
set(appIndex, oldApp.copy(groups = oldApp.groups + newGroups))
},
)
}
subscription.edit { mergeApp(newApp) }
}
addAppId = newApp.id
addedAppId = newApp.id
} else if (appId != null) {
// add specified app group
val newGroups = try {
if (jsonObject["groups"] is JsonArray) {
val id = jsonObject["id"] ?: error("缺少id")
if (!(id is JsonPrimitive && id.isString && id.content == appId)) {
error("id与当前应用不一致")
}
RawSubscription.parseApp(jsonObject).apply {
if (groups.isEmpty()) {
error("至少输入一个规则")
}
}.groups
} else {
null
} ?: listOf(RawSubscription.parseAppGroup(jsonObject))
} catch (e: Exception) {
LogUtils.d(e)
error("非法规则\n${e.message}")
}
newGroups.forEach { g ->
g.errorDesc?.let { error(it) }
}
val newGroups = input.parseAppGroups(appId)
requiredSubscription.update { subscription ->
val appIndex = subscription.apps.indexOfFirst { it.id == appId }
if (appIndex < 0) error("应用不存在")
val oldApp = subscription.apps[appIndex]
newGroups.forEach { group ->
checkGroupKeyName(oldApp.groups, group)
subscription.edit {
appendAppGroups(
targetApp = subscription.getApp(appId),
groups = newGroups,
)
}
val usedKeys = oldApp.groups.mapTo(mutableSetOf()) { it.key }
val normalizedGroups = newGroups.map { group ->
if (group.key in usedKeys) {
val newKey = requireNotNull(usedKeys.maxOrNull()) + 1
group.copy(key = newKey).also { usedKeys.add(newKey) }
} else {
group.also { usedKeys.add(it.key) }
}
}
subscription.copy(
apps = subscription.apps.toMutableList().apply {
set(
appIndex,
oldApp.copy(groups = oldApp.groups + normalizedGroups),
)
},
)
}
} else {
// add global group
val newGroup = try {
RawSubscription.parseGlobalGroup(jsonObject)
} catch (e: Exception) {
LogUtils.d(e)
error("非法规则\n${e.message}")
}
val newGroup = input.parseGlobalGroup()
requiredSubscription.update { subscription ->
checkGroupKeyName(subscription.globalGroups, newGroup)
val normalizedGroup = if (
subscription.globalGroups.any { it.key == newGroup.key }
) {
newGroup.copy(
key = subscription.globalGroups.maxOf { it.key } + 1,
)
} else {
newGroup
}
subscription.copy(
globalGroups = subscription.globalGroups + normalizedGroup,
)
subscription.edit { appendGlobalGroup(newGroup) }
}
}
}
@@ -309,18 +156,10 @@ class UpsertRuleGroupVm(val route: UpsertRuleGroupRoute) : BaseViewModel() {
} else {
toast("添加成功")
}
return addedAppId
}
init {
addCloseable { clearJson5TransformationCache() }
}
}
private fun checkGroupKeyName(
groups: List<RawSubscription.RawGroupProps>,
newGroup: RawSubscription.RawGroupProps
) {
if (groups.any { it.name == newGroup.name }) {
error("已存在同名「${newGroup.name}」规则")
}
}

View File

@@ -15,6 +15,7 @@ import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.ExcludeData
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.edit
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.SubsGlobalGroupExcludeRoute
import li.songe.gkd.ui.UpsertRuleGroupRoute
@@ -319,27 +320,20 @@ class RuleGroupState(
val groupKey = requireNotNull(state.groupKey)
SubscriptionStore.update(state.subsId) { subscription ->
if (state.appId == null) {
if (subscription.globalGroups.none { it.key == groupKey }) {
error("规则已不存在")
subscription.edit {
if (removeGlobalGroups { it.key == groupKey }.isEmpty()) {
error("规则已不存在")
}
}
subscription.copy(
globalGroups = subscription.globalGroups.filter { it.key != groupKey },
)
} else {
val appIndex = subscription.apps.indexOfFirst { it.id == state.appId }
if (appIndex < 0) error("应用规则已不存在")
val app = subscription.apps[appIndex]
if (app.groups.none { it.key == groupKey }) {
error("规则已不存在")
subscription.edit {
if (subscription.apps.none { it.id == state.appId }) {
error("应用规则已不存在")
}
if (removeAppGroups(state.appId) { it.key == groupKey }.isEmpty()) {
error("规则已不存在")
}
}
subscription.copy(
apps = subscription.apps.toMutableList().apply {
set(
appIndex,
app.copy(groups = app.groups.filter { it.key != groupKey }),
)
},
)
}
}
}

View File

@@ -0,0 +1,241 @@
package li.songe.gkd.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class SubscriptionEditorTest {
@Test
fun updatesArbitrarySubscriptionAndNestedFieldsInOneSnapshot() {
val source = createSubscription(
apps = listOf(RawSubscription.RawApp("app.id", "Old name")),
categories = listOf(RawSubscription.RawCategory(0, "Old", null, null)),
)
val edited = source.edit {
update { copy(name = "Edited", author = "Author") }
assertTrue(updateApp("app.id") { copy(name = "New name") })
assertTrue(updateCategory(0) { copy(name = "New category", desc = "Desc") })
}
assertEquals("Edited", edited.name)
assertEquals("Author", edited.author)
assertEquals("New name", edited.apps.single().name)
assertEquals("New category", edited.categories.single().name)
assertEquals("Desc", edited.categories.single().desc)
}
@Test
fun missingStrictNodeIsNotCreated() {
val source = createSubscription()
val edited = source.edit {
assertFalse(updateApp("missing") { copy(name = "Unexpected") })
assertFalse(updateCategory(1) { copy(name = "Unexpected") })
}
assertSame(source, edited)
}
@Test
fun removingLastAppGroupCanRemoveItsApp() {
val source = RawSubscription.parse(
"""
{
id: -2,
name: 'Local',
version: 0,
apps: [{
id: 'app.id',
groups: [{ key: 0, name: 'Rule', rules: [] }],
}],
}
""".trimIndent(),
)
val edited = source.edit {
val removed = removeAppGroups("app.id", removeAppIfEmpty = true) { it.key == 0 }
assertEquals(1, removed.size)
}
assertTrue(edited.apps.isEmpty())
}
@Test
fun replacesAndRemovesAppAndGlobalGroupsByKey() {
val source = RawSubscription.parse(
"""
{
id: -2,
name: 'Local',
version: 0,
globalGroups: [{ key: 1, name: 'Global', rules: [] }],
apps: [{
id: 'app.id',
groups: [{ key: 2, name: 'App', rules: [] }],
}],
}
""".trimIndent(),
)
val edited = source.edit {
val globalGroup = source.globalGroups.single()
replaceGlobalGroup(
groupKey = globalGroup.key,
expectedGroup = globalGroup,
newGroup = globalGroup.copy(name = "Edited global"),
)
val app = source.apps.single()
val appGroup = app.groups.single()
replaceAppGroup(
targetApp = app,
groupKey = appGroup.key,
expectedGroup = appGroup,
newGroup = appGroup.copy(name = "Edited app"),
)
}
assertEquals("Edited global", edited.globalGroups.single().name)
assertEquals("Edited app", edited.apps.single().groups.single().name)
val removed = edited.edit {
assertEquals(1, removeGlobalGroups { it.key == 1 }.size)
assertEquals(1, removeAppGroups("app.id") { it.key == 2 }.size)
}
assertTrue(removed.globalGroups.isEmpty())
assertTrue(removed.apps.single().groups.isEmpty())
}
@Test
fun subscriptionIdCannotBeChanged() {
val source = createSubscription()
assertThrows(IllegalArgumentException::class.java) {
source.edit { update { copy(id = 1) } }
}
}
@Test
fun appendingGroupsCreatesMissingAppAndNormalizesConflictingKeys() {
val source = createSubscription(
apps = listOf(
RawSubscription.RawApp(
id = "existing.app",
name = null,
groups = listOf(createAppGroup(0, "Existing")),
),
),
)
val edited = source.edit {
appendAppGroups(
source.apps.first { it.id == "existing.app" },
listOf(createAppGroup(0, "First"), createAppGroup(1, "Second")),
)
appendAppGroups(
RawSubscription.RawApp("new.app", "New app"),
listOf(createAppGroup(0, "New")),
)
}
assertEquals(
listOf(0, 1, 2),
edited.apps.first { it.id == "existing.app" }.groups.map { it.key },
)
val newApp = edited.apps.first { it.id == "new.app" }
assertEquals("New app", newApp.name)
assertEquals("New", newApp.groups.single().name)
}
@Test
fun duplicateGroupNamesAreRejectedWhenAppending() {
val source = createSubscription(
apps = listOf(
RawSubscription.RawApp(
id = "app.id",
name = null,
groups = listOf(createAppGroup(0, "Duplicate")),
),
),
)
val error = assertThrows(IllegalStateException::class.java) {
source.edit {
appendAppGroups(
source.apps.single(),
listOf(createAppGroup(1, "Duplicate")),
)
}
}
assertEquals("已存在同名「Duplicate」规则", error.message)
}
@Test
fun replacingGroupCreatesMissingAppAndRejectsStaleEdits() {
val originalGroup = createAppGroup(0, "Original")
val replacement = createAppGroup(0, "Replacement")
val missingAppEdited = createSubscription().edit {
replaceAppGroup(
targetApp = RawSubscription.RawApp("missing.app", "Missing app"),
groupKey = 0,
expectedGroup = originalGroup,
newGroup = replacement,
)
}
assertEquals("missing.app", missingAppEdited.apps.single().id)
assertEquals("Missing app", missingAppEdited.apps.single().name)
assertEquals("Replacement", missingAppEdited.apps.single().groups.single().name)
val changedGroup = createAppGroup(0, "Changed elsewhere")
val changedSource = createSubscription(
apps = listOf(RawSubscription.RawApp("app.id", null, listOf(changedGroup))),
)
val error = assertThrows(IllegalStateException::class.java) {
changedSource.edit {
replaceAppGroup(
targetApp = changedSource.apps.single(),
groupKey = 0,
expectedGroup = originalGroup,
newGroup = replacement,
)
}
}
assertEquals("规则已发生变化,请重新编辑", error.message)
}
private fun createSubscription(
apps: List<RawSubscription.RawApp> = emptyList(),
categories: List<RawSubscription.RawCategory> = emptyList(),
) = RawSubscription(
id = -2,
name = "Local",
version = 0,
apps = apps,
categories = categories,
)
private fun createAppGroup(
key: Int,
name: String,
): RawSubscription.RawAppGroup = RawSubscription.parse(
"""
{
id: -2,
name: 'Local',
version: 0,
apps: [{
id: 'app.id',
groups: [{ key: $key, name: '$name', rules: [] }],
}],
}
""".trimIndent(),
).apps.single().groups.single()
}

View File

@@ -0,0 +1,129 @@
package li.songe.gkd.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class SubscriptionInputParserTest {
@Test
fun singleGroupGetsDefaultKey() {
val input = SubscriptionInputParser.parse(
"""
{
name: 'Rule',
rules: [],
}
""".trimIndent(),
defaultGroupKey = 7,
)
val appGroup = input.parseAppGroup("app.id")
val globalGroup = input.parseGlobalGroup()
assertEquals(7, appGroup.key)
assertEquals(7, globalGroup.key)
}
@Test
fun fullAppGetsUniqueKeysWithoutDroppingGroups() {
val input = SubscriptionInputParser.parse(
"""
{
id: 'app.id',
groups: [
{ name: 'First', rules: [] },
{ key: 2, name: 'Explicit', rules: [] },
{ name: 'Second', rules: [] },
],
}
""".trimIndent(),
)
val groups = input.parseAppGroups("app.id")
assertEquals(listOf("First", "Explicit", "Second"), groups.map { it.name })
assertEquals(listOf(0, 2, 1), groups.map { it.key })
}
@Test
fun quotedNumericKeyIsReservedWhenFillingMissingKeys() {
val input = SubscriptionInputParser.parse(
"""
{
id: 'app.id',
groups: [
{ key: '0', name: 'Quoted', rules: [] },
{ name: 'Filled', rules: [] },
],
}
""".trimIndent(),
)
val groups = input.parseAppGroups("app.id")
assertEquals(listOf("Quoted", "Filled"), groups.map { it.name })
assertEquals(listOf(0, 1), groups.map { it.key })
}
@Test
fun appGroupInputAcceptsFullAppAndSingleGroup() {
val fullApp = SubscriptionInputParser.parse(
"""
{
id: 'app.id',
groups: [{ key: 3, name: 'Wrapped', rules: [] }],
}
""".trimIndent(),
)
val singleGroup = SubscriptionInputParser.parse(
"{ key: 4, name: 'Single', rules: [] }",
)
assertEquals("Wrapped", fullApp.parseAppGroup("app.id").name)
assertEquals("Single", singleGroup.parseAppGroup("app.id").name)
}
@Test
fun fullAppMustMatchExpectedAppId() {
val input = SubscriptionInputParser.parse(
"""
{
id: 'other.app',
groups: [{ key: 0, name: 'Rule', rules: [] }],
}
""".trimIndent(),
)
val error = assertThrows(IllegalStateException::class.java) {
input.parseAppGroups("app.id")
}
assertEquals("非法规则\nid与当前应用不一致", error.message)
}
@Test
fun appInputRequiresAtLeastOneGroup() {
val input = SubscriptionInputParser.parse("{ id: 'app.id', groups: [] }")
val error = assertThrows(IllegalStateException::class.java) {
input.parseApp()
}
assertEquals("非法规则\n至少输入一个规则", error.message)
}
@Test
fun reportsSyntaxAndTopLevelTypeErrors() {
val syntaxError = assertThrows(IllegalStateException::class.java) {
SubscriptionInputParser.parse("{")
}
val typeError = assertThrows(IllegalStateException::class.java) {
SubscriptionInputParser.parse("[]")
}
assertTrue(syntaxError.message.orEmpty().startsWith("非法格式\n"))
assertEquals("规则应为对象格式", typeError.message)
}
}