mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
ref: add validation for duplicate collection
This commit is contained in:
parent
23480f50b6
commit
f2eddbaf5b
4 changed files with 128 additions and 79 deletions
|
|
@ -1466,8 +1466,10 @@
|
|||
<string name="action_resume_episode">Resume %1$s</string>
|
||||
<string name="collections_import_error_empty_json">JSON is empty.</string>
|
||||
<string name="collections_import_error_collection_blank_id">Collection %1$d has blank id.</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">Collection id '%1$s' is used more than once.</string>
|
||||
<string name="collections_import_error_collection_blank_title">Collection '%1$s' has blank title.</string>
|
||||
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">Folder id '%1$s' is used more than once in '%2$s'.</string>
|
||||
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
|
||||
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
|
||||
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
|
|||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
||||
import com.nuvio.app.core.ui.NuvioStatusModal
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
|
|
@ -215,6 +216,9 @@ private fun CollectionReorderableList(
|
|||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val lazyListState = rememberLazyListState()
|
||||
val keyedCollections = remember(collections) {
|
||||
collections.withDuplicateSafeLazyKeys { collection -> collection.id }
|
||||
}
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||
lazyListState = lazyListState,
|
||||
) { from, to ->
|
||||
|
|
@ -229,8 +233,9 @@ private fun CollectionReorderableList(
|
|||
state = lazyListState,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
itemsIndexed(collections, key = { _, collection -> collection.id }) { _, collection ->
|
||||
ReorderableItem(reorderableLazyListState, key = collection.id) { isDragging ->
|
||||
itemsIndexed(keyedCollections, key = { _, collection -> collection.lazyKey }) { _, keyedCollection ->
|
||||
val collection = keyedCollection.value
|
||||
ReorderableItem(reorderableLazyListState, key = keyedCollection.lazyKey) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
|
||||
|
||||
Surface(
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ import kotlinx.serialization.json.JsonElement
|
|||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_collection_duplicate_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_empty_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_folder_duplicate_id
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
|
||||
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
|
||||
|
|
@ -128,6 +130,10 @@ object CollectionRepository {
|
|||
|
||||
fun importFromJson(jsonString: String): Result<List<Collection>> {
|
||||
return runCatching {
|
||||
val validation = validateJson(jsonString)
|
||||
if (!validation.valid) {
|
||||
throw IllegalArgumentException(validation.error.orEmpty())
|
||||
}
|
||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
|
||||
|
|
@ -145,87 +151,13 @@ object CollectionRepository {
|
|||
}
|
||||
return try {
|
||||
val collections = json.decodeFromString<List<Collection>>(jsonString)
|
||||
var totalFolders = 0
|
||||
collections.forEachIndexed { ci, c ->
|
||||
if (c.id.isBlank()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_collection_blank_id, ci + 1)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (c.title.isBlank()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(Res.string.collections_import_error_collection_blank_title, c.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
c.folders.forEachIndexed { fi, f ->
|
||||
if (f.id.isBlank()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_folder_blank_id,
|
||||
fi + 1,
|
||||
c.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (f.title.isBlank()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_folder_blank_title,
|
||||
f.id,
|
||||
c.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
f.resolvedSources.forEachIndexed { si, s ->
|
||||
if (s.hasInvalidTraktListId()) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_trakt_list_id,
|
||||
si + 1,
|
||||
f.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val invalidAddon = !s.isTmdb && !s.isTrakt &&
|
||||
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
|
||||
val invalidTmdb = s.isTmdb &&
|
||||
s.tmdbSourceType.isNullOrBlank()
|
||||
if (invalidAddon || invalidTmdb) {
|
||||
return ValidationResult(
|
||||
valid = false,
|
||||
error = runBlocking {
|
||||
getString(
|
||||
Res.string.collections_import_error_source_blank_fields,
|
||||
si + 1,
|
||||
f.title,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
totalFolders++
|
||||
}
|
||||
validateImportModel(collections)?.let { error ->
|
||||
return ValidationResult(valid = false, error = error.localizedMessage())
|
||||
}
|
||||
ValidationResult(
|
||||
valid = true,
|
||||
collectionCount = collections.size,
|
||||
folderCount = totalFolders,
|
||||
folderCount = collections.sumOf { it.folders.size },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(
|
||||
|
|
@ -294,3 +226,78 @@ object CollectionRepository {
|
|||
rawCollectionsJson = it
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface CollectionImportModelError {
|
||||
data class BlankCollectionId(val collectionIndex: Int) : CollectionImportModelError
|
||||
data class DuplicateCollectionId(val collectionId: String) : CollectionImportModelError
|
||||
data class BlankCollectionTitle(val collectionId: String) : CollectionImportModelError
|
||||
data class BlankFolderId(val folderIndex: Int, val collectionTitle: String) : CollectionImportModelError
|
||||
data class DuplicateFolderId(val folderId: String, val collectionTitle: String) : CollectionImportModelError
|
||||
data class BlankFolderTitle(val folderId: String, val collectionTitle: String) : CollectionImportModelError
|
||||
data class InvalidTraktListId(val sourceIndex: Int, val folderTitle: String) : CollectionImportModelError
|
||||
data class BlankSourceFields(val sourceIndex: Int, val folderTitle: String) : CollectionImportModelError
|
||||
}
|
||||
|
||||
internal fun validateImportModel(collections: List<Collection>): CollectionImportModelError? {
|
||||
val collectionIds = mutableSetOf<String>()
|
||||
collections.forEachIndexed { ci, c ->
|
||||
if (c.id.isBlank()) {
|
||||
return CollectionImportModelError.BlankCollectionId(ci + 1)
|
||||
}
|
||||
if (!collectionIds.add(c.id)) {
|
||||
return CollectionImportModelError.DuplicateCollectionId(c.id)
|
||||
}
|
||||
if (c.title.isBlank()) {
|
||||
return CollectionImportModelError.BlankCollectionTitle(c.id)
|
||||
}
|
||||
|
||||
val folderIds = mutableSetOf<String>()
|
||||
c.folders.forEachIndexed { fi, f ->
|
||||
if (f.id.isBlank()) {
|
||||
return CollectionImportModelError.BlankFolderId(fi + 1, c.title)
|
||||
}
|
||||
if (!folderIds.add(f.id)) {
|
||||
return CollectionImportModelError.DuplicateFolderId(f.id, c.title)
|
||||
}
|
||||
if (f.title.isBlank()) {
|
||||
return CollectionImportModelError.BlankFolderTitle(f.id, c.title)
|
||||
}
|
||||
f.resolvedSources.forEachIndexed { si, s ->
|
||||
if (s.hasInvalidTraktListId()) {
|
||||
return CollectionImportModelError.InvalidTraktListId(si + 1, f.title)
|
||||
}
|
||||
|
||||
val invalidAddon = !s.isTmdb && !s.isTrakt &&
|
||||
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
|
||||
val invalidTmdb = s.isTmdb &&
|
||||
s.tmdbSourceType.isNullOrBlank()
|
||||
if (invalidAddon || invalidTmdb) {
|
||||
return CollectionImportModelError.BlankSourceFields(si + 1, f.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun CollectionImportModelError.localizedMessage(): String =
|
||||
runBlocking {
|
||||
when (this@localizedMessage) {
|
||||
is CollectionImportModelError.BlankCollectionId ->
|
||||
getString(Res.string.collections_import_error_collection_blank_id, collectionIndex)
|
||||
is CollectionImportModelError.DuplicateCollectionId ->
|
||||
getString(Res.string.collections_import_error_collection_duplicate_id, collectionId)
|
||||
is CollectionImportModelError.BlankCollectionTitle ->
|
||||
getString(Res.string.collections_import_error_collection_blank_title, collectionId)
|
||||
is CollectionImportModelError.BlankFolderId ->
|
||||
getString(Res.string.collections_import_error_folder_blank_id, folderIndex, collectionTitle)
|
||||
is CollectionImportModelError.DuplicateFolderId ->
|
||||
getString(Res.string.collections_import_error_folder_duplicate_id, folderId, collectionTitle)
|
||||
is CollectionImportModelError.BlankFolderTitle ->
|
||||
getString(Res.string.collections_import_error_folder_blank_title, folderId, collectionTitle)
|
||||
is CollectionImportModelError.InvalidTraktListId ->
|
||||
getString(Res.string.collections_import_error_trakt_list_id, sourceIndex, folderTitle)
|
||||
is CollectionImportModelError.BlankSourceFields ->
|
||||
getString(Res.string.collections_import_error_source_blank_fields, sourceIndex, folderTitle)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,41 @@ class CollectionSourceSerializationTest {
|
|||
assertTrue(source.hasInvalidTraktListId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importModelRejectsDuplicateCollectionIds() {
|
||||
val collections = listOf(
|
||||
Collection(id = "collection-1", title = "One"),
|
||||
Collection(id = "collection-1", title = "Two"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
CollectionImportModelError.DuplicateCollectionId("collection-1"),
|
||||
validateImportModel(collections),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importModelRejectsDuplicateFolderIdsWithinCollection() {
|
||||
val collections = listOf(
|
||||
Collection(
|
||||
id = "collection-1",
|
||||
title = "Favorites",
|
||||
folders = listOf(
|
||||
CollectionFolder(id = "folder-1", title = "One"),
|
||||
CollectionFolder(id = "folder-1", title = "Two"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
CollectionImportModelError.DuplicateFolderId(
|
||||
folderId = "folder-1",
|
||||
collectionTitle = "Favorites",
|
||||
),
|
||||
validateImportModel(collections),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyAddonCatalogSourcesRemainCompatible() {
|
||||
val payload = """
|
||||
|
|
|
|||
Loading…
Reference in a new issue