diff --git a/androidApp/src/debug/kotlin/dev/obiente/nextcloudnative/SessionTestBootstrap.kt b/androidApp/src/debug/kotlin/dev/obiente/nextcloudnative/SessionTestBootstrap.kt index 54368109..aa4faef0 100644 --- a/androidApp/src/debug/kotlin/dev/obiente/nextcloudnative/SessionTestBootstrap.kt +++ b/androidApp/src/debug/kotlin/dev/obiente/nextcloudnative/SessionTestBootstrap.kt @@ -7,11 +7,15 @@ import org.json.JSONObject internal object SessionTestBootstrap { private const val IMPORT_FILENAME = "nc-native-test-session.json" - private const val PREFERENCES_NAME = "nextcloud_native" + private const val WRITE_SCOPE_IMPORT_FILENAME = "nc-native-test-write-scope.json" private const val KEY_SESSION = "encrypted_session" - private const val KEY_TEST_READ_ONLY = "emulator_test_read_only" fun importIfPresent(context: Context) { + importSessionIfPresent(context) + importWriteScopeIfPresent(context) + } + + private fun importSessionIfPresent(context: Context) { val importFile = File(context.filesDir, IMPORT_FILENAME) if (!importFile.isFile) return @@ -28,10 +32,12 @@ internal object SessionTestBootstrap { .let(SessionCipher()::encrypt) check( - context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE) .edit() .putString(KEY_SESSION, encryptedSession) .putBoolean(KEY_TEST_READ_ONLY, true) + .remove(KEY_TEST_WRITE_SCOPE_SERVER) + .remove(KEY_TEST_WRITE_SCOPE_PATH) .commit(), ) { "Could not store the read-only emulator session." @@ -43,6 +49,51 @@ internal object SessionTestBootstrap { } } + private fun importWriteScopeIfPresent(context: Context) { + val importFile = File(context.filesDir, WRITE_SCOPE_IMPORT_FILENAME) + if (!importFile.isFile) return + try { + val source = JSONObject(importFile.readText()) + val preferences = context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE) + if (source.optBoolean("clear", false)) { + check( + preferences.edit() + .remove(KEY_TEST_WRITE_SCOPE_SERVER) + .remove(KEY_TEST_WRITE_SCOPE_PATH) + .commit(), + ) { + "Could not clear the emulator write scope." + } + return + } + require(preferences.getBoolean(KEY_TEST_READ_ONLY, false)) { + "A write scope requires the imported read-only emulator session." + } + val encryptedSession = requireNotNull(preferences.getString(KEY_SESSION, null)) { + "The imported emulator session is missing." + } + val serverUrl = JSONObject(SessionCipher().decrypt(encryptedSession)) + .getString("serverUrl") + .validatedServerUrl() + val apiPathPrefix = source.getString("apiPathPrefix") + requireNotNull(ScopedTestWriteAuthorization.create(serverUrl, apiPathPrefix)) { + "The emulator write scope is invalid." + } + check( + preferences.edit() + .putString(KEY_TEST_WRITE_SCOPE_SERVER, serverUrl) + .putString(KEY_TEST_WRITE_SCOPE_PATH, apiPathPrefix.trim().trimEnd('/')) + .commit(), + ) { + "Could not store the emulator write scope." + } + } finally { + check(importFile.delete() || !importFile.exists()) { + "Could not remove the temporary emulator write scope." + } + } + } + private fun String.validatedServerUrl(): String { val normalized = trim().trimEnd('/') val uri = URI(normalized) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index b0f05262..abeb587f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -173,6 +173,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -182,6 +184,40 @@ import okhttp3.Response import org.json.JSONArray import org.json.JSONObject +internal fun resolveAndroidNextcloudRedirectLocation( + requestUrl: HttpUrl, + serverUrl: String, + location: String?, +): String? { + val target = location?.let(requestUrl::resolve) ?: return null + if (target.fragment != null) return null + val account = serverUrl.toHttpUrlOrNull() ?: return null + if ( + target.scheme != account.scheme || + target.host != account.host || + target.port != account.port + ) { + return null + } + val accountPath = account.encodedPath.trimEnd('/').takeUnless { it == "/" }.orEmpty() + if ( + accountPath.isNotEmpty() && + target.encodedPath != accountPath && + !target.encodedPath.startsWith("$accountPath/") + ) { + return null + } + val relativePath = target.encodedPath.removePrefix(accountPath) + if (!relativePath.startsWith('/') || relativePath.startsWith("//")) return null + return buildString { + append(relativePath) + target.encodedQuery?.let { query -> + append('?') + append(query) + } + } +} + internal suspend fun executeAndroidDynamicApiGet( accountId: String, requestIdentity: String, @@ -1562,6 +1598,12 @@ internal class AndroidNextcloudServices( request: NextcloudApiRequest, ): NextcloudApiResponse = withContext(Dispatchers.IO) { val safeRequest = request.requireSafe() + safeRequest.multipartBody?.let { multipart -> + return@withContext executeNextcloudMultipartUpload( + session, + multipart.toUploadRequest(safeRequest), + ) + } val accountId = NextcloudDocumentIds.cacheAccountId(session) val cacheIdentity = safeRequest.dynamicReadCacheIdentity() if (safeRequest.method != dev.obiente.nextcloudnative.app.NextcloudApiMethod.GET) { @@ -2198,10 +2240,7 @@ internal class AndroidNextcloudServices( client: OkHttpClient = httpClient, streamingBody: RequestBody? = null, ): HttpResponse { - check( - !appContext.isReadOnlyTestMode() || - method.isReadOnlyTestRequestMethod(), - ) { + check(appContext.isAllowedTestRequest(method, url)) { "This emulator is using a shared read-only test session. Cloud changes are blocked." } val requestBody = when { @@ -2235,7 +2274,15 @@ internal class AndroidNextcloudServices( body = responseBody.byteStream().readBounded(readLimit), contentType = responseBody.contentType()?.toString(), etag = response.header("ETag") ?: response.header("OC-Etag"), - location = response.header("Location"), + location = if (session == null) { + response.header("Location") + } else { + resolveAndroidNextcloudRedirectLocation( + requestUrl = response.request.url, + serverUrl = session.serverUrl, + location = response.header("Location"), + ) + }, chatLastGiven = response.header("X-Chat-Last-Given"), contentRange = response.header("Content-Range"), ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt index 0cb49744..63f25ad9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt @@ -1,14 +1,120 @@ package dev.obiente.nextcloudnative import android.content.Context +import java.net.URI +import java.util.Locale -private const val PREFERENCES_NAME = "nextcloud_native" -private const val KEY_TEST_READ_ONLY = "emulator_test_read_only" +internal const val TEST_PREFERENCES_NAME = "nextcloud_native" +internal const val KEY_TEST_READ_ONLY = "emulator_test_read_only" +internal const val KEY_TEST_WRITE_SCOPE_SERVER = "emulator_test_write_scope_server" +internal const val KEY_TEST_WRITE_SCOPE_PATH = "emulator_test_write_scope_path" internal fun Context.isReadOnlyTestMode(): Boolean = - getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE) .getBoolean(KEY_TEST_READ_ONLY, false) internal fun Context.cloudMutationGate(): () -> Boolean = { !isReadOnlyTestMode() } + +internal fun Context.isAllowedTestRequest(method: String, url: String): Boolean { + if (!isReadOnlyTestMode() || method.isReadOnlyTestRequestMethod()) return true + if (!BuildConfig.DEBUG) return false + val preferences = getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE) + val serverUrl = preferences.getString(KEY_TEST_WRITE_SCOPE_SERVER, null) ?: return false + val apiPathPrefix = preferences.getString(KEY_TEST_WRITE_SCOPE_PATH, null) ?: return false + return ScopedTestWriteAuthorization.create(serverUrl, apiPathPrefix) + ?.allows(method, url) == true +} + +internal class ScopedTestWriteAuthorization private constructor( + private val scheme: String, + private val host: String, + private val port: Int, + private val absolutePathPrefix: String, +) { + fun allows(method: String, url: String): Boolean { + if (method.uppercase(Locale.ROOT) !in SCOPED_TEST_MUTATION_METHODS) return false + val target = runCatching { URI(url) }.getOrNull() ?: return false + if ( + target.scheme?.lowercase(Locale.ROOT) != scheme || + target.host?.lowercase(Locale.ROOT) != host || + target.effectivePort() != port || + target.userInfo != null || + target.fragment != null + ) { + return false + } + val path = target.rawPath ?: return false + if (path.contains('%') || path.contains('\\') || path.contains("//")) return false + return path == absolutePathPrefix || path.startsWith("$absolutePathPrefix/") + } + + companion object { + fun create(serverUrl: String, apiPathPrefix: String): ScopedTestWriteAuthorization? { + val server = runCatching { URI(serverUrl.trim().trimEnd('/')) }.getOrNull() ?: return null + val scheme = server.scheme?.lowercase(Locale.ROOT) + val host = server.host?.lowercase(Locale.ROOT) + if ( + scheme != "https" || + host.isNullOrBlank() || + server.userInfo != null || + server.query != null || + server.fragment != null + ) { + return null + } + val normalizedApiPrefix = apiPathPrefix.trim().trimEnd('/') + if (!normalizedApiPrefix.isSafeScopedTestApiPrefix()) return null + val serverPath = server.rawPath.orEmpty().trimEnd('/') + if (!serverPath.isSafeServerBasePath()) return null + return ScopedTestWriteAuthorization( + scheme = scheme, + host = host, + port = server.effectivePort(), + absolutePathPrefix = "$serverPath$normalizedApiPrefix", + ) + } + } +} + +private fun String.isSafeScopedTestApiPrefix(): Boolean { + if ( + !startsWith("/ocs/v2.php/apps/") || + contains('\\') || + contains('?') || + contains('#') || + contains("//") + ) { + return false + } + val segments = split('/').filter(String::isNotEmpty) + if (segments.size < 7 || segments.getOrNull(4) != "api") return false + return segments.all { segment -> + segment !in setOf(".", "..") && + segment.isNotEmpty() && + segment.all { character -> + character.isLetterOrDigit() || character in "._~-" + } + } +} + +private fun String.isSafeServerBasePath(): Boolean = + isEmpty() || + ( + startsWith('/') && + !contains('%') && + !contains('\\') && + !contains("//") && + split('/').filter(String::isNotEmpty).all { segment -> + segment !in setOf(".", "..") + } + ) + +private fun URI.effectivePort(): Int = when { + port >= 0 -> port + scheme.equals("https", ignoreCase = true) -> 443 + else -> -1 +} + +private val SCOPED_TEST_MUTATION_METHODS = setOf("POST", "PUT", "PATCH", "DELETE") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudRedirectTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudRedirectTest.kt new file mode 100644 index 00000000..217739a8 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudRedirectTest.kt @@ -0,0 +1,45 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import okhttp3.HttpUrl.Companion.toHttpUrl + +class AndroidNextcloudRedirectTest { + @Test + fun `authenticated redirects resolve inside the account origin and base path`() { + val serverUrl = "https://cloud.example.test/nextcloud" + val requestUrl = "$serverUrl/apps/music/api/artwork/current".toHttpUrl() + + assertEquals( + "/apps/music/api/covers/7?size=large", + resolveAndroidNextcloudRedirectLocation( + requestUrl, + serverUrl, + "../covers/7?size=large", + ), + ) + assertEquals( + "/apps/music/api/covers/8", + resolveAndroidNextcloudRedirectLocation( + requestUrl, + serverUrl, + "$serverUrl/apps/music/api/covers/8", + ), + ) + assertNull( + resolveAndroidNextcloudRedirectLocation( + requestUrl, + serverUrl, + "https://other.example.test/nextcloud/apps/music/api/covers/9", + ), + ) + assertNull( + resolveAndroidNextcloudRedirectLocation( + requestUrl, + serverUrl, + "https://cloud.example.test/apps/music/api/covers/9", + ), + ) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ReadOnlyTestRequestPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ReadOnlyTestRequestPolicyTest.kt index f0447d12..60cb75fc 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ReadOnlyTestRequestPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/ReadOnlyTestRequestPolicyTest.kt @@ -2,7 +2,10 @@ package dev.obiente.nextcloudnative import java.util.Locale import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ReadOnlyTestRequestPolicyTest { @@ -32,4 +35,54 @@ class ReadOnlyTestRequestPolicyTest { assertFalse(method.isReadOnlyTestRequestMethod(), method) } } + + @Test + fun `debug write scope permits only one exact app record subtree`() { + val scope = assertNotNull( + ScopedTestWriteAuthorization.create( + serverUrl = "https://cloud.example.test/nextcloud", + apiPathPrefix = "/ocs/v2.php/apps/example/api/houses/1", + ), + ) + + listOf("POST", "PUT", "PATCH", "DELETE").forEach { method -> + assertTrue( + scope.allows( + method, + "https://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/1/lists?format=json", + ), + method, + ) + } + listOf( + "https://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/10/lists", + "https://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/2/lists", + "https://cloud.example.test/nextcloud/remote.php/dav/files/test", + "https://other.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/1/lists", + "http://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/1/lists", + ).forEach { url -> + assertFalse(scope.allows("POST", url), url) + } + assertFalse(scope.allows("MKCOL", "https://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/1")) + assertEquals(false, scope.allows("GET", "https://cloud.example.test/nextcloud/ocs/v2.php/apps/example/api/houses/1")) + } + + @Test + fun `debug write scope rejects broad malformed and non app prefixes`() { + listOf( + "/ocs/v2.php/apps/example/api", + "/ocs/v2.php/apps/example/api/houses", + "/ocs/v2.php/apps/example/api/houses/..", + "/ocs/v2.php/apps/example/api/houses/1?all=true", + "/remote.php/dav/files/test", + ).forEach { path -> + assertNull(ScopedTestWriteAuthorization.create("https://cloud.example.test", path), path) + } + assertNull( + ScopedTestWriteAuthorization.create( + "https://user@cloud.example.test", + "/ocs/v2.php/apps/example/api/houses/1", + ), + ) + } } diff --git a/changes/unreleased/252-native-pantry-workspace.md b/changes/unreleased/252-native-pantry-workspace.md new file mode 100644 index 00000000..19640ee7 --- /dev/null +++ b/changes/unreleased/252-native-pantry-workspace.md @@ -0,0 +1,7 @@ +category: feature +issue: 252 +pull: 258 +platforms: android, desktop +user-facing: yes + +Pantry and other dynamic collection apps now provide adaptive nested navigation, meaningful icons, option and relation pickers, permission-aware editing, reversible completion, and confirmed archive, restore, trash, and deletion actions. diff --git a/tools/android-emulator.sh b/tools/android-emulator.sh index 63a6476a..9b8429b9 100755 --- a/tools/android-emulator.sh +++ b/tools/android-emulator.sh @@ -118,6 +118,13 @@ ensure_avd() { fail "could not create emulator definition for '$instance'" fi fi + local avd_config="$avd_home/$avd_name.avd/config.ini" + [[ -f "$avd_config" ]] || fail "emulator definition is missing $avd_config" + if grep -q '^hw.keyboard=' "$avd_config"; then + sed -i 's/^hw\.keyboard=.*/hw.keyboard=yes/' "$avd_config" + else + printf '%s\n' 'hw.keyboard=yes' >>"$avd_config" + fi printf '%s\n' "$avd_name" } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt index 25e67380..84501f97 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt @@ -1,7 +1,17 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.EvidenceSource +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionFailureOutcome internal data class PendingDynamicDirectAction( val action: ActionSpec, @@ -14,6 +24,29 @@ internal enum class DynamicActionUiMode { ConfirmDirectly, } +internal data class DynamicDirectActionFailurePolicy( + val retryAllowed: Boolean, + val requiresReconciliation: Boolean, +) + +/** + * A rejected request is known not to have applied and may be retried. An unknown outcome may have + * reached the server, so the host must refresh authoritative state and suppress the same action + * until the user reviews that refreshed state. + */ +internal fun dynamicDirectActionFailurePolicy( + outcome: NativeActionFailureOutcome, +): DynamicDirectActionFailurePolicy = when (outcome) { + NativeActionFailureOutcome.Rejected -> DynamicDirectActionFailurePolicy( + retryAllowed = true, + requiresReconciliation = false, + ) + NativeActionFailureOutcome.Unknown -> DynamicDirectActionFailurePolicy( + retryAllowed = false, + requiresReconciliation = true, + ) +} + /** * Empty-body destructive actions do not benefit from an intermediate empty form. They remain * contract-backed and always require explicit confirmation before the executor receives them. @@ -21,17 +54,161 @@ internal enum class DynamicActionUiMode { internal fun dynamicActionUiMode( action: ActionSpec, editableFieldCount: Int, -): DynamicActionUiMode = if (action.intent == ActionIntent.delete && editableFieldCount == 0) { +): DynamicActionUiMode = if ( + action.risk == ActionRisk.destructive && + action.requiresConfirmation && + editableFieldCount == 0 +) { DynamicActionUiMode.ConfirmDirectly } else { DynamicActionUiMode.NavigateToForm } -internal fun dynamicHeaderActionLabel(action: ActionSpec, fallback: String): String = when (action.intent) { - ActionIntent.update -> "Edit" - ActionIntent.delete -> "Delete" - ActionIntent.create -> fallback - else -> fallback +/** + * Places only planner-issued contextual forms on the active native surface. + * + * Ordinary updates and deletes still require the selected-record path. The two cross-resource + * cases are narrow: a verified file upload may target its active collection, and a verified + * singleton update may target its one active read-only detail surface. + */ +internal fun dynamicContextualFormTargetsActiveSurface( + action: ActionSpec, + formView: ViewSpec, + activeView: ViewSpec, + activeReadAction: ActionSpec?, + plannedBindingValues: Map, + selectedRecordResourceId: String, + selectedCollectionState: String?, + hasEditableFileField: Boolean, + uniqueTargetResource: Boolean, +): Boolean { + val targetsCurrentRecord = action.resourceId.sameDynamicResourceAs(selectedRecordResourceId) + val targetsCurrentView = action.resourceId.sameDynamicResourceAs(activeView.resourceId) + val createsCurrentViewResource = action.intent == ActionIntent.create && + targetsCurrentView && + selectedCollectionState == null + val editsSelectedRecord = action.intent in setOf(ActionIntent.update, ActionIntent.delete) && + targetsCurrentRecord && + (targetsCurrentView || activeView.component == NativeComponent.detail) + if (createsCurrentViewResource || editsSelectedRecord) return true + if ( + !targetsCurrentView || + !uniqueTargetResource || + formView.resourceId != action.resourceId || + !formView.hasVerifiedNativeContractEvidence() || + !action.hasVerifiedNativeContractEvidence() || + !action.hasCompleteDynamicFormBindings(plannedBindingValues) + ) { + return false + } + val verifiedActiveRead = activeReadAction?.takeIf { read -> + read.resourceId.sameDynamicResourceAs(action.resourceId) && + read.binding.method == HttpMethod.GET && + read.risk == ActionRisk.readOnly && + read.hasVerifiedNativeContractEvidence() + } ?: return false + return when { + action.intent == ActionIntent.execute && + action.effect == ActionEffect.upload && + action.risk == ActionRisk.mutating && + hasEditableFileField && + verifiedActiveRead.intent in setOf(ActionIntent.list, ActionIntent.read) -> true + action.intent == ActionIntent.update && + action.risk == ActionRisk.mutating && + activeView.component == NativeComponent.detail && + verifiedActiveRead.intent in setOf(ActionIntent.read, ActionIntent.list) && + verifiedActiveRead.binding.isExactNativeSingletonRoute(action.binding) && + selectedCollectionState == null -> true + else -> false + } +} + +private fun ApiBinding.isExactNativeSingletonRoute(writeBinding: ApiBinding): Boolean = + path == writeBinding.path && + pathParameterNames.toSet() == writeBinding.pathParameterNames.toSet() && + requiredQueryParameterNames.toSet() == writeBinding.requiredQueryParameterNames.toSet() + +private fun ActionSpec.hasVerifiedNativeContractEvidence(): Boolean = + confidence == Confidence.verified || + ( + confidence == Confidence.high && + evidence.any { item -> + item.source == EvidenceSource.verifiedAppPackage + } + ) + +private fun ViewSpec.hasVerifiedNativeContractEvidence(): Boolean = + confidence == Confidence.verified || + ( + confidence == Confidence.high && + evidence.any { item -> + item.source == EvidenceSource.verifiedAppPackage + } + ) + +private fun ActionSpec.hasCompleteDynamicFormBindings(values: Map): Boolean { + val requiredNames = ( + binding.pathParameterNames + + binding.requiredPathParameterNames + + binding.requiredQueryParameterNames + ).distinct() + return requiredNames.all { name -> values[name]?.isNotBlank() == true } +} + +internal fun dynamicHeaderActionLabel(action: ActionSpec, fallback: String): String = when (action.effect) { + ActionEffect.archive -> "Archive" + ActionEffect.unarchive -> "Unarchive" + ActionEffect.restore -> "Restore" + ActionEffect.permanentDelete -> "Delete permanently" + ActionEffect.empty -> "Empty" + ActionEffect.copy -> "Copy" + ActionEffect.clear -> "Clear" + ActionEffect.leave -> "Leave" + else -> when (action.intent) { + ActionIntent.update -> "Edit" + ActionIntent.delete -> "Delete" + ActionIntent.create -> fallback + else -> fallback + } +} + +internal fun dynamicDirectActionTitle(action: ActionSpec, targetLabel: String): String = + "${dynamicHeaderActionLabel(action, action.label)} $targetLabel?" + +internal fun dynamicDirectActionDescription(action: ActionSpec): String = when (action.effect) { + ActionEffect.clear -> "This clears the selected data from the server and cannot be undone." + ActionEffect.empty -> "This permanently removes every item in this server collection." + ActionEffect.leave -> "You will lose access to this server collection." + ActionEffect.permanentDelete -> "This permanently deletes the item from the server." + else -> "This removes the item from the server and cannot be undone." +} + +internal fun dynamicDirectActionConfirmLabel(action: ActionSpec): String = + dynamicHeaderActionLabel(action, action.label) + +internal fun dynamicCollectionState(action: ActionSpec?): String? { + if (action?.intent != ActionIntent.list || action.binding.method != HttpMethod.GET) { + return null + } + return action.binding.path + .trimEnd('/') + .substringAfterLast('/') + .lowercase() + .filter(Char::isLetterOrDigit) + .takeIf(DYNAMIC_COLLECTION_STATE_WORDS::contains) +} + +internal fun dynamicSecondaryDestinationLabel( + destinationLabel: String, + resourceLabel: String, + duplicate: Boolean, +): String = if (duplicate) { + listOf(resourceLabel.trim(), destinationLabel.trim()) + .filter(String::isNotBlank) + .distinctBy(String::lowercase) + .joinToString(" ") +} else { + destinationLabel } /** Orders the most useful safe workflow actions ahead of destructive or uncommon operations. */ @@ -42,3 +219,10 @@ internal fun dynamicQuickActionPriority(action: ActionSpec?): Int = when (action ActionIntent.delete -> 3 ActionIntent.read, ActionIntent.list, null -> 4 } + +private val DYNAMIC_COLLECTION_STATE_WORDS = setOf( + "archive", + "archived", + "deleted", + "trash", +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt index c2476893..fbd1d557 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt @@ -107,6 +107,7 @@ internal fun DynamicContractChildInfo.reasonLabel(): String = when (status) { DynamicChildCandidateStatus.selfEdge -> "Omitted: self edge" DynamicChildCandidateStatus.cycle -> "Omitted: cycle" DynamicChildCandidateStatus.missingContext -> "Omitted: missing context" + DynamicChildCandidateStatus.ancestorOnlyContext -> "Omitted: ancestor context only" DynamicChildCandidateStatus.noLayout -> "Omitted: no layout" DynamicChildCandidateStatus.noLink -> "Omitted: no link" } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt new file mode 100644 index 00000000..b38d2074 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt @@ -0,0 +1,158 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs + +internal data class DynamicFormRelationLoadPlan( + val resourceId: String, + val actionId: String, +) + +/** + * Selects one exact, fully bound collection read for each accepted relationship field used by the + * active form. This never creates relationship evidence and never supplies missing request values. + */ +internal fun dynamicFormRelationLoadPlans( + schema: NativeAppSchema, + formView: ViewSpec, + availableValues: Map, +): List { + if (formView.component != NativeComponent.form) return emptyList() + val formAction = schema.action(formView.sourceActionId) ?: return emptyList() + val editableFieldIds = ( + formAction.binding.bodyFieldNames + formAction.binding.queryParameterNames + ).toSet() + return dynamicRelationLoadPlans( + schema = schema, + childResourceId = formView.resourceId, + editableFieldIds = editableFieldIds, + availableValues = availableValues, + ) +} + +/** + * Selects verified relationship reads for an explicitly declared set of editable fields. This is + * shared by ordinary forms and collection-batch dialogs; callers cannot request an unrelated + * resource because the accepted schema relationship remains the source of every load plan. + */ +internal fun dynamicRelationLoadPlans( + schema: NativeAppSchema, + childResourceId: String, + editableFieldIds: Set, + availableValues: Map, +): List { + if (editableFieldIds.isEmpty()) return emptyList() + val parentResourceIds = schema.relationships.asSequence() + .filter { relationship -> + relationship.childResourceId == childResourceId && + relationship.childFieldId in editableFieldIds && + relationship.confidence in setOf(Confidence.high, Confidence.verified) + } + .map { relationship -> relationship.parentResourceId } + .distinct() + .toList() + return parentResourceIds.mapNotNull { parentResourceId -> + schema.views.asSequence() + .filter { view -> + view.component != NativeComponent.form && + view.resourceId == parentResourceId + } + .mapNotNull { view -> + val action = schema.action(view.sourceActionId) ?: return@mapNotNull null + val requiredNames = ( + action.binding.requiredPathParameterNames + + action.binding.requiredQueryParameterNames + ).distinct() + val resolvedBindings = dynamicFormRelationBindingValues(action, availableValues) + action.takeIf { + action.resourceId == parentResourceId && + action.intent == ActionIntent.list && + action.risk == ActionRisk.readOnly && + action.binding.method == HttpMethod.GET && + action.confidence in setOf(Confidence.high, Confidence.verified) && + dynamicCollectionState(action) == null && + requiredNames.all(resolvedBindings::containsKey) + }?.let { + Triple(view, action, requiredNames.size) + } + } + .sortedWith( + compareByDescending> { + it.third + }.thenBy { (view, _, _) -> view.id }, + ) + .firstOrNull() + ?.let { (_, action, _) -> + DynamicFormRelationLoadPlan(parentResourceId, action.id) + } + }.distinctBy(DynamicFormRelationLoadPlan::resourceId) + .sortedBy(DynamicFormRelationLoadPlan::resourceId) +} + +/** + * Resolves only contract-declared relation-read bindings. + * + * Some APIs use a generic `{id}` placeholder even though the preceding literal route segment + * identifies a specific parent resource. In a nested form, the selected child can also expose an + * unrelated `id`; when one trusted qualified value matches that route segment, it is the exact + * value for `{id}`. Ambiguous aliases fail closed instead of falling back to the unrelated value. + */ +internal fun dynamicFormRelationBindingValues( + action: ActionSpec, + availableValues: Map, +): Map { + val binding = action.binding + val bindingNames = ( + binding.pathParameterNames + + binding.requiredPathParameterNames + + binding.queryParameterNames + + binding.requiredQueryParameterNames + ).distinct() + return bindingNames.mapNotNull { name -> + binding.dynamicFormRelationValue(name, availableValues)?.let { value -> name to value } + }.sortedBy { (name, _) -> name } + .toMap() +} + +private fun ApiBinding.dynamicFormRelationValue( + parameterName: String, + availableValues: Map, +): String? { + val routeResource = genericIdentityRouteResource(parameterName) + if (routeResource != null) { + val qualifiedMatches = availableValues.entries.filter { (name, value) -> + value.isNotBlank() && + name.length > 2 && + name.endsWith("Id", ignoreCase = true) && + name.dropLast(2).sameDynamicResourceAs(routeResource) + } + return qualifiedMatches.singleOrNull()?.value + ?: if (qualifiedMatches.isEmpty()) { + availableValues[parameterName]?.takeIf(String::isNotBlank) + } else { + null + } + } + return availableValues[parameterName]?.takeIf(String::isNotBlank) +} + +private fun ApiBinding.genericIdentityRouteResource(parameterName: String): String? { + if (!parameterName.equals("id", ignoreCase = true)) return null + val segments = path.substringBefore('?').split('/').filter(String::isNotBlank) + val placeholder = "{$parameterName}" + val indices = segments.indices.filter { index -> segments[index] == placeholder } + val index = indices.singleOrNull()?.takeIf { it > 0 } ?: return null + val resourceSegment = segments[index - 1] + return resourceSegment.takeIf { segment -> + segment.none { character -> character in "{}" } && + segment.any(Char::isLetterOrDigit) + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt new file mode 100644 index 00000000..f719fd6e --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt @@ -0,0 +1,144 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord + +/** + * How the host should treat its selected record after an authoritative mutation succeeds. + * + * A related parent must remain available while a child route reloads because its verified identity + * is still needed to bind that read. The parent is nevertheless stale and must be replaced by an + * authoritative read when its own surface becomes visible again. + */ +internal enum class DynamicSelectedRecordReconciliation { + Keep, + KeepRouteAndReloadWhenVisible, + ClearDeletedSelection, +} + +/** + * Contract-derived cache and collection reconciliation required after one mutation. + * + * Screen snapshots embed the host's accumulated related-resource map. Invalidating only views that + * directly render [affectedResourceIds] is therefore insufficient: an otherwise unrelated screen + * can retain stale parent or child records in its embedded snapshot. The host must invalidate every + * cached screen for the active account and app, discard the affected in-memory related records, and + * reload the visible view. + */ +internal data class DynamicMutationRefreshPlan( + val actionId: String, + val actionResourceId: String, + val affectedResourceIds: Set, + val affectedViewIds: Set, + val selectedRecordReconciliation: DynamicSelectedRecordReconciliation, + val invalidateAllAppScreenSnapshots: Boolean = true, + val reloadVisibleView: Boolean = true, +) { + fun discardAffectedRelatedRecords( + recordsByResourceId: Map>, + ): Map> = + recordsByResourceId.filterKeys { resourceId -> resourceId !in affectedResourceIds } +} + +/** + * Plans post-mutation refresh from exact schema IDs and declared resource relationships. + * + * No semantic-name matching is used. Missing, read-only, or internally inconsistent action + * contracts do not produce a refresh plan because the host cannot safely attribute the mutation. + */ +internal fun NativeAppSchema.planDynamicMutationRefresh( + action: ActionSpec, + selectedRecordResourceId: String?, +): DynamicMutationRefreshPlan? { + val canonicalAction = actions.singleOrNull { candidate -> candidate.id == action.id } + ?.takeIf { candidate -> candidate == action } + ?: return null + if ( + canonicalAction.binding.method == HttpMethod.GET || + canonicalAction.risk == ActionRisk.readOnly || + canonicalAction.intent in setOf(ActionIntent.list, ActionIntent.read) + ) { + return null + } + + val knownResourceIds = resources.mapTo(linkedSetOf()) { resource -> resource.id } + if (canonicalAction.resourceId !in knownResourceIds) return null + + val connectedResourceGroups = buildList { + relationships.forEach { relationship -> + val resources = setOf(relationship.parentResourceId, relationship.childResourceId) + if (resources.all(knownResourceIds::contains)) add(resources) + } + views.mapNotNull { view -> view.compositeDataGrid }.forEach { composite -> + val resources = setOf( + composite.parentResourceId, + composite.columnResourceId, + composite.rowResourceId, + ) + if (resources.all(knownResourceIds::contains)) add(resources) + } + } + val affectedResourceIds = linkedSetOf(canonicalAction.resourceId) + var expanded: Boolean + do { + expanded = false + connectedResourceGroups.forEach { group -> + if (group.any(affectedResourceIds::contains) && affectedResourceIds.addAll(group)) { + expanded = true + } + } + } while (expanded) + + val affectedViewIds = views.asSequence() + .filter { view -> + view.resourceId in affectedResourceIds || + view.compositeDataGrid?.let { composite -> + sequenceOf( + composite.parentResourceId, + composite.columnResourceId, + composite.rowResourceId, + ).any(affectedResourceIds::contains) + } == true + } + .mapTo(linkedSetOf()) { view -> view.id } + + val selectedRecordReconciliation = when { + selectedRecordResourceId == null -> + DynamicSelectedRecordReconciliation.Keep + selectedRecordResourceId == canonicalAction.resourceId && + canonicalAction.clearsSelectedRecordAfterSuccess() -> + DynamicSelectedRecordReconciliation.ClearDeletedSelection + selectedRecordResourceId in affectedResourceIds -> + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible + else -> + DynamicSelectedRecordReconciliation.Keep + } + + return DynamicMutationRefreshPlan( + actionId = canonicalAction.id, + actionResourceId = canonicalAction.resourceId, + affectedResourceIds = affectedResourceIds, + affectedViewIds = affectedViewIds, + selectedRecordReconciliation = selectedRecordReconciliation, + ) +} + +/** + * Selection lifetime follows the concrete operation effect, not its broad UI intent. Clearing + * subordinate data leaves the selected record available, while leaving a resource removes the + * caller's access even though its intent is execute. The unspecified fallback preserves schemas + * that predate ActionEffect and represented record deletion only by intent. + */ +private fun ActionSpec.clearsSelectedRecordAfterSuccess(): Boolean = when (effect) { + ActionEffect.delete, + ActionEffect.permanentDelete, + ActionEffect.leave, + -> true + ActionEffect.unspecified -> intent == ActionIntent.delete + else -> false +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index bdf07dda..96584bc1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -51,7 +51,10 @@ internal class DynamicNativeMemoryCache( session: NextcloudSession, appId: String, ): Boolean = discoveryMetadata[DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId)] - ?.takeIf { it.storedAt.elapsedNow() <= discoveryFreshFor } != null + ?.takeIf { entry -> + entry.discovery.versionStatus == DynamicContractVersionStatus.VerifiedCurrent && + entry.storedAt.elapsedNow() <= discoveryFreshFor + } != null fun shouldRetryDiscovery(session: NextcloudSession, appId: String): Boolean { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) @@ -94,6 +97,13 @@ internal class DynamicNativeMemoryCache( while (screens.size > maximumScreens) screens.remove(screens.keys.first()) } + fun invalidateScreens(session: NextcloudSession, appId: String) { + val account = session.dynamicAccountKey() + screens.keys.removeAll { key -> + key.account == account && key.appId == appId + } + } + private fun DynamicScreenSnapshot.bounded(): DynamicScreenSnapshot { val boundedRelated = relatedRecords.entries .take(MAXIMUM_RELATED_RESOURCES) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt index 718da452..a0942f30 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt @@ -3,12 +3,15 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.template.scanBracedTemplate import dev.obiente.nextcloudnative.nativeui.model.AdvertisedOpenApi +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk import dev.obiente.nextcloudnative.nativeui.model.AppIdentity import dev.obiente.nextcloudnative.nativeui.model.AuthKind import dev.obiente.nextcloudnative.nativeui.model.DynamicAction import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_LIST_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DynamicIntegerArrayParseResult import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptorCompiler import dev.obiente.nextcloudnative.nativeui.model.DynamicDiscoveryInput import dev.obiente.nextcloudnative.nativeui.model.DynamicHttpBinding @@ -19,14 +22,20 @@ import dev.obiente.nextcloudnative.nativeui.model.HttpMethod import dev.obiente.nextcloudnative.nativeui.model.HttpParameter import dev.obiente.nextcloudnative.nativeui.model.OpenApiTrust import dev.obiente.nextcloudnative.nativeui.model.ParameterSource +import dev.obiente.nextcloudnative.nativeui.model.isExactDynamicIntegerArraySchema +import dev.obiente.nextcloudnative.nativeui.model.parseDynamicIntegerArrayInput +import dev.obiente.nextcloudnative.nativeui.model.repeatableObjectInputSpec import dev.obiente.nextcloudnative.nativeui.model.requireValid +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionFailureOutcome import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionRequest import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredEntry import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredScalarKind import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredValue +import dev.obiente.nextcloudnative.nativeui.runtime.safeActionBindingValues import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -40,6 +49,8 @@ import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put +import kotlin.math.ceil +import kotlin.math.floor private val dynamicJson = Json { ignoreUnknownKeys = true @@ -52,8 +63,17 @@ data class DynamicDescriptorDiscovery( val sourcePath: String?, val acquisition: DynamicDescriptorAcquisition, val diagnostics: List = emptyList(), + val versionStatus: DynamicContractVersionStatus = DynamicContractVersionStatus.VerifiedCurrent, ) +enum class DynamicContractVersionStatus { + VerifiedCurrent, + LastKnownReadOnly, +} + +internal fun DynamicContractVersionStatus.allows(risk: ActionRisk): Boolean = + this == DynamicContractVersionStatus.VerifiedCurrent || risk == ActionRisk.readOnly + enum class DynamicDescriptorAcquisition { OcsApiViewer, StaticAppAsset, @@ -78,6 +98,8 @@ suspend fun discoverDynamicAppDescriptor( session: NextcloudSession, app: NextcloudAppEntry, serverVersion: String? = null, + installedAppVersionHint: String? = null, + serverVersionVerified: Boolean = true, ): DynamicDescriptorDiscovery { val sameOrigin = discoverDynamicAppDescriptor( serverUrl = session.serverUrl, @@ -91,7 +113,13 @@ suspend fun discoverDynamicAppDescriptor( "The server version is unavailable, so an App Store release could not be selected safely.", ) val contractAppId = app.canonicalAppStoreId() - val installedVersion = discoverInstalledAppVersion(services, session, contractAppId) + val observedInstalledVersion = discoverInstalledAppVersion(services, session, contractAppId) + val installedVersion = observedInstalledVersion ?: installedAppVersionHint?.safeDynamicVersionHint() + val versionStatus = if (serverVersionVerified && observedInstalledVersion != null) { + DynamicContractVersionStatus.VerifiedCurrent + } else { + DynamicContractVersionStatus.LastKnownReadOnly + } val acquired = runCatching { services.acquireSignedOpenApiContract(contractAppId, coreVersion, installedVersion) }.getOrElse { failure -> @@ -174,14 +202,46 @@ suspend fun discoverDynamicAppDescriptor( DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag else -> DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag }, - diagnostics = sameOrigin.diagnostics + acquired.successDiagnostic( - app = app, - endpointCount = descriptor.actions.size, - verifiedReadRouteCount = verifiedReadRouteCount, - ), + diagnostics = sameOrigin.diagnostics + + acquired.successDiagnostic( + app = app, + endpointCount = descriptor.actions.size, + verifiedReadRouteCount = verifiedReadRouteCount, + ) + + if (versionStatus == DynamicContractVersionStatus.LastKnownReadOnly) { + listOf( + "The cached contract uses the last verified server or app version. " + + "Reads remain available, but writes are disabled until both versions are verified again.", + ) + } else { + emptyList() + }, + versionStatus = versionStatus, ) } +internal fun DynamicDescriptorAcquisition.usesAppStoreContract(): Boolean = when (this) { + DynamicDescriptorAcquisition.SignedAppStorePackage, + DynamicDescriptorAcquisition.SignedAppStoreStaticRoutes, + DynamicDescriptorAcquisition.SignedAppStoreMergedContract, + DynamicDescriptorAcquisition.AppStoreLinkedGitHubTag, + DynamicDescriptorAcquisition.AppStoreLinkedStaticRoutes, + DynamicDescriptorAcquisition.AppStoreLinkedMergedContract, + -> true + DynamicDescriptorAcquisition.OcsApiViewer, + DynamicDescriptorAcquisition.StaticAppAsset, + DynamicDescriptorAcquisition.MetadataFallback, + -> false +} + +private fun String.safeDynamicVersionHint(): String? = trim() + .takeIf { version -> + version.length in 1..MAX_DYNAMIC_VERSION_HINT_CHARACTERS && + version.all { character -> + character.isLetterOrDigit() || character in setOf('.', '-', '_', '+') + } + } + private fun AcquiredOpenApiContractSourceKind.isSignedPackage(): Boolean = this == AcquiredOpenApiContractSourceKind.SignedAppPackage || this == AcquiredOpenApiContractSourceKind.SignedCompatibleAppPackage @@ -438,6 +498,8 @@ class DynamicNextcloudActionExecutor( private val session: NextcloudSession, private val descriptor: DynamicAppDescriptor, private val runtimeContext: Map = emptyMap(), + private val versionStatus: DynamicContractVersionStatus = DynamicContractVersionStatus.VerifiedCurrent, + private val onMultipartUploadSucceeded: (LocalUploadFile) -> Unit = {}, ) : NativeActionExecutor { init { descriptor.requireValid() @@ -445,7 +507,16 @@ class DynamicNextcloudActionExecutor( override suspend fun execute(request: NativeActionRequest): NativeActionExecutionResult { val action = descriptor.actions.firstOrNull { it.id == request.action.id } - ?: return NativeActionExecutionResult.Failure("The dynamic action is no longer available.") + ?: return NativeActionExecutionResult.Failure( + message = "The dynamic action is no longer available.", + outcome = NativeActionFailureOutcome.Rejected, + ) + if (!versionStatus.allows(action.risk)) { + return NativeActionExecutionResult.Failure( + message = "Reconnect to verify the server and app versions before changing cloud data.", + outcome = NativeActionFailureOutcome.Rejected, + ) + } val values = (request as? NativeActionRequest.Submit)?.values.orEmpty() val observedInputSchema = (request as? NativeActionRequest.Submit) ?.action @@ -456,10 +527,13 @@ class DynamicNextcloudActionExecutor( } ?.inputSchema if (request is NativeActionRequest.Submit && action.requiresConfirmation && !request.confirmed) { - return NativeActionExecutionResult.Failure("Confirm this action before changing server data.") + return NativeActionExecutionResult.Failure( + message = "Confirm this action before changing server data.", + outcome = NativeActionFailureOutcome.Rejected, + ) } return runCatching { - val response = executeDynamicAction( + val execution = executeDynamicAction( services, session, descriptor, @@ -468,10 +542,12 @@ class DynamicNextcloudActionExecutor( runtimeContext, observedInputSchema, ) - if (response.status !in 200..299) { - NativeActionExecutionResult.Failure("The server rejected ${action.label} (HTTP ${response.status}).") - } else { - NativeActionExecutionResult.Success("${action.label} completed.") + execution.response.toDynamicActionExecutionResult(action).also { result -> + releaseMultipartUploadAfterSuccess( + result = result, + file = execution.multipartFile, + release = onMultipartUploadSucceeded, + ) } }.getOrElse { failure -> NativeActionExecutionResult.Failure(failure.message ?: "The dynamic action failed.") @@ -479,6 +555,61 @@ class DynamicNextcloudActionExecutor( } } +/** + * Validates a mutation response against the action's declared response envelope. + * + * OCS endpoints can report an application failure inside an HTTP 2xx response. A declared OCS + * mutation therefore succeeds only when its bounded JSON metadata contains both an `ok` status + * and a successful OCS status code. Missing, malformed, or contradictory metadata fails closed + * without exposing the response body. + */ +internal fun NextcloudApiResponse.toDynamicActionExecutionResult( + action: DynamicAction, +): NativeActionExecutionResult { + if (status !in 200..299) { + return NativeActionExecutionResult.Failure( + message = "The server rejected ${action.label} (HTTP $status).", + outcome = if (status in 400..499) { + NativeActionFailureOutcome.Rejected + } else { + NativeActionFailureOutcome.Unknown + }, + ) + } + val ocs = action.binding.ocs + ?: return NativeActionExecutionResult.Success("${action.label} completed.") + val metadata = runCatching { + body.parseBoundedDynamicJson().atJsonPointer(ocs.responseMetaPointer) + }.getOrNull() as? JsonObject + ?: return malformedDynamicOcsActionResult(action) + val ocsStatus = (metadata["status"] as? JsonPrimitive) + ?.contentOrNull + ?.trim() + ?.takeIf(String::isNotEmpty) + ?: return malformedDynamicOcsActionResult(action) + val statusCode = (metadata["statuscode"] as? JsonPrimitive)?.longOrNull + ?: return malformedDynamicOcsActionResult(action) + val statusIsSuccessful = ocsStatus.equals("ok", ignoreCase = true) + val codeIsSuccessful = statusCode == 100L || statusCode in 200L..299L + if (statusIsSuccessful && codeIsSuccessful) { + return NativeActionExecutionResult.Success("${action.label} completed.") + } + val safeMessage = (metadata["message"] as? JsonPrimitive) + ?.contentOrNull + ?.toSafeDynamicErrorMessage() + return NativeActionExecutionResult.Failure( + message = safeMessage?.let { message -> "The server rejected ${action.label}: $message" } + ?: "The OCS endpoint rejected ${action.label}.", + outcome = NativeActionFailureOutcome.Rejected, + ) +} + +private fun malformedDynamicOcsActionResult( + action: DynamicAction, +): NativeActionExecutionResult.Failure = NativeActionExecutionResult.Failure( + "The server returned invalid OCS metadata for ${action.label}.", +) + suspend fun loadDynamicRecords( services: NextcloudPlatformServices, session: NextcloudSession, @@ -486,26 +617,14 @@ suspend fun loadDynamicRecords( actionId: String, values: Map = emptyMap(), runtimeContext: Map = emptyMap(), + cachePolicy: NextcloudApiCachePolicy = NextcloudApiCachePolicy.PreferCache, ): List { val action = descriptor.actions.firstOrNull { it.id == actionId } ?: error("This view has no declared load action.") - val declaredFieldIds = descriptor.resources.firstOrNull { it.id == action.resourceId } - ?.fields - .orEmpty() - .mapTo(linkedSetOf()) { it.id } - val bindingContext = (action.binding.pathParameters + action.binding.queryParameters) - .asSequence() - .mapNotNull { parameter -> - val value = values[parameter.name] ?: runtimeContext[parameter.name] - value?.takeIf(String::isNotBlank)?.let { parameter.name to it } - } - .distinctBy { (name, _) -> name.lowercase() } - .take(MAX_DYNAMIC_BINDING_CONTEXT_VALUES) - .toMap() + val bindingContext = dynamicReadBindingContext(action, values, runtimeContext) return executeDynamicReadWithFallback( descriptor = descriptor, actionId = actionId, - declaredFieldIds = declaredFieldIds, execute = { candidate -> val candidateValues = remapReadFallbackValues(action, candidate, values) val request = buildDynamicApiRequest( @@ -513,7 +632,7 @@ suspend fun loadDynamicRecords( action = candidate, values = candidateValues, runtimeContext = runtimeContext + candidateValues, - ) + ).copy(cachePolicy = cachePolicy) services.executeNextcloudApi(session, request).also { response -> if (response.status !in 200..299) { throw response.toDynamicReadLoadException(candidate, request.relativePath) @@ -521,12 +640,75 @@ suspend fun loadDynamicRecords( } }, ).map { record -> - if (bindingContext.isEmpty()) record else record.copy( - bindingContext = record.bindingContext + bindingContext, - ) + if (bindingContext.isEmpty()) { + record + } else { + val mergedBindingContext = safeActionBindingValues(record.bindingContext, bindingContext) + record.copy( + bindingContext = mergedBindingContext.orEmpty(), + actionBindingProvenanceValid = + record.actionBindingProvenanceValid && mergedBindingContext != null, + ) + } } } +/** + * Retains the exact request identities that scoped a dynamic read without conflating a collection + * parent with each returned record's own identity. + * + * Some contracts expose a nested collection at a route such as `/houses/{id}/categories`. The + * request must still bind the literal `id` parameter, but that value identifies the parent house, + * not a returned category. Requalify it only when the same trusted response schema declares + * exactly one matching parent identity such as `houseId`. Otherwise the generic key is retained + * and downstream mutation/relationship planners continue to fail closed on any identity conflict. + */ +internal fun dynamicReadBindingContext( + action: DynamicAction, + values: Map, + runtimeContext: Map, +): Map = ( + action.binding.pathParameters.map { parameter -> parameter to true } + + action.binding.queryParameters.map { parameter -> parameter to false } + ) + .asSequence() + .mapNotNull { (parameter, pathParameter) -> + val value = values[parameter.name] ?: runtimeContext[parameter.name] + value?.takeIf(String::isNotBlank)?.let { + action.recordBindingContextName(parameter, pathParameter) to it + } + } + .distinctBy { (name, _) -> name.lowercase() } + .take(MAX_DYNAMIC_BINDING_CONTEXT_VALUES) + .toMap() + +private fun DynamicAction.recordBindingContextName( + parameter: HttpParameter, + pathParameter: Boolean, +): String { + if (!pathParameter || !parameter.name.equals("id", ignoreCase = true)) return parameter.name + val segments = binding.path.substringBefore('?').split('/').filter(String::isNotBlank) + val placeholder = "{${parameter.name}}" + val indices = segments.indices.filter { index -> segments[index].equals(placeholder, ignoreCase = true) } + val parameterIndex = indices.singleOrNull() + ?.takeIf { index -> index > 0 && index < segments.lastIndex } + ?: return parameter.name + val parentRouteResource = segments[parameterIndex - 1].takeIf { segment -> + segment.none { character -> character in "{}" } && + segment.any(Char::isLetterOrDigit) + } ?: return parameter.name + val targetsReturnedCollection = segments.drop(parameterIndex + 1).any { segment -> + segment.none { character -> character in "{}" } && + segment.sameDynamicResourceAs(resourceId) + } + if (!targetsReturnedCollection) return parameter.name + return responseFieldIds.singleOrNull { fieldId -> + fieldId.length > 2 && + fieldId.endsWith("Id", ignoreCase = true) && + fieldId.dropLast(2).sameDynamicResourceAs(parentRouteResource) + } ?: parameter.name +} + /** * Equivalent verified reads sometimes rename a single parent identity (`id` versus * `mailboxId`) while moving it between path and query. Contract acquisition proves the fallback @@ -570,7 +752,6 @@ private fun DynamicHttpBinding.requiredReadParameters(): List = internal suspend fun executeDynamicReadWithFallback( descriptor: DynamicAppDescriptor, actionId: String, - declaredFieldIds: Set = emptySet(), execute: suspend (DynamicAction) -> NextcloudApiResponse, ): List { val actionsById = descriptor.actions.associateBy(DynamicAction::id) @@ -594,7 +775,7 @@ internal suspend fun executeDynamicReadWithFallback( intent = preferred.intent, ) } - parseDynamicRecords(parsingAction, execute(candidate), declaredFieldIds) + parseDynamicRecords(parsingAction, execute(candidate), candidate.responseFieldIds.toSet()) }.onFailure { failure -> val specificity = (failure as? DynamicReadLoadException)?.specificity ?: 0 if (bestFailure == null || specificity > bestFailureSpecificity) { @@ -746,6 +927,19 @@ private fun String.toSafeDynamicErrorMessage(): String? { return compact.removeSuffix(".") + "." } +private data class DynamicActionExecution( + val response: NextcloudApiResponse, + val multipartFile: LocalUploadFile?, +) + +internal fun releaseMultipartUploadAfterSuccess( + result: NativeActionExecutionResult, + file: LocalUploadFile?, + release: (LocalUploadFile) -> Unit, +) { + if (result is NativeActionExecutionResult.Success && file != null) release(file) +} + private suspend fun executeDynamicAction( services: NextcloudPlatformServices, session: NextcloudSession, @@ -754,7 +948,7 @@ private suspend fun executeDynamicAction( values: Map, runtimeContext: Map, observedInputSchema: JsonElement? = null, -): NextcloudApiResponse { +): DynamicActionExecution { val request = buildDynamicApiRequest( descriptor, action, @@ -762,7 +956,10 @@ private suspend fun executeDynamicAction( runtimeContext, observedInputSchema, ) - return services.executeNextcloudApi(session, request) + return DynamicActionExecution( + response = services.executeNextcloudApi(session, request), + multipartFile = request.multipartBody?.file, + ) } internal fun buildDynamicApiRequest( @@ -790,15 +987,27 @@ internal fun buildDynamicApiRequest( collectionRead = action.intent == dev.obiente.nextcloudnative.nativeui.model.ActionIntent.list, ).toMutableMap() binding.ocs?.formatQueryParameter?.takeIf(String::isNotBlank)?.let { query.putIfAbsent(it, "json") } - val body = binding.body?.let { declaredBody -> - buildDynamicBody(declaredBody.contentType, declaredBody.schema, values, observedInputSchema) - } + val normalizedBodyContentType = binding.body?.contentType + ?.substringBefore(';') + ?.trim() + ?.lowercase() + val multipartBody = binding.body + ?.takeIf { normalizedBodyContentType == "multipart/form-data" } + ?.let { declaredBody -> buildDynamicMultipartBody(declaredBody.schema, values) } + val body = binding.body + ?.takeUnless { normalizedBodyContentType == "multipart/form-data" } + ?.let { declaredBody -> + buildDynamicBody(declaredBody.contentType, declaredBody.schema, values, observedInputSchema) + } return NextcloudApiRequest( method = binding.method.toTransportMethod(), relativePath = path, queryParameters = query, - contentType = binding.body?.contentType, + contentType = binding.body?.contentType?.takeUnless { + normalizedBodyContentType == "multipart/form-data" + }, body = body, + multipartBody = multipartBody, // Nextcloud's OCS controllers reject requests without this marker. A // few app specs omit the header from an individual operation even // though the validated route is still under /ocs/. Treat the path as a @@ -810,6 +1019,96 @@ internal fun buildDynamicApiRequest( ).requireSafe() } +private fun buildDynamicMultipartBody( + schema: JsonElement, + values: Map, +): NextcloudMultipartBody { + val objectSchema = schema as? JsonObject + ?: error("Only object-shaped multipart request bodies are supported.") + require((objectSchema["type"] as? JsonPrimitive)?.contentOrNull == "object") { + "Only exact object-shaped multipart request bodies are supported." + } + val properties = objectSchema["properties"] as? JsonObject + ?: error("A multipart request must declare its fields.") + require(properties.size <= MAX_DYNAMIC_MULTIPART_FIELDS) { + "The multipart request declares too many fields." + } + val fileFields = properties.entries.filter { (_, element) -> + (element as? JsonObject)?.let { property -> + property["type"]?.let { it as? JsonPrimitive }?.contentOrNull == "string" && + property["format"]?.let { it as? JsonPrimitive }?.contentOrNull == "binary" + } == true + } + require(fileFields.size == 1) { + "A dynamic multipart request must declare exactly one binary file field." + } + val fileFieldName = fileFields.single().key + require(fileFieldName.isSafeDynamicMultipartFieldName()) { + "The multipart file field name is invalid." + } + val required = (objectSchema["required"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + .orEmpty() + require(required.all(properties::containsKey)) { + "The multipart request has invalid required fields." + } + require(fileFieldName in required) { + "Optional multipart file fields are not supported." + } + required.forEach { property -> + require(!values[property].isNullOrBlank()) { "$property is required." } + } + val file = decodeDynamicLocalUploadSelection( + values[fileFieldName]?.takeIf(String::isNotBlank) + ?: error("$fileFieldName is required."), + ) + val textFields = properties.entries + .asSequence() + .filter { (name, _) -> name != fileFieldName } + .mapNotNull { (name, element) -> + require(name.isSafeDynamicMultipartFieldName()) { + "The multipart text field name is invalid." + } + val property = element as? JsonObject + ?: error("Multipart fields require exact scalar schemas.") + val type = (property["type"] as? JsonPrimitive)?.contentOrNull + require(type in DYNAMIC_MULTIPART_SCALAR_TYPES) { + "Multipart arrays and objects require an exact encoding contract." + } + val value = values[name] + if (value.isNullOrBlank() && name !in required) { + null + } else { + value.orEmpty().requireDynamicMultipartScalar(type) + MultipartTextField(name, value.orEmpty()) + } + } + .toList() + return NextcloudMultipartBody( + file = file, + fileFieldName = fileFieldName, + textFields = textFields, + ).requireSafe() +} + +private fun String.requireDynamicMultipartScalar(type: String?) { + when (type) { + "boolean" -> require(toBooleanStrictOrNull() != null) { "Enter true or false." } + "integer" -> require(toLongOrNull() != null) { "Enter a whole number." } + "number" -> require(toDoubleOrNull()?.isFinite() == true) { "Enter a valid number." } + } +} + +private fun String.isSafeDynamicMultipartFieldName(): Boolean = + length in 1..64 && + first().let { it in 'A'..'Z' || it in 'a'..'z' } && + all { character -> + character in 'A'..'Z' || + character in 'a'..'z' || + character.isDigit() || + character in "_.-" + } + /** Builds a recovery request only from a verified signed refresh/sync operation in the descriptor. */ internal fun buildDynamicRefreshRecoveryRequest( descriptor: DynamicAppDescriptor, @@ -838,6 +1137,47 @@ internal fun buildDynamicRefreshRecoveryRequest( .firstOrNull() } +/** + * Builds the exact authoritative GET declared for an idempotent replacement mutation. + * + * The descriptor validator proves same-route PUT/GET pairing. This function repeats the critical + * checks at the execution boundary so deserialized or independently constructed descriptors fail + * closed instead of turning an arbitrary action reference into a recovery read. + */ +internal fun buildDynamicResultRecoveryRequest( + descriptor: DynamicAppDescriptor, + mutation: DynamicAction, + values: Map, + runtimeContext: Map = values, +): NextcloudApiRequest? { + if (mutation.binding.method != HttpMethod.PUT) return null + val recoveryId = mutation.resultRecoveryActionId ?: return null + val recovery = descriptor.actions.singleOrNull { action -> action.id == recoveryId } ?: return null + val safeKinds = setOf( + dev.obiente.nextcloudnative.nativeui.model.ProvenanceKind.verifiedAppPackage, + dev.obiente.nextcloudnative.nativeui.model.ProvenanceKind.appStoreLinkedSourceTag, + ) + if ( + recovery.binding.method != HttpMethod.GET || + recovery.binding.path != mutation.binding.path || + recovery.binding.body != null || + recovery.binding.queryParameters.any(HttpParameter::required) || + recovery.fallbackOnly || + mutation.provenance.none { provenance -> provenance.kind in safeKinds } || + recovery.provenance.none { provenance -> provenance.kind in safeKinds } + ) { + return null + } + return runCatching { + buildDynamicApiRequest( + descriptor = descriptor, + action = recovery, + values = values, + runtimeContext = runtimeContext, + ) + }.getOrNull() +} + internal fun Throwable.isUnsynchronizedDynamicCollectionFailure(): Boolean = message.orEmpty().contains("has not been synchronized", ignoreCase = true) @@ -887,8 +1227,7 @@ private fun HttpParameter.initialCollectionPageValue(collectionRead: Boolean): S if (!collectionRead || required) return null val normalizedName = name.lowercase().filter(Char::isLetterOrDigit) if (normalizedName !in INITIAL_PAGE_SIZE_PARAMETER_NAMES) return null - val type = (schema as? JsonObject)?.get("type")?.let { it as? JsonPrimitive }?.contentOrNull - return INITIAL_COLLECTION_PAGE_SIZE.takeIf { type == "integer" || type == "number" }?.toString() + return automaticCollectionPageSize()?.toString() } internal enum class DynamicPaginationMode { @@ -937,7 +1276,7 @@ internal fun DynamicAction.dynamicPaginationSpec(): DynamicPaginationSpec? { } val pageSize = optionalIntegerParameters.firstOrNull { parameter -> parameter.name.normalizedDynamicParameterName() in INITIAL_PAGE_SIZE_PARAMETER_NAMES - }?.let { INITIAL_COLLECTION_PAGE_SIZE } + }?.automaticCollectionPageSize() val pagingParameter = optionalIntegerParameters.firstOrNull { parameter -> parameter.name.normalizedDynamicParameterName() in PAGE_NUMBER_PARAMETER_NAMES } ?: optionalIntegerParameters.firstOrNull { parameter -> @@ -963,6 +1302,58 @@ private fun HttpParameter.isIntegerNumberParameter(): Boolean { return type == "integer" || type == "number" } +/** + * Chooses a useful initial page size only from a conventional optional numeric parameter whose + * declared schema can be satisfied safely. A valid explicit default wins. Otherwise the normal + * initial size is clamped to inclusive minimum/maximum bounds. Malformed, contradictory, fractional + * defaults, and minimums above the automatic-fetch safety ceiling are left to the server by + * omitting the optional parameter. + */ +private fun HttpParameter.automaticCollectionPageSize(): Int? { + val objectSchema = schema as? JsonObject ?: return null + val type = (objectSchema["type"] as? JsonPrimitive)?.contentOrNull + if (type != "integer" && type != "number") return null + + fun declaredNumber(name: String): Double? { + val element = objectSchema[name] ?: return null + val primitive = element as? JsonPrimitive ?: return Double.NaN + return primitive + .takeUnless { it.isString } + ?.doubleOrNull + ?.takeIf { it.isFinite() } + ?: Double.NaN + } + + val minimum = declaredNumber("minimum") + val maximum = declaredNumber("maximum") + if (minimum?.isNaN() == true || maximum?.isNaN() == true) return null + + val lowerBound = maxOf(1.0, ceil(minimum ?: 1.0)) + val upperBound = minOf( + MAX_AUTOMATIC_COLLECTION_PAGE_SIZE.toDouble(), + floor(maximum ?: MAX_AUTOMATIC_COLLECTION_PAGE_SIZE.toDouble()), + ) + if (lowerBound > upperBound) return null + + if ("default" in objectSchema) { + val declaredDefault = declaredNumber("default") + ?.takeUnless { it.isNaN() } + ?: return null + if ( + declaredDefault % 1.0 != 0.0 || + declaredDefault < lowerBound || + declaredDefault > upperBound + ) { + return null + } + return declaredDefault.toInt() + } + + return INITIAL_COLLECTION_PAGE_SIZE.toDouble() + .coerceIn(lowerBound, upperBound) + .toInt() +} + private fun String.normalizedDynamicParameterName(): String = lowercase().filter(Char::isLetterOrDigit) private fun HttpParameter.resolve( @@ -995,6 +1386,7 @@ private fun String.identityResourceStem(): String? = takeIf { private fun String.sameRuntimeResource(other: String): Boolean = runtimeResourceIdentity() == other.runtimeResourceIdentity() internal const val INITIAL_COLLECTION_PAGE_SIZE = 50 +private const val MAX_AUTOMATIC_COLLECTION_PAGE_SIZE = 500 private val INITIAL_PAGE_SIZE_PARAMETER_NAMES = setOf("limit", "pagesize", "perpage", "maxresults") private val PAGE_NUMBER_PARAMETER_NAMES = setOf("page", "pagenumber", "pageno") private val OFFSET_PARAMETER_NAMES = setOf("offset") @@ -1006,7 +1398,8 @@ private fun String.runtimeResourceIdentity(): String { return when { normalized.endsWith("ies") && normalized.length > 3 -> normalized.dropLast(3) + "y" normalized.endsWith("ches") || normalized.endsWith("shes") -> normalized.dropLast(2) - normalized.endsWith("ses") || normalized.endsWith("xes") || normalized.endsWith("zes") -> normalized.dropLast(2) + normalized.endsWith("sses") || normalized.endsWith("xes") || normalized.endsWith("zes") -> + normalized.dropLast(2) normalized.endsWith('s') && normalized.length > 1 -> normalized.dropLast(1) else -> normalized } @@ -1043,12 +1436,34 @@ private fun buildDynamicBody( } else { values.filterKeys(properties::containsKey) } - return when (contentType.substringBefore(';').trim().lowercase()) { + val normalizedContentType = contentType.substringBefore(';').trim().lowercase() + if (normalizedContentType == "application/x-www-form-urlencoded") { + allowed.keys.forEach { name -> + val property = properties[name] as? JsonObject ?: return@forEach + require((property["type"] as? JsonPrimitive)?.contentOrNull != "array") { + "Form-encoded array fields require an exact serialization contract " + + "that is not supported yet." + } + } + } + return when (normalizedContentType) { "application/json" -> { buildJsonObject { val wireNames = mutableSetOf() allowed.forEach { (name, value) -> val property = properties[name] as? JsonObject + val propertyType = property?.dynamicPropertyType() + if ( + value.isBlank() && + name !in requiredProperties && + ( + property?.acceptsDynamicNull() == true || + propertyType in setOf("boolean", "integer", "number") || + property.isExactDynamicIntegerArraySchema() + ) + ) { + return@forEach + } val observedProperty = observedProperties[name] ?.takeIf { allowsObservedSettings } val wireName = (property?.get(SETTINGS_WIRE_NAME_EXTENSION) as? JsonPrimitive) @@ -1101,6 +1516,9 @@ private fun String.toObservedSettingsJsonValue(): JsonElement { } private fun String.toTypedJsonValue(schema: JsonElement): JsonElement { + schema.repeatableObjectInputSpec()?.let { repeatable -> + return repeatable.canonicalJson(this) + } val declaredObjectSchema = schema.explicitObjectSchema() if (declaredObjectSchema != null) { val booleanMap = (declaredObjectSchema["x-nextcloud-native-boolean-map"] as? JsonPrimitive) @@ -1121,7 +1539,7 @@ private fun String.toTypedJsonValue(schema: JsonElement): JsonElement { } return value } - return when (((schema as? JsonObject)?.get("type") as? JsonPrimitive)?.contentOrNull) { + return when ((schema as? JsonObject)?.dynamicPropertyType()) { "boolean" -> JsonPrimitive(toBooleanStrictOrNull() ?: error("Enter true or false.")) "integer" -> JsonPrimitive(toLongOrNull() ?: error("Enter a whole number.")) "number" -> JsonPrimitive( @@ -1131,27 +1549,55 @@ private fun String.toTypedJsonValue(schema: JsonElement): JsonElement { val objectSchema = schema val format = (objectSchema["format"] as? JsonPrimitive)?.contentOrNull val itemType = ((objectSchema["items"] as? JsonObject)?.get("type") as? JsonPrimitive)?.contentOrNull - require( - format in setOf(DYNAMIC_STRING_LIST_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) && itemType == "string", - ) { - "Only contract-declared text arrays can be edited as a dynamic list." + when { + objectSchema.isExactDynamicIntegerArraySchema() -> { + when (val parsed = parseDynamicIntegerArrayInput(this, objectSchema)) { + is DynamicIntegerArrayParseResult.Valid -> + JsonArray(parsed.values.map(::JsonPrimitive)) + is DynamicIntegerArrayParseResult.Invalid -> error(parsed.message) + } + } + format in setOf(DYNAMIC_STRING_LIST_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) && + itemType == "string" -> { + val items = lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + val preserved = if (format == DYNAMIC_STRING_LIST_FORMAT) items.distinct() else items + JsonArray( + preserved + .take(MAX_DYNAMIC_STRING_LIST_ITEMS + 1) + .toList() + .also { + require(it.size <= MAX_DYNAMIC_STRING_LIST_ITEMS) { + "This list has too many values." + } + } + .map(::JsonPrimitive), + ) + } + format == DYNAMIC_INTEGER_ARRAY_FORMAT -> + error("Only exact contract-declared integer arrays can be edited.") + else -> error("Only contract-declared scalar arrays can be edited.") } - val items = lineSequence() - .map(String::trim) - .filter(String::isNotEmpty) - val preserved = if (format == DYNAMIC_STRING_LIST_FORMAT) items.distinct() else items - JsonArray( - preserved - .take(MAX_DYNAMIC_STRING_LIST_ITEMS + 1) - .toList() - .also { require(it.size <= MAX_DYNAMIC_STRING_LIST_ITEMS) { "This list has too many values." } } - .map(::JsonPrimitive), - ) } else -> JsonPrimitive(this) } } +private fun JsonObject.dynamicPropertyType(): String? = when (val type = get("type")) { + is JsonPrimitive -> type.contentOrNull + is JsonArray -> type.firstNotNullOfOrNull { candidate -> + (candidate as? JsonPrimitive)?.contentOrNull?.takeUnless { declared -> declared == "null" } + } + else -> null +} + +private fun JsonObject.acceptsDynamicNull(): Boolean = + (get("nullable") as? JsonPrimitive)?.booleanOrNull == true || + (get("type") as? JsonArray)?.any { candidate -> + (candidate as? JsonPrimitive)?.contentOrNull == "null" + } == true + private fun JsonElement.explicitObjectSchema(): JsonObject? { val schema = this as? JsonObject ?: return null if ((schema["type"] as? JsonPrimitive)?.contentOrNull == "object") return schema @@ -1583,6 +2029,8 @@ private fun String.safeObservedLabel(): String = buildString(length + 4) { private const val MAX_EPHEMERAL_FIELDS_PER_RECORD = 24 internal const val MAX_DYNAMIC_NATIVE_RECORDS = 1_000 private const val MAX_DYNAMIC_BINDING_CONTEXT_VALUES = 32 +private const val MAX_DYNAMIC_MULTIPART_FIELDS = 17 +private val DYNAMIC_MULTIPART_SCALAR_TYPES = setOf("string", "integer", "number", "boolean") private const val MAX_DYNAMIC_JSON_DEPTH = 64 private const val MAX_DYNAMIC_RECORD_ID_LENGTH = 256 private const val MAX_NATIVE_FIELDS_PER_RECORD = 128 @@ -1691,5 +2139,6 @@ private fun String.encodeUrlComponent(): String = buildString { private const val DYNAMIC_HEX = "0123456789ABCDEF" private const val MAX_DYNAMIC_ERROR_BODY_CHARS = 8_192 private const val MAX_DYNAMIC_ERROR_MESSAGE_CHARS = 240 +private const val MAX_DYNAMIC_VERSION_HINT_CHARACTERS = 128 private const val OCS_API_VIEWER_CATALOG_PATH = "/index.php/apps/ocs_api_viewer/apps" private const val OCS_API_VIEWER_SPEC_PATH = "/index.php/apps/ocs_api_viewer/apps" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LocalFileUpload.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LocalFileUpload.kt index 36050026..1c4a07e6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LocalFileUpload.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LocalFileUpload.kt @@ -107,6 +107,58 @@ data class MultipartTextField( } } +/** + * Typed multipart payload carried by a same-origin dynamic API request. + * + * This retains the picker capability as structured data until the platform streaming executor + * resolves it. It deliberately cannot contain raw bytes, a local path, a URI, or arbitrary + * multipart headers. + */ +data class NextcloudMultipartBody( + val file: LocalUploadFile, + val fileFieldName: String, + val textFields: List = emptyList(), + val maximumFileBytes: Long = DEFAULT_LOCAL_UPLOAD_LIMIT_BYTES, +) { + fun requireSafe(): NextcloudMultipartBody { + require(fileFieldName.isMultipartToken()) { "The multipart file field name is invalid." } + require(textFields.size <= MAX_MULTIPART_TEXT_FIELDS) { + "The multipart request has too many text fields." + } + require(textFields.map(MultipartTextField::name).distinct().size == textFields.size) { + "Multipart text field names must be unique." + } + require(textFields.none { it.name == fileFieldName }) { + "The multipart file field must be distinct from text fields." + } + require(maximumFileBytes in 1..MAX_LOCAL_UPLOAD_LIMIT_BYTES) { + "The local upload limit is outside the allowed range." + } + require(file.sizeBytes == null || file.sizeBytes <= maximumFileBytes) { + "The selected file is larger than the allowed upload limit." + } + return this + } + + fun toUploadRequest(request: NextcloudApiRequest): NextcloudMultipartUploadRequest { + val safeRequest = request.requireSafe() + require(safeRequest.multipartBody === this || safeRequest.multipartBody == this) { + "The multipart body does not belong to this request." + } + return NextcloudMultipartUploadRequest( + method = safeRequest.method, + relativePath = safeRequest.relativePath, + file = file, + queryParameters = safeRequest.queryParameters, + fileFieldName = fileFieldName, + textFields = textFields, + ocsApiRequest = safeRequest.ocsApiRequest, + maximumFileBytes = maximumFileBytes, + maximumResponseBytes = safeRequest.maximumResponseBytes, + ).requireSafe() + } +} + /** * Dedicated request for one user-selected file and a bounded set of text fields. * @@ -181,6 +233,70 @@ fun localUploadFile( sizeBytes = sizeBytes, ) +/** + * Encodes picker metadata for a generated form without ever exposing the platform path or URI. + * + * The length-prefixed representation is intentionally private to this runtime and strictly + * decoded. Manually entered paths, content URIs, malformed values, and trailing data cannot become + * upload capabilities. + */ +internal fun encodeDynamicLocalUploadSelection(file: LocalUploadFile): String = buildString { + append(DYNAMIC_UPLOAD_SELECTION_PREFIX) + listOf( + file.selectionId, + file.displayName, + file.mimeType.orEmpty(), + file.sizeBytes?.toString().orEmpty(), + ).forEach { value -> + append(value.length) + append(':') + append(value) + } +}.also { encoded -> + require(encoded.length <= MAX_DYNAMIC_UPLOAD_SELECTION_CHARACTERS) { + "The local upload selection metadata is too large." + } +} + +internal fun decodeDynamicLocalUploadSelection(value: String): LocalUploadFile { + require(value.length <= MAX_DYNAMIC_UPLOAD_SELECTION_CHARACTERS) { + "The local upload selection metadata is too large." + } + require(value.startsWith(DYNAMIC_UPLOAD_SELECTION_PREFIX)) { + "Choose a file with the native file picker." + } + var offset = DYNAMIC_UPLOAD_SELECTION_PREFIX.length + fun component(): String { + val delimiter = value.indexOf(':', offset) + require(delimiter in (offset + 1)..minOf(offset + 4, value.lastIndex)) { + "The local upload selection metadata is invalid." + } + val lengthText = value.substring(offset, delimiter) + require(lengthText.all(Char::isDigit) && (lengthText == "0" || !lengthText.startsWith('0'))) { + "The local upload selection metadata is invalid." + } + val length = lengthText.toIntOrNull() + ?: throw IllegalArgumentException("The local upload selection metadata is invalid.") + val start = delimiter + 1 + val end = start + length + require(end >= start && end <= value.length) { + "The local upload selection metadata is invalid." + } + offset = end + return value.substring(start, end) + } + val selectionId = component() + val displayName = component() + val mimeType = component().takeIf(String::isNotEmpty) + val sizeText = component() + val size = sizeText.takeIf(String::isNotEmpty)?.toLongOrNull() + require(sizeText.isEmpty() || size != null) { + "The local upload selection metadata is invalid." + } + require(offset == value.length) { "The local upload selection metadata is invalid." } + return LocalUploadFile(selectionId, displayName, mimeType, size) +} + fun requireSafeUploadPickerRequest( acceptedMimeTypes: List, maximumBytes: Long, @@ -387,9 +503,11 @@ private val MIME_TOKEN_PUNCTUATION = setOf('!', '#', '$', '&', '+', '-', '.', '^ private const val MAX_ACCEPTED_UPLOAD_MIME_TYPES = 16 private const val MAX_MULTIPART_TEXT_FIELDS = 16 private const val MAX_MULTIPART_TEXT_FIELD_BYTES = 16 * 1024 +private const val MAX_DYNAMIC_UPLOAD_SELECTION_CHARACTERS = 512 private const val MAX_UPLOAD_FILENAME_CHARACTERS = 180 private const val MULTIPART_STREAM_BUFFER_BYTES = 32 * 1024 private const val DEFAULT_UPLOAD_FILENAME = "upload.bin" private const val DEFAULT_UPLOAD_MIME_TYPE = "application/octet-stream" +private const val DYNAMIC_UPLOAD_SELECTION_PREFIX = "ncn-upload-v1:" private const val CRLF = "\r\n" private const val HEX_DIGITS = "0123456789ABCDEF" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt index 198a4dc3..6c07225a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative.app import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -30,9 +29,6 @@ import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema import dev.obiente.nextcloudnative.nativeui.model.NativeComponent import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import dev.obiente.nextcloudnative.nativeui.model.ViewSpec -import dev.obiente.nextcloudnative.nativeui.runtime.GenericNativeAppScreen -import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult -import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.NativeScreenState @@ -80,14 +76,44 @@ enum class MarketingCaptureScenario( ), AdaptiveApp( "adaptive-dynamic-data", "adaptive-dynamic-data.png", NextcloudPresentation.Desktop, - "Dynamic apps", "Data table", "Ready", MarketingCapturePurpose.Showcase, + "Dynamic apps", "Nested collection and semantic form", "Synthetic visual QA", + MarketingCapturePurpose.Showcase, "desktop", "wide", width = 1_440, height = 900, density = 1f, ), AdaptiveAppMobile( "adaptive-dynamic-data-mobile", "adaptive-dynamic-data-mobile.png", NextcloudPresentation.Adaptive, - "Dynamic apps", "Data table", "Ready", MarketingCapturePurpose.Showcase, + "Dynamic apps", "Nested collection and semantic form", "Synthetic visual QA", + MarketingCapturePurpose.StateCoverage, "mobile", "phone-portrait", width = 1_080, height = 1_800, density = 2.625f, ), + AdaptiveAppCollectionMobile( + "adaptive-dynamic-collection-mobile", + "adaptive-dynamic-collection-mobile.png", + NextcloudPresentation.Adaptive, + "Dynamic apps", + "Nested collection actions", + "Synthetic visual QA", + MarketingCapturePurpose.StateCoverage, + "mobile", + "phone-portrait", + width = 1_080, + height = 1_800, + density = 2.625f, + ), + AdaptiveAppContextMenuMobile( + "adaptive-dynamic-context-menu-mobile", + "adaptive-dynamic-context-menu-mobile.png", + NextcloudPresentation.Adaptive, + "Dynamic apps", + "Context workspace menu", + "Synthetic visual QA", + MarketingCapturePurpose.StateCoverage, + "mobile", + "phone-portrait", + width = 1_080, + height = 1_800, + density = 2.625f, + ), PhotoTimelineRevalidationErrorMobile( "photo-timeline-revalidation-error-mobile", "photo-timeline-revalidation-error-mobile.png", @@ -672,34 +698,13 @@ internal fun MarketingMediaBackupScenario() { internal fun MarketingAdaptiveAppScenario(scenario: MarketingCaptureScenario) { require( scenario == MarketingCaptureScenario.AdaptiveApp || - scenario == MarketingCaptureScenario.AdaptiveAppMobile, + scenario == MarketingCaptureScenario.AdaptiveAppMobile || + scenario == MarketingCaptureScenario.AdaptiveAppCollectionMobile || + scenario == MarketingCaptureScenario.AdaptiveAppContextMenuMobile, ) { "${scenario.id} is not an adaptive data capture." } - val schema = marketingAdaptiveSchema - val view = schema.views.single() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val compact = shouldUseCompactDynamicAppChrome(maxWidth.value, maxHeight.value) - Column(modifier = Modifier.fillMaxSize()) { - DynamicAppChromeHeader( - title = schema.app.name, - subtitle = view.title, - onBack = {}, - compact = compact, - onContractInfo = {}, - ) - GenericNativeAppScreen( - schema = schema, - view = view, - state = NativeScreenState.Ready(marketingAdaptiveRecords), - actionExecutor = NativeActionExecutor { - NativeActionExecutionResult.Failure("This fixture is read-only.") - }, - onSelectRecord = {}, - modifier = Modifier.weight(1f), - ) - } - } + MarketingDynamicUiScenario(scenario) } @Composable diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt new file mode 100644 index 00000000..d3d83d0d --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt @@ -0,0 +1,605 @@ +package dev.obiente.nextcloudnative.app + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudPresentation +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionDestination +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationMode +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationModel +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionWorkspaceScaffold +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudRadii +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationDestination +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.runtime.GenericNativeAppScreen +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor +import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.runtime.NativeScreenState +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRelationOptionWindow +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRelationOptions +import dev.obiente.nextcloudnative.nativeui.runtime.nativeScalarRelationClearChoice + +/** + * Inventory of the production-generic behavior exercised by this capture-only fixture. + * + * The fixture deliberately contains no installed app identity, endpoint, account, or server data. + */ +internal enum class MarketingDynamicUiFeature { + ContractIdentity, + RecordVisualIdentity, + NestedCollection, + EnumField, + OptionalRelationClear, + LargeRelationSearch, + BooleanControl, + RecurrenceControl, + SemanticForm, + StaleMutationSuppression, + RelationRetry, +} + +internal data class MarketingDynamicUiFixture( + val appName: String, + val description: String, + val iconText: String, + val accentArgb: Long, + val breadcrumbs: List, + val relationOptionCount: Int, + val features: Set, +) + +internal val marketingDynamicUiFixture = MarketingDynamicUiFixture( + appName = "Community workspace", + description = "A contract-driven nested collection with reusable native controls.", + iconText = "CW", + accentArgb = 0xFF5B5BD6, + breadcrumbs = listOf("Projects", "Garden renewal", "Work items"), + relationOptionCount = 240, + features = MarketingDynamicUiFeature.entries.toSet(), +) + +private val marketingDynamicGroupsResource = ResourceSpec( + id = "groups", + name = "Groups", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("id", "ID", FieldKind.string, required = true, readOnly = true), + FieldSpec("title", "Title", FieldKind.string, required = true, readOnly = true), + ), +) + +internal val marketingDynamicWorkItemsResource = ResourceSpec( + id = "work-items", + name = "Work items", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("title", "Title", FieldKind.string, required = true, readOnly = false), + FieldSpec("description", "Description", FieldKind.string, required = false, readOnly = true), + FieldSpec("icon", "Icon", FieldKind.string, required = false, readOnly = true), + FieldSpec("color", "Color", FieldKind.string, required = false, readOnly = true), + FieldSpec( + id = "status", + label = "Status", + kind = FieldKind.enumeration, + required = true, + readOnly = false, + enumValues = listOf("planned", "in-progress", "ready"), + ), + FieldSpec("groupId", "Parent group", FieldKind.string, required = false, readOnly = false), + FieldSpec("sendReminders", "Send reminders", FieldKind.boolean, required = false, readOnly = false), + FieldSpec("rrule", "Repeat", FieldKind.string, required = false, readOnly = false), + ), +) + +private val marketingDynamicListView = ViewSpec( + id = "work-items.collection", + title = "Work items", + resourceId = marketingDynamicWorkItemsResource.id, + component = NativeComponent.collectionList, + sourceActionId = "work-items.list", + confidence = Confidence.verified, +) + +private val marketingDynamicFormView = ViewSpec( + id = "work-items.create", + title = "Create work item", + resourceId = marketingDynamicWorkItemsResource.id, + component = NativeComponent.form, + sourceActionId = "work-items.create", + confidence = Confidence.verified, +) + +private val marketingDynamicCreateAction = ActionSpec( + id = marketingDynamicFormView.sourceActionId, + label = "Create work item", + resourceId = marketingDynamicWorkItemsResource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/synthetic/work-items", + operationId = "createSyntheticWorkItem", + bodyFieldNames = listOf("title", "status", "groupId", "sendReminders", "rrule"), + requiredBodyFieldNames = listOf("title", "status"), + bodyContentType = "application/json", + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, +) + +internal val marketingDynamicUiSchema = NativeAppSchema( + schemaVersion = "visual-qa", + app = AppIdentity("synthetic-dynamic-ui", "Synthetic dynamic UI", "fixture"), + confidence = Confidence.verified, + resources = listOf(marketingDynamicGroupsResource, marketingDynamicWorkItemsResource), + views = listOf(marketingDynamicListView, marketingDynamicFormView), + actions = listOf(marketingDynamicCreateAction), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = marketingDynamicGroupsResource.id, + childResourceId = marketingDynamicWorkItemsResource.id, + parentFieldId = "id", + childFieldId = "groupId", + confidence = Confidence.verified, + ), + ), +) + +internal val marketingDynamicWorkItemRecords = listOf( + NativeRecord( + id = "work-item-1", + values = mapOf( + "title" to "Map planting beds", + "description" to "Prepare a clear layout for volunteers.", + "icon" to "garden", + "color" to "5B5BD6", + "status" to "in-progress", + "groupId" to "group-1", + "sendReminders" to "true", + "rrule" to "FREQ=WEEKLY;INTERVAL=2", + ), + ), + NativeRecord( + id = "work-item-2", + values = mapOf( + "title" to "Confirm volunteer schedule", + "description" to "Check availability for the next work day.", + "icon" to "calendar", + "color" to "2F9E44", + "status" to "planned", + "groupId" to "group-2", + "sendReminders" to "false", + "rrule" to "", + ), + ), + NativeRecord( + id = "work-item-3", + values = mapOf( + "title" to "Review materials", + "description" to "Verify the shared tools and supplies list.", + "icon" to "tools", + "color" to "D97706", + "status" to "ready", + "groupId" to null, + "sendReminders" to "true", + "rrule" to "FREQ=MONTHLY", + ), + ), +) + +internal val marketingDynamicRelatedGroupRecords = List(marketingDynamicUiFixture.relationOptionCount) { index -> + val number = index + 1 + NativeRecord( + id = "group-$number", + values = mapOf( + "id" to "group-$number", + "title" to when (number) { + 1 -> "Garden team" + 2 -> "Planning group" + else -> "Workspace group ${number.toString().padStart(3, '0')}" + }, + ), + ) +} + +internal val marketingDynamicDatasetContext = NativeDatasetContext( + relatedRecords = mapOf(marketingDynamicGroupsResource.id to marketingDynamicRelatedGroupRecords), +) + +private val marketingCaptureActionExecutor = NativeActionExecutor { + NativeActionExecutionResult.Failure("Synthetic visual QA actions are disabled.") +} + +@Composable +internal fun MarketingDynamicUiScenario( + scenario: MarketingCaptureScenario, + fixture: MarketingDynamicUiFixture = marketingDynamicUiFixture, +) { + require( + scenario == MarketingCaptureScenario.AdaptiveApp || + scenario == MarketingCaptureScenario.AdaptiveAppMobile || + scenario == MarketingCaptureScenario.AdaptiveAppCollectionMobile || + scenario == MarketingCaptureScenario.AdaptiveAppContextMenuMobile, + ) { + "${scenario.id} is not a synthetic dynamic UI capture." + } + val desktop = scenario.presentation == NextcloudPresentation.Desktop + Column(modifier = Modifier.fillMaxSize()) { + MarketingDynamicContractHeader(fixture, compact = !desktop) + if (scenario == MarketingCaptureScenario.AdaptiveAppContextMenuMobile) { + MarketingDynamicContextMenuCapture( + fixture = fixture, + modifier = Modifier.weight(1f), + ) + } else if (desktop) { + MarketingDynamicDesktopCapture(fixture, Modifier.weight(1f)) + } else if (scenario == MarketingCaptureScenario.AdaptiveAppCollectionMobile) { + GenericNativeAppScreen( + schema = marketingDynamicUiSchema, + view = marketingDynamicListView, + state = NativeScreenState.Ready(marketingDynamicWorkItemRecords), + actionExecutor = marketingCaptureActionExecutor, + modifier = Modifier.weight(1f), + datasetContext = marketingDynamicDatasetContext, + showCollectionCreateAction = true, + ) + } else { + GenericNativeAppScreen( + schema = marketingDynamicUiSchema, + view = marketingDynamicFormView, + state = NativeScreenState.Ready(emptyList()), + actionExecutor = marketingCaptureActionExecutor, + modifier = Modifier.weight(1f), + datasetContext = marketingDynamicDatasetContext, + ) + } + } +} + +private val marketingContextMenuViews = listOf( + ViewSpec( + id = "context.tasks", + title = "Tasks", + resourceId = "context-tasks", + component = NativeComponent.taskList, + sourceActionId = "context.tasks.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "context.notes", + title = "Notes", + resourceId = "context-notes", + component = NativeComponent.collectionList, + sourceActionId = "context.notes.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "context.media", + title = "Media", + resourceId = "context-media", + component = NativeComponent.mediaGrid, + sourceActionId = "context.media.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "context.members", + title = "Members", + resourceId = "context-members", + component = NativeComponent.contactList, + sourceActionId = "context.members.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "context.activity", + title = "Activity", + resourceId = "context-activity", + component = NativeComponent.timeline, + sourceActionId = "context.activity.list", + confidence = Confidence.verified, + ), + ViewSpec( + id = "context.preferences", + title = "Preferences", + resourceId = "context-preferences", + component = NativeComponent.dataTable, + sourceActionId = "context.preferences.read", + confidence = Confidence.verified, + ), +) + +private val marketingContextMenuSchema = marketingDynamicUiSchema.copy( + resources = marketingContextMenuViews.map { view -> + ResourceSpec( + id = view.resourceId, + name = view.title, + confidence = Confidence.verified, + fields = emptyList(), + ) + }, + views = marketingContextMenuViews, + actions = emptyList(), + relationships = emptyList(), +) + +private val marketingContextMenuDestinations = marketingContextMenuViews.map { view -> + DynamicNavigationDestination( + layoutId = view.id, + label = view.title, + resourceId = view.resourceId, + actionId = view.sourceActionId, + pathParameterValues = mapOf("workspaceId" to "synthetic-workspace"), + ) to NextcloudCollectionDestination( + id = view.id, + label = view.title, + accessibilityId = view.sourceActionId, + ) +} + +@Composable +private fun MarketingDynamicContextMenuCapture( + fixture: MarketingDynamicUiFixture, + modifier: Modifier, +) { + val navigationModel = NextcloudCollectionNavigationModel.create( + destinations = marketingContextMenuDestinations.map { (_, destination) -> destination }, + selectedDestinationId = null, + ) + NextcloudCollectionWorkspaceScaffold( + model = navigationModel, + mode = NextcloudCollectionNavigationMode.Drawer, + title = fixture.appName, + subtitle = "Garden renewal", + onBack = {}, + hasHierarchyBack = true, + onDestinationSelected = {}, + destinationIcon = { NextcloudIcons.Apps }, + modifier = modifier, + ) { + DynamicContextDestinationMenu( + recordLabel = "Garden renewal", + destinations = marketingContextMenuDestinations, + schema = marketingContextMenuSchema, + onDestinationSelected = { _, _ -> }, + ) + } +} + +@Composable +private fun MarketingDynamicDesktopCapture( + fixture: MarketingDynamicUiFixture, + modifier: Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + Column( + modifier = Modifier.weight(0.9f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Text( + fixture.breadcrumbs.joinToString(" / "), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + MarketingStaleReadOnlyBanner() + GenericNativeAppScreen( + schema = marketingDynamicUiSchema, + view = marketingDynamicListView, + state = NativeScreenState.Ready(marketingDynamicWorkItemRecords), + actionExecutor = marketingCaptureActionExecutor, + modifier = Modifier.weight(1f), + datasetContext = marketingDynamicDatasetContext, + showCollectionCreateAction = true, + ) + Box(modifier = Modifier.fillMaxWidth().height(280.dp)) { + GenericNativeAppScreen( + schema = marketingDynamicUiSchema, + view = marketingDynamicFormView, + state = NativeScreenState.Error( + message = "Related choices could not be loaded. The current selection remains unchanged.", + retry = {}, + retryLabel = "Retry", + ), + actionExecutor = marketingCaptureActionExecutor, + datasetContext = marketingDynamicDatasetContext, + ) + } + } + Column( + modifier = Modifier.weight(1.1f).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + GenericNativeAppScreen( + schema = marketingDynamicUiSchema, + view = marketingDynamicFormView, + state = NativeScreenState.Ready(emptyList()), + actionExecutor = marketingCaptureActionExecutor, + modifier = Modifier.weight(1f), + datasetContext = marketingDynamicDatasetContext, + ) + MarketingExpandedRelationCaptureState() + } + } +} + +@Composable +private fun MarketingDynamicContractHeader( + fixture: MarketingDynamicUiFixture, + compact: Boolean, +) { + val accent = Color(fixture.accentArgb) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + ) { + Row( + modifier = Modifier.padding( + horizontal = if (compact) NextcloudSpacing.Medium else NextcloudSpacing.XLarge, + vertical = if (compact) NextcloudSpacing.Small else NextcloudSpacing.Large, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(if (compact) 42.dp else 56.dp), + color = accent, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + fixture.iconText, + color = Color.White, + fontWeight = FontWeight.Bold, + style = if (compact) { + MaterialTheme.typography.labelLarge + } else { + MaterialTheme.typography.titleMedium + }, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + fixture.appName, + style = if (compact) { + MaterialTheme.typography.titleMedium + } else { + MaterialTheme.typography.headlineSmall + }, + fontWeight = FontWeight.SemiBold, + ) + Text( + fixture.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + color = accent.copy(alpha = 0.18f), + contentColor = accent, + shape = MaterialTheme.shapes.large, + ) { + Text( + "Verified contract", + modifier = Modifier.padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.XSmall, + ), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} + +@Composable +private fun MarketingStaleReadOnlyBanner() { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + Text("Cached read-only snapshot", fontWeight = FontWeight.SemiBold) + Text( + "Browsing remains available. Mutation controls are suppressed until the contract is verified.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +/** + * The production relation picker owns its expanded state privately. This capture-only companion + * uses the same schema relationship resolution, clear choice, search filtering, and bounded + * option-window functions so that those otherwise pointer-driven states remain visible in a + * deterministic screenshot. + */ +@Composable +private fun MarketingExpandedRelationCaptureState() { + val field = marketingDynamicWorkItemsResource.fields.single { it.id == "groupId" } + val options = nativeRelationOptions( + field = field, + formResource = marketingDynamicWorkItemsResource, + schema = marketingDynamicUiSchema, + context = marketingDynamicDatasetContext, + ) + val clearChoice = nativeScalarRelationClearChoice(field) + val window = nativeRelationOptionWindow(options, query = "") + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + Text("Expanded relation menu (capture state)", style = MaterialTheme.typography.titleSmall) + Text( + "Search parent group", + modifier = Modifier.fillMaxWidth().padding( + horizontal = NextcloudSpacing.Medium, + vertical = NextcloudSpacing.Small, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() + Text("${clearChoice?.label} - ${clearChoice?.supportingText}") + window.options.take(2).forEach { option -> + Text("${option.label} - ${option.supportingText}") + } + if (window.hasMore) { + Text( + "Showing the first ${window.options.size} of ${options.size} choices. Search to narrow the list.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3641dc61..98388880 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -86,6 +86,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -93,6 +95,10 @@ import com.mikepenz.markdown.m3.Markdown import dev.obiente.nextcloudnative.app.design.NextcloudAppBackground import dev.obiente.nextcloudnative.app.design.NextcloudAppTile import dev.obiente.nextcloudnative.app.design.NextcloudBottomNavigation +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionDestination +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationHost +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationModel +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionWorkspaceScaffold import dev.obiente.nextcloudnative.app.design.NextcloudDestination import dev.obiente.nextcloudnative.app.design.NextcloudIcons import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme @@ -109,13 +115,17 @@ import dev.obiente.nextcloudnative.app.design.NextcloudSpacing import dev.obiente.nextcloudnative.app.design.NextcloudTheme import dev.obiente.nextcloudnative.app.design.isNextcloudDarkTheme import dev.obiente.nextcloudnative.app.design.resolveNextcloudRootShellLayout +import dev.obiente.nextcloudnative.app.design.resolveNextcloudCollectionNavigationMode import dev.obiente.nextcloudnative.app.design.shouldUseNextcloudRootShell +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor import dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationDestination import dev.obiente.nextcloudnative.nativeui.model.DynamicResourceRecordContext import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema import dev.obiente.nextcloudnative.nativeui.model.NativeComponent import dev.obiente.nextcloudnative.nativeui.model.ViewSpec -import dev.obiente.nextcloudnative.nativeui.model.isSecondaryTechnicalDestination import dev.obiente.nextcloudnative.nativeui.model.planDynamicNavigation import dev.obiente.nextcloudnative.nativeui.model.preferredSemanticContextualChild import dev.obiente.nextcloudnative.nativeui.model.singleSafeContextualChild @@ -127,13 +137,18 @@ import dev.obiente.nextcloudnative.nativeui.runtime.GenericNativeAppScreen import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutor import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionRequest +import dev.obiente.nextcloudnative.nativeui.runtime.NativeCollectionBatchRelationLoader +import dev.obiente.nextcloudnative.nativeui.runtime.NativeCollectionBatchRelationLoadResult import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRelatedRecordPaging import dev.obiente.nextcloudnative.nativeui.runtime.NativeImageLoader +import dev.obiente.nextcloudnative.nativeui.runtime.NativeFileFieldPicker import dev.obiente.nextcloudnative.nativeui.runtime.NativeAudioRecordPlayer import dev.obiente.nextcloudnative.nativeui.runtime.nativeAudioTrack import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.effectiveNativeResourceId import dev.obiente.nextcloudnative.nativeui.runtime.actionBindingValues +import dev.obiente.nextcloudnative.nativeui.runtime.safeActionBindingValues import dev.obiente.nextcloudnative.nativeui.runtime.NativeScreenState import dev.obiente.nextcloudnative.nativeui.runtime.settingsFormPrefillView import dev.obiente.nextcloudnative.nativeui.runtime.editableNativeFields @@ -143,6 +158,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged @@ -197,6 +214,8 @@ private sealed interface Screen { data class AppInfo( val app: NextcloudAppEntry, val navigation: DynamicAppNavigationState = DynamicAppNavigationState(), + val lastKnownServerVersion: String? = null, + val lastKnownInstalledAppVersion: String? = null, ) : Screen @Serializable data class MediaViewer( @@ -245,6 +264,321 @@ private val screenSaver = Saver( }, ) +internal data class DynamicContractResumePlan( + val serverVersion: String?, + val installedAppVersionHint: String?, + val serverVersionVerified: Boolean, +) + +internal fun planDynamicContractResume( + liveServerVersion: String?, + lastKnownServerVersion: String?, + lastKnownInstalledAppVersion: String?, +): DynamicContractResumePlan { + val live = liveServerVersion?.trim()?.takeIf(String::isNotEmpty) + return DynamicContractResumePlan( + serverVersion = live ?: lastKnownServerVersion?.trim()?.takeIf(String::isNotEmpty), + installedAppVersionHint = lastKnownInstalledAppVersion?.trim()?.takeIf(String::isNotEmpty), + serverVersionVerified = live != null, + ) +} + +internal fun resolveDynamicContractRediscovery( + cachedDiscovery: DynamicDescriptorDiscovery?, + candidate: DynamicDescriptorDiscovery, +): DynamicDescriptorDiscovery { + val retainedCachedContract = cachedDiscovery?.takeIf { cached -> + candidate.acquisition == DynamicDescriptorAcquisition.MetadataFallback && + cached.acquisition != DynamicDescriptorAcquisition.MetadataFallback + } ?: return candidate + // Rediscovery did not verify that this cached contract still matches the installed version. + // Retain its useful read surfaces, but never retain its former write authority. + return retainedCachedContract.copy(versionStatus = DynamicContractVersionStatus.LastKnownReadOnly) +} + +internal fun retainedDynamicContractAfterDiscoveryFailure( + cachedDiscovery: DynamicDescriptorDiscovery?, +): DynamicDescriptorDiscovery? = cachedDiscovery?.copy( + versionStatus = DynamicContractVersionStatus.LastKnownReadOnly, +) + +/** + * Projects a retained contract into the action surface its current version evidence permits. + * + * Executor rejection remains the final defense, but unavailable writes must not be offered as + * forms, record controls, inline actions, or header actions in the first place. + */ +internal fun NativeAppSchema.forDynamicContractVersion( + versionStatus: DynamicContractVersionStatus, +): NativeAppSchema { + if (versionStatus == DynamicContractVersionStatus.VerifiedCurrent) return this + val availableActions = actions.filter { action -> versionStatus.allows(action.risk) } + val availableActionIds = availableActions.mapTo(hashSetOf(), ActionSpec::id) + return copy( + actions = availableActions, + views = views.filter { view -> view.sourceActionId in availableActionIds }, + ) +} + +internal data class DynamicFormRelationCacheKey( + val resourceId: String, + val actionId: String, + val bindingValues: Map, +) + +internal data class DynamicFormRelationLoadRequest( + val plan: DynamicFormRelationLoadPlan, + val cacheKey: DynamicFormRelationCacheKey, +) + +internal data class DynamicFormRelationContinuation( + val spec: DynamicPaginationSpec, + val nextPageNumber: Int, + val nextRequestValue: String, + val loadedRecordCount: Int, +) + +private data class DynamicFormRelationLoadResult( + val records: List, + val pagination: DynamicPaginationSpec?, +) + +internal data class DynamicFormRelationCacheState( + val recordsByKey: Map> = emptyMap(), + val continuationsByKey: Map = emptyMap(), + val discardedRecordCountsByKey: Map = emptyMap(), + val failedKeys: Set = emptySet(), +) { + fun pendingRequests( + requests: List, + ): List = requests.filter { request -> + request.cacheKey !in recordsByKey && request.cacheKey !in failedKeys + } + + fun relatedRecords( + requests: List, + ): Map> = requests.mapNotNull { request -> + recordsByKey[request.cacheKey]?.let { records -> request.plan.resourceId to records } + }.toMap() + + fun datasetRelatedRecords( + genericRecords: Map>, + requests: List, + ): Map> { + val scopedResourceIds = requests.mapTo(hashSetOf()) { request -> request.plan.resourceId } + return genericRecords.filterKeys { resourceId -> resourceId !in scopedResourceIds } + + relatedRecords(requests) + } + + fun failedRequests( + requests: List, + ): List = requests.filter { request -> + request.cacheKey in failedKeys + } + + fun loadSucceeded( + request: DynamicFormRelationLoadRequest, + records: List, + pagination: DynamicPaginationSpec? = null, + ): DynamicFormRelationCacheState { + val distinctRecords = records.distinctBy(NativeRecord::id) + val discardedRecordCount = + (distinctRecords.size - MAX_DYNAMIC_FORM_RELATION_RECORDS).coerceAtLeast(0) + val boundedRecords = distinctRecords.takeLast(MAX_DYNAMIC_FORM_RELATION_RECORDS) + val continuation = pagination?.nextDynamicFormRelationContinuation( + lastPage = records, + loadedRecordCount = records.size, + ) + return copy( + recordsByKey = recordsByKey.putBounded(request.cacheKey, boundedRecords), + continuationsByKey = if (continuation == null) { + continuationsByKey - request.cacheKey + } else { + continuationsByKey.putBounded(request.cacheKey, continuation) + }, + discardedRecordCountsByKey = if (discardedRecordCount == 0) { + discardedRecordCountsByKey - request.cacheKey + } else { + discardedRecordCountsByKey.putBounded(request.cacheKey, discardedRecordCount) + }, + failedKeys = failedKeys - request.cacheKey, + ) + } + + fun appendPageSucceeded( + request: DynamicFormRelationLoadRequest, + page: List, + ): DynamicFormRelationCacheState { + val current = recordsByKey[request.cacheKey].orEmpty() + val activeContinuation = continuationsByKey[request.cacheKey] ?: return this + val currentIds = current.mapTo(hashSetOf(), NativeRecord::id) + val novelRecords = page.distinctBy(NativeRecord::id) + .filterNot { record -> record.id in currentIds } + val unboundedWindow = current + novelRecords + val discardedFromWindow = + (unboundedWindow.size - MAX_DYNAMIC_FORM_RELATION_RECORDS).coerceAtLeast(0) + val merged = unboundedWindow.takeLast(MAX_DYNAMIC_FORM_RELATION_RECORDS) + val nextContinuation = activeContinuation.spec.nextDynamicFormRelationContinuation( + lastPage = page, + loadedRecordCount = activeContinuation.loadedRecordCount + page.size, + novelRecordCount = novelRecords.size, + nextPageNumber = activeContinuation.nextPageNumber + 1, + ) + val discardedRecordCount = + ((discardedRecordCountsByKey[request.cacheKey] ?: 0).toLong() + discardedFromWindow) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt() + return copy( + recordsByKey = recordsByKey.putBounded(request.cacheKey, merged), + continuationsByKey = if (nextContinuation == null) { + continuationsByKey - request.cacheKey + } else { + continuationsByKey.putBounded(request.cacheKey, nextContinuation) + }, + discardedRecordCountsByKey = if (discardedRecordCount == 0) { + discardedRecordCountsByKey - request.cacheKey + } else { + discardedRecordCountsByKey.putBounded(request.cacheKey, discardedRecordCount) + }, + ) + } + + fun continuation( + request: DynamicFormRelationLoadRequest, + ): DynamicFormRelationContinuation? = continuationsByKey[request.cacheKey] + + fun discardedRecordCount(request: DynamicFormRelationLoadRequest): Int = + discardedRecordCountsByKey[request.cacheKey] ?: 0 + + fun loadFailed( + request: DynamicFormRelationLoadRequest, + ): DynamicFormRelationCacheState = copy( + recordsByKey = recordsByKey - request.cacheKey, + continuationsByKey = continuationsByKey - request.cacheKey, + discardedRecordCountsByKey = discardedRecordCountsByKey - request.cacheKey, + failedKeys = (failedKeys + request.cacheKey) + .toList() + .takeLast(MAX_DYNAMIC_FORM_RELATION_CACHE_SCOPES) + .toSet(), + ) + + fun retry( + requests: List, + ): DynamicFormRelationCacheState { + val retryKeys = requests.mapTo(hashSetOf(), DynamicFormRelationLoadRequest::cacheKey) + return copy(failedKeys = failedKeys - retryKeys) + } +} + +private fun DynamicPaginationSpec.nextDynamicFormRelationContinuation( + lastPage: List, + loadedRecordCount: Int, + novelRecordCount: Int = lastPage.size, + nextPageNumber: Int = 2, +): DynamicFormRelationContinuation? { + if (!canContinue(lastPage.size, novelRecordCount)) return null + val nextValue = nextValue(nextPageNumber, loadedRecordCount, lastPage) ?: return null + return DynamicFormRelationContinuation(this, nextPageNumber, nextValue, loadedRecordCount) +} + +private suspend fun loadInitialDynamicFormRelationRecords( + services: NextcloudPlatformServices, + session: NextcloudSession, + descriptor: DynamicAppDescriptor, + request: DynamicFormRelationLoadRequest, + values: Map, + cachePolicy: NextcloudApiCachePolicy = NextcloudApiCachePolicy.PreferCache, +): DynamicFormRelationLoadResult { + val action = descriptor.actions.singleOrNull { action -> action.id == request.plan.actionId } + ?: error("This relation has no declared load action.") + val boundValues = dynamicFormRelationRuntimeValues(request, values) + return DynamicFormRelationLoadResult( + records = loadDynamicRecords( + services = services, + session = session, + descriptor = descriptor, + actionId = action.id, + values = boundValues, + runtimeContext = boundValues, + cachePolicy = cachePolicy, + ), + pagination = action.dynamicPaginationSpec(), + ) +} + +internal fun dynamicFormRelationRuntimeValues( + request: DynamicFormRelationLoadRequest, + availableValues: Map, + additionalValues: Map = emptyMap(), +): Map = + availableValues + request.cacheKey.bindingValues + additionalValues + +internal fun dynamicFormRelationLoadRequests( + schema: NativeAppSchema, + formView: ViewSpec, + availableValues: Map, +): List = dynamicRelationLoadRequests( + schema = schema, + plans = dynamicFormRelationLoadPlans( + schema = schema, + formView = formView, + availableValues = availableValues, + ), + availableValues = availableValues, +) + +internal fun dynamicCollectionBatchRelationLoadRequests( + schema: NativeAppSchema, + childResourceId: String, + relatedFieldIds: Set, + availableValues: Map, +): List = dynamicRelationLoadRequests( + schema = schema, + plans = dynamicRelationLoadPlans( + schema = schema, + childResourceId = childResourceId, + editableFieldIds = relatedFieldIds, + availableValues = availableValues, + ), + availableValues = availableValues, +) + +private fun dynamicRelationLoadRequests( + schema: NativeAppSchema, + plans: List, + availableValues: Map, +): List = plans.mapNotNull { plan -> + val action = schema.action(plan.actionId) ?: return@mapNotNull null + val bindingNames = ( + action.binding.pathParameterNames + + action.binding.requiredPathParameterNames + + action.binding.queryParameterNames + + action.binding.requiredQueryParameterNames + ).distinct() + if (bindingNames.size > MAX_DYNAMIC_FORM_RELATION_BINDINGS) return@mapNotNull null + val bindingValues = dynamicFormRelationBindingValues(action, availableValues) + DynamicFormRelationLoadRequest( + plan = plan, + cacheKey = DynamicFormRelationCacheKey( + resourceId = plan.resourceId, + actionId = plan.actionId, + bindingValues = bindingValues, + ), + ) +} + +private fun Map.putBounded(key: K, value: V): Map = + ((this - key) + (key to value)) + .entries + .toList() + .takeLast(MAX_DYNAMIC_FORM_RELATION_CACHE_SCOPES) + .associate(Map.Entry::toPair) + +private const val MAX_DYNAMIC_FORM_RELATION_BINDINGS = 32 +private const val MAX_DYNAMIC_FORM_RELATION_CACHE_SCOPES = 16 +internal const val MAX_DYNAMIC_FORM_RELATION_RECORDS = 500 +private const val DYNAMIC_MUTATION_AUTHORITATIVE_READ_DELAY_MILLIS = 500L + private class PhotoTimelineUiState { val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) val backupStatuses = mutableStateOf>(emptyMap()) @@ -435,6 +769,8 @@ fun NextcloudNativeMarketingCapture( } MarketingCaptureScenario.AdaptiveApp, MarketingCaptureScenario.AdaptiveAppMobile, + MarketingCaptureScenario.AdaptiveAppCollectionMobile, + MarketingCaptureScenario.AdaptiveAppContextMenuMobile, -> MarketingAdaptiveAppScenario(scenario) MarketingCaptureScenario.PhotoTimelineRevalidationErrorMobile, MarketingCaptureScenario.PhotoTimelineReturnToNewestErrorMobile, @@ -642,7 +978,10 @@ private fun AuthenticatedApp( destination = NextcloudDestination.Activity Screen.Root } - else -> Screen.AppInfo(app) + else -> Screen.AppInfo( + app = app, + lastKnownServerVersion = serverInfo?.version, + ) } } @@ -1012,29 +1351,52 @@ private fun AuthenticatedApp( openMediaViewer(listOf(file), file, current) }, ) - is Screen.AppInfo -> AppInfoScreen( - services = services, - session = session, - app = current.app, - serverVersion = serverInfo?.version, - cachedDiscovery = cachedAppDiscoveries[current.app.id] - ?: sharedDynamicNativeMemoryCache.discovery(session, current.app.id), - onDiscovery = { candidate -> - val cached = cachedAppDiscoveries[current.app.id] - if (cached == null || candidate.acquisition != DynamicDescriptorAcquisition.MetadataFallback) { - cachedAppDiscoveries[current.app.id] = candidate - sharedDynamicNativeMemoryCache.storeDiscovery(session, current.app.id, candidate) - } - }, - navigation = current.navigation, - onNavigationChanged = { navigation -> - val active = screen as? Screen.AppInfo - if (active?.app?.id == current.app.id && active.navigation != navigation) { - screen = active.copy(navigation = navigation) - } - }, - onBack = ::navigateBack, - ) + is Screen.AppInfo -> { + val resumePlan = planDynamicContractResume( + liveServerVersion = serverInfo?.version, + lastKnownServerVersion = current.lastKnownServerVersion, + lastKnownInstalledAppVersion = current.lastKnownInstalledAppVersion, + ) + AppInfoScreen( + services = services, + session = session, + app = current.app, + serverVersion = resumePlan.serverVersion, + installedAppVersionHint = resumePlan.installedAppVersionHint, + serverVersionVerified = resumePlan.serverVersionVerified, + onRetryServerInfo = { discoveryAttempt += 1 }, + cachedDiscovery = cachedAppDiscoveries[current.app.id] + ?: sharedDynamicNativeMemoryCache.discovery(session, current.app.id), + onDiscovery = { candidate -> + val cached = cachedAppDiscoveries[current.app.id] + if (cached == null || candidate.acquisition != DynamicDescriptorAcquisition.MetadataFallback) { + cachedAppDiscoveries[current.app.id] = candidate + sharedDynamicNativeMemoryCache.storeDiscovery(session, current.app.id, candidate) + } + val liveServerVersion = serverInfo?.version + val active = screen as? Screen.AppInfo + if ( + active?.app?.id == current.app.id && + liveServerVersion != null && + candidate.versionStatus == DynamicContractVersionStatus.VerifiedCurrent && + candidate.acquisition.usesAppStoreContract() + ) { + screen = active.copy( + lastKnownServerVersion = liveServerVersion, + lastKnownInstalledAppVersion = candidate.descriptor.app.version, + ) + } + }, + navigation = current.navigation, + onNavigationChanged = { navigation -> + val active = screen as? Screen.AppInfo + if (active?.app?.id == current.app.id && active.navigation != navigation) { + screen = active.copy(navigation = navigation) + } + }, + onBack = ::navigateBack, + ) + } is Screen.MediaViewer -> { val route = MediaViewerNavigationRoute( key = current.navigationKey, @@ -1201,7 +1563,10 @@ private fun AppsScreen( OutlinedTextField( value = search, onValueChange = { search = it }, - modifier = Modifier.fillMaxWidth().padding(horizontal = NextcloudSpacing.XLarge, vertical = 14.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = NextcloudSpacing.XLarge, vertical = 14.dp) + .semantics { contentDescription = "Search apps" }, leadingIcon = { Icon(NextcloudIcons.Search, contentDescription = null) }, placeholder = { Text("Find an app") }, singleLine = true, @@ -1228,6 +1593,7 @@ private fun AppsScreen( supportingText = if (app.id in nativeAppIds) nativeSubtitle(app.id) else nativeFamily(app.id), onClick = { onOpenApp(app) }, modifier = Modifier.fillMaxWidth().height(140.dp), + accessibilityId = app.id, ) } } @@ -1344,6 +1710,9 @@ private fun AppInfoScreen( session: NextcloudSession, app: NextcloudAppEntry, serverVersion: String?, + installedAppVersionHint: String?, + serverVersionVerified: Boolean, + onRetryServerInfo: () -> Unit, cachedDiscovery: DynamicDescriptorDiscovery?, onDiscovery: (DynamicDescriptorDiscovery) -> Unit, navigation: DynamicAppNavigationState, @@ -1355,7 +1724,19 @@ private fun AppInfoScreen( var discoveryError by remember(app.id, session) { mutableStateOf(null) } var discoveryAttempt by remember(app.id, session) { mutableStateOf(0) } - LaunchedEffect(app.id, session, serverVersion, discoveryAttempt) { + fun retryDiscoveryAndServerInfo() { + discoveryAttempt += 1 + onRetryServerInfo() + } + + LaunchedEffect( + app.id, + session, + serverVersion, + installedAppVersionHint, + serverVersionVerified, + discoveryAttempt, + ) { if (cachedDiscovery != null) { discovery = cachedDiscovery } @@ -1367,26 +1748,44 @@ private fun AppInfoScreen( } if (!shouldRetry) discovery = null discoveryError = null - runCatching { discoverDynamicAppDescriptor(services, session, app, serverVersion) } + runCatching { + discoverDynamicAppDescriptor( + services = services, + session = session, + app = app, + serverVersion = serverVersion, + installedAppVersionHint = installedAppVersionHint, + serverVersionVerified = serverVersionVerified, + ) + } .onSuccess { candidate -> - onDiscovery(candidate) - discovery = if ( - candidate.acquisition == DynamicDescriptorAcquisition.MetadataFallback && - cachedDiscovery?.acquisition != DynamicDescriptorAcquisition.MetadataFallback - ) { - cachedDiscovery + val resolvedDiscovery = resolveDynamicContractRediscovery(cachedDiscovery, candidate) + val retainedCachedContract = resolvedDiscovery !== candidate + onDiscovery(resolvedDiscovery) + discovery = resolvedDiscovery + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, resolvedDiscovery) + if (retainedCachedContract) { + discoveryError = + "Could not verify the current server and app versions. " + + "The last verified contract remains available in read-only mode." } else { - candidate - } - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, discovery ?: candidate) - if (cachedDiscovery == null || candidate.acquisition != DynamicDescriptorAcquisition.MetadataFallback) { - discovery = candidate + discoveryError = null } } .onFailure { failure -> + if (failure is CancellationException) throw failure + val retainedReadOnly = retainedDynamicContractAfterDiscoveryFailure(cachedDiscovery) + if (retainedReadOnly != null) { + onDiscovery(retainedReadOnly) + discovery = retainedReadOnly + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedReadOnly) + } sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id) - if (cachedDiscovery == null) { - discoveryError = failure.message ?: "Could not discover this app's native API." + discoveryError = if (cachedDiscovery == null) { + failure.message ?: "Could not discover this app's native API." + } else { + "Could not verify the current server and app versions. " + + "The last verified contract remains available in read-only mode." } } } @@ -1424,7 +1823,7 @@ private fun AppInfoScreen( } } discoveryError?.let { message -> - ErrorMessage("Dynamic contract failed: $message") { discoveryAttempt += 1 } + ErrorMessage("Dynamic contract failed: $message", ::retryDiscoveryAndServerInfo) } if (resolved == null) { GenericNativeAppScreen( @@ -1435,13 +1834,39 @@ private fun AppInfoScreen( modifier = Modifier.weight(1f), ) } else { + if (resolved.versionStatus == DynamicContractVersionStatus.LastKnownReadOnly) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding( + horizontal = NextcloudSpacing.Large, + vertical = NextcloudSpacing.Small, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Using the last verified contract. Browsing remains available, but changes require " + + "a fresh server and app version check.", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + TextButton(onClick = ::retryDiscoveryAndServerInfo) { + Text("Retry") + } + } + } + } DynamicDiscoveredAppScreen( services = services, session = session, discovery = resolved, restoredNavigation = navigation, onNavigationChanged = onNavigationChanged, - onRetryDiscovery = { discoveryAttempt += 1 }, + onRetryDiscovery = ::retryDiscoveryAndServerInfo, onExit = onBack, modifier = Modifier.weight(1f), ) @@ -1461,7 +1886,9 @@ private fun DynamicDiscoveredAppScreen( modifier: Modifier = Modifier, ) { val descriptor = discovery.descriptor - val schema = remember(descriptor) { descriptor.toNativeAppSchema() } + val schema = remember(descriptor, discovery.versionStatus) { + descriptor.toNativeAppSchema().forDynamicContractVersion(discovery.versionStatus) + } val initialViewId = remember(descriptor, schema) { descriptor.planDynamicNavigation().rootDestinations.firstOrNull()?.layoutId ?: schema.views.firstOrNull { it.component != NativeComponent.form }?.id @@ -1495,17 +1922,118 @@ private fun DynamicDiscoveredAppScreen( var selectedPathParameterValues by remember(descriptor) { mutableStateOf(restoredPathParameterValues) } - var navigationHistory by remember(descriptor) { mutableStateOf(restoredNavigation.history) } + var contextualMenuRecordToken by rememberSaveable( + session.serverUrl, + session.loginName, + descriptor.app.id, + ) { + mutableStateOf(null) + } + var contextualMenuOpen by rememberSaveable( + session.serverUrl, + session.loginName, + descriptor.app.id, + ) { + mutableStateOf(false) + } + var navigationHistory by remember(descriptor) { + mutableStateOf(restoreDynamicNavigationHistory(restoredNavigation.history)) + } var viewState by remember(descriptor) { mutableStateOf(NativeScreenState.Loading) } var recordsByResourceId by remember(descriptor) { mutableStateOf>>(emptyMap()) } + var formRelationCache by remember( + session.serverUrl, + session.loginName, + descriptor, + discovery.versionStatus, + ) { + mutableStateOf(DynamicFormRelationCacheState()) + } + var formRelationLoadAttempt by remember( + session.serverUrl, + session.loginName, + descriptor, + discovery.versionStatus, + ) { + mutableStateOf(0) + } + var loadingFormRelationPageKeys by remember(descriptor) { + mutableStateOf>(emptySet()) + } + var formRelationPageErrors by remember(descriptor) { + mutableStateOf>(emptyMap()) + } var loadAttempt by remember(descriptor) { mutableStateOf(0) } + val dynamicReadCachePolicy = if (loadAttempt == 0) { + NextcloudApiCachePolicy.PreferCache + } else { + NextcloudApiCachePolicy.ForceNetwork + } + var mutationReconciliationGeneration by rememberSaveable( + session.serverUrl, + session.loginName, + descriptor.app.id, + ) { + mutableStateOf(0) + } var paginationState by remember(descriptor) { mutableStateOf(null) } var loadingMore by remember(descriptor) { mutableStateOf(false) } var loadMoreError by remember(descriptor) { mutableStateOf(null) } val dynamicRecoveryScope = rememberCoroutineScope() val dynamicPaginationScope = rememberCoroutineScope() + val formRelationPageScope = rememberCoroutineScope() + val selectedDynamicUploadFiles = remember(descriptor) { + mutableMapOf() + } + fun releaseSelectedDynamicUploadFile(file: LocalUploadFile) { + val selectedField = selectedDynamicUploadFiles.entries + .firstOrNull { (_, selected) -> selected.selectionId == file.selectionId } + ?.key + if (selectedField != null) { + selectedDynamicUploadFiles.remove(selectedField) + services.releaseLocalUploadFile(file) + } + } + DisposableEffect(services, descriptor) { + onDispose { + selectedDynamicUploadFiles.values.forEach(services::releaseLocalUploadFile) + selectedDynamicUploadFiles.clear() + } + } + val dynamicFilePicker = remember(services, descriptor) { + NativeFileFieldPicker { field, onSelected -> + dynamicRecoveryScope.launch { + val acceptedMimeType = field.format + ?.takeIf { format -> + format != "binary" && + runCatching { + requireSafeUploadPickerRequest( + listOf(format), + DEFAULT_LOCAL_UPLOAD_LIMIT_BYTES, + ) + }.isSuccess + } + ?: "*/*" + when ( + val selection = services.chooseLocalUploadFile( + acceptedMimeTypes = listOf(acceptedMimeType), + maximumBytes = DEFAULT_LOCAL_UPLOAD_LIMIT_BYTES, + ) + ) { + is LocalUploadSelectionResult.Selected -> { + selectedDynamicUploadFiles.put(field.id, selection.file) + ?.let(services::releaseLocalUploadFile) + onSelected(encodeDynamicLocalUploadSelection(selection.file)) + } + LocalUploadSelectionResult.Cancelled -> Unit + is LocalUploadSelectionResult.Rejected -> Unit + is LocalUploadSelectionResult.Unavailable -> Unit + } + } + } + } val dynamicAssetCache = remember(session.serverUrl, session.loginName, descriptor.app.id) { DynamicArtworkMemoryCache( maximumBytes = MAX_DYNAMIC_ARTWORK_DECODED_BYTES, @@ -1548,6 +2076,12 @@ private fun DynamicDiscoveredAppScreen( } val selectedView = schema.views.firstOrNull { it.id == selectedViewId } ?: schema.views.firstOrNull() + val formRelationValues = selectedRecord?.toDynamicRuntimeValues().orEmpty() + selectedPathParameterValues + val formRelationRequests = remember(schema, selectedView, formRelationValues) { + selectedView?.let { view -> + dynamicFormRelationLoadRequests(schema, view, formRelationValues) + }.orEmpty() + } val audioSourceCapability = remember(discovery) { descriptor.actions.firstNotNullOfOrNull { action -> nativeAudioSourceCapability(discovery, action) @@ -1602,13 +2136,28 @@ private fun DynamicDiscoveredAppScreen( selectedRecord = selectedRecord, selectedRecordResourceId = selectedRecordResourceId, pathParameterValues = selectedPathParameterValues, - history = navigationHistory, + history = saveDynamicNavigationHistory(navigationHistory), ), ) } - LaunchedEffect(descriptor, selectedView?.id, selectedRecord?.id, selectedPathParameterValues, loadAttempt) { + LaunchedEffect( + descriptor, + selectedView?.id, + selectedRecord?.id, + selectedPathParameterValues, + formRelationRequests, + formRelationLoadAttempt, + loadAttempt, + ) { val view = selectedView ?: return@LaunchedEffect + if (loadAttempt > 0) { + // Some app controllers acknowledge a mutation just before the corresponding GET + // projection becomes visible. Debounce the forced authoritative reload so that an + // immediately stale response cannot be promoted to a fresh process-level snapshot. + delay(DYNAMIC_MUTATION_AUTHORITATIVE_READ_DELAY_MILLIS) + currentCoroutineContext().ensureActive() + } val cacheKey = dynamicScreenCacheKey( session = session, appId = descriptor.app.id, @@ -1620,6 +2169,45 @@ private fun DynamicDiscoveredAppScreen( loadingMore = false loadMoreError = null if (view.component == NativeComponent.form) { + val values = formRelationValues + val pendingRelationRequests = formRelationCache.pendingRequests(formRelationRequests) + if (pendingRelationRequests.isNotEmpty()) { + viewState = NativeScreenState.Loading + val relationOutcomes = coroutineScope { + pendingRelationRequests.map { request -> + async { + val result = runCatching { + loadInitialDynamicFormRelationRecords( + services = services, + session = session, + descriptor = descriptor, + request = request, + values = values, + cachePolicy = dynamicReadCachePolicy, + ) + }.getOrElse { failure -> + if (failure is CancellationException) throw failure + null + } + request to result + } + }.awaitAll() + } + currentCoroutineContext().ensureActive() + var updatedRelationCache = formRelationCache + relationOutcomes.forEach { (request, result) -> + updatedRelationCache = if (result == null) { + updatedRelationCache.loadFailed(request) + } else { + updatedRelationCache.loadSucceeded( + request = request, + records = result.records, + pagination = result.pagination, + ) + } + } + formRelationCache = updatedRelationCache + } val rememberedRecords = recordsByResourceId[view.resourceId].orEmpty() val cachedRecords = rememberedRecords.ifEmpty { sharedDynamicNativeMemoryCache.screen(cacheKey)?.records.orEmpty() @@ -1635,16 +2223,18 @@ private fun DynamicDiscoveredAppScreen( return@LaunchedEffect } viewState = NativeScreenState.Loading - val values = selectedRecord?.toDynamicRuntimeValues().orEmpty() + selectedPathParameterValues runCatching { - loadDynamicRecords( + val records = loadDynamicRecords( services = services, session = session, descriptor = descriptor, actionId = prefillView.sourceActionId, values = values, runtimeContext = values, + cachePolicy = dynamicReadCachePolicy, ) + currentCoroutineContext().ensureActive() + records }.onSuccess { records -> if (records.isEmpty()) { viewState = NativeScreenState.Error( @@ -1655,12 +2245,14 @@ private fun DynamicDiscoveredAppScreen( val updatedRecords = recordsByResourceId + (view.resourceId to records) recordsByResourceId = updatedRecords viewState = NativeScreenState.Ready(records) + mutationReconciliationGeneration += 1 sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(records, updatedRecords), ) } }.onFailure { failure -> + if (failure is CancellationException) throw failure viewState = NativeScreenState.Error( message = failure.message ?: "Could not load the current settings.", retry = { loadAttempt += 1 }, @@ -1700,7 +2292,7 @@ private fun DynamicDiscoveredAppScreen( if (staleSnapshot == null) viewState = NativeScreenState.Loading val values = selectedRecord?.toDynamicRuntimeValues().orEmpty() + selectedPathParameterValues runCatching { - coroutineScope { + val loaded = coroutineScope { listOf( composite.columnResourceId to composite.columnSourceActionId, composite.rowResourceId to composite.rowSourceActionId, @@ -1713,20 +2305,25 @@ private fun DynamicDiscoveredAppScreen( actionId = actionId, values = values, runtimeContext = values, + cachePolicy = dynamicReadCachePolicy, ) } }.awaitAll() } + currentCoroutineContext().ensureActive() + loaded }.onSuccess { loaded -> val updatedRecords = recordsByResourceId + loaded.toMap() val rows = loaded.first { (resourceId, _) -> resourceId == composite.rowResourceId }.second recordsByResourceId = updatedRecords viewState = NativeScreenState.Ready(rows) + mutationReconciliationGeneration += 1 sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(rows, updatedRecords), ) }.onFailure { failure -> + if (failure is CancellationException) throw failure viewState = staleSnapshot?.let { NativeScreenState.Ready(it.records) } ?: NativeScreenState.Error( message = failure.message ?: "Could not load ${view.title}.", @@ -1749,14 +2346,17 @@ private fun DynamicDiscoveredAppScreen( if (staleSnapshot == null) viewState = NativeScreenState.Loading val values = selectedRecord?.toDynamicRuntimeValues().orEmpty() + selectedPathParameterValues runCatching { - loadDynamicRecords( + val records = loadDynamicRecords( services = services, session = session, descriptor = descriptor, actionId = view.sourceActionId, values = values, runtimeContext = values, + cachePolicy = dynamicReadCachePolicy, ) + currentCoroutineContext().ensureActive() + records }.onSuccess { records -> val updatedRecords = recordsByResourceId + (view.resourceId to records) val nextPagination = descriptor.actions.firstOrNull { action -> action.id == view.sourceActionId } @@ -1764,6 +2364,7 @@ private fun DynamicDiscoveredAppScreen( ?.toDynamicPaginationState(view.id, records) recordsByResourceId = updatedRecords viewState = NativeScreenState.Ready(records) + mutationReconciliationGeneration += 1 records.firstOrNull()?.let { authoritative -> if ( view.component == NativeComponent.detail && @@ -1785,6 +2386,7 @@ private fun DynamicDiscoveredAppScreen( ), ) }.onFailure { failure -> + if (failure is CancellationException) throw failure if (staleSnapshot != null) { viewState = NativeScreenState.Ready(staleSnapshot.records) return@onFailure @@ -1803,9 +2405,11 @@ private fun DynamicDiscoveredAppScreen( "Mailbox synchronization failed (HTTP ${response.status})." } delay(1_200) + currentCoroutineContext().ensureActive() }.onSuccess { loadAttempt += 1 }.onFailure { recoveryFailure -> + if (recoveryFailure is CancellationException) throw recoveryFailure viewState = NativeScreenState.Error( message = recoveryFailure.message ?: "Could not synchronize this mailbox.", retry = { loadAttempt += 1 }, @@ -1838,9 +2442,228 @@ private fun DynamicDiscoveredAppScreen( return } - val runtimeValues = selectedRecord?.toDynamicRuntimeValues().orEmpty() + selectedPathParameterValues - val executor = remember(services, session, descriptor, runtimeValues) { - DynamicNextcloudActionExecutor(services, session, descriptor, runtimeValues) + val selectedRuntimeValues = selectedDynamicRecordRuntimeValues( + record = selectedRecord, + resourceId = selectedRecordResourceId, + parameterNames = schema.action(selectedView.sourceActionId) + ?.binding + ?.pathParameterNames + .orEmpty(), + ) + val runtimeValues = selectedRuntimeValues + ?.let { values -> safeActionBindingValues(values, selectedPathParameterValues) } + .orEmpty() + val datasetBindingValues = dynamicDatasetBindingValues( + component = selectedView.component, + declaredParameterNames = schema.action(selectedView.sourceActionId) + ?.binding + ?.let { binding -> binding.pathParameterNames + binding.queryParameterNames } + .orEmpty(), + selectedPathParameterValues = selectedPathParameterValues, + runtimeValues = runtimeValues, + ) + val datasetRelatedRecords = formRelationCache.datasetRelatedRecords( + genericRecords = recordsByResourceId, + requests = formRelationRequests, + ) + val relatedRecordPaging = remember( + formRelationRequests, + formRelationCache, + loadingFormRelationPageKeys, + formRelationPageErrors, + formRelationValues, + ) { + formRelationRequests.mapNotNull { request -> + val continuation = formRelationCache.continuation(request) + val loading = request.cacheKey in loadingFormRelationPageKeys + val discardedRecordCount = formRelationCache.discardedRecordCount(request) + val error = formRelationPageErrors[request.cacheKey] + if ( + continuation == null && + error == null && + !loading && + discardedRecordCount == 0 + ) { + return@mapNotNull null + } + request.plan.resourceId to NativeRelatedRecordPaging( + loading = loading, + error = error, + discardedChoiceCount = discardedRecordCount, + loadMore = continuation?.takeUnless { loading }?.let { + { + if (request.cacheKey !in loadingFormRelationPageKeys) { + loadingFormRelationPageKeys += request.cacheKey + formRelationPageErrors -= request.cacheKey + formRelationPageScope.launch { + val active = formRelationCache.continuation(request) + if (active == null) { + loadingFormRelationPageKeys -= request.cacheKey + return@launch + } + val pageValues = dynamicFormRelationRuntimeValues( + request = request, + availableValues = formRelationValues, + additionalValues = mapOf( + active.spec.parameterName to active.nextRequestValue, + ), + ) + runCatching { + loadDynamicRecords( + services = services, + session = session, + descriptor = descriptor, + actionId = request.plan.actionId, + values = pageValues, + runtimeContext = pageValues, + cachePolicy = dynamicReadCachePolicy, + ) + }.onSuccess { page -> + formRelationCache = formRelationCache.appendPageSucceeded(request, page) + formRelationPageErrors -= request.cacheKey + }.onFailure { failure -> + if (failure is CancellationException) throw failure + formRelationPageErrors = formRelationPageErrors.putBounded( + request.cacheKey, + failure.message ?: "Could not load more choices.", + ) + } + loadingFormRelationPageKeys -= request.cacheKey + } + } + } + }, + returnToFirstPage = discardedRecordCount.takeIf { count -> count > 0 && !loading }?.let { + { + if (request.cacheKey !in loadingFormRelationPageKeys) { + loadingFormRelationPageKeys += request.cacheKey + formRelationPageErrors -= request.cacheKey + formRelationPageScope.launch { + runCatching { + loadInitialDynamicFormRelationRecords( + services = services, + session = session, + descriptor = descriptor, + request = request, + values = formRelationValues, + cachePolicy = dynamicReadCachePolicy, + ) + }.onSuccess { result -> + formRelationCache = formRelationCache.loadSucceeded( + request = request, + records = result.records, + pagination = result.pagination, + ) + formRelationPageErrors -= request.cacheKey + }.onFailure { failure -> + if (failure is CancellationException) throw failure + formRelationPageErrors = formRelationPageErrors.putBounded( + request.cacheKey, + failure.message ?: "Could not return to the first choices.", + ) + } + loadingFormRelationPageKeys -= request.cacheKey + } + } + } + }, + ) + }.toMap() + } + val failedFormRelationRequests = formRelationCache.failedRequests(formRelationRequests) + val executor = remember(services, session, descriptor, runtimeValues, discovery.versionStatus) { + DynamicNextcloudActionExecutor( + services = services, + session = session, + descriptor = descriptor, + runtimeContext = runtimeValues, + versionStatus = discovery.versionStatus, + onMultipartUploadSucceeded = ::releaseSelectedDynamicUploadFile, + ) + } + val collectionBatchRelationLoader = remember(services, session, descriptor, schema) { + NativeCollectionBatchRelationLoader { request -> + val action = schema.action(request.actionId) + ?.takeIf { candidate -> + candidate.resourceId.sameDynamicResourceAs(request.resourceId) && + request.relatedResourceIdsByField.keys.all { fieldId -> + fieldId in candidate.binding.bodyFieldNames || + fieldId in candidate.binding.queryParameterNames + } + } + if (action == null) { + NativeCollectionBatchRelationLoadResult( + recordsByResourceId = emptyMap(), + errorsByResourceId = request.relatedResourceIdsByField.values + .distinct() + .associateWith { "The batch relation contract is unavailable." }, + ) + } else { + val requestedResourceIds = request.relatedResourceIdsByField.values.toSet() + val loadRequests = dynamicCollectionBatchRelationLoadRequests( + schema = schema, + childResourceId = request.resourceId, + relatedFieldIds = request.relatedResourceIdsByField.keys, + availableValues = request.bindingValues, + ).associateBy { candidate -> candidate.plan.resourceId } + val outcomes = coroutineScope { + requestedResourceIds.map { relatedResourceId -> + async { + val relationRequest = loadRequests[relatedResourceId] + ?: return@async Triple( + relatedResourceId, + null, + "No verified, fully bound relation read is available.", + ) + runCatching { + loadInitialDynamicFormRelationRecords( + services = services, + session = session, + descriptor = descriptor, + request = relationRequest, + values = request.bindingValues, + cachePolicy = if (request.forceRefresh) { + NextcloudApiCachePolicy.ForceNetwork + } else { + NextcloudApiCachePolicy.PreferCache + }, + ) + }.fold( + onSuccess = { result -> + val boundedRecords = DynamicFormRelationCacheState() + .loadSucceeded( + request = relationRequest, + records = result.records, + pagination = result.pagination, + ) + .relatedRecords(listOf(relationRequest))[relatedResourceId] + .orEmpty() + Triple(relatedResourceId, boundedRecords, null) + }, + onFailure = { failure -> + if (failure is CancellationException) throw failure + Triple( + relatedResourceId, + null, + (failure.message ?: "Could not load choices.").take( + MAX_DYNAMIC_BATCH_RELATION_ERROR_LENGTH, + ), + ) + }, + ) + } + }.awaitAll() + } + NativeCollectionBatchRelationLoadResult( + recordsByResourceId = outcomes.mapNotNull { (resourceId, records, _) -> + records?.let { resourceId to it } + }.toMap(), + errorsByResourceId = outcomes.mapNotNull { (resourceId, _, error) -> + error?.let { resourceId to it } + }.toMap(), + ) + } + } } val recordContext = selectedRecord?.let { record -> val visitedStates = buildSet { @@ -1867,6 +2690,7 @@ private fun DynamicDiscoveredAppScreen( fieldValues = record.values, parameterValues = selectedPathParameterValues, actionSafeIdentity = record.actionSafeIdentity, + actionBindingProvenanceValid = record.actionBindingProvenanceValid, currentLayoutId = selectedView.id, visitedStates = visitedStates, ) @@ -1937,42 +2761,57 @@ private fun DynamicDiscoveredAppScreen( } } } - val secondaryNavigationDestinations = remember( - descriptor, - recordContext, - navigationDestinations, - ) { - val context = recordContext ?: return@remember emptyList() - navigationDestinations.filter { (destination, _) -> - descriptor.isSecondaryTechnicalDestination(context, destination) - } + // Every verified read destination belongs in the adaptive, scrollable navigator. Keeping + // technical or trash collections in the small header popup makes them unreachable on compact + // screens once the menu exceeds the viewport. Semantic ranking still controls the preferred + // automatic child; it must not hide an explicitly verified user destination. + val primaryNavigationDestinations = navigationDestinations + val secondaryNavigationDestinations = + emptyList>() + val selectedCollectionState = remember(schema, selectedView.sourceActionId) { + dynamicCollectionState(schema.action(selectedView.sourceActionId)) } - val primaryNavigationDestinations = remember( - navigationDestinations, - secondaryNavigationDestinations, + val actionViews = remember( + navigationPlan, + schema, + selectedRecord, + selectedView.resourceId, + selectedCollectionState, ) { - val secondaryViewIds = secondaryNavigationDestinations.mapTo(hashSetOf()) { (_, view) -> view.id } - navigationDestinations.filterNot { (_, view) -> view.id in secondaryViewIds } - } - val actionViews = remember(navigationPlan, schema, selectedRecord, selectedView.resourceId) { val planned = if (selectedRecord == null) { navigationPlan.rootFormActions.filter { action -> - action.resourceId == selectedView.resourceId + action.resourceId == selectedView.resourceId && + selectedCollectionState == null } } else { val currentResourceId = selectedRecordResourceId.orEmpty() navigationPlan.contextualFormActions.filter { action -> val spec = schema.action(action.actionId) - val targetsCurrentRecord = action.resourceId.sameDynamicResourceAs(currentResourceId) - val targetsCurrentView = action.resourceId.sameDynamicResourceAs(selectedView.resourceId) - val createsCurrentViewResource = spec?.intent == ActionIntent.create && targetsCurrentView - val editsSelectedRecord = spec?.intent in setOf(ActionIntent.update, ActionIntent.delete) && - targetsCurrentRecord && - (targetsCurrentView || selectedView.component == NativeComponent.detail) - // Create actions belong to the active collection tab. Update and - // delete actions belong to the selected record and stay visible - // on its detail surface, not on unrelated child collections. - createsCurrentViewResource || editsSelectedRecord + ?: return@filter false + val formView = schema.views.singleOrNull { candidate -> + candidate.id == action.formId && + candidate.resourceId.sameDynamicResourceAs(spec.resourceId) + } ?: return@filter false + val activeReadAction = schema.actions.singleOrNull { candidate -> + candidate.id == selectedView.sourceActionId + } + val actionResource = schema.resources.singleOrNull { candidate -> + candidate.id.sameDynamicResourceAs(spec.resourceId) + } + dynamicContextualFormTargetsActiveSurface( + action = spec, + formView = formView, + activeView = selectedView, + activeReadAction = activeReadAction, + plannedBindingValues = action.pathParameterValues, + selectedRecordResourceId = currentResourceId, + selectedCollectionState = selectedCollectionState, + hasEditableFileField = actionResource + ?.let { resource -> editableNativeFields(resource, spec) } + ?.any { field -> field.kind == FieldKind.file } + ?: false, + uniqueTargetResource = actionResource != null, + ) } } planned.mapNotNull { action -> @@ -1991,18 +2830,23 @@ private fun DynamicDiscoveredAppScreen( "$label|$route" } } - val quickActionViews = remember(actionViews, schema) { - actionViews.sortedBy { (action, _) -> - dynamicQuickActionPriority(schema.action(action.actionId)) - }.take(2) + val primaryCreateAction = remember(actionViews, schema) { + actionViews + .filter { (action, _) -> schema.action(action.actionId)?.intent == ActionIntent.create } + .minByOrNull { (action, _) -> dynamicQuickActionPriority(schema.action(action.actionId)) } + } + val overflowActionViews = remember(actionViews, primaryCreateAction) { + actionViews.filterNot { candidate -> candidate == primaryCreateAction } } var actionMenuExpanded by remember(descriptor) { mutableStateOf(false) } - var advancedNavigationExpanded by remember(descriptor, recordContext) { mutableStateOf(false) } - var pendingDirectAction by remember(descriptor) { + var pendingDirectAction by remember(descriptor, discovery.versionStatus) { mutableStateOf(null) } var directActionRunning by remember(descriptor) { mutableStateOf(false) } var directActionError by remember(descriptor) { mutableStateOf(null) } + var directActionFailureState by remember(descriptor) { + mutableStateOf(null) + } var contractInfoExpanded by remember(descriptor) { mutableStateOf(false) } val contractInfo = remember(discovery, recordContext) { discovery.toContractInfo(recordContext) } val dynamicActionScope = rememberCoroutineScope() @@ -2027,6 +2871,7 @@ private fun DynamicDiscoveredAppScreen( actionId = pagingView.sourceActionId, values = values, runtimeContext = values, + cachePolicy = dynamicReadCachePolicy, ) }.onSuccess { pageRecords -> if (selectedViewId != pagingView.id) return@onSuccess @@ -2069,16 +2914,30 @@ private fun DynamicDiscoveredAppScreen( } fun rememberCurrentLocation() { - navigationHistory = navigationHistory + DynamicNavigationSnapshot( - viewId = selectedView.id, - resourceId = selectedView.resourceId, - record = selectedRecord, - recordResourceId = selectedRecordResourceId, - pathParameterValues = selectedPathParameterValues, - ) + navigationHistory = ( + navigationHistory + + DynamicNavigationSnapshot( + viewId = selectedView.id, + resourceId = selectedView.resourceId, + record = selectedRecord, + recordResourceId = selectedRecordResourceId, + pathParameterValues = selectedPathParameterValues, + ) + ).takeLast(MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY) } fun navigateWithinDynamicApp() { + val activeContextToken = selectedRecord?.dynamicContextNavigationToken( + selectedRecordResourceId.orEmpty(), + ) + if (activeContextToken != null && activeContextToken == contextualMenuRecordToken) { + if (!contextualMenuOpen) { + contextualMenuOpen = true + return + } + contextualMenuOpen = false + contextualMenuRecordToken = null + } navigationHistory.lastOrNull()?.let { previous -> navigationHistory = navigationHistory.dropLast(1) selectedViewId = previous.viewId @@ -2109,6 +2968,35 @@ private fun DynamicDiscoveredAppScreen( onExit() } + fun reconcileSuccessfulMutation( + action: ActionSpec, + leaveMutatedSurface: Boolean, + ) { + sharedDynamicNativeMemoryCache.invalidateScreens(session, descriptor.app.id) + formRelationCache = DynamicFormRelationCacheState() + val refreshPlan = schema.planDynamicMutationRefresh( + action = action, + selectedRecordResourceId = selectedRecordResourceId, + ) + if (refreshPlan != null) { + recordsByResourceId = refreshPlan.discardAffectedRelatedRecords(recordsByResourceId) + } + val deletedSelectedRecord = refreshPlan?.selectedRecordReconciliation == + DynamicSelectedRecordReconciliation.ClearDeletedSelection + when { + deletedSelectedRecord && navigationHistory.isNotEmpty() -> navigateWithinDynamicApp() + deletedSelectedRecord -> { + navigationHistory = emptyList() + selectedRecord = null + selectedRecordResourceId = null + selectedPathParameterValues = emptyMap() + selectedViewId = initialViewId + } + leaveMutatedSurface -> navigateWithinDynamicApp() + } + loadAttempt += 1 + } + fun selectDynamicAction( action: dev.obiente.nextcloudnative.nativeui.model.DynamicNavigationFormAction, view: ViewSpec, @@ -2129,6 +3017,7 @@ private fun DynamicDiscoveredAppScreen( !value.isNullOrBlank() }?.value ?: selectedRecord?.id ?: schema.resource(actionSpec.resourceId)?.name ?: "item" directActionError = null + directActionFailureState = null pendingDirectAction = PendingDynamicDirectAction( action = actionSpec, values = action.pathParameterValues, @@ -2141,8 +3030,30 @@ private fun DynamicDiscoveredAppScreen( selectedViewId = view.id } - val hasInternalBack = navigationHistory.isNotEmpty() || selectedRecord != null || selectedViewId != initialViewId - PlatformBackHandler(enabled = hasInternalBack, onBack = ::navigateWithinDynamicApp) + fun selectCollectionDestination( + destination: DynamicNavigationDestination, + view: ViewSpec, + ) { + actionMenuExpanded = false + contextualMenuOpen = false + val selection = planDynamicCollectionDestinationSelection( + isTopLevelDestination = selectedRecord == null, + destinationPathParameterValues = destination.pathParameterValues, + ) + if (selection.clearHierarchyContext) { + navigationHistory = emptyList() + selectedRecord = null + selectedRecordResourceId = null + paginationState = null + loadingMore = false + loadMoreError = null + } + selectedPathParameterValues = selection.pathParameterValues + selectedViewId = view.id + } + + val hasCollectionHierarchyBack = navigationHistory.isNotEmpty() || selectedRecord != null + PlatformBackHandler(enabled = true, onBack = ::navigateWithinDynamicApp) val showFallbackRecordDetail = shouldShowDynamicRecordFallbackDetail( viewResourceId = selectedView.resourceId, viewComponent = selectedView.component, @@ -2152,49 +3063,189 @@ private fun DynamicDiscoveredAppScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val compactLandscape = shouldUseCompactDynamicAppChrome(maxWidth.value, maxHeight.value) - Column(modifier = Modifier.fillMaxSize()) { - DynamicAppChromeHeader( - title = descriptor.app.name, - subtitle = selectedRecord?.dynamicContextSubtitle( - selectedView, - schema.resource(selectedRecordResourceId.orEmpty())?.name, - ) ?: selectedView.dynamicRootSubtitle(descriptor.app.name), - onBack = ::navigateWithinDynamicApp, - compact = compactLandscape, - onContractInfo = { contractInfoExpanded = true }, - trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (actionViews.isNotEmpty()) { - Box { - IconButton(onClick = { actionMenuExpanded = true }) { - Icon(NextcloudIcons.More, contentDescription = "Available actions") - } - DropdownMenu( - expanded = actionMenuExpanded, - onDismissRequest = { actionMenuExpanded = false }, - ) { - actionViews.forEach { (action, view) -> - val actionSpec = schema.action(action.actionId) - DropdownMenuItem( - text = { - Text( - actionSpec?.let { spec -> - dynamicHeaderActionLabel(spec, view.dynamicActionLabel()) - } ?: view.dynamicActionLabel(), + val collectionDestinationEntries = remember( + primaryNavigationDestinations, + descriptor.app.name, + ) { + primaryNavigationDestinations + .distinctBy { (_, view) -> view.id } + .map { (destination, view) -> + destination to NextcloudCollectionDestination( + id = view.id, + label = destination.label + .dynamicUiLabel(descriptor.app.name) + .ifBlank { view.dynamicNavigationLabel(descriptor.app.name) }, + accessibilityId = destination.actionId, + ) + } + } + val selectedCollectionDestinationId = collectionDestinationEntries + .firstOrNull { (_, item) -> item.id == selectedView.id } + ?.second + ?.id + val collectionNavigationModel = remember( + collectionDestinationEntries, + selectedCollectionDestinationId, + ) { + NextcloudCollectionNavigationModel.create( + destinations = collectionDestinationEntries.map { (_, item) -> item }, + selectedDestinationId = selectedCollectionDestinationId, + ) + } + val workspaceCapabilities = LocalNextcloudWorkspaceCapabilities.current + val collectionNavigationHost = if (workspaceCapabilities.isDesktop) { + NextcloudCollectionNavigationHost.Desktop + } else { + NextcloudCollectionNavigationHost.AdaptiveAndroid + } + val collectionNavigationMode = resolveNextcloudCollectionNavigationMode( + host = collectionNavigationHost, + availableWidthDp = maxWidth.value.toInt(), + destinationCount = collectionNavigationModel.destinations.size, + ) + val collectionSubtitle = selectedRecord?.dynamicContextSubtitle( + selectedView, + schema.resource(selectedRecordResourceId.orEmpty())?.name, + ) ?: selectedView.dynamicRootSubtitle(descriptor.app.name) + val activeContextToken = selectedRecord?.dynamicContextNavigationToken( + selectedRecordResourceId.orEmpty(), + ) + val showContextDestinationMenu = contextualMenuOpen && + contextualMenuRecordToken == activeContextToken && + shouldShowDynamicContextDestinationMenu( + collectionDestinationEntries.map { (_, destination) -> destination.id }, + ) + + NextcloudCollectionWorkspaceScaffold( + model = collectionNavigationModel, + mode = collectionNavigationMode, + title = descriptor.app.name, + subtitle = if (showContextDestinationMenu) { + selectedRecord?.dynamicContextLabel() + } else { + collectionSubtitle + }, + onBack = ::navigateWithinDynamicApp, + hasHierarchyBack = hasCollectionHierarchyBack, + onDestinationSelected = { selected -> + collectionDestinationEntries + .firstOrNull { (_, item) -> item.id == selected.id } + ?.first + ?.let { destination -> + schema.views.firstOrNull { view -> view.id == selected.id } + ?.let { view -> selectCollectionDestination(destination, view) } + } + }, + compactHeader = compactLandscape, + destinationIcon = { destination -> + schema.views.firstOrNull { view -> view.id == destination.id } + ?.dynamicCollectionNavigationIcon() + }, + headerActions = { + if (!showContextDestinationMenu) { + primaryCreateAction?.let { (action, view) -> + val actionSpec = schema.action(action.actionId) + val label = actionSpec?.let { spec -> + dynamicHeaderActionLabel(spec, view.dynamicActionLabel()) + } ?: view.dynamicActionLabel() + IconButton( + onClick = { selectDynamicAction(action, view) }, + modifier = Modifier.semantics { + contentDescription = "$label; action ${action.actionId}" + }, + ) { + Icon( + NextcloudIcons.Add, + contentDescription = null, + ) + } + } + Box { + IconButton(onClick = { actionMenuExpanded = true }) { + Icon(NextcloudIcons.More, contentDescription = "More options") + } + DropdownMenu( + expanded = actionMenuExpanded, + onDismissRequest = { actionMenuExpanded = false }, + ) { + overflowActionViews.forEach { (action, view) -> + val actionSpec = schema.action(action.actionId) + DropdownMenuItem( + text = { + Text( + actionSpec?.let { spec -> + dynamicHeaderActionLabel( + spec, + view.dynamicActionLabel(), ) - }, - onClick = { selectDynamicAction(action, view) }, + } ?: view.dynamicActionLabel(), ) - } - } + }, + onClick = { selectDynamicAction(action, view) }, + ) } - } - if (compactLandscape) { - TextButton(onClick = { contractInfoExpanded = true }) { Text("Contract") } + if ( + overflowActionViews.isNotEmpty() && + secondaryNavigationDestinations.isNotEmpty() + ) { + HorizontalDivider() + } + secondaryNavigationDestinations.forEach { (destination, view) -> + val baseLabel = destination.label.dynamicUiLabel(descriptor.app.name) + val duplicate = secondaryNavigationDestinations.count { + (candidate, _) -> + candidate.label.dynamicUiLabel(descriptor.app.name) + .equals(baseLabel, ignoreCase = true) + } > 1 + DropdownMenuItem( + text = { + Text( + dynamicSecondaryDestinationLabel( + destinationLabel = baseLabel, + resourceLabel = schema.resource(view.resourceId)?.name + ?: view.resourceId, + duplicate = duplicate, + ), + ) + }, + modifier = Modifier.semantics { + contentDescription = + "Open destination ${destination.actionId}" + }, + onClick = { + selectCollectionDestination(destination, view) + }, + ) + } + if ( + overflowActionViews.isNotEmpty() || + secondaryNavigationDestinations.isNotEmpty() + ) { + HorizontalDivider() + } + DropdownMenuItem( + text = { Text("Contract info") }, + onClick = { + actionMenuExpanded = false + contractInfoExpanded = true + }, + ) } } - }, - ) + } + }, + modifier = Modifier.fillMaxSize(), + ) { + if (showContextDestinationMenu) { + DynamicContextDestinationMenu( + recordLabel = requireNotNull(selectedRecord).dynamicContextLabel(), + destinations = collectionDestinationEntries, + schema = schema, + onDestinationSelected = { destination, view -> + selectCollectionDestination(destination, view) + }, + ) + } else Column(modifier = Modifier.fillMaxSize()) { if (discovery.acquisition == DynamicDescriptorAcquisition.MetadataFallback) { Surface( modifier = Modifier.fillMaxWidth().padding( @@ -2221,82 +3272,43 @@ private fun DynamicDiscoveredAppScreen( } } } - if (primaryNavigationDestinations.size > 1 || secondaryNavigationDestinations.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - LazyRow( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues( - start = NextcloudSpacing.Large, - end = NextcloudSpacing.Small, - top = NextcloudSpacing.Small, - bottom = NextcloudSpacing.Small, + if (failedFormRelationRequests.isNotEmpty()) { + val failedResources = failedFormRelationRequests + .map { request -> + schema.resource(request.plan.resourceId)?.name ?: request.plan.resourceId + } + .distinct() + Surface( + modifier = Modifier.fillMaxWidth().padding( + horizontal = NextcloudSpacing.Large, + vertical = NextcloudSpacing.Small, ), - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(NextcloudRadii.Small), ) { - listItems(primaryNavigationDestinations, key = { (_, view) -> view.id }) { (destination, view) -> - FilterChip( - selected = view.id == selectedView.id, - onClick = { - selectedPathParameterValues = destination.pathParameterValues - selectedViewId = view.id + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (failedResources.size == 1) { + "Could not load ${failedResources.single()} choices. " + + "Other form fields remain available." + } else { + "Some choices could not be loaded. Other form fields remain available." }, - label = { Text(destination.label.dynamicUiLabel(descriptor.app.name)) }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, ) - } - } - if (secondaryNavigationDestinations.isNotEmpty()) { - Box { - IconButton(onClick = { advancedNavigationExpanded = true }) { - Icon(NextcloudIcons.More, contentDescription = "Advanced views") - } - DropdownMenu( - expanded = advancedNavigationExpanded, - onDismissRequest = { advancedNavigationExpanded = false }, + TextButton( + onClick = { + formRelationCache = formRelationCache.retry(formRelationRequests) + formRelationLoadAttempt += 1 + }, ) { - secondaryNavigationDestinations.forEach { (destination, view) -> - DropdownMenuItem( - text = { - Text(destination.label.dynamicUiLabel(descriptor.app.name)) - }, - onClick = { - selectedPathParameterValues = destination.pathParameterValues - selectedViewId = view.id - advancedNavigationExpanded = false - }, - ) - } - } - } - } - } - } - if (!compactLandscape && quickActionViews.isNotEmpty()) { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues( - start = NextcloudSpacing.Large, - end = NextcloudSpacing.Large, - bottom = NextcloudSpacing.Small, - ), - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - ) { - listItems(quickActionViews, key = { (action, view) -> "${action.actionId}:${view.id}" }) { - (action, view) -> - val actionSpec = schema.action(action.actionId) - val label = actionSpec?.let { spec -> - dynamicHeaderActionLabel(spec, view.dynamicActionLabel()) - } ?: view.dynamicActionLabel() - if (actionSpec?.intent == ActionIntent.create) { - Button(onClick = { selectDynamicAction(action, view) }) { - Text(label) - } - } else { - OutlinedButton(onClick = { selectDynamicAction(action, view) }) { - Text(label) - } + Text("Retry choices") } } } @@ -2311,8 +3323,13 @@ private fun DynamicDiscoveredAppScreen( datasetContext = NativeDatasetContext( parentResourceId = selectedRecordResourceId, parentRecord = selectedRecord, - relatedRecords = recordsByResourceId, + bindingValues = datasetBindingValues, + relatedRecords = datasetRelatedRecords, + relatedRecordPaging = relatedRecordPaging, ), + mutationReconciliationGeneration = mutationReconciliationGeneration, + collectionBatchRelationLoader = collectionBatchRelationLoader, + filePicker = dynamicFilePicker, onSelectRecord = selectedView.takeIf { it.component != NativeComponent.detail && it.component != NativeComponent.form }?.let { @@ -2329,6 +3346,7 @@ private fun DynamicDiscoveredAppScreen( fieldValues = record.values, parameterValues = inheritedParameters, actionSafeIdentity = record.actionSafeIdentity, + actionBindingProvenanceValid = record.actionBindingProvenanceValid, currentLayoutId = selectedView.id, ) val nextPlan = descriptor.planDynamicNavigation(nextContext) @@ -2354,12 +3372,26 @@ private fun DynamicDiscoveredAppScreen( parentResourceId = selectedParentResourceId, destinations = nextPlan.contextualChildDestinations, ) - val nextViewId = compositeTarget?.id - ?: primaryContentTarget?.layoutId - ?: preferredCollectionChild?.layoutId - ?: detailTarget?.id - ?: directChild?.layoutId - ?: selectedViewId + val contextualSurfaceIds = buildSet { + nextPlan.contextualChildDestinations.mapTo(this) { destination -> + destination.layoutId + } + compositeTarget?.id?.let(::add) + detailTarget?.id?.let(::add) + } + val showDestinationMenu = shouldShowDynamicContextDestinationMenu( + contextualSurfaceIds.toList(), + ) + val nextViewId = if (showDestinationMenu) { + selectedViewId + } else { + compositeTarget?.id + ?: primaryContentTarget?.layoutId + ?: preferredCollectionChild?.layoutId + ?: detailTarget?.id + ?: directChild?.layoutId + ?: selectedViewId + } val explicitTargetParameters = primaryContentTarget?.pathParameterValues ?: preferredCollectionChild?.pathParameterValues ?: directChild?.pathParameterValues @@ -2371,31 +3403,40 @@ private fun DynamicDiscoveredAppScreen( .associate(Map.Entry::toPair) selectedRecord = record selectedRecordResourceId = selectedParentResourceId - selectedPathParameterValues = resolveDynamicRecordSelectionParameters( - currentViewId = selectedViewId.orEmpty(), - nextViewId = nextViewId.orEmpty(), - currentParameters = selectedPathParameterValues, - explicitTargetParameters = explicitTargetParameters, - fallbackTargetParameters = fallbackTargetParameters, - ) + contextualMenuRecordToken = if (showDestinationMenu) { + record.dynamicContextNavigationToken(selectedParentResourceId) + } else { + null + } + contextualMenuOpen = showDestinationMenu + selectedPathParameterValues = if (showDestinationMenu) { + inheritedParameters + } else { + resolveDynamicRecordSelectionParameters( + currentViewId = selectedViewId.orEmpty(), + nextViewId = nextViewId.orEmpty(), + currentParameters = selectedPathParameterValues, + explicitTargetParameters = explicitTargetParameters, + fallbackTargetParameters = fallbackTargetParameters, + ) + } selectedViewId = nextViewId } }, - onActionSucceeded = { - if (schema.action(selectedView.sourceActionId)?.intent == ActionIntent.delete) { - navigationHistory = emptyList() - selectedRecord = null - selectedRecordResourceId = null - selectedPathParameterValues = emptyMap() - selectedViewId = initialViewId - } else { - // Update/config actions return to their previous surface, which immediately - // reloads the authoritative server representation through loadAttempt. - navigateWithinDynamicApp() - } - loadAttempt += 1 + onActionSucceeded = { action -> + reconcileSuccessfulMutation( + action = action, + leaveMutatedSurface = true, + ) + }, + onInlineActionSucceeded = { action -> + reconcileSuccessfulMutation( + action = action, + leaveMutatedSurface = false, + ) }, - onInlineActionSucceeded = { loadAttempt += 1 }, + showCollectionCreateAction = primaryCreateAction == null && + selectedCollectionState == null, onOpenLink = services::openExternalUrl, imageLoader = imageLoader, audioPlayer = audioSourceCapability?.let { @@ -2456,6 +3497,7 @@ private fun DynamicDiscoveredAppScreen( } } } + } if (contractInfoExpanded) { DynamicContractInfoDialog( info = contractInfo, @@ -2463,17 +3505,34 @@ private fun DynamicDiscoveredAppScreen( ) } pendingDirectAction?.let { pending -> + val outcomeUnknown = directActionFailureState?.requiresReconciliation == true AlertDialog( onDismissRequest = { if (!directActionRunning) { pendingDirectAction = null directActionError = null + directActionFailureState = null } }, - title = { Text("Delete ${pending.targetLabel}?") }, + title = { + Text( + if (outcomeUnknown) { + "${dynamicHeaderActionLabel(pending.action, pending.action.label)} result unknown" + } else { + dynamicDirectActionTitle(pending.action, pending.targetLabel) + }, + ) + }, text = { Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { - Text("This removes the item from the server and cannot be undone.") + if (outcomeUnknown) { + Text( + "The server may already have completed this action. The view is being refreshed " + + "to reconcile the result. Review the refreshed state before trying again.", + ) + } else { + Text(dynamicDirectActionDescription(pending.action)) + } directActionError?.let { error -> Text( error, @@ -2489,52 +3548,68 @@ private fun DynamicDiscoveredAppScreen( onClick = { pendingDirectAction = null directActionError = null + directActionFailureState = null }, ) { - Text("Cancel") + Text(if (outcomeUnknown) "Close" else "Cancel") } }, confirmButton = { - Button( - enabled = !directActionRunning, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = { - directActionRunning = true - directActionError = null - dynamicActionScope.launch { - when ( - val result = executor.execute( - NativeActionRequest.Submit( - action = pending.action, - values = pending.values, - confirmed = true, - ), - ) - ) { - is NativeActionExecutionResult.Success -> { - pendingDirectAction = null - navigationHistory = emptyList() - selectedRecord = null - selectedRecordResourceId = null - selectedPathParameterValues = emptyMap() - selectedViewId = initialViewId - loadAttempt += 1 - } - is NativeActionExecutionResult.Failure -> { - directActionError = result.message + if (directActionFailureState?.retryAllowed != false) { + Button( + enabled = !directActionRunning, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onClick = { + directActionRunning = true + directActionError = null + directActionFailureState = null + dynamicActionScope.launch { + when ( + val result = executor.execute( + NativeActionRequest.Submit( + action = pending.action, + values = pending.values, + confirmed = true, + ), + ) + ) { + is NativeActionExecutionResult.Success -> { + pendingDirectAction = null + reconcileSuccessfulMutation( + action = pending.action, + leaveMutatedSurface = true, + ) + } + is NativeActionExecutionResult.Failure -> { + directActionError = result.message + val failurePolicy = dynamicDirectActionFailurePolicy(result.outcome) + directActionFailureState = failurePolicy + if (failurePolicy.requiresReconciliation) { + reconcileSuccessfulMutation( + action = pending.action, + leaveMutatedSurface = true, + ) + } + } } + directActionRunning = false } - directActionRunning = false + }, + ) { + if (directActionRunning) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Text( + if (directActionFailureState == null) { + dynamicDirectActionConfirmLabel(pending.action) + } else { + "Try again" + }, + ) } - }, - ) { - if (directActionRunning) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - ) - } else { - Text("Delete") } } }, @@ -2948,8 +4023,7 @@ private fun ContractInfoSection(label: String, value: String) { private fun List.safeContractList(): String = ifEmpty { listOf("none") }.joinToString(", ") -@Serializable -private data class DynamicNavigationSnapshot( +internal data class DynamicNavigationSnapshot( val viewId: String, val resourceId: String, val record: NativeRecord?, @@ -2957,13 +4031,119 @@ private data class DynamicNavigationSnapshot( val pathParameterValues: Map, ) +@Serializable +internal data class SavedDynamicNavigationSnapshot( + val viewId: String, + val resourceId: String, + val recordId: String? = null, + val recordResourceId: String? = null, + val pathParameterValues: Map = emptyMap(), +) + @Serializable private data class DynamicAppNavigationState( val selectedViewId: String? = null, val selectedRecord: NativeRecord? = null, val selectedRecordResourceId: String? = null, val pathParameterValues: Map = emptyMap(), - val history: List = emptyList(), + val history: List = emptyList(), +) + +internal fun saveDynamicNavigationHistory( + history: List, +): List = history + .takeLast(MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY) + .mapNotNull(DynamicNavigationSnapshot::toSavedDynamicNavigationSnapshot) + +internal fun restoreDynamicNavigationHistory( + history: List, +): List = history + .takeLast(MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY) + .mapNotNull(SavedDynamicNavigationSnapshot::toDynamicNavigationSnapshot) + +private fun DynamicNavigationSnapshot.toSavedDynamicNavigationSnapshot(): SavedDynamicNavigationSnapshot? { + if (!viewId.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS)) return null + if (!resourceId.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS)) return null + val savedParameters = pathParameterValues.toSavedDynamicNavigationParameters() ?: return null + val savedRecordId = record?.id?.let { value -> + value.takeIf { it.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_RECORD_ID_CHARS) } + ?: return null + } + val savedRecordResourceId = recordResourceId?.let { value -> + value.takeIf { it.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS) } + ?: return null + }?.takeIf { savedRecordId != null } + return SavedDynamicNavigationSnapshot( + viewId = viewId, + resourceId = resourceId, + recordId = savedRecordId, + recordResourceId = savedRecordResourceId, + pathParameterValues = savedParameters, + ) +} + +private fun SavedDynamicNavigationSnapshot.toDynamicNavigationSnapshot(): DynamicNavigationSnapshot? { + if (!viewId.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS)) return null + if (!resourceId.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS)) return null + val restoredParameters = pathParameterValues.toSavedDynamicNavigationParameters() ?: return null + val restoredRecordId = recordId?.let { value -> + value.takeIf { it.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_RECORD_ID_CHARS) } + ?: return null + } + val restoredRecordResourceId = recordResourceId?.let { value -> + value.takeIf { it.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS) } + ?: return null + }?.takeIf { restoredRecordId != null } + return DynamicNavigationSnapshot( + viewId = viewId, + resourceId = resourceId, + record = restoredRecordId?.let { recordId -> + NativeRecord( + id = recordId, + values = emptyMap(), + // A persisted identity can reload a detail route, but only the authoritative + // read response may authorize a mutation after process restoration. + actionSafeIdentity = false, + ) + }, + recordResourceId = restoredRecordResourceId, + pathParameterValues = restoredParameters, + ) +} + +private fun Map.toSavedDynamicNavigationParameters(): Map? { + if (size > MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETERS) return null + if (any { (key, value) -> + !key.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETER_NAME_CHARS) || + !value.isSafeSavedDynamicNavigationValue(MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETER_VALUE_CHARS) + } + ) { + return null + } + return toMap() +} + +private fun String.isSafeSavedDynamicNavigationValue(maximumChars: Int): Boolean = + isNotBlank() && length <= maximumChars && none(Char::isISOControl) + +internal const val MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY = 16 +private const val MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETERS = 8 +private const val MAX_SAVED_DYNAMIC_NAVIGATION_ID_CHARS = 128 +private const val MAX_SAVED_DYNAMIC_RECORD_ID_CHARS = 256 +private const val MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETER_NAME_CHARS = 64 +private const val MAX_SAVED_DYNAMIC_NAVIGATION_PARAMETER_VALUE_CHARS = 256 + +internal data class DynamicCollectionDestinationSelectionPlan( + val pathParameterValues: Map, + val clearHierarchyContext: Boolean, +) + +internal fun planDynamicCollectionDestinationSelection( + isTopLevelDestination: Boolean, + destinationPathParameterValues: Map, +): DynamicCollectionDestinationSelectionPlan = DynamicCollectionDestinationSelectionPlan( + pathParameterValues = destinationPathParameterValues.toMap(), + clearHierarchyContext = isTopLevelDestination, ) private data class DynamicPaginationState( @@ -3049,22 +4229,117 @@ private fun String.isMailNavigationAncestor(): Boolean = dynamicResourceWords(). ) } -private fun NativeRecord.dynamicContextSubtitle(view: ViewSpec, resourceName: String?): String { - val title = listOf("name", "title", "displayName", "subject", "what", "merchant", "label", "description") +internal fun shouldShowDynamicContextDestinationMenu(destinationIds: List): Boolean = + destinationIds.filter(String::isNotBlank).distinct().size >= 2 + +private fun NativeRecord.dynamicContextNavigationToken(resourceId: String): String = + "$resourceId\u0000$id" + +private fun NativeRecord.dynamicContextLabel(): String = + listOf("name", "title", "displayName", "subject", "what", "merchant", "label", "description") .firstNotNullOfOrNull { key -> (displayValues[key] ?: values[key])?.takeIf(String::isNotBlank) } ?: id + +private fun NativeRecord.dynamicContextSubtitle(view: ViewSpec, resourceName: String?): String { + val title = dynamicContextLabel() val section = view.dynamicNavigationLabel(resourceName.orEmpty()).takeIf(String::isNotBlank) return listOfNotNull(title, section?.takeUnless { it.equals(title, ignoreCase = true) }).joinToString(" · ") } +@Composable +internal fun DynamicContextDestinationMenu( + recordLabel: String, + destinations: List>, + schema: NativeAppSchema, + onDestinationSelected: (DynamicNavigationDestination, ViewSpec) -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxWidth().padding( + start = NextcloudSpacing.XLarge, + top = NextcloudSpacing.Large, + end = NextcloudSpacing.XLarge, + bottom = NextcloudSpacing.Medium, + ), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + Text( + "Choose a section", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Go directly to the part of $recordLabel you need.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + contentPadding = PaddingValues( + start = NextcloudSpacing.XLarge, + top = NextcloudSpacing.Small, + end = NextcloudSpacing.XLarge, + bottom = NextcloudSpacing.XXLarge, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + items(destinations, key = { (_, destination) -> destination.id }) { (planned, destination) -> + val view = schema.views.singleOrNull { candidate -> candidate.id == destination.id } + ?: return@items + val resourceLabel = schema.resource(view.resourceId)?.name + ?.takeUnless { name -> name.equals(destination.label, ignoreCase = true) } + NextcloudAppTile( + title = destination.label, + icon = view.dynamicCollectionNavigationIcon(), + supportingText = resourceLabel, + onClick = { onDestinationSelected(planned, view) }, + modifier = Modifier.fillMaxWidth().height(140.dp), + accessibilityId = destination.accessibilityId, + accessibilityDescription = "Open destination ${destination.accessibilityId}", + ) + } + } + } +} + private fun ViewSpec.dynamicRootSubtitle(appName: String): String = when (component) { NativeComponent.form -> dynamicActionLabel() NativeComponent.detail -> "Overview" else -> dynamicNavigationLabel(appName) } +private fun ViewSpec.dynamicCollectionNavigationIcon(): ImageVector = when (component) { + NativeComponent.fileBrowser -> NextcloudIcons.Folder + NativeComponent.mediaGrid, + NativeComponent.mediaLibrary, + -> NextcloudIcons.Photo + + NativeComponent.calendar, + NativeComponent.timeline, + -> NextcloudIcons.Calendar + + NativeComponent.board -> NextcloudIcons.Board + NativeComponent.mailbox -> NextcloudIcons.Mail + NativeComponent.taskList -> NextcloudIcons.Task + NativeComponent.dataTable -> NextcloudIcons.Table + NativeComponent.recipeList -> NextcloudIcons.Recipe + NativeComponent.form, + NativeComponent.documentEditor, + -> NextcloudIcons.Edit + + NativeComponent.detail -> NextcloudIcons.Info + NativeComponent.dashboard -> NextcloudIcons.Home + NativeComponent.collectionList, + NativeComponent.contactList, + NativeComponent.conversationList, + NativeComponent.chatThread, + -> NextcloudIcons.ListView +} + private fun ViewSpec.dynamicNavigationLabel(appName: String): String { val cleaned = title .removePrefix("API ") @@ -3103,6 +4378,55 @@ private fun NativeRecord.toDynamicRuntimeValues(): Map = buildMa putAll(actionBindingValues(allowUnsafeIdentity = true)) } +/** + * Qualifies a selected parent identity with the exact path-parameter name declared by the active + * action. This lets any nested resource supply its parent identity to a declared child action even + * when a restored form no longer carries the navigation planner's transient parameter map. + */ +internal fun selectedDynamicRecordRuntimeValues( + record: NativeRecord?, + resourceId: String?, + parameterNames: List, +): Map? { + record ?: return emptyMap() + val runtimeValues = record.safeActionBindingValues() ?: return null + val parentResourceId = resourceId?.takeIf(String::isNotBlank) ?: return runtimeValues + val identity = record.id.takeIf { record.actionSafeIdentity && it.isNotBlank() } ?: return runtimeValues + val qualifiedParentValues = buildMap { + parameterNames.forEach { parameterName -> + val resourceStem = parameterName + .takeIf { it.length > 2 && it.endsWith("Id", ignoreCase = true) } + ?.dropLast(2) + ?: return@forEach + if (resourceStem.sameDynamicResourceAs(parentResourceId)) { + put(parameterName, identity) + } + } + } + return safeActionBindingValues(runtimeValues, qualifiedParentValues) +} + +/** + * Makes only the active form action's declared technical parameters available to form binding. + * + * The action executor already receives these qualified values. Supplying the same bounded subset + * to the renderer keeps its preflight validation aligned with execution without exposing the + * selected parent's ordinary fields as child-form defaults. + */ +internal fun dynamicDatasetBindingValues( + component: NativeComponent, + declaredParameterNames: List, + selectedPathParameterValues: Map, + runtimeValues: Map, +): Map { + if (component != NativeComponent.form) return selectedPathParameterValues + val declaredNames = declaredParameterNames.toSet() + return ( + runtimeValues.filterKeys(declaredNames::contains) + + selectedPathParameterValues.filterKeys(declaredNames::contains) + ) +} + internal fun inheritDynamicParentParameters( selectedPathParameterValues: Map, runtimeValues: Map, @@ -6496,7 +7820,17 @@ private fun PersonMediaScreen( var error by remember(person.id, person.backend) { mutableStateOf(null) } var loadAttempt by remember(person.id, person.backend) { mutableStateOf(0) } var actionMenuExpanded by remember(person.id) { mutableStateOf(false) } - var showFaceRectangles by rememberSaveable(currentUserId, person.id, person.backend) { mutableStateOf(false) } + // Server info is loaded again after Android restores the activity. During that window, + // currentUserId falls back to the login name and can then change to the canonical user ID. + // Keep saveable workflow state keyed to the stable session identity instead. + var showFaceRectangles by rememberSaveable( + session.serverUrl, + session.loginName, + person.id, + person.backend, + ) { + mutableStateOf(false) + } var photoSelectionMode by remember(person.id) { mutableStateOf(null) } var renameDialogVisible by remember(person.id) { mutableStateOf(false) } var renameDraft by remember(person.id) { mutableStateOf(person.name) } @@ -6510,7 +7844,8 @@ private fun PersonMediaScreen( var pendingMergeWorkflow by remember(person.id) { mutableStateOf(null) } var pendingPlan by remember(person.id) { mutableStateOf(null) } var postMutationRefreshExpectationState by rememberSaveable( - currentUserId, + session.serverUrl, + session.loginName, person.id, person.backend, ) { @@ -6519,7 +7854,12 @@ private fun PersonMediaScreen( val postMutationRefreshExpectation = remember(postMutationRefreshExpectationState) { postMutationRefreshExpectationState?.let(::decodePeoplePostMutationExpectation) } - var postMutationRefreshAttempt by rememberSaveable(currentUserId, person.id, person.backend) { + var postMutationRefreshAttempt by rememberSaveable( + session.serverUrl, + session.loginName, + person.id, + person.backend, + ) { mutableStateOf(0) } var postMutationRefreshRunning by remember(person.id, person.backend) { mutableStateOf(false) } @@ -9111,3 +10451,5 @@ private fun formatBytes(bytes: Long?): String = when { bytes < 1_073_741_824 -> "${bytes / 1_048_576} MB" else -> "${bytes / 1_073_741_824} GB" } + +private const val MAX_DYNAMIC_BATCH_RELATION_ERROR_LENGTH = 1_024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 6eee75ce..588ddaf8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -173,6 +173,7 @@ data class NextcloudApiRequest( val ocsApiRequest: Boolean = false, val maximumResponseBytes: Long = DEFAULT_DYNAMIC_API_RESPONSE_LIMIT_BYTES, val cachePolicy: NextcloudApiCachePolicy = NextcloudApiCachePolicy.PreferCache, + val multipartBody: NextcloudMultipartBody? = null, ) data class NextcloudApiResponse( @@ -180,7 +181,11 @@ data class NextcloudApiResponse( val body: ByteArray, val contentType: String?, val etag: String?, - /** Present only when redirects are disabled and the server returned a Location header. */ + /** + * Present only when redirects are disabled and the server returned a safe Location header. + * Authenticated platform transports resolve same-account redirects and expose only the + * account-relative path; cross-origin and out-of-account locations are withheld. + */ val location: String? = null, ) @@ -1293,6 +1298,15 @@ fun NextcloudApiRequest.requireSafe(): NextcloudApiRequest { "Dynamic API response limit is outside the allowed range." } require(contentType == null || contentType.length <= 160) { "Dynamic API content type is invalid." } + multipartBody?.let { multipart -> + require(method in setOf(NextcloudApiMethod.POST, NextcloudApiMethod.PUT, NextcloudApiMethod.PATCH)) { + "Multipart uploads require POST, PUT, or PATCH." + } + require(body == null && contentType == null) { + "A typed multipart body cannot be combined with a raw request body or content type." + } + multipart.requireSafe() + } return this } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt index 4133d4c0..3fe488be 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt @@ -39,7 +39,7 @@ fun resolveNextcloudRootShellLayout( destination: NextcloudDestination, ): NextcloudRootShellLayout = when (presentation) { NextcloudPresentation.Adaptive -> { - if (availableWidthDp < AdaptiveRailBreakpointDp) { + if (availableWidthDp < NextcloudWorkspaceBreakpoints.AdaptiveRailDp) { NextcloudRootShellLayout( navigationStyle = NextcloudNavigationStyle.BottomBar, navigationWidthDp = 0, @@ -62,7 +62,7 @@ fun resolveNextcloudRootShellLayout( } NextcloudPresentation.Desktop -> { - val expanded = availableWidthDp >= DesktopSidebarBreakpointDp + val expanded = availableWidthDp >= NextcloudWorkspaceBreakpoints.DesktopSidebarDp NextcloudRootShellLayout( navigationStyle = if (expanded) { NextcloudNavigationStyle.ExpandedSidebar @@ -79,9 +79,7 @@ fun resolveNextcloudRootShellLayout( } } -private const val AdaptiveRailBreakpointDp = 600 private const val AdaptiveRailWidthDp = 88 -private const val DesktopSidebarBreakpointDp = 900 private const val DesktopAuxiliaryPaneBreakpointDp = 1_180 private const val DesktopSidebarWidthDp = 252 private const val DesktopRailWidthDp = 76 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt index 85cb576b..11a92ea4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt @@ -11,6 +11,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp /** @@ -22,6 +24,7 @@ import androidx.compose.ui.unit.dp */ data class NextcloudCardAction( val label: String, + val semanticId: String? = null, val destructive: Boolean = false, val enabled: Boolean = true, val onClick: () -> Unit, @@ -67,9 +70,17 @@ fun NextcloudCardOverflow( DropdownMenu( expanded = expanded, onDismissRequest = { onExpandedChange(false) }, + modifier = Modifier.semantics { + contentDescription = "Record actions for $itemLabel" + }, ) { actions.forEach { action -> DropdownMenuItem( + modifier = action.semanticId?.let { semanticId -> + Modifier.semantics(mergeDescendants = true) { + contentDescription = "Run record action $semanticId" + } + } ?: Modifier.semantics(mergeDescendants = true) {}, text = { Text( action.label, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt new file mode 100644 index 00000000..eb13fc2b --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt @@ -0,0 +1,770 @@ +package dev.obiente.nextcloudnative.app.design + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Badge +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +enum class NextcloudCollectionNavigationMode { + Hidden, + Drawer, + Rail, + Sidebar, +} + +enum class NextcloudCollectionNavigationHost { + AdaptiveAndroid, + Desktop, +} + +enum class NextcloudCollectionLeadingControl { + Back, + Menu, +} + +enum class NextcloudCollectionNavigationMove { + Previous, + Next, + First, + Last, +} + +@Immutable +data class NextcloudCollectionDestination( + val id: String, + val label: String, + val count: Int? = null, + val accessibilityId: String = id, +) { + init { + require(id.isNotBlank()) { "Collection destination IDs must not be blank." } + require(label.isNotBlank()) { "Collection destination labels must not be blank." } + require(count == null || count >= 0) { "Collection destination counts must not be negative." } + require(accessibilityId.isNotBlank()) { + "Collection destination accessibility IDs must not be blank." + } + } +} + +internal fun NextcloudCollectionDestination.accessibilityDescription(): String = + "Open destination $label" + +internal fun NextcloudCollectionDestination.automationTestTag(): String = + "collection-destination:$accessibilityId:$id" + +/** + * Immutable collection navigation state. Selection callbacks remain in the host instead of the + * destination data so restored route state cannot retain stale lambdas. + */ +@Immutable +class NextcloudCollectionNavigationModel private constructor( + destinations: List, + val selectedDestinationId: String?, +) { + val destinations: List = destinations.toList() + + val selectedDestination: NextcloudCollectionDestination? + get() = selectedDestinationId?.let { selectedId -> + this.destinations.first { destination -> destination.id == selectedId } + } + + init { + require(this.destinations.map(NextcloudCollectionDestination::id).distinct().size == this.destinations.size) { + "Collection destination IDs must be unique." + } + if (selectedDestinationId != null) { + require(this.destinations.any { destination -> destination.id == selectedDestinationId }) { + "The selected collection destination must exist in the model." + } + } + } + + companion object { + fun create( + destinations: List, + selectedDestinationId: String?, + ): NextcloudCollectionNavigationModel = NextcloudCollectionNavigationModel( + destinations = destinations, + selectedDestinationId = selectedDestinationId, + ) + } +} + +/** + * Resolves keyboard focus movement without changing the active destination. + * + * Navigation can legitimately have no selected primary destination while a contextual or + * secondary view is active. In that state, forward movement begins at the first destination and + * backward movement begins at the last destination without falsely selecting either one. + */ +fun resolveNextcloudCollectionKeyboardDestination( + model: NextcloudCollectionNavigationModel, + focusedDestinationId: String?, + move: NextcloudCollectionNavigationMove, +): NextcloudCollectionDestination? { + if (model.destinations.isEmpty()) return null + + val currentIndex = focusedDestinationId + ?.let { destinationId -> + model.destinations.indexOfFirst { destination -> destination.id == destinationId } + } + ?.takeIf { index -> index >= 0 } + ?: model.selectedDestination + ?.let { selected -> model.destinations.indexOf(selected) } + ?.takeIf { index -> index >= 0 } + + val targetIndex = when (move) { + NextcloudCollectionNavigationMove.First -> 0 + NextcloudCollectionNavigationMove.Last -> model.destinations.lastIndex + NextcloudCollectionNavigationMove.Previous -> + currentIndex?.let { index -> (index - 1).floorMod(model.destinations.size) } + ?: model.destinations.lastIndex + + NextcloudCollectionNavigationMove.Next -> + currentIndex?.let { index -> (index + 1).floorMod(model.destinations.size) } + ?: 0 + } + return model.destinations[targetIndex] +} + +fun resolveNextcloudCollectionNavigationMode( + host: NextcloudCollectionNavigationHost, + availableWidthDp: Int, + destinationCount: Int, +): NextcloudCollectionNavigationMode { + require(availableWidthDp >= 0) { "Available width must not be negative." } + require(destinationCount >= 0) { "Destination count must not be negative." } + if (destinationCount <= 1) return NextcloudCollectionNavigationMode.Hidden + + return when (host) { + NextcloudCollectionNavigationHost.AdaptiveAndroid -> + if (availableWidthDp < NextcloudWorkspaceBreakpoints.AdaptiveRailDp) { + NextcloudCollectionNavigationMode.Drawer + } else { + NextcloudCollectionNavigationMode.Rail + } + + NextcloudCollectionNavigationHost.Desktop -> + if (availableWidthDp < NextcloudWorkspaceBreakpoints.DesktopSidebarDp) { + NextcloudCollectionNavigationMode.Rail + } else { + NextcloudCollectionNavigationMode.Sidebar + } + } +} + +fun resolveNextcloudCollectionLeadingControl( + mode: NextcloudCollectionNavigationMode, + hasHierarchyBack: Boolean, +): NextcloudCollectionLeadingControl = when { + mode == NextcloudCollectionNavigationMode.Drawer -> + NextcloudCollectionLeadingControl.Menu + + else -> NextcloudCollectionLeadingControl.Back +} + +fun shouldShowNextcloudCollectionTrailingNavigation( + mode: NextcloudCollectionNavigationMode, + hasHierarchyBack: Boolean, +): Boolean = false + +internal fun resolveNextcloudCollectionDestinationLabelMaxLines( + mode: NextcloudCollectionNavigationMode, +): Int = when (mode) { + NextcloudCollectionNavigationMode.Drawer, + NextcloudCollectionNavigationMode.Sidebar -> 2 + + NextcloudCollectionNavigationMode.Hidden, + NextcloudCollectionNavigationMode.Rail -> 1 +} + +/** + * Owns collection navigation and the single contextual header for a native app workspace. + * + * Compact Android uses a drawer. Large Android and narrow desktop windows use a rail. Wide + * desktop windows use a persistent 252 dp sidebar. + */ +@Composable +fun NextcloudCollectionWorkspaceScaffold( + model: NextcloudCollectionNavigationModel, + mode: NextcloudCollectionNavigationMode, + title: String, + subtitle: String?, + onBack: () -> Unit, + hasHierarchyBack: Boolean, + onDestinationSelected: (NextcloudCollectionDestination) -> Unit, + modifier: Modifier = Modifier, + compactHeader: Boolean = false, + destinationIcon: (NextcloudCollectionDestination) -> ImageVector? = { null }, + headerActions: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + when (mode) { + NextcloudCollectionNavigationMode.Drawer -> NextcloudCollectionDrawerScaffold( + model = model, + title = title, + subtitle = subtitle, + onBack = onBack, + hasHierarchyBack = hasHierarchyBack, + onDestinationSelected = onDestinationSelected, + destinationIcon = destinationIcon, + compactHeader = compactHeader, + headerActions = headerActions, + modifier = modifier, + content = content, + ) + + NextcloudCollectionNavigationMode.Hidden -> NextcloudCollectionMainPane( + title = title, + subtitle = subtitle, + onBack = onBack, + leadingControl = resolveNextcloudCollectionLeadingControl(mode, hasHierarchyBack), + onOpenNavigation = null, + compactHeader = compactHeader, + headerActions = headerActions, + modifier = modifier, + content = content, + ) + + NextcloudCollectionNavigationMode.Rail -> Row(modifier.fillMaxSize()) { + NextcloudCollectionNavigationRail( + model = model, + onDestinationSelected = onDestinationSelected, + destinationIcon = destinationIcon, + ) + NextcloudCollectionMainPane( + title = title, + subtitle = subtitle, + onBack = onBack, + leadingControl = resolveNextcloudCollectionLeadingControl(mode, hasHierarchyBack), + onOpenNavigation = null, + compactHeader = compactHeader, + headerActions = headerActions, + modifier = Modifier.weight(1f), + content = content, + ) + } + + NextcloudCollectionNavigationMode.Sidebar -> Row(modifier.fillMaxSize()) { + NextcloudCollectionNavigationSidebar( + model = model, + label = title, + onDestinationSelected = onDestinationSelected, + destinationIcon = destinationIcon, + ) + NextcloudCollectionMainPane( + title = title, + subtitle = subtitle, + onBack = onBack, + leadingControl = resolveNextcloudCollectionLeadingControl(mode, hasHierarchyBack), + onOpenNavigation = null, + compactHeader = compactHeader, + headerActions = headerActions, + modifier = Modifier.weight(1f), + content = content, + ) + } + } +} + +@Composable +private fun NextcloudCollectionDrawerScaffold( + model: NextcloudCollectionNavigationModel, + title: String, + subtitle: String?, + onBack: () -> Unit, + hasHierarchyBack: Boolean, + onDestinationSelected: (NextcloudCollectionDestination) -> Unit, + destinationIcon: (NextcloudCollectionDestination) -> ImageVector?, + compactHeader: Boolean, + headerActions: @Composable () -> Unit, + modifier: Modifier, + content: @Composable () -> Unit, +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + val leadingControl = resolveNextcloudCollectionLeadingControl( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = hasHierarchyBack, + ) + + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = true, + drawerContent = { + ModalDrawerSheet(modifier = Modifier.width(NextcloudCollectionDrawerWidthDp.dp)) { + Text( + text = title, + modifier = Modifier + .padding( + horizontal = NextcloudSpacing.Large, + vertical = NextcloudSpacing.Medium, + ) + .semantics { heading() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + NextcloudCollectionDestinationList( + model = model, + onDestinationSelected = { destination -> + onDestinationSelected(destination) + coroutineScope.launch { drawerState.close() } + }, + destinationIcon = destinationIcon, + labelMaxLines = resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Drawer, + ), + modifier = Modifier + .weight(1f) + .padding(NextcloudSpacing.Small), + ) + } + }, + modifier = modifier, + ) { + NextcloudCollectionMainPane( + title = title, + subtitle = subtitle, + onBack = { + if (drawerState.isOpen) { + coroutineScope.launch { drawerState.close() } + } else { + onBack() + } + }, + leadingControl = leadingControl, + onOpenNavigation = { coroutineScope.launch { drawerState.open() } }, + showHierarchyBack = hasHierarchyBack, + compactHeader = compactHeader, + headerActions = headerActions, + content = content, + ) + } +} + +@Composable +private fun NextcloudCollectionMainPane( + title: String, + subtitle: String?, + onBack: () -> Unit, + leadingControl: NextcloudCollectionLeadingControl, + onOpenNavigation: (() -> Unit)?, + showHierarchyBack: Boolean = false, + compactHeader: Boolean, + headerActions: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column(modifier.fillMaxSize()) { + NextcloudCollectionHeader( + title = title, + subtitle = subtitle, + onBack = onBack, + leadingControl = leadingControl, + onOpenNavigation = onOpenNavigation, + showHierarchyBack = showHierarchyBack, + compact = compactHeader, + actions = headerActions, + ) + Box(Modifier.weight(1f).fillMaxWidth()) { + content() + } + } +} + +@Composable +private fun NextcloudCollectionHeader( + title: String, + subtitle: String?, + onBack: () -> Unit, + leadingControl: NextcloudCollectionLeadingControl, + onOpenNavigation: (() -> Unit)?, + showHierarchyBack: Boolean, + compact: Boolean, + actions: @Composable () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = if (compact) 54.dp else 64.dp) + .padding(horizontal = NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + when (leadingControl) { + NextcloudCollectionLeadingControl.Back -> IconButton( + onClick = onBack, + modifier = Modifier.size(NextcloudCollectionMinimumTouchTargetDp.dp), + ) { + Icon(NextcloudIcons.Back, contentDescription = "Back") + } + + NextcloudCollectionLeadingControl.Menu -> IconButton( + onClick = requireNotNull(onOpenNavigation), + modifier = Modifier.size(NextcloudCollectionMinimumTouchTargetDp.dp), + ) { + Icon(NextcloudIcons.Menu, contentDescription = "Open sections") + } + } + if (showHierarchyBack) { + IconButton( + onClick = onBack, + modifier = Modifier.size(NextcloudCollectionMinimumTouchTargetDp.dp), + ) { + Icon(NextcloudIcons.Back, contentDescription = "Back") + } + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = NextcloudSpacing.Small), + ) { + Text( + text = title, + modifier = Modifier.semantics { heading() }, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + subtitle?.let { supportingText -> + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + actions() + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + +@Composable +private fun NextcloudCollectionNavigationRail( + model: NextcloudCollectionNavigationModel, + onDestinationSelected: (NextcloudCollectionDestination) -> Unit, + destinationIcon: (NextcloudCollectionDestination) -> ImageVector?, +) { + val focusRequesters = rememberNextcloudCollectionFocusRequesters(model) + var focusedDestinationId by remember(model.destinations) { mutableStateOf(null) } + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + Row { + NavigationRail( + modifier = Modifier + .width(NextcloudCollectionRailWidthDp.dp) + .fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.background, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nextcloudCollectionKeyboardNavigation( + model = model, + focusedDestinationId = focusedDestinationId, + focusRequesters = focusRequesters, + listState = listState, + coroutineScope = coroutineScope, + ) + .selectableGroup(), + state = listState, + ) { + items( + items = model.destinations, + key = NextcloudCollectionDestination::id, + ) { destination -> + val focusRequester = rememberNextcloudCollectionFocusRequester( + destinationId = destination.id, + registry = focusRequesters, + ) + NavigationRailItem( + selected = destination.id == model.selectedDestinationId, + onClick = { onDestinationSelected(destination) }, + modifier = Modifier + .heightIn(min = NextcloudCollectionMinimumTouchTargetDp.dp) + .semantics { + contentDescription = destination.accessibilityDescription() + } + .testTag(destination.automationTestTag()) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) focusedDestinationId = destination.id + }, + icon = { + destinationIcon(destination)?.let { icon -> + Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp)) + } + }, + label = { + Text(destination.label, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + alwaysShowLabel = true, + ) + } + } + } + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +@Composable +private fun NextcloudCollectionNavigationSidebar( + model: NextcloudCollectionNavigationModel, + label: String, + onDestinationSelected: (NextcloudCollectionDestination) -> Unit, + destinationIcon: (NextcloudCollectionDestination) -> ImageVector?, +) { + Row { + Column( + modifier = Modifier + .width(NextcloudCollectionSidebarWidthDp.dp) + .fillMaxHeight() + .padding(NextcloudSpacing.Medium), + ) { + Text( + text = label, + modifier = Modifier + .padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.Medium, + ) + .semantics { heading() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + NextcloudCollectionDestinationList( + model = model, + onDestinationSelected = onDestinationSelected, + destinationIcon = destinationIcon, + labelMaxLines = resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Sidebar, + ), + modifier = Modifier.weight(1f), + ) + } + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +@Composable +private fun NextcloudCollectionDestinationList( + model: NextcloudCollectionNavigationModel, + onDestinationSelected: (NextcloudCollectionDestination) -> Unit, + destinationIcon: (NextcloudCollectionDestination) -> ImageVector?, + labelMaxLines: Int, + modifier: Modifier = Modifier, +) { + val focusRequesters = rememberNextcloudCollectionFocusRequesters(model) + var focusedDestinationId by remember(model.destinations) { mutableStateOf(null) } + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LazyColumn( + modifier = modifier + .fillMaxWidth() + .nextcloudCollectionKeyboardNavigation( + model = model, + focusedDestinationId = focusedDestinationId, + focusRequesters = focusRequesters, + listState = listState, + coroutineScope = coroutineScope, + ) + .selectableGroup(), + state = listState, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + items( + items = model.destinations, + key = NextcloudCollectionDestination::id, + ) { destination -> + val focusRequester = rememberNextcloudCollectionFocusRequester( + destinationId = destination.id, + registry = focusRequesters, + ) + NavigationDrawerItem( + label = { + Text( + text = destination.label, + maxLines = labelMaxLines, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = destination.id == model.selectedDestinationId, + onClick = { onDestinationSelected(destination) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = NextcloudCollectionMinimumTouchTargetDp.dp) + .semantics { + contentDescription = destination.accessibilityDescription() + } + .testTag(destination.automationTestTag()) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused) focusedDestinationId = destination.id + }, + icon = destinationIcon(destination)?.let { imageVector -> + { + Icon(imageVector, contentDescription = null, modifier = Modifier.size(24.dp)) + } + }, + badge = destination.count?.let { count -> + { + Badge { + Text(count.toString()) + } + } + }, + ) + } + } +} + +internal class NextcloudCollectionComposedDestinationRegistry { + private val values = mutableMapOf() + + val retainedCount: Int + get() = values.size + + operator fun get(destinationId: String): T? = values[destinationId] + + fun attach(destinationId: String, value: T) { + values[destinationId] = value + } + + fun detach(destinationId: String, value: T) { + if (values[destinationId] === value) values.remove(destinationId) + } +} + +@Composable +private fun rememberNextcloudCollectionFocusRequesters( + model: NextcloudCollectionNavigationModel, +): NextcloudCollectionComposedDestinationRegistry = remember(model.destinations) { + NextcloudCollectionComposedDestinationRegistry() +} + +@Composable +private fun rememberNextcloudCollectionFocusRequester( + destinationId: String, + registry: NextcloudCollectionComposedDestinationRegistry, +): FocusRequester { + val focusRequester = remember(destinationId) { FocusRequester() } + DisposableEffect(registry, destinationId, focusRequester) { + registry.attach(destinationId, focusRequester) + onDispose { + registry.detach(destinationId, focusRequester) + } + } + return focusRequester +} + +private fun Modifier.nextcloudCollectionKeyboardNavigation( + model: NextcloudCollectionNavigationModel, + focusedDestinationId: String?, + focusRequesters: NextcloudCollectionComposedDestinationRegistry, + listState: LazyListState, + coroutineScope: CoroutineScope, +): Modifier = onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + val move = when (event.key) { + Key.DirectionUp -> NextcloudCollectionNavigationMove.Previous + Key.DirectionDown -> NextcloudCollectionNavigationMove.Next + Key.MoveHome -> NextcloudCollectionNavigationMove.First + Key.MoveEnd -> NextcloudCollectionNavigationMove.Last + else -> return@onPreviewKeyEvent false + } + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = focusedDestinationId, + move = move, + )?.let { destination -> + val targetIndex = model.destinations.indexOfFirst { candidate -> candidate.id == destination.id } + if (targetIndex < 0) return@onPreviewKeyEvent false + coroutineScope.launch { + listState.scrollToItem(targetIndex) + repeat(NextcloudCollectionFocusAttachmentFrameLimit) { + withFrameNanos { } + focusRequesters[destination.id]?.let { focusRequester -> + focusRequester.requestFocus() + return@launch + } + } + } + true + } ?: false +} + +private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus + +private const val NextcloudCollectionMinimumTouchTargetDp = 48 +private const val NextcloudCollectionDrawerWidthDp = 320 +private const val NextcloudCollectionRailWidthDp = 88 +private const val NextcloudCollectionSidebarWidthDp = 252 +private const val NextcloudCollectionFocusAttachmentFrameLimit = 2 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt index 67e5eca9..c7a08ed0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -162,11 +164,19 @@ fun NextcloudAppTile( modifier: Modifier = Modifier, supportingText: String? = null, enabled: Boolean = true, + accessibilityId: String = title, + accessibilityDescription: String = "Open app $accessibilityId", ) { + require(accessibilityId.isNotBlank()) { "App tile accessibility IDs must not be blank." } + require(accessibilityDescription.isNotBlank()) { + "App tile accessibility descriptions must not be blank." + } val dense = LocalNextcloudWorkspaceCapabilities.current.usesDenseControls Card( onClick = onClick, - modifier = modifier, + modifier = modifier.semantics { + contentDescription = accessibilityDescription + }, enabled = enabled, colors = CardDefaults.cardColors( containerColor = NextcloudTheme.colors.appTile, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt index 40f56c5a..49a2e7c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt @@ -7,14 +7,27 @@ import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.automirrored.outlined.DirectionsRun +import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.AcUnit import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Agriculture import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.AttachMoney +import androidx.compose.material.icons.outlined.BakeryDining +import androidx.compose.material.icons.outlined.BeachAccess +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material.icons.outlined.BusinessCenter import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.CardGiftcard +import androidx.compose.material.icons.outlined.Chair +import androidx.compose.material.icons.outlined.ChildCare +import androidx.compose.material.icons.outlined.Checkroom import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.Checklist @@ -22,44 +35,91 @@ import androidx.compose.material.icons.outlined.CleaningServices import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Contacts +import androidx.compose.material.icons.outlined.Cookie import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.Diamond +import androidx.compose.material.icons.outlined.DinnerDining +import androidx.compose.material.icons.outlined.DirectionsCar import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.DragIndicator import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Eco import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.FaceRetouchingNatural import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.Fastfood +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.FitnessCenter import androidx.compose.material.icons.outlined.FormatBold import androidx.compose.material.icons.outlined.FormatItalic import androidx.compose.material.icons.outlined.FormatQuote import androidx.compose.material.icons.outlined.GridView +import androidx.compose.material.icons.outlined.HealthAndSafety import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Icecream import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Inventory2 import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.KebabDining +import androidx.compose.material.icons.outlined.Laptop import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Lightbulb import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.LocalCafe +import androidx.compose.material.icons.outlined.LocalFlorist +import androidx.compose.material.icons.outlined.LocalGasStation +import androidx.compose.material.icons.outlined.LocalDrink +import androidx.compose.material.icons.outlined.LocalPizza +import androidx.compose.material.icons.outlined.LocalPharmacy +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.LunchDining +import androidx.compose.material.icons.outlined.Liquor import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.MusicNote +import androidx.compose.material.icons.outlined.Medication import androidx.compose.material.icons.outlined.NoteAlt import androidx.compose.material.icons.outlined.NotificationsNone +import androidx.compose.material.icons.outlined.OutdoorGrill import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Park +import androidx.compose.material.icons.outlined.PedalBike import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Pets import androidx.compose.material.icons.outlined.PeopleOutline import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Restaurant import androidx.compose.material.icons.outlined.RestaurantMenu import androidx.compose.material.icons.outlined.Savings +import androidx.compose.material.icons.outlined.School import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.SetMeal import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.ShoppingBasket +import androidx.compose.material.icons.outlined.ShoppingCart +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.SportsSoccer +import androidx.compose.material.icons.outlined.Smartphone import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material.icons.outlined.TableChart +import androidx.compose.material.icons.outlined.Tapas import androidx.compose.material.icons.outlined.TaskAlt +import androidx.compose.material.icons.outlined.Storefront import androidx.compose.material.icons.outlined.Title +import androidx.compose.material.icons.outlined.Toys import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.material.icons.outlined.ViewKanban +import androidx.compose.material.icons.outlined.Warehouse +import androidx.compose.material.icons.outlined.Web import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.SkipNext @@ -83,9 +143,11 @@ object NextcloudIcons { val Settings: ImageVector = Icons.Outlined.Settings val Back: ImageVector = Icons.AutoMirrored.Outlined.ArrowBack val More: ImageVector = Icons.Outlined.MoreHoriz + val Menu: ImageVector = Icons.Outlined.Menu val Drag: ImageVector = Icons.Outlined.DragIndicator val Add: ImageVector = Icons.Outlined.Add val ChevronRight: ImageVector = Icons.Outlined.ChevronRight + val ExpandMore: ImageVector = Icons.Outlined.ExpandMore val Refresh: ImageVector = Icons.Outlined.Refresh val Error: ImageVector = Icons.Outlined.ErrorOutline val Cloud: ImageVector = Icons.Outlined.Cloud @@ -100,6 +162,11 @@ object NextcloudIcons { val Image: ImageVector = Icons.Outlined.Image val Video: ImageVector = Icons.Outlined.VideoLibrary val ListView: ImageVector = Icons.AutoMirrored.Outlined.ViewList + val Board: ImageVector = Icons.Outlined.ViewKanban + val Mail: ImageVector = Icons.Outlined.Email + val Task: ImageVector = Icons.Outlined.TaskAlt + val Table: ImageVector = Icons.Outlined.TableChart + val Recipe: ImageVector = Icons.Outlined.RestaurantMenu val Edit: ImageVector = Icons.Outlined.Edit val Save: ImageVector = Icons.Outlined.Save val Info: ImageVector = Icons.Outlined.Info @@ -160,4 +227,99 @@ object NextcloudIcons { "forms" -> Icons.Outlined.Checklist else -> Icons.Outlined.Apps } + + /** + * Resolves bounded record-provided icon keys to bundled native vectors. Unknown values return + * null; they are never interpreted as URLs, file paths, CSS classes, or remote assets. + */ + fun semantic(iconKey: String): ImageVector? = when (iconKey.normalizedSemanticIconKey()) { + "clipboard-check", "format-list-checks", "checklist", "task", "todo" -> FormatChecklist + "clipboard-list", "list" -> ListView + "cart", "shopping" -> Icons.Outlined.ShoppingCart + "supermarket", "market" -> Icons.Outlined.Storefront + "basket" -> Icons.Outlined.ShoppingBasket + "produce" -> Icons.Outlined.Agriculture + "food" -> Icons.Outlined.Fastfood + "bakery" -> Icons.Outlined.BakeryDining + "dairy" -> Icons.Outlined.Icecream + "meat" -> Icons.Outlined.OutdoorGrill + "fish" -> Icons.Outlined.SetMeal + "snacks" -> Icons.Outlined.Tapas + "cookie" -> Icons.Outlined.Cookie + "drinks" -> Icons.Outlined.LocalDrink + "frozen" -> Icons.Outlined.AcUnit + "silverware" -> Icons.Outlined.Restaurant + "deli" -> Icons.Outlined.LunchDining + "butcher" -> Icons.Outlined.KebabDining + "seafood" -> Icons.Outlined.DinnerDining + "coffee" -> Icons.Outlined.LocalCafe + "pizza" -> Icons.Outlined.LocalPizza + "home", "household" -> Home + "homegoods" -> Icons.Outlined.Inventory2 + "furniture" -> Icons.Outlined.Chair + "pets", "paw" -> Icons.Outlined.Pets + "baby" -> Icons.Outlined.ChildCare + "leaf", "fruit" -> Icons.Outlined.Eco + "vegetable" -> Icons.Outlined.Agriculture + "flower", "florist" -> Icons.Outlined.LocalFlorist + "tree", "garden" -> Icons.Outlined.Park + "star" -> Favorite + "heart" -> Icons.Outlined.FavoriteBorder + "calendar" -> Calendar + "bell" -> Activity + "flag" -> Icons.Outlined.Flag + "bookmark" -> Icons.Outlined.BookmarkBorder + "pin", "map-marker", "location" -> Icons.Outlined.LocationOn + "briefcase", "office" -> Icons.Outlined.BusinessCenter + "wrench", "tools", "hardware" -> Icons.Outlined.Build + "gift" -> Icons.Outlined.CardGiftcard + "book", "books" -> Icons.AutoMirrored.Outlined.MenuBook + "school" -> Icons.Outlined.School + "palette" -> Icons.Outlined.Palette + "camera" -> Icons.Outlined.PhotoCamera + "music", "audio" -> Icons.Outlined.MusicNote + "gamepad" -> Icons.Outlined.SportsEsports + "online" -> Icons.Outlined.Web + "electronics" -> Icons.Outlined.Laptop + "phone" -> Icons.Outlined.Smartphone + "toys" -> Icons.Outlined.Toys + "run" -> Icons.AutoMirrored.Outlined.DirectionsRun + "dumbbell" -> Icons.Outlined.FitnessCenter + "sports" -> Icons.Outlined.SportsSoccer + "pill" -> Icons.Outlined.Medication + "pharmacy" -> Icons.Outlined.LocalPharmacy + "health" -> Icons.Outlined.HealthAndSafety + "broom" -> Icons.Outlined.CleaningServices + "lightbulb" -> Icons.Outlined.Lightbulb + "package" -> Icons.Outlined.Inventory2 + "car", "gas" -> Icons.Outlined.DirectionsCar + "bike" -> Icons.Outlined.PedalBike + "beach" -> Icons.Outlined.BeachAccess + "store", "storefront", "convenience" -> Icons.Outlined.Storefront + "warehouse" -> Icons.Outlined.Warehouse + "clothing", "shoes" -> Icons.Outlined.Checkroom + "jewelry" -> Icons.Outlined.Diamond + "liquor" -> Icons.Outlined.Liquor + "beauty" -> Icons.Outlined.FaceRetouchingNatural + "tag", "category", "label" -> Tag + "note" -> Icons.Outlined.NoteAlt + else -> null + } + + /** + * Resolves a contract icon token without dropping the option. Unknown tokens share one neutral + * bundled glyph so arbitrary strings never become remote assets or invented app-specific art. + */ + fun semanticOrFallback(iconKey: String): ImageVector = semantic(iconKey) ?: Apps + + private fun String.normalizedSemanticIconKey(): String = + trim() + .lowercase() + .map { character -> + if (character.isLetterOrDigit()) character else '-' + } + .joinToString("") + .split('-') + .filter(String::isNotBlank) + .joinToString("-") } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudWorkspaceBreakpoints.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudWorkspaceBreakpoints.kt new file mode 100644 index 00000000..8469efe5 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudWorkspaceBreakpoints.kt @@ -0,0 +1,7 @@ +package dev.obiente.nextcloudnative.app.design + +/** Shared named breakpoints for root and in-workspace navigation. */ +object NextcloudWorkspaceBreakpoints { + const val AdaptiveRailDp: Int = 600 + const val DesktopSidebarDp: Int = 900 +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt index a7079337..1097bb42 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt @@ -169,6 +169,7 @@ data class FormField( val required: Boolean, val format: String? = null, val enumValues: List? = null, + val repeatableObjectInput: RepeatableObjectInputSpec? = null, ) @Serializable @@ -184,10 +185,20 @@ data class DynamicAction( val fallbackActionIds: List = emptyList(), /** True when this action exists only as a runtime fallback and must not become a UI surface. */ val fallbackOnly: Boolean = false, + /** + * Fields declared by this action's successful read-response item schema. + * + * This is deliberately action-local: [DynamicResource.fields] may also contain fields merged + * from mutation request bodies and therefore cannot authorize record identities for writes. + */ + val responseFieldIds: List = emptyList(), + /** Exact same-route GET action used to reconcile an idempotent replacement result. */ + val resultRecoveryActionId: String? = null, val capabilityIds: List = emptyList(), val permissionIds: List = emptyList(), val confidence: Confidence, val provenance: List = emptyList(), + val effect: ActionEffect = ActionEffect.unspecified, ) @Serializable diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt index 0fadbac0..128eabc9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt @@ -13,6 +13,76 @@ private const val READ_FALLBACK_OPERATION_IDS_EXTENSION = "x-nextcloud-native-read-fallback-operation-ids" private const val READ_FALLBACK_FOR_OPERATION_EXTENSION = "x-nextcloud-native-fallback-for-operation-id" +private const val DESCRIPTION_INFERRED_MULTIPART_EXTENSION = + "x-nextcloud-native-description-inferred-multipart" +private const val MAX_MULTIPART_DESCRIPTION_CHARACTERS = 2_048 +private const val MAX_INFERRED_MULTIPART_TEXT_FIELDS = 16 +private const val MAX_SIGNED_DESCRIPTION_ENUM_CHARACTERS = 2_048 +private const val MAX_SIGNED_DESCRIPTION_ENUM_FIELD_LENGTH = 128 +private const val MAX_SIGNED_DESCRIPTION_ENUM_VALUES = 16 +private const val MAX_SIGNED_DESCRIPTION_ENUM_VALUE_LENGTH = 128 + +private val INFERRED_MULTIPART_SCALAR_TYPES = setOf("string", "integer", "number", "boolean") +private val EDITABLE_DYNAMIC_STRING_ARRAY_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "items", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", +) +private val EDITABLE_DYNAMIC_STRING_ARRAY_ITEM_SCHEMA_KEYS = setOf( + "\$comment", + "deprecated", + "description", + "example", + "examples", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", +) +private val FIELD_COMPOSITION_ANNOTATION_KEYS = setOf( + "default", + "deprecated", + "description", + "example", + "examples", + "nullable", + "readOnly", + "title", + "writeOnly", +) +private val MULTIPART_FILE_FIELD_DESCRIPTION = Regex( + """\bmultipart/form-data\b.{0,320}?\b(image|photo|audio|video|binary|file)\s+file\b.{0,160}?\bfield\s+named\s+\*\*([A-Za-z][A-Za-z0-9_.-]{0,63})\*\*""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), +) +private val ADDITIONAL_MULTIPART_FIELDS_DESCRIPTION = Regex( + """\boptional\s+([A-Za-z][A-Za-z0-9_.-]*(?:\s*(?:,\s*|\s+and\s+)[A-Za-z][A-Za-z0-9_.-]*)*)\s+may\s+be\s+sent\s+as\s+additional\s+form\s+fields?\b""", + RegexOption.IGNORE_CASE, +) + +private fun String.isSafeInferredMultipartFieldName(): Boolean = + length in 1..64 && + first().isAsciiLetter() && + all { character -> + character.isAsciiLetter() || character.isDigit() || character in "_.-" + } + +private fun Char.isAsciiLetter(): Boolean = this in 'A'..'Z' || this in 'a'..'z' + +private fun String.parseInferredMultipartFieldList(): List = + replace(Regex("""\s+and\s+""", RegexOption.IGNORE_CASE), ",") + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) /** Common Kotlin compiler used until the Rust compiler is linked into each platform app. */ class DynamicAppDescriptorCompiler { @@ -93,13 +163,38 @@ class DynamicAppDescriptorCompiler { "Imported advertised OpenAPI ${document.string("openapi")}" }, ) - val state = KotlinCompilerState(input, document, source) + val state = KotlinCompilerState( + input = input, + document = document, + source = source, + allowSignedDescriptionMultipartInference = + advertised.trust == OpenApiTrust.nextcloudSignedAppPackage || + advertised.trust == OpenApiTrust.nextcloudSignedCompatibleAppPackage, + allowSignedDescriptionEnumInference = + advertised.trust == OpenApiTrust.nextcloudSignedAppPackage || + advertised.trust == OpenApiTrust.nextcloudSignedCompatibleAppPackage, + ) if (sanitized.ignoredCount > 0) { state.warnings += DynamicWarning( code = "opaque-external-schema-reference", message = "Ignored ${sanitized.ignoredCount} external OpenAPI schema references; endpoints remain available without inferred fields.", ) } + val inferredReadRouteResourceIdentities = paths.entries.mapNotNull { (openApiPath, itemElement) -> + val path = combinePaths(serverBase, openApiPath) + val pathItem = itemElement as? JsonObject ?: return@mapNotNull null + state.routeResourceIdentity(path, pathItem)?.let { identity -> path to identity } + }.toMap() + val readRouteResourceIdentities = inferredReadRouteResourceIdentities.mapValues { (path, identity) -> + ( + path.collectionRouteForTerminalIdentity() + ?: path.collectionRouteForTerminalState() + ) + ?.let(inferredReadRouteResourceIdentities::get) + ?.takeIf { parent -> parent.collection } + ?.let { parent -> identity.copy(resourceId = parent.resourceId) } + ?: identity + } paths.entries.sortedBy(Map.Entry::key).forEach { (openApiPath, itemElement) -> require(openApiPath.startsWith('/') && !openApiPath.startsWith("//")) { @@ -109,6 +204,7 @@ class DynamicAppDescriptorCompiler { require(path.isApproved(input.endpointPolicy)) { "Unapproved OpenAPI path: $path" } val pathItem = itemElement as? JsonObject ?: return@forEach val inheritedParameters = pathItem["parameters"] as? JsonArray + val routeResourceIdentity = readRouteResourceIdentities[path] pathItem.entries.sortedBy(Map.Entry::key).forEach operationLoop@{ (methodName, operationElement) -> val method = methodName.toHttpMethod() ?: return@operationLoop val operation = operationElement as? JsonObject ?: return@operationLoop @@ -120,12 +216,38 @@ class DynamicAppDescriptorCompiler { ) return@operationLoop } + val operationId = advertisedOperationId ?: "get-${path.stableId()}" + if ( + method != HttpMethod.GET && + operation.isSensitiveCredentialMutation(path, operationId) + ) { + state.warnings += DynamicWarning( + code = "ignored-sensitive-credential-write", + message = "Ignored documented $methodName $path because generic credential mutations require a dedicated trusted workflow.", + ) + return@operationLoop + } + if ( + method != HttpMethod.GET && + operation.requiresAmbiguousResultRecoveryPolicy(path, operationId) + ) { + if (state.exactIdempotentResultRecoveryActionId(path, method) == null) { + state.warnings += DynamicWarning( + code = "ignored-ambiguous-result-write", + message = "Ignored documented $methodName $path because generic send, share, and merge mutations require an exact verified read recovery surface.", + ) + return@operationLoop + } + } state.addOperation( path = path, method = method, operation = operation, - operationId = advertisedOperationId ?: "get-${path.stableId()}", + operationId = operationId, inheritedParameters = inheritedParameters, + routeResourceIdentity = routeResourceIdentity, + readRouteResourceIdentities = readRouteResourceIdentities, + resultRecoveryActionId = state.exactIdempotentResultRecoveryActionId(path, method), ) } } @@ -234,6 +356,7 @@ private fun compileObservedReads(input: DynamicDiscoveryInput): DynamicAppDescri auth = listOf(AuthRequirement("nextcloud-session", AuthKind.nextcloudSession)), ocs = if (observation.ocs) ocsMetadata(observation.path, query) else null, ), + responseFieldIds = discoveredFields.map(DynamicField::id), permissionIds = (listOf(sessionPermissionId) + observation.permissionIds).distinct().sorted(), confidence = Confidence.medium, provenance = listOf(source), @@ -365,10 +488,146 @@ private fun String.observedResourceId(): String = split('/').asReversed().firstO segment.isNotBlank() && !segment.startsWith('{') && !segment.matches(Regex("v[0-9]+")) }?.stableId()?.takeIf(String::isNotBlank) ?: "resource" +private data class KotlinRouteResourceIdentity( + val resourceId: String, + val collection: Boolean, +) + +private fun String.collectionRouteForTerminalIdentity(): String? { + val segments = trimEnd('/').split('/') + val terminal = segments.lastOrNull() ?: return null + if (!terminal.startsWith('{') || !terminal.endsWith('}')) return null + return segments.dropLast(1).joinToString("/").ifBlank { "/" } +} + +private fun String.collectionRouteForTerminalState(): String? { + val segments = trimEnd('/').split('/') + if (terminalCollectionState() == null) return null + return segments.dropLast(1).joinToString("/").ifBlank { "/" } +} + +private fun String.terminalCollectionState(): String? = + trimEnd('/') + .substringAfterLast('/') + .stableId() + .takeIf(COLLECTION_STATE_ROUTE_WORDS::contains) + +/** + * Command routes such as `/{id}/toggle`, `/trash/{id}/restore`, and `/batch/move` + * describe an effect on an existing resource rather than a new resource named after the terminal + * route segment. Match their normalized route back to a verified JSON GET route only when that + * route proves ownership: either the normalized routes are equal, or a collection read owns the + * single terminal item identity targeted by the command. + * + * This deliberately depends on route shape plus the already-derived semantic effect. It does not + * know an app ID, operation ID, entity name, or server-specific parameter name. If equally strong + * candidates disagree about the resource, the action remains unbound instead of guessing. + */ +private fun transitionRouteResourceIdentity( + path: String, + effect: ActionEffect, + readRouteResourceIdentities: Map, +): KotlinRouteResourceIdentity? { + val targetRoute = path.transitionResourceRoute(effect) ?: return null + val candidates = readRouteResourceIdentities.mapNotNull { (readPath, identity) -> + val candidateRoute = readPath.readResourceRoute() + val exact = candidateRoute == targetRoute + val collectionItem = + identity.collection && + targetRoute == candidateRoute + "{}" + if (!exact && !collectionItem) return@mapNotNull null + val stateSegmentCount = readPath.routeSegments().count { segment -> + segment.stableId() in COLLECTION_STATE_ROUTE_WORDS + } + KotlinTransitionRouteCandidate( + identity = identity, + exact = exact, + routeSegmentCount = candidateRoute.size, + stateSegmentCount = stateSegmentCount, + ) + } + val bestScore = candidates.maxOfOrNull(KotlinTransitionRouteCandidate::score) ?: return null + val best = candidates.filter { candidate -> candidate.score == bestScore } + val resourceIds = best.map { candidate -> candidate.identity.resourceId }.distinct() + if (resourceIds.size != 1) return null + return best.first().identity +} + +private data class KotlinTransitionRouteCandidate( + val identity: KotlinRouteResourceIdentity, + val exact: Boolean, + val routeSegmentCount: Int, + val stateSegmentCount: Int, +) { + val score: Int + get() = + (if (exact) 1_000_000 else 0) + + routeSegmentCount * 100 - + stateSegmentCount +} + +private fun String.transitionResourceRoute(effect: ActionEffect): List? { + val effectWords = when (effect) { + ActionEffect.toggle -> TOGGLE_WORDS + ActionEffect.archive -> setOf("archive", "archived") + ActionEffect.unarchive -> setOf("archive", "archived", "unarchive") + ActionEffect.restore -> setOf("restore") + ActionEffect.move -> setOf("move") + ActionEffect.copy -> setOf("copy", "duplicate") + ActionEffect.reorder -> REORDER_WORDS + ActionEffect.batch -> return routeSegments() + .takeWhile { segment -> segment.stableId() != "batch" } + .toNormalizedResourceRoute() + .takeIf(List::isNotEmpty) + ActionEffect.upload -> setOf("upload", "import", "image") + ActionEffect.leave -> setOf("leave") + ActionEffect.clear -> setOf("clear", "image") + ActionEffect.permanentDelete -> PERMANENT_DELETE_WORDS + setOf("delete") + ActionEffect.empty -> setOf("empty") + ActionEffect.unspecified, + ActionEffect.list, + ActionEffect.read, + ActionEffect.create, + ActionEffect.update, + ActionEffect.delete, + ActionEffect.assign, + ActionEffect.execute, + -> return null + } + return routeSegments() + .filterNot { segment -> + val word = segment.stableId() + word in effectWords || word in COLLECTION_STATE_ROUTE_WORDS + } + .toNormalizedResourceRoute() + .takeIf(List::isNotEmpty) +} + +private fun String.readResourceRoute(): List = routeSegments() + .filterNot { segment -> segment.stableId() in COLLECTION_STATE_ROUTE_WORDS } + .toNormalizedResourceRoute() + +private fun String.routeSegments(): List = trim('/') + .split('/') + .filter(String::isNotBlank) + +private fun List.toNormalizedResourceRoute(): List = map { segment -> + if (segment.startsWith('{') && segment.endsWith('}')) "{}" else segment.stableId() +} + +private val COLLECTION_STATE_ROUTE_WORDS = setOf( + "archive", + "archived", + "deleted", + "trash", +) + private class KotlinCompilerState( private val input: DynamicDiscoveryInput, private val document: JsonObject, private val source: Provenance, + private val allowSignedDescriptionMultipartInference: Boolean, + private val allowSignedDescriptionEnumInference: Boolean, ) { private val resources = linkedMapOf() private val actions = linkedMapOf() @@ -377,15 +636,93 @@ private class KotlinCompilerState( private val permissions = linkedMapOf() private val operationActionIds = linkedMapOf() private val fallbackOperationIds = linkedMapOf>() + private val readActionIdsByExactContractPath = linkedMapOf>() val warnings = mutableListOf() + /** + * An ambiguous semantic write is recoverable only when PUT makes the operation idempotent and a + * trusted, already-compiled GET reads the exact same route without additional required query + * input. The returned action ID is retained as executable recovery evidence. + */ + fun exactIdempotentResultRecoveryActionId( + path: String, + method: HttpMethod, + ): String? { + if ( + method != HttpMethod.PUT || + source.kind !in setOf( + ProvenanceKind.verifiedAppPackage, + ProvenanceKind.appStoreLinkedSourceTag, + ) + ) { + return null + } + return readActionIdsByExactContractPath[path] + .orEmpty() + .mapNotNull(actions::get) + .singleOrNull { candidate -> + candidate.binding.method == HttpMethod.GET && + candidate.binding.body == null && + candidate.binding.queryParameters.none(HttpParameter::required) && + candidate.intent in setOf(ActionIntent.read, ActionIntent.list) && + candidate.risk == ActionRisk.readOnly && + !candidate.fallbackOnly && + candidate.provenance.any { provenance -> + provenance.kind in setOf( + ProvenanceKind.verifiedAppPackage, + ProvenanceKind.appStoreLinkedSourceTag, + ) + } + } + ?.id + } + + /** + * One REST route is one resource even when its read and write operations use different tags. + * OpenAPI generators commonly group GET and mutations by controller instead of by returned + * record type. Anchor the route family to its JSON GET while retaining whether that response is + * a collection so creates normalize parent bindings without turning detail routes into lists. + */ + fun routeResourceIdentity(path: String, pathItem: JsonObject): KotlinRouteResourceIdentity? { + val operation = pathItem["get"] as? JsonObject ?: return null + if (hasSuccessfulBinaryResponse(operation)) return null + val operationId = operation.string("operationId") + ?.takeIf(String::isNotBlank) + ?: "get-${path.stableId()}" + val filteredCollectionResourceId = + semanticFilteredCollectionResourceId(operation, path, operationId, HttpMethod.GET) + val (itemSchema, responseCollection) = responseItemSchema(responseSchema(operation)) + if (itemSchema == null && filteredCollectionResourceId == null) return null + val resourceId = resourceId( + operation = operation, + path = path, + operationId = operationId, + method = HttpMethod.GET, + filteredCollectionResourceId = filteredCollectionResourceId, + ) + return KotlinRouteResourceIdentity( + resourceId = resourceId, + collection = responseCollection || filteredCollectionResourceId != null, + ) + } + fun addOperation( path: String, method: HttpMethod, operation: JsonObject, operationId: String, inheritedParameters: JsonArray?, + routeResourceIdentity: KotlinRouteResourceIdentity?, + readRouteResourceIdentities: Map, + resultRecoveryActionId: String?, ) { + val declaredBody = body(operation) + if ("requestBody" in operation && declaredBody == null) { + return + } + if (declaredBody?.isUnsupportedOptionalFileMultipartBody() == true) { + return + } val actionId = uniqueId(actions.keys, operationId.stableId()) operationActionIds.putIfAbsent(operationId, actionId) val fallbackForOperationId = operation.string(READ_FALLBACK_FOR_OPERATION_EXTENSION) @@ -395,7 +732,7 @@ private class KotlinCompilerState( } val filteredCollectionResourceId = semanticFilteredCollectionResourceId(operation, path, operationId, method) - val resourceId = resourceId( + val inferredResourceId = resourceId( operation = operation, path = path, operationId = operationId, @@ -403,12 +740,35 @@ private class KotlinCompilerState( filteredCollectionResourceId = filteredCollectionResourceId, ) val response = responseSchema(operation) - val binaryRead = method == HttpMethod.GET && operation.hasSuccessfulBinaryResponse() + val binaryRead = method == HttpMethod.GET && hasSuccessfulBinaryResponse(operation) val (itemSchema, responseCollection) = responseItemSchema(response) - val collection = responseCollection || filteredCollectionResourceId != null + val preliminaryCollection = + responseCollection || + filteredCollectionResourceId != null || + routeResourceIdentity?.collection == true + val label = operation.string("summary") ?: operationId.humanize() + val effect = actionEffect( + method = method, + path = path, + operationId = operationId, + label = label, + collection = preliminaryCollection, + ) + val semanticRouteResourceIdentity = transitionRouteResourceIdentity( + path = path, + effect = effect, + readRouteResourceIdentities = readRouteResourceIdentities, + ) + ?: routeResourceIdentity + val collection = preliminaryCollection || semanticRouteResourceIdentity?.collection == true + val resourceId = + filteredCollectionResourceId + ?: semanticRouteResourceIdentity?.resourceId + ?: inferredResourceId val resource = resources.getOrPut(resourceId) { KotlinResourceBuilder(resourceId) } resource.collection = resource.collection || collection - itemSchema?.let { resource.mergeFields(fieldsFromSchema(it)) } + val responseFields = itemSchema?.let(::fieldsFromSchema).orEmpty() + resource.mergeFields(responseFields) val (documentedPathParameters, queryParameters) = parameters( inheritedParameters, @@ -425,17 +785,20 @@ private class KotlinCompilerState( val (boundPath, pathParameters) = normalizeCollectionParentIdentifier( defaultBoundPath, defaultPathParameters, - collection, + collection && method == HttpMethod.GET, ) - val label = operation.string("summary") ?: operationId.humanize() - val body = semanticActionBody( + val body = declaredBody + (body?.schema as? JsonObject)?.let { bodySchema -> + resource.mergeFields(fieldsFromSchema(bodySchema)) + } + val auth = auth(operation) + val risk = actionRisk( method = method, + effect = effect, path = boundPath, operationId = operationId, label = label, - declared = body(operation), ) - val auth = auth(operation) val permissionIds = auth.map { requirement -> val permissionId = "auth.${requirement.scheme.stableId()}" permissions.putIfAbsent( @@ -455,21 +818,25 @@ private class KotlinCompilerState( ) permissionId } + // The exact contract path used by an idempotent replacement may have been normalized on + // its collection GET (for example `{entityId}` -> `{id}`). Once the original contract path + // has proven the recovery relationship, use that GET's executable path binding for the PUT + // as well. This keeps validation and post-write recovery exact without guessing an alias at + // runtime. + val recoveryPathBinding = resultRecoveryActionId + ?.let(actions::get) + ?.binding val action = DynamicAction( id = actionId, label = label, resourceId = resourceId, - intent = intent(method, boundPath, operationId, collection), - risk = when (method) { - HttpMethod.GET -> ActionRisk.readOnly - HttpMethod.DELETE -> ActionRisk.destructive - else -> ActionRisk.mutating - }, - requiresConfirmation = method != HttpMethod.GET, + intent = effect.toActionIntent(), + risk = risk, + requiresConfirmation = risk == ActionRisk.destructive, binding = DynamicHttpBinding( method = method, - path = boundPath, - pathParameters = pathParameters, + path = recoveryPathBinding?.path ?: boundPath, + pathParameters = recoveryPathBinding?.pathParameters ?: pathParameters, queryParameters = queryParameters, body = body, auth = auth, @@ -480,16 +847,43 @@ private class KotlinCompilerState( ocs = ocsMetadata(path, queryParameters), ), fallbackOnly = fallbackForOperationId != null, + responseFieldIds = if (method == HttpMethod.GET) { + responseFields.map(DynamicField::id) + } else { + emptyList() + }, permissionIds = permissionIds, confidence = Confidence.high, provenance = listOf(source), + effect = effect, + resultRecoveryActionId = resultRecoveryActionId, ) actions[actionId] = action + if (method == HttpMethod.GET) { + readActionIdsByExactContractPath.getOrPut(path) { mutableListOf() } += actionId + } if (method == HttpMethod.GET) { if (fallbackForOperationId == null && !binaryRead) { val kind = if (collection) LayoutKind.list else LayoutKind.detail - val layoutId = if (kind == LayoutKind.list && filteredCollectionResourceId != null) { + val collectionState = path.terminalCollectionState() + val layoutId = if ( + ( + kind == LayoutKind.list && + ( + filteredCollectionResourceId != null || + collectionState != null || + pathParameters.count { parameter -> + parameter.name.isIdentityParameterName() + } > 1 + ) + ) || + ( + kind == LayoutKind.detail && + pathParameters.isNotEmpty() && + resourceId.isScopedSingletonSurface() + ) + ) { "$resourceId.${kind.name}.${operationId.stableId()}" } else { "$resourceId.${kind.name}" @@ -497,13 +891,14 @@ private class KotlinCompilerState( layoutPreference(resourceId, boundPath, operationId, kind, pathParameters)?.let { preference -> val candidate = KotlinLayoutSeed( id = layoutId, - title = resourceId.humanize(), + title = collectionState?.humanize() ?: resourceId.humanize(), resourceId = resourceId, kind = kind, sourceActionId = actionId, preference = preference, semanticFamily = resourceId.surfaceFamily(), - alternate = isAlternateSurface(resourceId, boundPath, operationId), + alternate = collectionState == null && + isAlternateSurface(resourceId, boundPath, operationId), ) val current = layoutSeeds[layoutId] if (current == null || candidate.isPreferredTo(current)) layoutSeeds[layoutId] = candidate @@ -511,7 +906,7 @@ private class KotlinCompilerState( } } else { val ocsFormatParameter = action.binding.ocs?.formatQueryParameter - val bodyFields = body?.schema?.let(::formFields).orEmpty() + val bodyFields = body?.let(::formFields).orEmpty() val queryFields = queryParameters .filter { parameter -> parameter.source == ParameterSource.userInput && parameter.name != ocsFormatParameter @@ -652,6 +1047,37 @@ private class KotlinCompilerState( ?.let(::resolveLocal) } + /** + * Exact binary response schemas remain media capabilities even when an OpenAPI generator emits + * the wildcard content type. Local references are resolved before classification so those + * bytes cannot accidentally enter the JSON record parser. + */ + private fun hasSuccessfulBinaryResponse(operation: JsonObject): Boolean { + val responseMedia = operation.objectValue("responses") + ?.entries + ?.filter { (status, _) -> status.startsWith('2') } + ?.mapNotNull { (_, responseElement) -> + (resolveLocal(responseElement) as? JsonObject)?.objectValue("content") + } + ?.flatMap { content -> content.entries } + .orEmpty() + if (responseMedia.isEmpty()) return false + return responseMedia.all { (declaredType, mediaElement) -> + val type = declaredType.substringBefore(';').trim().lowercase() + if (type.contains("json")) return@all false + val media = resolveLocal(mediaElement) as? JsonObject + val schema = media?.get("schema")?.let(::resolveLocal) as? JsonObject + val exactBinarySchema = schema?.let { declared -> + declared.string("type") == "string" && declared.string("format") == "binary" + } == true + exactBinarySchema || + type.startsWith("image/") || + type.startsWith("audio/") || + type.startsWith("video/") || + type == "application/octet-stream" + } + } + private fun responseItemSchema(schema: JsonElement?): Pair { val value = schema?.let(::resolveLocal) as? JsonObject ?: return null to false if (value.string("type") == "array") { @@ -700,7 +1126,7 @@ private class KotlinCompilerState( .entries .sortedBy(Map.Entry::key) .mapNotNull { (id, element) -> - val field = resolveLocal(element) as? JsonObject ?: return@mapNotNull null + val field = resolveFieldSchema(element) as? JsonObject ?: return@mapNotNull null DynamicField( id = id, label = field.string("title") ?: id.humanize(), @@ -709,7 +1135,7 @@ private class KotlinCompilerState( readOnly = field.boolean("readOnly") ?: false, nullable = field.boolean("nullable") ?: false, multiple = field.string("type") == "array", - format = field.string("format"), + format = field.dynamicEditorFormat(), enumValues = field.stringArray("enum"), confidence = Confidence.high, provenance = listOf(source), @@ -750,15 +1176,33 @@ private class KotlinCompilerState( } private fun body(operation: JsonObject): HttpBody? { - val request = operation["requestBody"]?.let(::resolveLocal) as? JsonObject ?: return null - val content = request.objectValue("content") ?: return null + val request = operation["requestBody"]?.let(::resolveLocal) as? JsonObject + val content = request?.objectValue("content") + inferredSignedDescriptionMultipartBody(operation, request, content)?.let { return it } + request ?: return null + content ?: return null val contentType = listOf( + "multipart/form-data", "application/json", "application/x-www-form-urlencoded", - "multipart/form-data", - ).firstOrNull(content::containsKey) ?: content.keys.firstOrNull() ?: return null + ).firstOrNull { candidate -> + val media = content[candidate] as? JsonObject ?: return@firstOrNull false + val schema = media["schema"]?.let(::resolveLocal) + candidate != "multipart/form-data" || isExactDynamicMultipartSchema(schema) + } ?: content.keys.firstOrNull() ?: return null val media = content[contentType] as? JsonObject ?: return null - val schema = media["schema"]?.let(::resolveLocal)?.withDynamicFormFormats() ?: return null + val declaredSchema = media["schema"]?.let(::resolveLocal) ?: return null + // The generic array editors produce a typed JSON value. Form and multipart array + // serialization depends on exact media encoding/style/explode metadata that the descriptor + // does not retain, so those bodies must not acquire a JSON-oriented editor format. + val jsonBody = contentType.substringBefore(';').trim().equals("application/json", ignoreCase = true) + if (jsonBody && declaredSchema.hasUnnormalizableReadOnlyRepeatableObjectProperty()) return null + if (jsonBody && declaredSchema.hasUnsupportedDynamicStringArrayProperty()) return null + val schema = if (jsonBody) { + declaredSchema.withDynamicFormFormats() + } else { + declaredSchema + } return HttpBody( contentType = contentType, required = request.boolean("required") ?: false, @@ -766,24 +1210,256 @@ private class KotlinCompilerState( ) } + private fun JsonElement.hasUnnormalizableReadOnlyRepeatableObjectProperty(): Boolean { + val objectSchema = this as? JsonObject ?: return false + val properties = objectSchema.objectValue("properties") ?: return false + return properties.values.any { element -> + val array = resolveFieldSchema(element) as? JsonObject ?: return@any false + if (array.string("type") != "array") return@any false + val item = array["items"]?.let(::resolveLocal) as? JsonObject ?: return@any false + if (item.string("type") != "object") return@any false + val nestedProperties = item.objectValue("properties") ?: return@any false + val containsReadOnly = nestedProperties.values.any nestedField@{ nestedElement -> + val nested = resolveFieldSchema(nestedElement) as? JsonObject ?: return@nestedField false + nested.boolean("readOnly") == true + } + containsReadOnly && normalizeRepeatableObjectArraySchema(array) == null + } + } + + private fun JsonElement.hasUnsupportedDynamicStringArrayProperty(): Boolean { + val objectSchema = this as? JsonObject ?: return false + val properties = objectSchema.objectValue("properties") ?: return false + return properties.values.any { element -> + val array = resolveFieldSchema(element) as? JsonObject ?: return@any false + val item = array["items"]?.let(::resolveLocal) as? JsonObject ?: return@any false + array.string("type") == "array" && + item.string("type") == "string" && + !array.isExactEditableDynamicStringArraySchema(item) + } + } + + private fun JsonObject.isExactEditableDynamicStringArraySchema(item: JsonObject): Boolean = + keys.all(EDITABLE_DYNAMIC_STRING_ARRAY_SCHEMA_KEYS::contains) && + item.keys.all(EDITABLE_DYNAMIC_STRING_ARRAY_ITEM_SCHEMA_KEYS::contains) && + ("nullable" !in this || boolean("nullable") == false) && + ("nullable" !in item || item.boolean("nullable") == false) + + private fun inferredSignedDescriptionMultipartBody( + operation: JsonObject, + request: JsonObject?, + content: JsonObject?, + ): HttpBody? { + if (!allowSignedDescriptionMultipartInference) return null + val description = operation.string("description") + ?.takeIf { it.length in 1..MAX_MULTIPART_DESCRIPTION_CHARACTERS } + ?: return null + val fileMatches = MULTIPART_FILE_FIELD_DESCRIPTION.findAll(description).toList() + if (fileMatches.size != 1) return null + val fileFieldName = fileMatches.single().groupValues[2] + if (!fileFieldName.isSafeInferredMultipartFieldName()) return null + val declaredJson = content?.entries + ?.singleOrNull { (type, _) -> + type.substringBefore(';').trim().equals("application/json", ignoreCase = true) + } + ?.value as? JsonObject + if (content != null && (content.size != 1 || declaredJson == null)) return null + val declaredJsonSchema = declaredJson + ?.get("schema") + ?.let(::resolveLocal) as? JsonObject + val declaredProperties = declaredJsonSchema?.objectValue("properties").orEmpty() + if (fileFieldName in declaredProperties) return null + + val additionalMatches = ADDITIONAL_MULTIPART_FIELDS_DESCRIPTION.findAll(description).toList() + if (additionalMatches.size > 1) return null + val additionalFields = additionalMatches.singleOrNull() + ?.groupValues + ?.get(1) + ?.parseInferredMultipartFieldList() + ?: emptyList() + if (additionalFields.size > MAX_INFERRED_MULTIPART_TEXT_FIELDS) return null + if ( + additionalFields.any { !it.isSafeInferredMultipartFieldName() } || + additionalFields.distinct().size != additionalFields.size + ) { + return null + } + + if (declaredJsonSchema == null) { + if (request != null || content != null || additionalFields.isNotEmpty()) return null + } else { + if (declaredJsonSchema.string("type") != "object") return null + if (additionalFields.toSet() != declaredProperties.keys) return null + if (declaredProperties.values.any { element -> + val property = resolveLocal(element) as? JsonObject ?: return@any true + property.string("type") !in INFERRED_MULTIPART_SCALAR_TYPES || + property.string("format") == "binary" + } + ) { + return null + } + } + + val mediaWord = fileMatches.single().groupValues[1].lowercase() + val fileProperty = buildMap { + put("type", JsonPrimitive("string")) + put("format", JsonPrimitive("binary")) + when (mediaWord) { + "image", "photo" -> put("contentMediaType", JsonPrimitive("image/*")) + "audio" -> put("contentMediaType", JsonPrimitive("audio/*")) + "video" -> put("contentMediaType", JsonPrimitive("video/*")) + } + put(DESCRIPTION_INFERRED_MULTIPART_EXTENSION, JsonPrimitive(true)) + } + val properties = JsonObject( + declaredProperties + (fileFieldName to JsonObject(fileProperty)), + ) + val declaredRequired = (declaredJsonSchema?.get("required") as? JsonArray) + .orEmpty() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + .filter(declaredProperties::containsKey) + val schema = JsonObject( + buildMap { + put("type", JsonPrimitive("object")) + put("properties", properties) + put("required", JsonArray((declaredRequired + fileFieldName).distinct().map(::JsonPrimitive))) + put(DESCRIPTION_INFERRED_MULTIPART_EXTENSION, JsonPrimitive(true)) + }, + ) + return HttpBody( + contentType = "multipart/form-data", + required = request?.boolean("required") ?: true, + schema = schema, + ) + } + + private fun isExactDynamicMultipartSchema(element: JsonElement?): Boolean { + val schema = element as? JsonObject ?: return false + if (schema.string("type") != "object") return false + val properties = schema.objectValue("properties") ?: return false + if (properties.isEmpty() || properties.size > MAX_INFERRED_MULTIPART_TEXT_FIELDS + 1) return false + var binaryFieldName: String? = null + properties.forEach { (name, propertyElement) -> + if (!name.isSafeInferredMultipartFieldName()) return false + val property = resolveLocal(propertyElement) as? JsonObject ?: return false + if (property.string("type") == "string" && property.string("format") == "binary") { + if (binaryFieldName != null) return false + binaryFieldName = name + } else if (property.string("type") !in INFERRED_MULTIPART_SCALAR_TYPES) { + return false + } + } + val required = (schema["required"] as? JsonArray) + .orEmpty() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + val exactBinaryFieldName = binaryFieldName ?: return false + return exactBinaryFieldName in required + } + + private fun HttpBody.isUnsupportedOptionalFileMultipartBody(): Boolean { + if (!contentType.substringBefore(';').trim().equals("multipart/form-data", ignoreCase = true)) { + return false + } + val objectSchema = schema as? JsonObject ?: return false + val properties = objectSchema.objectValue("properties") ?: return false + val binaryFields = properties.entries.filter { (_, element) -> + val property = resolveLocal(element) as? JsonObject ?: return@filter false + property.string("type") == "string" && property.string("format") == "binary" + } + if (binaryFields.size != 1) return false + val required = (objectSchema["required"] as? JsonArray) + .orEmpty() + .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + return binaryFields.single().key !in required + } + private fun JsonElement.withDynamicFormFormats(): JsonElement { val schema = this as? JsonObject ?: return this val properties = schema["properties"] as? JsonObject ?: return schema val formattedProperties = JsonObject( properties.mapValues { (_, property) -> - val propertySchema = property as? JsonObject ?: return@mapValues property + val propertySchema = resolveFieldSchema(property) as? JsonObject ?: return@mapValues property val type = propertySchema.string("type") - val itemType = (propertySchema["items"] as? JsonObject)?.string("type") - if (type == "array" && itemType == "string") { - JsonObject(propertySchema + ("format" to JsonPrimitive(DYNAMIC_STRING_ARRAY_FORMAT))) - } else { - property + val itemType = (propertySchema["items"]?.let(::resolveLocal) as? JsonObject)?.string("type") + when { + type == "array" && itemType == "string" && + propertySchema.isExactEditableDynamicStringArraySchema( + propertySchema["items"]?.let(::resolveLocal) as? JsonObject + ?: return@mapValues property, + ) -> + JsonObject(propertySchema + ("format" to JsonPrimitive(DYNAMIC_STRING_ARRAY_FORMAT))) + type == "array" && itemType == "integer" -> + JsonObject(propertySchema + ("format" to JsonPrimitive(DYNAMIC_INTEGER_ARRAY_FORMAT))) + type == "array" && itemType == "object" -> + normalizeRepeatableObjectArraySchema(propertySchema) ?: propertySchema + type == "array" -> propertySchema + else -> property } }, ) return JsonObject(schema + ("properties" to formattedProperties)) } + private fun normalizeRepeatableObjectArraySchema(array: JsonObject): JsonObject? { + val item = array["items"]?.let(::resolveLocal) as? JsonObject ?: return null + if (item.string("type") != "object") return null + val properties = item.objectValue("properties") ?: return null + val description = array.string("description") + val readOnlyFieldIds = mutableSetOf() + val normalizedProperties = linkedMapOf() + properties.forEach { (fieldId, element) -> + val scalar = resolveFieldSchema(element) as? JsonObject ?: return null + if (scalar.boolean("readOnly") == true) { + readOnlyFieldIds += fieldId + return@forEach + } + val recoveredEnum = if ( + allowSignedDescriptionEnumInference && + scalar.string("type") == "string" && + scalar["enum"] == null + ) { + description?.exactSignedDescriptionEnum(fieldId) + } else { + null + } + normalizedProperties[fieldId] = if (recoveredEnum == null) { + scalar + } else { + JsonObject( + scalar + ( + DESCRIPTION_ENUM_EXTENSION to + JsonArray(recoveredEnum.map(::JsonPrimitive)) + ), + ) + } + } + if (normalizedProperties.isEmpty()) return null + val normalizedItemValues = item.toMutableMap().apply { + put("properties", JsonObject(normalizedProperties)) + val required = item["required"] as? JsonArray + if (required != null && readOnlyFieldIds.isNotEmpty()) { + put( + "required", + JsonArray( + required.filterNot { element -> + val fieldId = (element as? JsonPrimitive) + ?.takeIf(JsonPrimitive::isString) + ?.contentOrNull + fieldId != null && fieldId in readOnlyFieldIds + }, + ), + ) + } + } + val normalizedItem = JsonObject(normalizedItemValues) + val normalized = JsonObject( + array + + ("items" to normalizedItem) + + ("format" to JsonPrimitive(DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT)), + ) + return normalized.takeIf { it.repeatableObjectInputSpec() != null } + } + private fun bindDocumentedPathDefaults( path: String, parameters: List, @@ -851,29 +1527,36 @@ private class KotlinCompilerState( ).orEmpty() } - private fun formFields(schemaElement: JsonElement): List? { - val schema = resolveLocal(schemaElement) as? JsonObject ?: return null + private fun formFields(body: HttpBody): List? { + val schema = resolveLocal(body.schema) as? JsonObject ?: return null + val supportsTypedArrays = body.contentType.substringBefore(';').trim() + .equals("application/json", ignoreCase = true) val required = (schema["required"] as? JsonArray) .orEmpty() .mapNotNull { (it as? JsonPrimitive)?.contentOrNull } .toSet() val properties = schema.objectValue("properties") ?: return null return properties.entries.sortedBy(Map.Entry::key).mapNotNull { (id, element) -> - val field = resolveLocal(element) as? JsonObject ?: return@mapNotNull null + val field = resolveFieldSchema(element) as? JsonObject ?: return@mapNotNull null if (field.boolean("readOnly") == true) return@mapNotNull null + if (field.string("type") == "array" && !supportsTypedArrays) return@mapNotNull null + val repeatableObjectInput = field.repeatableObjectInputSpec() FormField( fieldId = id, label = field.string("title") ?: id.humanize(), kind = fieldKind(id, field), required = id in required, - format = field.string("format"), + format = repeatableObjectInput + ?.let { DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT } + ?: field.dynamicEditorFormat(), enumValues = field.stringArray("enum"), + repeatableObjectInput = repeatableObjectInput, ) } } private fun formField(parameter: HttpParameter): FormField? { - val schema = resolveLocal(parameter.schema) as? JsonObject ?: return null + val schema = resolveFieldSchema(parameter.schema) as? JsonObject ?: return null return FormField( fieldId = parameter.name, label = schema.string("title") ?: parameter.name.humanize(), @@ -884,6 +1567,27 @@ private class KotlinCompilerState( ) } + /** + * Resolves the exact scalar/field shape behind a single-member OpenAPI `allOf` wrapper. + * + * OpenAPI generators commonly express a nullable enum property as + * `{ "nullable": true, "allOf": [{ "$ref": "..." }] }`. This is not an enum union: one + * referenced schema supplies the value shape while the wrapper supplies annotations. Multiple + * members or structural wrapper keywords remain unresolved so downstream UI inference fails + * closed instead of inventing a combined type or option set. + */ + private fun resolveFieldSchema(element: JsonElement, depth: Int = 0): JsonElement { + require(depth <= 24) { "OpenAPI field composition depth exceeded" } + val resolved = resolveLocal(element) as? JsonObject ?: return resolveLocal(element) + val allOf = resolved["allOf"] as? JsonArray ?: return resolved + val member = allOf.singleOrNull() ?: return resolved + val wrapper = resolved.filterKeys { key -> key != "allOf" } + if (wrapper.keys.any { key -> key !in FIELD_COMPOSITION_ANNOTATION_KEYS }) return resolved + val inherited = resolveFieldSchema(member, depth + 1) as? JsonObject ?: return resolved + if ("allOf" in inherited) return resolved + return JsonObject(inherited + wrapper) + } + private fun resolveLocal(element: JsonElement, depth: Int = 0): JsonElement { require(depth <= 24) { "OpenAPI reference depth exceeded" } val objectValue = element as? JsonObject ?: return element @@ -911,7 +1615,24 @@ private data class KotlinResourceBuilder( val fields: MutableMap = linkedMapOf(), ) { fun mergeFields(discovered: List) { - discovered.forEach { fields.putIfAbsent(it.id, it) } + discovered.forEach { candidate -> + val current = fields[candidate.id] + fields[candidate.id] = when { + current == null -> candidate + current.kind == FieldKind.unknown && candidate.kind != FieldKind.unknown -> + candidate.copy( + required = current.required || candidate.required, + readOnly = current.readOnly, + provenance = (current.provenance + candidate.provenance).distinct(), + ) + current.format == null && candidate.format != null -> + current.copy( + format = candidate.format, + provenance = (current.provenance + candidate.provenance).distinct(), + ) + else -> current + } + } } fun finish(): DynamicResource = DynamicResource( @@ -1153,26 +1874,112 @@ private fun JsonObject.referencesSemanticResource(resourceId: String, operationI } } +private fun JsonObject.isSensitiveCredentialMutation(path: String, operationId: String): Boolean { + val semantics = listOfNotNull( + operationId, + string("summary"), + string("description"), + path, + ).joinToString(" ").lowercase() + val credentialTarget = SENSITIVE_CREDENTIAL_CONCEPTS.any(semantics::contains) + val credentialMutation = SENSITIVE_CREDENTIAL_MUTATION_CONCEPTS.any(semantics::contains) + return credentialTarget && credentialMutation +} + +private val SENSITIVE_CREDENTIAL_CONCEPTS = setOf( + "api key", + "apikey", + "credential", + "password", + "secret", + "token", + "userkey", + "/keys", + "/key/", +) + +private val SENSITIVE_CREDENTIAL_MUTATION_CONCEPTS = setOf( + "create", + "generate", + "issue", + "reset", + "rotate", +) + /** - * Binary reads are valid API capabilities, but they are not record/detail layouts. Their bytes are - * consumed by native artwork/media loaders instead of being sent through the JSON record parser. + * Some mutations produce externally visible or non-repeatable effects whose result cannot be + * recovered safely by the generic request pipeline after a timeout or disconnect. Keep those + * operations out of generic discovery until the operation declares a dedicated ambiguous-result + * policy. Exact semantic words avoid withholding unrelated concepts such as senders, shared + * preferences, or mergeable records. */ -private fun JsonObject.hasSuccessfulBinaryResponse(): Boolean { - val responseContent = objectValue("responses") - ?.entries - ?.filter { (status, _) -> status.startsWith('2') } - ?.mapNotNull { (_, response) -> (response as? JsonObject)?.objectValue("content") } - .orEmpty() - if (responseContent.isEmpty()) return false - val contentTypes = responseContent.flatMap { it.keys }.map { it.substringBefore(';').lowercase() } - return contentTypes.isNotEmpty() && - contentTypes.none { type -> type.contains("json") } && - contentTypes.all { type -> - type.startsWith("image/") || - type.startsWith("audio/") || - type.startsWith("video/") || - type == "application/octet-stream" +private fun JsonObject.requiresAmbiguousResultRecoveryPolicy( + path: String, + operationId: String, +): Boolean { + val words = actionSemanticWords( + path = path, + operationId = operationId, + label = string("summary").orEmpty(), + ) + return words.any(AMBIGUOUS_RESULT_MUTATION_WORDS::contains) +} + +private val AMBIGUOUS_RESULT_MUTATION_WORDS = setOf( + "merge", + "send", + "share", +) + +/** + * Recovers only the narrow signed prose shape `field ('first' or 'second')`. + * + * The field name must occur exactly once as a complete word, the parenthesized expression must + * immediately follow it, and every alternative must be a quoted bounded scalar. General prose, + * examples, comma lists, and unquoted words remain non-authoritative. + */ +private fun String.exactSignedDescriptionEnum(fieldId: String): List? { + if ( + length !in 1..MAX_SIGNED_DESCRIPTION_ENUM_CHARACTERS || + fieldId.isBlank() || + fieldId.length > MAX_SIGNED_DESCRIPTION_ENUM_FIELD_LENGTH + ) { + return null + } + val occurrences = indices.filter { index -> + index + fieldId.length <= length && + regionMatches(index, fieldId, 0, fieldId.length, ignoreCase = true) && + (index == 0 || !this[index - 1].isLetterOrDigit()) && + (index + fieldId.length == length || !this[index + fieldId.length].isLetterOrDigit()) + } + val start = occurrences.singleOrNull() ?: return null + val tail = substring(start + fieldId.length).trimStart() + if (!tail.startsWith('(')) return null + val close = tail.indexOf(')') + if (close <= 1) return null + val expression = tail.substring(1, close) + val tokens = expression.split(Regex("""\s+or\s+""", RegexOption.IGNORE_CASE)) + if (tokens.size !in 2..MAX_SIGNED_DESCRIPTION_ENUM_VALUES) return null + val values = tokens.mapNotNull { token -> + val quoted = token.trim() + if ( + quoted.length < 3 || + quoted.first() !in setOf('\'', '"') || + quoted.last() != quoted.first() + ) { + return@mapNotNull null } + quoted.substring(1, quoted.lastIndex).takeIf { value -> + value.length in 1..MAX_SIGNED_DESCRIPTION_ENUM_VALUE_LENGTH && + value.all { character -> + character.isLetterOrDigit() || character in setOf('-', '_', '.', ' ') + } + } + } + return values.takeIf { + it.size == tokens.size && + it.distinct().size == it.size + } } /** @@ -1202,96 +2009,6 @@ private fun semanticFilteredCollectionResourceId( } } -private fun semanticActionBody( - method: HttpMethod, - path: String, - operationId: String, - label: String, - declared: HttpBody?, -): HttpBody? { - if (method == HttpMethod.GET || method == HttpMethod.DELETE) return declared - val declaredBody = declared ?: return null - val declaredSchema = declaredBody.schema as? JsonObject ?: return declared - val declaredType = declaredSchema.string("type") - if (declaredType != null && declaredType != "object") return declared - val declaredProperties = declaredSchema["properties"] as? JsonObject - if (!declaredProperties.isNullOrEmpty()) return declared - - val semantics = "$operationId $label $path".lowercase() - val recipeAction = "recipe" in semantics - if (!recipeAction) return declared - - val properties = when { - "import" in semantics && ("url" in semantics || "website" in semantics) -> JsonObject( - mapOf( - "url" to semanticStringSchema( - title = "Recipe URL", - description = "Link to a webpage containing a recipe", - format = "uri", - ), - ), - ) - method in setOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH) && - listOf("create", "new", "update", "edit").any(semantics::contains) -> recipeEditProperties() - else -> return declared - } - val required = if ("url" in properties) listOf("url") else listOf("name") - return HttpBody( - contentType = declaredBody.contentType, - required = declaredBody.required, - schema = JsonObject( - mapOf( - "type" to JsonPrimitive("object"), - "properties" to properties, - "required" to JsonArray(required.map(::JsonPrimitive)), - ), - ), - ) -} - -private fun recipeEditProperties(): JsonObject = JsonObject( - mapOf( - "name" to semanticStringSchema("Recipe name"), - "description" to semanticStringSchema("Description"), - "recipeYield" to JsonObject( - mapOf( - "type" to JsonPrimitive("integer"), - "title" to JsonPrimitive("Servings"), - "minimum" to JsonPrimitive(1), - ), - ), - "recipeCategory" to semanticStringSchema("Category"), - "keywords" to semanticStringSchema("Tags", "Separate tags with commas"), - "prepTime" to semanticStringSchema("Preparation time", "For example: PT20M"), - "cookTime" to semanticStringSchema("Cooking time", "For example: PT45M"), - "recipeIngredient" to semanticStringArraySchema("Ingredients"), - "recipeInstructions" to semanticStringArraySchema("Instructions"), - "tool" to semanticStringArraySchema("Tools"), - ), -) - -private fun semanticStringSchema( - title: String, - description: String? = null, - format: String? = null, -): JsonObject = JsonObject( - buildMap { - put("type", JsonPrimitive("string")) - put("title", JsonPrimitive(title)) - description?.let { put("description", JsonPrimitive(it)) } - format?.let { put("format", JsonPrimitive(it)) } - }, -) - -private fun semanticStringArraySchema(title: String): JsonObject = JsonObject( - mapOf( - "type" to JsonPrimitive("array"), - "title" to JsonPrimitive(title), - "items" to JsonObject(mapOf("type" to JsonPrimitive("string"))), - "format" to JsonPrimitive(DYNAMIC_STRING_ARRAY_FORMAT), - ), -) - private const val RESOURCE_ID_EXTENSION = "x-nextcloud-native-resource-id" private fun String.provesResource(resourceId: String): Boolean { @@ -1405,6 +2122,9 @@ private fun String.identityResourceStem(): String? = takeIf { length > 2 && endsWith("Id", ignoreCase = true) }?.dropLast(2)?.takeIf(String::isNotBlank) +private fun String.isIdentityParameterName(): Boolean = + equals("id", ignoreCase = true) || identityResourceStem() != null + private fun String.hierarchyParent( childResourceId: String, resources: List, @@ -1476,16 +2196,36 @@ private fun layoutPreference( private fun String.resourceNameVariants(): Set { val normalized = stableId() - val singular = when { - normalized.endsWith("ies") && normalized.length > 3 -> normalized.dropLast(3) + "y" - normalized.endsWith("ches") || normalized.endsWith("shes") -> normalized.dropLast(2) - normalized.endsWith("ses") || normalized.endsWith("xes") || normalized.endsWith("zes") -> normalized.dropLast(2) - normalized.endsWith('s') && !normalized.endsWith("ss") -> normalized.dropLast(1) - else -> normalized - } - return setOf(normalized, singular) + return buildSet { + add(normalized) + when { + normalized.endsWith("ies") && normalized.length > 3 -> add(normalized.dropLast(3) + "y") + normalized.endsWith("ches") || normalized.endsWith("shes") -> add(normalized.dropLast(2)) + normalized.endsWith("ses") || normalized.endsWith("xes") || normalized.endsWith("zes") -> { + // Both forms are linguistically valid: boxes -> box, while houses -> house. + // Retain both bounded candidates and let the declared resource set disambiguate. + add(normalized.dropLast(2)) + add(normalized.dropLast(1)) + } + normalized.endsWith('s') && !normalized.endsWith("ss") -> add(normalized.dropLast(1)) + } + } } +private fun String.isScopedSingletonSurface(): Boolean = + semanticBaseVariants().any(SCOPED_SINGLETON_SURFACES::contains) + +private val SCOPED_SINGLETON_SURFACES = setOf( + "capability", + "config", + "configuration", + "preference", + "prefs", + "profile", + "setting", + "status", +) + private fun String.surfaceFamily(): String = stableId() .split('-') .filter { it !in SURFACE_SCOPE_WORDS } @@ -1514,22 +2254,133 @@ private val ALTERNATE_SURFACE_WORDS = setOf( private val SURFACE_SCOPE_WORDS = ALTERNATE_SURFACE_WORDS + setOf("api", "local", "shared") -private fun intent( +private fun actionEffect( method: HttpMethod, path: String, operationId: String, + label: String, collection: Boolean, -): ActionIntent = when { - method == HttpMethod.GET && path.endsWithIdentityPlaceholder() -> ActionIntent.read - method == HttpMethod.GET && collection -> ActionIntent.list - method == HttpMethod.GET && operationId.looksLikeCollectionReadOperation() -> ActionIntent.list - method == HttpMethod.GET && path.contains('{') -> ActionIntent.read - method == HttpMethod.GET -> ActionIntent.read - method == HttpMethod.POST -> ActionIntent.create - method == HttpMethod.DELETE -> ActionIntent.delete - else -> ActionIntent.update +): ActionEffect { + if (method == HttpMethod.GET) { + return when { + path.endsWithIdentityPlaceholder() -> ActionEffect.read + collection -> ActionEffect.list + operationId.looksLikeCollectionReadOperation() -> ActionEffect.list + path.contains('{') -> ActionEffect.read + else -> ActionEffect.read + } + } + + val words = actionSemanticWords(path, operationId, label) + return when { + "empty" in words -> ActionEffect.empty + words.any { it in PERMANENT_DELETE_WORDS } -> ActionEffect.permanentDelete + words.any { it in REORDER_WORDS } -> ActionEffect.reorder + "batch" in words -> ActionEffect.batch + "restore" in words -> ActionEffect.restore + "unarchive" in words -> ActionEffect.unarchive + "archive" in words -> ActionEffect.archive + words.any { it in COMPLETION_TRANSITION_WORDS } || + ("toggle" in words && words.any { it in COMPLETION_STATE_WORDS }) -> ActionEffect.toggle + "move" in words -> ActionEffect.move + "copy" in words || "duplicate" in words -> ActionEffect.copy + "upload" in words || "import" in words -> ActionEffect.upload + "assign" in words || "replace" in words -> ActionEffect.assign + "leave" in words -> ActionEffect.leave + "clear" in words -> ActionEffect.clear + method == HttpMethod.DELETE -> ActionEffect.delete + words.any { it in CREATE_WORDS } -> ActionEffect.create + method == HttpMethod.PUT || method == HttpMethod.PATCH -> ActionEffect.update + else -> ActionEffect.execute + } } +private fun actionRisk( + method: HttpMethod, + effect: ActionEffect, + path: String, + operationId: String, + label: String, +): ActionRisk { + if (method == HttpMethod.GET) return ActionRisk.readOnly + if ( + effect in setOf( + ActionEffect.delete, + ActionEffect.permanentDelete, + ActionEffect.empty, + ActionEffect.leave, + ActionEffect.clear, + ) + ) { + return ActionRisk.destructive + } + val words = actionSemanticWords(path, operationId, label) + return if (words.any { it in DESTRUCTIVE_ACTION_WORDS }) { + ActionRisk.destructive + } else { + ActionRisk.mutating + } +} + +private fun ActionEffect.toActionIntent(): ActionIntent = when (this) { + ActionEffect.list -> ActionIntent.list + ActionEffect.read -> ActionIntent.read + ActionEffect.create -> ActionIntent.create + ActionEffect.update, + ActionEffect.assign, + -> ActionIntent.update + ActionEffect.delete, + ActionEffect.permanentDelete, + ActionEffect.empty, + ActionEffect.clear, + -> ActionIntent.delete + ActionEffect.unspecified, + ActionEffect.toggle, + ActionEffect.archive, + ActionEffect.unarchive, + ActionEffect.restore, + ActionEffect.move, + ActionEffect.copy, + ActionEffect.reorder, + ActionEffect.batch, + ActionEffect.upload, + ActionEffect.leave, + ActionEffect.execute, + -> ActionIntent.execute +} + +private fun actionSemanticWords( + path: String, + operationId: String, + label: String, +): Set = sequenceOf(path, operationId.humanize(), label) + .flatMap { value -> value.stableId().split('-').asSequence() } + .filter(String::isNotBlank) + .toSet() + +private val CREATE_WORDS = setOf("add", "create", "new") +private val TOGGLE_WORDS = setOf("toggle", "complete", "reopen") +private val COMPLETION_TRANSITION_WORDS = setOf("complete", "reopen") +private val COMPLETION_STATE_WORDS = setOf( + "checked", + "complete", + "completed", + "completion", + "done", +) +private val REORDER_WORDS = setOf("reorder", "reposition", "sort") +private val PERMANENT_DELETE_WORDS = setOf("permanent", "permanently", "purge") +private val DESTRUCTIVE_ACTION_WORDS = setOf( + "clear", + "delete", + "destroy", + "empty", + "permanent", + "permanently", + "purge", + "remove", +) + /** * A terminal identity placeholder proves an item read even when a plural operation name such as * `recipeDetails` resembles a collection controller. Non-identity filters such as @@ -1606,6 +2457,7 @@ private fun fieldKind(id: String, schema: JsonObject): FieldKind { val value = if (schema.string("type") == "array") schema["items"] as? JsonObject ?: schema else schema val lowerId = id.lowercase() return when { + value.string("type") == "string" && value.string("format") == "binary" -> FieldKind.file value.string("type") == "string" && value.string("format") == "date" -> FieldKind.date value.string("type") == "string" && value.string("format") == "date-time" -> FieldKind.dateTime value.string("type") == "integer" -> FieldKind.integer @@ -1626,6 +2478,27 @@ private fun fieldKind(id: String, schema: JsonObject): FieldKind { } } +private fun JsonObject.dynamicEditorFormat(): String? { + val format = string("format") + if (format != "binary") return format + return string("contentMediaType") + ?.trim() + ?.lowercase() + ?.takeIf(String::isSafeDynamicUploadMimeFilter) + ?: format +} + +private fun String.isSafeDynamicUploadMimeFilter(): Boolean { + if (length !in 3..160 || count { it == '/' } != 1) return false + val type = substringBefore('/') + val subtype = substringAfter('/') + if (type.isEmpty() || subtype.isEmpty() || type == "*") return false + fun String.safeToken(): Boolean = all { character -> + character.isAsciiLetter() || character.isDigit() || character in "!#$&+-.^_" + } + return type.safeToken() && (subtype == "*" || subtype.safeToken()) +} + private fun DynamicField.layoutRole(index: Int): LayoutFieldRole = when { kind == FieldKind.image -> LayoutFieldRole.image id.lowercase() in setOf("id", "uuid", "token") -> LayoutFieldRole.identity @@ -1656,7 +2529,26 @@ private fun String.stableId(): String = buildString { } }.trim('-') -private fun String.humanize(): String = replace('-', ' ').replace('_', ' ').replace('.', ' ') +private fun String.humanize(): String = buildString(length + 4) { + this@humanize.forEachIndexed { index, character -> + val previous = this@humanize.getOrNull(index - 1) + val next = this@humanize.getOrNull(index + 1) + when { + character == '-' || character == '_' || character == '.' || character.isWhitespace() -> { + if (isNotEmpty() && last() != ' ') append(' ') + } + character.isUpperCase() && ( + previous?.isLowerCase() == true || + previous?.isDigit() == true || + (previous?.isUpperCase() == true && next?.isLowerCase() == true) + ) -> { + if (isNotEmpty() && last() != ' ') append(' ') + append(character) + } + else -> append(character) + } + } +} .split(' ') .filter(String::isNotBlank) .joinToString(" ") { word -> word.replaceFirstChar(Char::uppercaseChar) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt index 2094eef4..252f0fbf 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt @@ -28,6 +28,7 @@ fun DynamicAppDescriptor.toNativeAppSchema(): NativeAppSchema { readOnly = false, format = input.format ?: existing.format, enumValues = input.enumValues ?: existing.enumValues, + repeatableObjectInput = input.repeatableObjectInput ?: existing.repeatableObjectInput, ) } } @@ -101,9 +102,11 @@ fun DynamicAppDescriptor.toNativeAppSchema(): NativeAppSchema { confidence = action.confidence, inputSchema = formsByActionId[action.id]?.toNativeInputSchema(), evidence = action.provenance.map(Provenance::toEvidence), + effect = action.effect, + resultRecoveryActionId = action.resultRecoveryActionId, ) } - val nativeRelationships = links.mapNotNull { link -> + val linkedRelationships = links.mapNotNull { link -> val target = link.target as? DynamicLinkTarget.Action ?: return@mapNotNull null val childResourceId = actions.firstOrNull { it.id == target.actionId }?.resourceId ?: return@mapNotNull null if (childResourceId == link.resourceId) return@mapNotNull null @@ -118,7 +121,44 @@ fun DynamicAppDescriptor.toNativeAppSchema(): NativeAppSchema { }?.id, confidence = link.confidence, ) - }.distinctBy { relationship -> + } + val inferredForeignKeyRelationships = resources.flatMap { childResource -> + childResource.fields.mapNotNull { childField -> + val parentBase = childField.id.foreignKeyBase() ?: return@mapNotNull null + val parentResource = resources.filter { candidate -> + parentBase in setOf( + candidate.id.relationBase(), + candidate.label.relationBase(), + ) + }.singleOrNull() ?: return@mapNotNull null + val parentIdentity = parentResource.fields + .filter { field -> field.id.lowercase() in setOf("databaseid", "id", "uuid", "token") } + .minByOrNull { field -> + when (field.id.lowercase()) { + "databaseid" -> 0 + "id" -> 1 + "uuid" -> 2 + else -> 3 + } + } ?: return@mapNotNull null + ResourceRelationshipSpec( + parentResourceId = parentResource.id, + childResourceId = childResource.id, + parentFieldId = parentIdentity.id, + childFieldId = childField.id, + // Name inference may support labels and relation choices, but it cannot by itself + // become verified evidence that authorizes an automatic write binding. + confidence = minOf( + Confidence.high, + parentResource.confidence, + childResource.confidence, + parentIdentity.confidence, + childField.confidence, + ), + ) + } + } + val nativeRelationships = (linkedRelationships + inferredForeignKeyRelationships).distinctBy { relationship -> listOf( relationship.parentResourceId, relationship.childResourceId, @@ -246,7 +286,11 @@ private fun kotlinx.serialization.json.JsonElement.requiredPropertyNames(): List private fun String.foreignKeyBase(): String? { val normalized = lowercase().filter(Char::isLetterOrDigit) - return normalized.takeIf { it.length > 2 && it.endsWith("id") }?.dropLast(2)?.relationBase() + return when { + normalized.length > 3 && normalized.endsWith("ids") -> normalized.dropLast(3).relationBase() + normalized.length > 2 && normalized.endsWith("id") -> normalized.dropLast(2).relationBase() + else -> null + } } private fun String.relationBase(): String { @@ -288,6 +332,7 @@ private fun FormField.toNativeField(): FieldSpec = FieldSpec( readOnly = false, format = format, enumValues = enumValues, + repeatableObjectInput = repeatableObjectInput, ) private fun DynamicLayout.toNativeComponent( @@ -316,6 +361,10 @@ private fun DynamicLayout.toNativeComponent( it in setOf("stackid", "stack", "columnid", "column", "laneid", "lane", "listid", "list", "stage", "status") } val hasBoardOrdering = normalizedFields.keys.any { it in setOf("order", "position", "sortorder", "sort", "index") } + val hasCompletionShape = normalizedFields.any { (id, field) -> + id in setOf("completed", "done", "iscompleted", "isdone") && + field.kind == FieldKind.boolean + } val hasMeasure = normalizedFields.any { (id, field) -> field.kind in setOf(FieldKind.integer, FieldKind.decimal, FieldKind.currency) && id in setOf( @@ -371,6 +420,7 @@ private fun DynamicLayout.toNativeComponent( fields.any { it in setOf("boardid", "stackid", "order", "position") } -> { NativeComponent.board } + hasTitle && hasCompletionShape -> NativeComponent.taskList hasTitle && hasBoardGrouping && hasBoardOrdering -> NativeComponent.board hasMeasure && hasFinancialSemantics -> NativeComponent.dashboard hasSettingsSemantics && action?.binding?.method == HttpMethod.GET -> NativeComponent.detail diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt index 803928e6..b1dc297a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt @@ -74,7 +74,18 @@ fun DynamicAppDescriptor.validationErrors(): List = buildList { } } actions.forEach { action -> - if (action.resourceId !in resourcesById) add("Missing resource reference: ${action.resourceId}") + val resource = resourcesById[action.resourceId] + if (resource == null) { + add("Missing resource reference: ${action.resourceId}") + } else { + val resourceFieldIds = resource.fields.mapTo(mutableSetOf(), DynamicField::id) + action.responseFieldIds.filter { it !in resourceFieldIds }.forEach { + add("Missing response field reference: ${action.resourceId}.$it") + } + } + if (action.binding.method != HttpMethod.GET && action.responseFieldIds.isNotEmpty()) { + add("Mutation action declares read-response fields: ${action.id}") + } if (!action.binding.path.isSafeRelativePath()) { add("Invalid action endpoint: ${action.binding.path}") } else if (endpointPolicy.approvedApiPrefixes.none { action.binding.path.matchesPrefix(it) }) { @@ -95,6 +106,28 @@ fun DynamicAppDescriptor.validationErrors(): List = buildList { !fallback.fallbackOnly -> add("Fallback action is not hidden: $fallbackId") } } + action.resultRecoveryActionId?.let { recoveryId -> + val recovery = actionsById[recoveryId] + when { + recovery == null -> add("Missing result recovery action reference: $recoveryId") + action.binding.method != HttpMethod.PUT -> + add("Result recovery is only valid for idempotent PUT: ${action.id}") + recovery.binding.method != HttpMethod.GET || + recovery.intent !in setOf(ActionIntent.read, ActionIntent.list) || + recovery.risk != ActionRisk.readOnly -> + add("Result recovery must target a read-only GET: ${action.id} -> $recoveryId") + recovery.binding.path != action.binding.path -> + add("Result recovery must use the exact mutation route: ${action.id} -> $recoveryId") + recovery.binding.body != null -> + add("Result recovery GET cannot declare a request body: $recoveryId") + recovery.binding.queryParameters.any(HttpParameter::required) -> + add("Result recovery requires unsupported query input: $recoveryId") + recovery.fallbackOnly -> add("Result recovery action is hidden: $recoveryId") + action.provenance.none(Provenance::isTrustedResultRecoveryEvidence) || + recovery.provenance.none(Provenance::isTrustedResultRecoveryEvidence) -> + add("Result recovery requires trusted mutation and read evidence: ${action.id} -> $recoveryId") + } + } if (action.fallbackOnly && action.binding.method != HttpMethod.GET) { add("Hidden fallback action is not read-only: ${action.id}") } @@ -113,6 +146,12 @@ fun DynamicAppDescriptor.validationErrors(): List = buildList { } } +private fun Provenance.isTrustedResultRecoveryEvidence(): Boolean = + kind in setOf( + ProvenanceKind.verifiedAppPackage, + ProvenanceKind.appStoreLinkedSourceTag, + ) + fun DynamicAppDescriptor.requireValid(): DynamicAppDescriptor { val errors = validationErrors() require(errors.isEmpty()) { errors.joinToString(separator = "; ") } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt index 9c34ff55..139ee8ab 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt @@ -1,5 +1,14 @@ package dev.obiente.nextcloudnative.nativeui.model +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull + /** Set-like user settings rendered one item per line; duplicate entries are normalized away. */ const val DYNAMIC_STRING_LIST_FORMAT = "nextcloud-string-list" @@ -10,3 +19,203 @@ const val DYNAMIC_STRING_LIST_FORMAT = "nextcloud-string-list" * never upgrades an untyped object into an editable list, and duplicate entries remain data. */ const val DYNAMIC_STRING_ARRAY_FORMAT = "nextcloud-string-array" + +/** + * Ordered contract-declared integer arrays. + * + * The marker identifies a `type: array` schema whose direct item schema declares `type: integer`. + * The generic editor additionally requires a supported exact constraint subset and accepts a + * bounded JSON array so integer types remain distinguishable from quoted strings, decimals, + * objects, and other unsupported values. + */ +const val DYNAMIC_INTEGER_ARRAY_FORMAT = "nextcloud-integer-array" + +internal const val MAX_DYNAMIC_INTEGER_ARRAY_ITEMS = 256 +private const val MAX_DYNAMIC_INTEGER_ARRAY_INPUT_LENGTH = 16 * 1024 + +internal sealed interface DynamicIntegerArrayParseResult { + data class Valid(val values: List) : DynamicIntegerArrayParseResult + data class Invalid(val message: String) : DynamicIntegerArrayParseResult +} + +private data class DynamicIntegerArrayConstraints( + val minItems: Int = 0, + val maxItems: Int = MAX_DYNAMIC_INTEGER_ARRAY_ITEMS, + val uniqueItems: Boolean = false, + val minimum: Long? = null, + val maximum: Long? = null, + val exclusiveMinimum: Long? = null, + val exclusiveMaximum: Long? = null, + val multipleOf: Long? = null, +) + +private val SUPPORTED_DYNAMIC_INTEGER_ARRAY_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "format", + "items", + "maxItems", + "minItems", + "nullable", + "readOnly", + "title", + "type", + "uniqueItems", + "writeOnly", + "x-nextcloud-native-wire-name", +) + +private val SUPPORTED_DYNAMIC_INTEGER_ITEM_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "maximum", + "minimum", + "multipleOf", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", +) + +internal fun JsonElement?.isExactDynamicIntegerArraySchema(): Boolean { + return dynamicIntegerArrayConstraints() != null +} + +private fun JsonElement?.dynamicIntegerArrayConstraints(): DynamicIntegerArrayConstraints? { + val schema = this as? JsonObject ?: return null + if ((schema["type"] as? JsonPrimitive)?.contentOrNull != "array") return null + if ((schema["format"] as? JsonPrimitive)?.contentOrNull != DYNAMIC_INTEGER_ARRAY_FORMAT) return null + if (schema.keys.any { it !in SUPPORTED_DYNAMIC_INTEGER_ARRAY_KEYS }) return null + val items = schema["items"] as? JsonObject ?: return null + if ((items["type"] as? JsonPrimitive)?.contentOrNull != "integer") return null + if (items.keys.any { it !in SUPPORTED_DYNAMIC_INTEGER_ITEM_KEYS }) return null + val itemFormat = (items["format"] as? JsonPrimitive)?.contentOrNull + if ("format" in items && itemFormat !in setOf("int32", "int64")) return null + + val minItems = schema.exactNonNegativeInt("minItems") ?: if ("minItems" in schema) return null else 0 + val declaredMaxItems = schema.exactNonNegativeInt("maxItems") + ?: if ("maxItems" in schema) return null else MAX_DYNAMIC_INTEGER_ARRAY_ITEMS + if (minItems > declaredMaxItems || minItems > MAX_DYNAMIC_INTEGER_ARRAY_ITEMS) return null + val uniqueItems = schema.exactBoolean("uniqueItems") + ?: if ("uniqueItems" in schema) return null else false + + var minimum = items.exactLong("minimum") ?: if ("minimum" in items) return null else null + var maximum = items.exactLong("maximum") ?: if ("maximum" in items) return null else null + var exclusiveMinimum = items.exactLong("exclusiveMinimum") + var exclusiveMaximum = items.exactLong("exclusiveMaximum") + if ("exclusiveMinimum" in items && exclusiveMinimum == null) { + val exclusive = items.exactBoolean("exclusiveMinimum") ?: return null + if (exclusive) { + exclusiveMinimum = minimum ?: return null + minimum = null + } + } + if ("exclusiveMaximum" in items && exclusiveMaximum == null) { + val exclusive = items.exactBoolean("exclusiveMaximum") ?: return null + if (exclusive) { + exclusiveMaximum = maximum ?: return null + maximum = null + } + } + val multipleOf = items.exactLong("multipleOf") ?: if ("multipleOf" in items) return null else null + if (multipleOf != null && multipleOf <= 0L) return null + + return DynamicIntegerArrayConstraints( + minItems = minItems, + maxItems = minOf(declaredMaxItems, MAX_DYNAMIC_INTEGER_ARRAY_ITEMS), + uniqueItems = uniqueItems, + minimum = minimum, + maximum = maximum, + exclusiveMinimum = exclusiveMinimum, + exclusiveMaximum = exclusiveMaximum, + multipleOf = multipleOf, + ) +} + +private fun JsonObject.exactLong(name: String): Long? = + (get(name) as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + +private fun JsonObject.exactNonNegativeInt(name: String): Int? = + exactLong(name)?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }?.toInt() + +private fun JsonObject.exactBoolean(name: String): Boolean? = + (get(name) as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + +internal fun parseDynamicIntegerArrayInput( + value: String, + schema: JsonElement? = null, +): DynamicIntegerArrayParseResult { + val constraints = if (schema == null) { + DynamicIntegerArrayConstraints() + } else { + schema.dynamicIntegerArrayConstraints() + ?: return DynamicIntegerArrayParseResult.Invalid( + "This integer list does not have a supported exact contract schema.", + ) + } + if (value.length > MAX_DYNAMIC_INTEGER_ARRAY_INPUT_LENGTH) { + return DynamicIntegerArrayParseResult.Invalid("This integer list is too large.") + } + val array = runCatching { Json.parseToJsonElement(value) }.getOrNull() as? JsonArray + ?: return DynamicIntegerArrayParseResult.Invalid("Enter a JSON array of whole numbers.") + if (array.size < constraints.minItems) { + return DynamicIntegerArrayParseResult.Invalid( + "Use at least ${constraints.minItems} whole numbers.", + ) + } + if (array.size > constraints.maxItems) { + return DynamicIntegerArrayParseResult.Invalid( + "Use at most ${constraints.maxItems} whole numbers.", + ) + } + val values = array.map { element -> + val scalar = element as? JsonPrimitive + ?: return DynamicIntegerArrayParseResult.Invalid("Use only whole numbers in this array.") + val parsed = scalar.takeUnless { it.isString }?.longOrNull + ?: return DynamicIntegerArrayParseResult.Invalid("Use only whole numbers in this array.") + parsed + } + if (constraints.uniqueItems && values.distinct().size != values.size) { + return DynamicIntegerArrayParseResult.Invalid("Use each whole number only once.") + } + values.forEach { item -> + if (constraints.minimum?.let { item < it } == true) { + return DynamicIntegerArrayParseResult.Invalid( + "Use whole numbers greater than or equal to ${constraints.minimum}.", + ) + } + if (constraints.maximum?.let { item > it } == true) { + return DynamicIntegerArrayParseResult.Invalid( + "Use whole numbers less than or equal to ${constraints.maximum}.", + ) + } + if (constraints.exclusiveMinimum?.let { item <= it } == true) { + return DynamicIntegerArrayParseResult.Invalid( + "Use whole numbers greater than ${constraints.exclusiveMinimum}.", + ) + } + if (constraints.exclusiveMaximum?.let { item >= it } == true) { + return DynamicIntegerArrayParseResult.Invalid( + "Use whole numbers less than ${constraints.exclusiveMaximum}.", + ) + } + if (constraints.multipleOf?.let { item % it != 0L } == true) { + return DynamicIntegerArrayParseResult.Invalid( + "Use whole numbers divisible by ${constraints.multipleOf}.", + ) + } + } + return DynamicIntegerArrayParseResult.Valid(values) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt index f5f1ad36..1a880fb2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt @@ -1,6 +1,10 @@ package dev.obiente.nextcloudnative.nativeui.model import dev.obiente.nextcloudnative.template.scanBracedTemplate +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull /** A selected resource record and any exact parameter bindings already known by the host. */ data class DynamicResourceRecordContext( @@ -11,6 +15,13 @@ data class DynamicResourceRecordContext( val currentLayoutId: String? = null, val visitedStates: Set = emptySet(), val actionSafeIdentity: Boolean = true, + /** + * Whether the selected record's request, parent, and response identities agree. + * + * Read-only navigation may remain useful when display data is retained after a conflict, but + * the record must not authorize a mutation until its provenance is unambiguous again. + */ + val actionBindingProvenanceValid: Boolean = true, ) /** Resource/view/context identity used to prevent a dynamic navigation graph from revisiting itself. */ @@ -73,21 +84,44 @@ fun DynamicAppDescriptor.singleSafeContextualChild( /** * Chooses a useful collection child for container records that otherwise have a technical detail - * surface. This is semantic rather than app-specific: account-like containers prefer their single - * mailbox collection, and mailbox-like containers prefer their single message collection. + * surface. This is semantic rather than app-specific: list, checklist, project, and container + * records prefer their single item, task, or entry collection; account-like containers prefer + * their single mailbox collection; and mailbox-like containers prefer their single message + * collection. * - * Every candidate has already passed the planner's read-only, complete-binding, and cycle checks. - * Ambiguous equal-scoring children remain explicit tabs instead of being guessed. + * Automatic entry requires an accepted declared relationship, a read-only list action, a + * collection layout, and complete record-context binding. Ambiguous equal-scoring children remain + * explicit tabs instead of being guessed. */ fun DynamicAppDescriptor.preferredSemanticContextualChild( context: DynamicResourceRecordContext, ): DynamicNavigationDestination? { - val collectionLayoutIds = layouts + val actionsById = actions.associateBy(DynamicAction::id) + val collectionLayoutsById = layouts .filter(DynamicLayout::isCollectionNavigationLayout) - .mapTo(hashSetOf(), DynamicLayout::id) - val scored = planDynamicNavigation(context).contextualChildDestinations.mapNotNull { destination -> - if (destination.layoutId !in collectionLayoutIds) return@mapNotNull null - val score = preferredSemanticChildScore(context.resourceId, destination) + .associateBy(DynamicLayout::id) + val declaredChildLinksByAction = acyclicNavigationLinks(actionsById) + .filter { edge -> edge.link.resourceId.sameResourceAs(context.resourceId) } + .associateBy { edge -> edge.action.id } + val plannedChildren = planDynamicNavigation(context).contextualChildDestinations + val hasDeclaredContentCollection = plannedChildren.any { destination -> + semanticConceptsForDestination(destination).any(SEMANTIC_CONTENT_CONTAINER_CONCEPTS::contains) + } + val scored = plannedChildren.mapNotNull { destination -> + val layout = collectionLayoutsById[destination.layoutId] ?: return@mapNotNull null + val action = actionsById[destination.actionId] + ?.takeIf(DynamicAction::isCollectionReadAction) + ?: return@mapNotNull null + val edge = declaredChildLinksByAction[action.id] ?: return@mapNotNull null + if (!layout.resourceId.sameResourceAs(action.resourceId)) return@mapNotNull null + val resolution = action.resolveNavigationParameters(context, edge.link) + if (!resolution.complete || !resolution.usedContext) return@mapNotNull null + if (isSecondaryTechnicalDestination(context, destination)) return@mapNotNull null + val score = preferredSemanticChildScore( + context = context, + destination = destination, + hasDeclaredContentCollection = hasDeclaredContentCollection, + ) destination.takeIf { score > 0 }?.let { it to score } } val bestScore = scored.maxOfOrNull { (_, score) -> score } ?: return null @@ -111,39 +145,84 @@ fun DynamicAppDescriptor.isSecondaryTechnicalDestination( val child = ( destination.resourceId + " " + destination.label + " " + destination.actionId ).navigationSemanticIdentity() - if (TECHNICAL_CHILD_CONCEPTS.any(child::hasNavigationConcept)) return true + if (SECONDARY_CHILD_CONCEPTS.any(child::hasNavigationConcept)) return true val parentIsMessage = parent.hasNavigationConcept("message") || parent.hasNavigationConcept("email") return parentIsMessage && MESSAGE_HELPER_CHILD_CONCEPTS.any(child::hasNavigationConcept) } -private fun preferredSemanticChildScore( - parentResourceId: String, +private fun DynamicAppDescriptor.preferredSemanticChildScore( + context: DynamicResourceRecordContext, destination: DynamicNavigationDestination, + hasDeclaredContentCollection: Boolean, ): Int { - val parent = parentResourceId.navigationSemanticIdentity() - val parentIsAccount = parent.hasNavigationConcept("account") || parent.hasNavigationConcept("container") - val parentIsMailbox = parent.hasNavigationConcept("mailbox") || parent.hasNavigationConcept("folder") - val parentIsTaxonomy = - parentResourceId.hasAnySemanticConcept(SEMANTIC_TAXONOMY_CONCEPTS) - val mailboxEvidence = destination.semanticConceptEvidence("mailbox") - val folderEvidence = destination.semanticConceptEvidence("folder") - val messageEvidence = maxOf( - destination.semanticConceptEvidence("message"), - destination.semanticConceptEvidence("email"), + val parentConcepts = semanticConceptsForResource(context.resourceId) + val childConcepts = semanticConceptsForDestination(destination) + val parentIsContentContainer = parentConcepts.any(SEMANTIC_CONTENT_CONTAINER_CONCEPTS::contains) + val parentIsAccount = parentConcepts.any(SEMANTIC_ACCOUNT_CONTAINER_CONCEPTS::contains) + val parentIsMailbox = parentConcepts.any(SEMANTIC_MAILBOX_CONTAINER_CONCEPTS::contains) + val parentIsTaxonomy = parentConcepts.any(SEMANTIC_TAXONOMY_CONCEPTS::contains) + val contentEvidence = destination.semanticConceptEvidence( + childConcepts, + SEMANTIC_CONTENT_CHILD_CONCEPTS, + ) + val contentCollectionEvidence = destination.semanticConceptEvidence( + childConcepts, + SEMANTIC_CONTENT_CONTAINER_CONCEPTS, + ) + val mailboxEvidence = destination.semanticConceptEvidence(childConcepts, setOf("mailbox")) + val folderEvidence = destination.semanticConceptEvidence(childConcepts, setOf("folder")) + val messageEvidence = destination.semanticConceptEvidence(childConcepts, setOf("message", "email")) + val taxonomyContentEvidence = destination.semanticConceptEvidence( + childConcepts, + PRIMARY_CONTENT_ROOT_CONCEPTS, ) return when { + parentIsContentContainer && contentEvidence > 0 -> 500 + contentEvidence + hasDeclaredContentCollection && contentCollectionEvidence > 0 -> 450 + contentCollectionEvidence parentIsAccount && mailboxEvidence > 0 -> 400 + mailboxEvidence parentIsAccount && folderEvidence > 0 -> 300 + folderEvidence parentIsMailbox && messageEvidence > 0 -> 400 + messageEvidence - parentIsTaxonomy -> 350 + parentIsTaxonomy && taxonomyContentEvidence > 0 -> 350 + taxonomyContentEvidence else -> 0 } } -private fun DynamicNavigationDestination.semanticConceptEvidence(concept: String): Int = when { - resourceId.navigationSemanticIdentity().hasNavigationConcept(concept) -> 3 - label.navigationSemanticIdentity().hasNavigationConcept(concept) -> 2 - actionId.navigationSemanticIdentity().hasNavigationConcept(concept) -> 1 +private fun DynamicAppDescriptor.semanticConceptsForResource(resourceId: String): Set = buildSet { + addAll(resourceId.semanticConceptTokens()) + resources + .asSequence() + .filter { resource -> resource.id.sameResourceAs(resourceId) } + .flatMap { resource -> sequenceOf(resource.id, resource.label) } + .flatMap { value -> value.semanticConceptTokens().asSequence() } + .forEach(::add) + layouts + .asSequence() + .filter { layout -> layout.resourceId.sameResourceAs(resourceId) } + .flatMap { layout -> sequenceOf(layout.resourceId, layout.title) } + .flatMap { value -> value.semanticConceptTokens().asSequence() } + .forEach(::add) +} + +private fun DynamicAppDescriptor.semanticConceptsForDestination( + destination: DynamicNavigationDestination, +): Set = buildSet { + addAll(destination.resourceId.semanticConceptTokens()) + addAll(destination.label.semanticConceptTokens()) + addAll(destination.actionId.semanticConceptTokens()) + actions.firstOrNull { action -> action.id == destination.actionId }?.let { action -> + addAll(action.label.semanticConceptTokens()) + addAll(action.resourceId.semanticConceptTokens()) + } +} + +private fun DynamicNavigationDestination.semanticConceptEvidence( + concepts: Set, + expectedConcepts: Set, +): Int = when { + resourceId.semanticConceptTokens().any(expectedConcepts::contains) -> 3 + label.semanticConceptTokens().any(expectedConcepts::contains) -> 2 + actionId.semanticConceptTokens().any(expectedConcepts::contains) -> 1 + concepts.any(expectedConcepts::contains) -> 1 else -> 0 } @@ -153,13 +232,18 @@ private fun String.navigationSemanticIdentity(): String = private fun String.hasNavigationConcept(singular: String): Boolean = contains(singular) || contains("${singular}s") -private val TECHNICAL_CHILD_CONCEPTS = setOf( +private val SECONDARY_CHILD_CONCEPTS = setOf( + "archive", + "deleted", "debug", "diagnostic", + "history", "internal", "metadata", "protocol", + "recycle", "schema", + "trash", ) private val MESSAGE_HELPER_CHILD_CONCEPTS = setOf( @@ -173,6 +257,29 @@ private val MESSAGE_HELPER_CHILD_CONCEPTS = setOf( "thread", ) +private val SEMANTIC_CONTENT_CONTAINER_CONCEPTS = setOf( + "checklist", + "container", + "list", + "project", +) + +private val SEMANTIC_CONTENT_CHILD_CONCEPTS = setOf( + "entry", + "item", + "task", +) + +private val SEMANTIC_ACCOUNT_CONTAINER_CONCEPTS = setOf( + "account", + "container", +) + +private val SEMANTIC_MAILBOX_CONTAINER_CONCEPTS = setOf( + "folder", + "mailbox", +) + /** * Resolves a declared read action for a selected record, including a response-observed identity. * Observed identities are deliberately allowed only here and in other read-navigation paths; form @@ -196,6 +303,7 @@ enum class DynamicChildCandidateStatus { selfEdge, cycle, missingContext, + ancestorOnlyContext, noLayout, noLink, } @@ -237,6 +345,7 @@ fun DynamicAppDescriptor.explainDynamicChildNavigation( rawContextLinks.isNotEmpty() && !accepted -> DynamicChildCandidateStatus.cycle !resolution.complete -> DynamicChildCandidateStatus.missingContext rawContextLinks.isEmpty() -> DynamicChildCandidateStatus.noLink + !resolution.usedSelectedRecord -> DynamicChildCandidateStatus.ancestorOnlyContext else -> DynamicChildCandidateStatus.noLink } DynamicChildNavigationDiagnostic( @@ -285,6 +394,12 @@ fun DynamicAppDescriptor.planDynamicNavigation( val rootForms = forms.mapNotNull { form -> val action = actionsById[form.actionId] ?: return@mapNotNull null if (action.binding.method == HttpMethod.GET || action.binding.pathParameters.isNotEmpty()) return@mapNotNull null + if ( + action.effect == ActionEffect.upload && + !action.isVerifiedCompiledUploadForm(form) + ) { + return@mapNotNull null + } if (rootResourceIds.none { root -> root.sameResourceAs(form.resourceId) }) return@mapNotNull null DynamicNavigationFormAction( formId = form.id, @@ -321,6 +436,7 @@ fun DynamicAppDescriptor.planDynamicNavigation( ) return@mapNotNull null val resolution = action.resolveNavigationParameters(selectedRecord, link) if (!resolution.complete || (!resolution.usedContext && link == null)) return@mapNotNull null + if (!resolution.usedSelectedRecord) return@mapNotNull null if (!layout.advancesFrom(selectedRecord, action, resolution, link)) return@mapNotNull null layout.toDestination( action = action, @@ -332,19 +448,43 @@ fun DynamicAppDescriptor.planDynamicNavigation( .sortedWith(compareBy(DynamicNavigationDestination::label, DynamicNavigationDestination::layoutId)) .toList() - val contextualForms = forms.mapNotNull { form -> - val action = actionsById[form.actionId] ?: return@mapNotNull null - if (action.binding.method == HttpMethod.GET || action.binding.pathParameters.isEmpty()) return@mapNotNull null - val resolution = action.resolveNavigationParameters(selectedRecord, allowEphemeralIdentity = false) - if (!resolution.complete || !resolution.usedContext) return@mapNotNull null - DynamicNavigationFormAction( - formId = form.id, - label = form.title, - resourceId = form.resourceId, - actionId = action.id, - pathParameterValues = resolution.values, - ) - }.sortedWith(compareBy(DynamicNavigationFormAction::label, DynamicNavigationFormAction::formId)) + val contextualForms = forms + .takeIf { selectedRecord.actionBindingProvenanceValid } + .orEmpty() + .mapNotNull { form -> + val action = actionsById[form.actionId] ?: return@mapNotNull null + if (action.binding.method == HttpMethod.GET) return@mapNotNull null + if ( + action.effect == ActionEffect.upload && + !action.isVerifiedCompiledUploadForm(form) + ) { + return@mapNotNull null + } + if ( + action.intent == ActionIntent.update && + !action.resourceId.sameResourceAs(selectedRecord.resourceId) && + !hasUnambiguousContextBoundSingletonUpdate( + action = action, + form = form, + context = selectedRecord, + ) + ) { + return@mapNotNull null + } + val values = action.resolveContextualFormValues( + form = form, + context = selectedRecord, + parentLinks = actionLinks, + ) ?: return@mapNotNull null + if (!selectedRecord.permitsContextualForm(action, resources)) return@mapNotNull null + DynamicNavigationFormAction( + formId = form.id, + label = form.title, + resourceId = form.resourceId, + actionId = action.id, + pathParameterValues = values, + ) + }.sortedWith(compareBy(DynamicNavigationFormAction::label, DynamicNavigationFormAction::formId)) return DynamicNavigationPlan( rootDestinations = rootDestinations, @@ -354,6 +494,216 @@ fun DynamicAppDescriptor.planDynamicNavigation( ) } +private fun DynamicAction.isVerifiedCompiledUploadForm(form: DynamicForm): Boolean { + val body = binding.body ?: return false + val properties = (body.schema as? JsonObject) + ?.get("properties") as? JsonObject + ?: return false + val fileField = form.fields.singleOrNull { field -> field.kind == FieldKind.file } + ?: return false + val fileSchema = properties[fileField.fieldId] as? JsonObject ?: return false + return intent == ActionIntent.execute && + effect == ActionEffect.upload && + risk == ActionRisk.mutating && + hasVerifiedDynamicContractEvidence() && + form.hasVerifiedDynamicContractEvidence() && + form.resourceId.sameResourceAs(resourceId) && + fileSchema["type"] == JsonPrimitive("string") && + fileSchema["format"] == JsonPrimitive("binary") && + body.contentType.substringBefore(';').trim().lowercase().startsWith("multipart/") +} + +/** + * A scoped singleton write may target the active child surface while its trusted context record is + * the selected parent. The exact active detail layout must prove one read surface for the action's + * resource; this is deliberately separate from ordinary same-record update authorization. + */ +private fun DynamicAppDescriptor.hasUnambiguousContextBoundSingletonUpdate( + action: DynamicAction, + form: DynamicForm, + context: DynamicResourceRecordContext, +): Boolean { + if ( + action.risk != ActionRisk.mutating || + !action.hasVerifiedDynamicContractEvidence() || + !form.hasVerifiedDynamicContractEvidence() || + !form.resourceId.sameResourceAs(action.resourceId) + ) { + return false + } + val currentLayoutId = context.currentLayoutId ?: return false + val activeLayout = layouts.singleOrNull { layout -> + layout.id == currentLayoutId && + layout.kind == LayoutKind.detail && + layout.resourceId.sameResourceAs(action.resourceId) && + layout.hasVerifiedDynamicContractEvidence() + } ?: return false + val readActionId = activeLayout.sourceActionId ?: return false + val activeRead = actions.singleOrNull { candidate -> + candidate.id == readActionId && + candidate.resourceId.sameResourceAs(action.resourceId) && + candidate.binding.method == HttpMethod.GET && + candidate.intent in setOf(ActionIntent.read, ActionIntent.list) && + candidate.risk == ActionRisk.readOnly && + candidate.hasVerifiedDynamicContractEvidence() + } ?: return false + if (activeRead.id == action.id) return false + if (!activeRead.binding.isExactContextBoundSingletonRoute(action.binding)) return false + return resources.count { resource -> + resource.id.sameResourceAs(action.resourceId) + } == 1 +} + +private fun DynamicHttpBinding.isExactContextBoundSingletonRoute( + writeBinding: DynamicHttpBinding, +): Boolean = + path == writeBinding.path && + pathParameters.map(HttpParameter::name).toSet() == + writeBinding.pathParameters.map(HttpParameter::name).toSet() && + queryParameters.filter(HttpParameter::required).map(HttpParameter::name).toSet() == + writeBinding.queryParameters.filter(HttpParameter::required).map(HttpParameter::name).toSet() + +private fun DynamicAction.hasVerifiedDynamicContractEvidence(): Boolean = + confidence == Confidence.verified || + ( + confidence == Confidence.high && + provenance.any { evidence -> + evidence.kind == ProvenanceKind.verifiedAppPackage + } + ) + +private fun DynamicForm.hasVerifiedDynamicContractEvidence(): Boolean = + confidence == Confidence.verified || + ( + confidence == Confidence.high && + provenance.any { evidence -> + evidence.kind == ProvenanceKind.verifiedAppPackage + } + ) + +private fun DynamicLayout.hasVerifiedDynamicContractEvidence(): Boolean = + confidence == Confidence.verified || + ( + confidence == Confidence.high && + provenance.any { evidence -> + evidence.kind == ProvenanceKind.verifiedAppPackage + } + ) + +/** + * Applies exact record-level capability fields before exposing a mutation form. + * + * A relationship-proven child create is governed by the child action contract, not by whether the + * selected parent itself is editable. For same-record writes, however, a declared capability whose + * value is absent or malformed is unknown and therefore cannot authorize the form. Once any + * edit/delete capability is declared, each mutation category needs its own affirmative evidence. + */ +private fun DynamicResourceRecordContext.permitsContextualForm( + action: DynamicAction, + resources: List, +): Boolean { + if (!action.resourceId.sameResourceAs(resourceId)) return true + val resource = resources.firstOrNull { candidate -> + candidate.id.sameResourceAs(action.resourceId) + } ?: return true + val capabilityFields = resource.fields.mapNotNull { field -> + val semanticId = field.id.lowercase().filter(Char::isLetterOrDigit) + semanticId.takeIf(RECORD_MUTATION_CAPABILITY_IDS::contains)?.let { it to field.id } + }.toMap() + if (capabilityFields.isEmpty()) return true + + fun declaredCapability(id: String): Boolean? { + val fieldId = capabilityFields[id] ?: return null + return fieldValues[fieldId]?.dynamicCapabilityBooleanOrNull() + } + + if ("readonly" in capabilityFields && declaredCapability("readonly") != false) return false + if ("writable" in capabilityFields && declaredCapability("writable") != true) return false + if ("canwrite" in capabilityFields && declaredCapability("canwrite") != true) return false + + val deletion = when (action.effect) { + ActionEffect.delete, + ActionEffect.permanentDelete, + -> true + ActionEffect.clear, + ActionEffect.leave, + -> false + else -> action.intent == ActionIntent.delete + } + val scopedCapabilities = setOf("canedit", "canupdate", "candelete") + .filter(capabilityFields::containsKey) + if (scopedCapabilities.isEmpty()) return true + return if (deletion) { + "candelete" in scopedCapabilities && declaredCapability("candelete") == true + } else { + val editCapabilities = setOf("canedit", "canupdate").filter(scopedCapabilities::contains) + action.intent !in setOf(ActionIntent.update, ActionIntent.execute) || + ( + editCapabilities.isNotEmpty() && + editCapabilities.all { id -> declaredCapability(id) == true } + ) + } +} + +private fun String.dynamicCapabilityBooleanOrNull(): Boolean? = when (trim().lowercase()) { + "true", "1", "yes" -> true + "false", "0", "no" -> false + else -> null +} + +/** + * Resolves a contextual mutation from either route parameters or one exact required parent field. + * + * Some verified contracts scope child creation in the request body instead of the URL. Such an + * action is contextual only when an accepted parent-child link proves the resource relationship, + * the action is a create mutation, and one required form field names the selected parent exactly. + * This keeps the rule reusable while withholding unrelated or ambiguous writes. + */ +private fun DynamicAction.resolveContextualFormValues( + form: DynamicForm, + context: DynamicResourceRecordContext, + parentLinks: List, +): Map? { + val routeResolution = resolveNavigationParameters(context, allowEphemeralIdentity = false) + // A same-named field is contextual data, not proof that this mutation targets the selected + // record. Route writes require the resolver to identify the selected record itself. + if (routeResolution.complete && routeResolution.usedSelectedRecord) { + return routeResolution.values + } + if (!routeResolution.complete || routeResolution.usedContext) return null + if (intent != ActionIntent.create || risk != ActionRisk.mutating) return null + if (!context.actionSafeIdentity || !context.actionBindingProvenanceValid) return null + + val parentLink = parentLinks.singleOrNull { edge -> + edge.action.resourceId.sameResourceAs(resourceId) + } ?: return null + + val requiredBodyFieldIds = ((binding.body?.schema as? JsonObject)?.get("required") as? JsonArray) + ?.mapNotNull { element -> (element as? JsonPrimitive)?.contentOrNull } + ?.toSet() + .orEmpty() + if (requiredBodyFieldIds.isEmpty()) return null + val bodyFieldNames = form.fields + .asSequence() + .filter(FormField::required) + .map(FormField::fieldId) + .filter(requiredBodyFieldIds::contains) + .filter { fieldId -> fieldId.isContextFilterFor(context.resourceId) } + .distinct() + .toList() + val parentFieldId = bodyFieldNames.singleOrNull() ?: return null + val parentValue = context.exactValue(parentFieldId) + ?: context.recordIdFor( + parameterName = parentFieldId, + actionResourceId = resourceId, + actionPath = binding.path, + link = parentLink.link, + allowEphemeralIdentity = false, + ) + ?: return null + return routeResolution.values + (parentFieldId to parentValue) +} + private fun String.normalizedActionLabel(): String = lowercase() .replace(Regex("^\\[api\\s+v?[0-9.]+]\\s*"), "") .replace(" a ", " ") @@ -376,6 +726,7 @@ private val ROOT_SINGLETON_IDENTITIES = setOf( "config", "configuration", "household", + "prefs", "preferences", "profile", "settings", @@ -391,7 +742,24 @@ private fun DynamicAction.isContextualReadAction(): Boolean = private fun DynamicAction.isRootReadAction(): Boolean = binding.method == HttpMethod.GET && intent in setOf(ActionIntent.list, ActionIntent.read) && - risk == ActionRisk.readOnly && !binding.hasUnboundRequiredBodyFields() + risk == ActionRisk.readOnly && + !binding.hasUnboundRequiredBodyFields() && + !isInteractiveLookupHelper() + +/** + * Search-as-you-type endpoints are data sources for relation pickers, not standalone app roots. + * Require both explicit helper semantics and a declared query term so ordinary filterable + * collections and search-centric apps remain navigable. + */ +private fun DynamicAction.isInteractiveLookupHelper(): Boolean { + val concepts = ( + id + " " + label + " " + resourceId + " " + binding.path + ).semanticConceptTokens() + if (concepts.none(INTERACTIVE_LOOKUP_CONCEPTS::contains)) return false + return binding.queryParameters.any { parameter -> + parameter.name.semanticConceptTokens().any(INTERACTIVE_LOOKUP_QUERY_CONCEPTS::contains) + } +} /** * A response contract may expose helper GET routes whose input is modeled as a JSON body, such as @@ -404,6 +772,23 @@ private fun DynamicHttpBinding.hasUnboundRequiredBodyFields(): Boolean { ?.isNotEmpty() == true } +private val INTERACTIVE_LOOKUP_CONCEPTS = setOf( + "autocomplete", + "autocompletion", + "lookup", + "suggest", + "suggestion", + "typeahead", +) + +private val INTERACTIVE_LOOKUP_QUERY_CONCEPTS = setOf( + "prefix", + "query", + "search", + "term", + "text", +) + private fun DynamicLayout.toDestination( action: DynamicAction, pathParameterValues: Map, @@ -420,6 +805,7 @@ private data class PathParameterResolution( val values: Map, val complete: Boolean, val usedContext: Boolean, + val usedSelectedRecord: Boolean = false, val missingRequiredParameterNames: List = emptyList(), ) @@ -450,21 +836,76 @@ private fun DynamicAction.resolveNavigationParameters( } val resolved = linkedMapOf() var usedContext = false + var usedSelectedRecord = false navigationParameters.forEach { parameter -> - val value = context?.exactValue(parameter.name) - ?: context?.recordId.takeIf { + val exactParameterValue = context?.parameterValues + ?.get(parameter.name) + ?.takeIf(String::isNotBlank) + val exactFieldValue = context?.fieldValues + ?.get(parameter.name) + ?.takeIf { !it.isNullOrBlank() } + val ephemeralIdentityValue = context?.recordId.takeIf { context != null && allowEphemeralIdentity && parameter.name.isContextFilterFor(context.resourceId) } - ?: context?.recordIdFor( + val declaredIdentityValue = context?.recordIdFor( parameterName = parameter.name, actionResourceId = resourceId, actionPath = binding.path, link = link, allowEphemeralIdentity = allowEphemeralIdentity, ) + val value = exactParameterValue + ?: exactFieldValue + ?: ephemeralIdentityValue + ?: declaredIdentityValue if (!value.isNullOrBlank()) { resolved[parameter.name] = value usedContext = true + val exactParameterIsSelectedIdentity = + exactParameterValue != null && + ( + parameter.name.isContextFilterFor(context.resourceId) || + ( + parameter.name.isIdentityField() && + binding.path.nestsParameterUnderResource( + parameter.name, + context.resourceId, + ) + ) + ) + val exactFieldIsSelectedIdentity = + exactParameterValue == null && + exactFieldValue != null && + ( + parameter.name.isContextFilterFor(context.resourceId) || + ( + parameter.name.isIdentityField() && + ( + link?.resourceId?.sameResourceAs(context.resourceId) == true || + binding.path.nestsParameterUnderResource( + parameter.name, + context.resourceId, + ) + ) + ) + ) + val ephemeralIdentityWasSelected = + exactParameterValue == null && + exactFieldValue == null && + ephemeralIdentityValue != null + val declaredIdentityWasSelected = + exactParameterValue == null && + exactFieldValue == null && + ephemeralIdentityValue == null && + declaredIdentityValue != null + if ( + exactParameterIsSelectedIdentity || + exactFieldIsSelectedIdentity || + ephemeralIdentityWasSelected || + declaredIdentityWasSelected + ) { + usedSelectedRecord = true + } } } val unresolvedPlaceholders = pathTemplate.tokens.any { it.name !in resolved } @@ -475,6 +916,7 @@ private fun DynamicAction.resolveNavigationParameters( values = resolved, complete = !unresolvedPlaceholders && missingRequiredNames.isEmpty(), usedContext = usedContext, + usedSelectedRecord = usedSelectedRecord, missingRequiredParameterNames = missingRequiredNames, ) } @@ -685,6 +1127,15 @@ private fun DynamicNavigationDestination.primaryRootScore(descriptor: DynamicApp return score } +private val RECORD_MUTATION_CAPABILITY_IDS = setOf( + "readonly", + "writable", + "canwrite", + "canedit", + "canupdate", + "candelete", +) + private val PRIMARY_CONTENT_ROOT_CONCEPTS = setOf( "board", "card", @@ -743,7 +1194,7 @@ private fun String.resourceIdentity(): String { return when { tail.endsWith("ies") && tail.length > 3 -> tail.dropLast(3) + "y" tail.endsWith("ches") || tail.endsWith("shes") -> tail.dropLast(2) - tail.endsWith("ses") || tail.endsWith("xes") || tail.endsWith("zes") -> tail.dropLast(2) + tail.endsWith("sses") || tail.endsWith("xes") || tail.endsWith("zes") -> tail.dropLast(2) tail.endsWith("s") && tail.length > 1 -> tail.dropLast(1) else -> tail } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredForm.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredForm.kt new file mode 100644 index 00000000..0c5c1fd7 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredForm.kt @@ -0,0 +1,506 @@ +package dev.obiente.nextcloudnative.nativeui.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Native editor marker for a bounded array whose entries are exact contract-declared objects. + * + * A renderer should edit [RepeatableObjectInputRow] values directly. JSON is produced only at the + * action boundary by [encode], so users never need to author an opaque JSON document. + */ +const val DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT: String = "nextcloud-repeatable-object-array" + +@Serializable +data class RepeatableObjectInputSpec( + val minimumItems: Int, + val maximumItems: Int, + val fields: List, +) { + init { + require(minimumItems in 0..maximumItems) + require(maximumItems in 1..MAX_REPEATABLE_OBJECT_INPUT_ITEMS) + require(fields.isNotEmpty() && fields.size <= MAX_REPEATABLE_OBJECT_INPUT_FIELDS) + require(fields.map(RepeatableObjectInputFieldSpec::id).distinct().size == fields.size) + } + + fun encode(rows: List): String = + canonicalJson(rows).toString() + + internal fun canonicalJson(rows: List): JsonArray { + require(rows.size in minimumItems..maximumItems) { + "Enter between $minimumItems and $maximumItems items." + } + val fieldsById = fields.associateBy(RepeatableObjectInputFieldSpec::id) + return JsonArray( + rows.mapIndexed { index, row -> + require( + row.values.keys.all(fieldsById::containsKey) && + row.nullFieldIds.all(fieldsById::containsKey) && + row.values.keys.none(row.nullFieldIds::contains), + ) { + "Item ${index + 1} contains an undeclared field." + } + JsonObject( + fields.mapNotNull { field -> + if (field.id in row.nullFieldIds) { + require(field.nullable) { + "Item ${index + 1}: ${field.label} cannot be null." + } + return@mapNotNull field.id to JsonNull + } + val supplied = row.values[field.id] + if (supplied == null || supplied.isBlank()) { + require(!field.required) { + "Item ${index + 1}: ${field.label} is required." + } + null + } else { + field.id to field.canonicalJsonValue(supplied, index) + } + }.toMap(), + ) + }, + ) + } + + internal fun canonicalJson(encoded: String): JsonArray { + require(encoded.length <= MAX_REPEATABLE_OBJECT_INPUT_LENGTH) { + "This structured value is too large." + } + val array = runCatching { Json.parseToJsonElement(encoded) }.getOrNull() as? JsonArray + ?: error("This value is not a structured item list.") + val rows = array.mapIndexed { index, element -> + val item = element as? JsonObject + ?: error("Item ${index + 1} must be an object.") + val nullFieldIds = item.entries + .filter { (_, value) -> value is JsonNull } + .mapTo(linkedSetOf()) { (fieldId, _) -> fieldId } + RepeatableObjectInputRow( + values = item.filterValues { value -> value !is JsonNull }.mapValues { (fieldId, value) -> + fields.singleOrNull { field -> field.id == fieldId } + ?.wireValue(value, index) + ?: error("Item ${index + 1} contains an undeclared field.") + }, + nullFieldIds = nullFieldIds, + ) + } + return canonicalJson(rows) + } +} + +@Serializable +data class RepeatableObjectInputRow( + val values: Map = emptyMap(), + val nullFieldIds: Set = emptySet(), +) + +@Serializable +data class RepeatableObjectInputFieldSpec( + val id: String, + val label: String, + val kind: RepeatableObjectInputScalarKind, + val required: Boolean, + val nullable: Boolean = false, + val format: String? = null, + val enumValues: List? = null, + val minimum: String? = null, + val maximum: String? = null, + val minimumLength: Int? = null, + val maximumLength: Int? = null, +) { + init { + require(id.isSafeRepeatableObjectFieldId()) + require(label.isNotBlank() && label.length <= MAX_REPEATABLE_OBJECT_INPUT_LABEL_LENGTH) + require(enumValues?.let { values -> + values.isNotEmpty() && + values.size <= MAX_REPEATABLE_OBJECT_ENUM_VALUES && + values.distinct().size == values.size && + values.all(String::isSafeRepeatableObjectScalar) + } != false) + require(minimumLength?.let { it >= 0 } != false) + require(maximumLength?.let { it >= 0 } != false) + require( + minimumLength == null || maximumLength == null || minimumLength <= maximumLength, + ) + } + + internal fun canonicalJsonValue(value: String, rowIndex: Int): JsonElement { + val prefix = "Item ${rowIndex + 1}: $label" + require(value.length <= MAX_REPEATABLE_OBJECT_SCALAR_LENGTH && value.none(Char::isISOControl)) { + "$prefix contains unsupported text." + } + return when (kind) { + RepeatableObjectInputScalarKind.String -> { + minimumLength?.let { minimum -> + require(value.length >= minimum) { "$prefix is too short." } + } + maximumLength?.let { maximum -> + require(value.length <= maximum) { "$prefix is too long." } + } + JsonPrimitive(value) + } + RepeatableObjectInputScalarKind.Enumeration -> { + require(value in enumValues.orEmpty()) { "$prefix is not an allowed value." } + JsonPrimitive(value) + } + RepeatableObjectInputScalarKind.Integer -> { + val parsed = value.toLongOrNull() ?: error("$prefix must be a whole number.") + minimum?.toLongOrNull()?.let { lower -> + require(parsed >= lower) { "$prefix is below the allowed minimum." } + } + maximum?.toLongOrNull()?.let { upper -> + require(parsed <= upper) { "$prefix exceeds the allowed maximum." } + } + JsonPrimitive(parsed) + } + RepeatableObjectInputScalarKind.Decimal -> { + val parsed = value.toExactJsonDecimalOrNull() + ?: error("$prefix must be an exact JSON number.") + minimum?.toExactJsonDecimalOrNull()?.let { lower -> + require(parsed >= lower) { "$prefix is below the allowed minimum." } + } + maximum?.toExactJsonDecimalOrNull()?.let { upper -> + require(parsed <= upper) { "$prefix exceeds the allowed maximum." } + } + parsed.primitive + } + RepeatableObjectInputScalarKind.Boolean -> + JsonPrimitive(value.toBooleanStrictOrNull() ?: error("$prefix must be true or false.")) + } + } + + internal fun wireValue(value: JsonElement, rowIndex: Int): String { + require(value !is JsonNull) { "Item ${rowIndex + 1}: $label requires explicit null state." } + val primitive = value as? JsonPrimitive + ?: error("Item ${rowIndex + 1}: $label must be a scalar value.") + return when (kind) { + RepeatableObjectInputScalarKind.String, + RepeatableObjectInputScalarKind.Enumeration, + -> primitive.takeIf(JsonPrimitive::isString)?.contentOrNull + RepeatableObjectInputScalarKind.Integer -> + primitive.takeUnless(JsonPrimitive::isString)?.longOrNull?.toString() + RepeatableObjectInputScalarKind.Decimal -> + primitive.takeUnless(JsonPrimitive::isString)?.contentOrNull + ?.takeIf { value -> value.toExactJsonDecimalOrNull() != null } + RepeatableObjectInputScalarKind.Boolean -> + primitive.takeUnless(JsonPrimitive::isString)?.booleanOrNull?.toString() + } ?: error("Item ${rowIndex + 1}: $label has the wrong scalar type.") + } +} + +@Serializable +enum class RepeatableObjectInputScalarKind { + String, + Integer, + Decimal, + Boolean, + Enumeration, +} + +internal fun JsonElement?.repeatableObjectInputSpec(): RepeatableObjectInputSpec? { + val array = this as? JsonObject ?: return null + if ((array["type"] as? JsonPrimitive)?.contentOrNull != "array") return null + if (!array.keys.all(REPEATABLE_OBJECT_ARRAY_SCHEMA_KEYS::contains)) return null + if ((array["format"] as? JsonPrimitive)?.contentOrNull != DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT) { + return null + } + if ("nullable" in array && (array["nullable"] as? JsonPrimitive)?.booleanOrNull == null) return null + if ("uniqueItems" in array) { + val uniqueItems = (array["uniqueItems"] as? JsonPrimitive)?.booleanOrNull ?: return null + if (uniqueItems) return null + } + if (!array.hasOptionalNonNegativeStructuredInt("minItems")) return null + if (!array.hasOptionalNonNegativeStructuredInt("maxItems")) return null + val declaredMinimum = array.nonNegativeStructuredInt("minItems") ?: 0 + val declaredMaximum = array.nonNegativeStructuredInt("maxItems") + ?: MAX_REPEATABLE_OBJECT_INPUT_ITEMS + val maximum = minOf(declaredMaximum, MAX_REPEATABLE_OBJECT_INPUT_ITEMS) + if (declaredMinimum > maximum) return null + val item = array["items"] as? JsonObject ?: return null + if ((item["type"] as? JsonPrimitive)?.contentOrNull != "object") return null + if (!item.keys.all(REPEATABLE_OBJECT_ITEM_SCHEMA_KEYS::contains)) return null + if ( + "additionalProperties" in item && + (item["additionalProperties"] as? JsonPrimitive)?.booleanOrNull == null + ) { + return null + } + if ((item["additionalProperties"] as? JsonPrimitive)?.booleanOrNull == true) return null + val properties = item["properties"] as? JsonObject ?: return null + if (properties.isEmpty() || properties.size > MAX_REPEATABLE_OBJECT_INPUT_FIELDS) return null + val required = item.structuredRequiredProperties() ?: return null + if (!required.all(properties::containsKey)) return null + val fields = properties.mapNotNull { (id, element) -> + element.toRepeatableObjectInputField(id, id in required) + } + if (fields.size != properties.size) return null + return runCatching { + RepeatableObjectInputSpec( + minimumItems = declaredMinimum, + maximumItems = maximum, + fields = fields, + ) + }.getOrNull() +} + +private fun JsonElement.toRepeatableObjectInputField( + id: String, + required: Boolean, +): RepeatableObjectInputFieldSpec? { + val schema = this as? JsonObject ?: return null + if (!schema.keys.all(REPEATABLE_OBJECT_SCALAR_SCHEMA_KEYS::contains)) return null + val type = (schema["type"] as? JsonPrimitive)?.contentOrNull ?: return null + if ("nullable" in schema && (schema["nullable"] as? JsonPrimitive)?.booleanOrNull == null) return null + if ( + "format" in schema && + (schema["format"] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull == null + ) { + return null + } + val nullable = (schema["nullable"] as? JsonPrimitive)?.booleanOrNull == true + val enumValues = schema.structuredStringEnum() + if ("enum" in schema && enumValues == null) return null + val recoveredEnum = schema[DESCRIPTION_ENUM_EXTENSION] + ?.let { it as? JsonArray } + ?.mapNotNull { value -> (value as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull } + ?.takeIf { values -> values.isNotEmpty() && values.distinct().size == values.size } + if (DESCRIPTION_ENUM_EXTENSION in schema && recoveredEnum == null) return null + val allowed = enumValues ?: recoveredEnum + val kind = when (type) { + "string" -> if (allowed != null) { + RepeatableObjectInputScalarKind.Enumeration + } else { + RepeatableObjectInputScalarKind.String + } + "integer" -> RepeatableObjectInputScalarKind.Integer + "number" -> RepeatableObjectInputScalarKind.Decimal + "boolean" -> RepeatableObjectInputScalarKind.Boolean + else -> return null + } + return runCatching { + val minimum = (schema["minimum"] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.contentOrNull + val maximum = (schema["maximum"] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.contentOrNull + val minimumLength = schema.nonNegativeStructuredInt("minLength") + val maximumLength = schema.nonNegativeStructuredInt("maxLength") + require("minimum" !in schema || minimum != null) + require("maximum" !in schema || maximum != null) + require(schema.hasOptionalNonNegativeStructuredInt("minLength")) + require(schema.hasOptionalNonNegativeStructuredInt("maxLength")) + when (type) { + "integer" -> { + require(minimum?.toLongOrNull() != null || minimum == null) + require(maximum?.toLongOrNull() != null || maximum == null) + require(minimum == null || maximum == null || minimum.toLong() <= maximum.toLong()) + } + "number" -> { + val exactMinimum = minimum?.toExactJsonDecimalOrNull() + val exactMaximum = maximum?.toExactJsonDecimalOrNull() + require(exactMinimum != null || minimum == null) + require(exactMaximum != null || maximum == null) + require(exactMinimum == null || exactMaximum == null || exactMinimum <= exactMaximum) + } + else -> require("minimum" !in schema && "maximum" !in schema) + } + RepeatableObjectInputFieldSpec( + id = id, + label = (schema["title"] as? JsonPrimitive)?.contentOrNull + ?.takeIf { it.isNotBlank() && it.length <= MAX_REPEATABLE_OBJECT_INPUT_LABEL_LENGTH } + ?: id.structuredHumanize(), + kind = kind, + required = required, + nullable = nullable, + format = (schema["format"] as? JsonPrimitive)?.contentOrNull, + enumValues = allowed, + minimum = minimum, + maximum = maximum, + minimumLength = minimumLength, + maximumLength = maximumLength, + ) + }.getOrNull() +} + +private fun JsonObject.structuredRequiredProperties(): Set? { + val element = this["required"] ?: return emptySet() + val array = element as? JsonArray ?: return null + val values = array.mapNotNull { item -> + (item as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + } + return values.toSet().takeIf { values.size == array.size && values.distinct().size == values.size } +} + +private fun JsonObject.structuredStringEnum(): List? { + val element = this["enum"] ?: return null + val array = element as? JsonArray ?: return null + val values = array.mapNotNull { item -> + (item as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + } + return values.takeIf { + values.size == array.size && + values.isNotEmpty() && + values.size <= MAX_REPEATABLE_OBJECT_ENUM_VALUES && + values.distinct().size == values.size && + values.all(String::isSafeRepeatableObjectScalar) + } +} + +private fun JsonObject.nonNegativeStructuredInt(name: String): Int? = + (this[name] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.contentOrNull + ?.toIntOrNull() + ?.takeIf { it >= 0 } + +private fun JsonObject.hasOptionalNonNegativeStructuredInt(name: String): Boolean = + name !in this || nonNegativeStructuredInt(name) != null + +private fun String.isSafeRepeatableObjectFieldId(): Boolean = + length in 1..128 && first().let { it.isLetter() || it == '_' } && + all { it.isLetterOrDigit() || it == '_' || it == '-' } + +private fun String.isSafeRepeatableObjectScalar(): Boolean = + length in 1..MAX_REPEATABLE_OBJECT_SCALAR_LENGTH && none(Char::isISOControl) + +/** + * A bounded, platform-neutral decimal representation used to compare JSON numbers without first + * rounding them through binary floating point. The original primitive is retained for emission so + * monetary, scientific, and large integer-like decimal values reach the server byte-for-byte. + */ +private data class ExactJsonDecimal( + val primitive: JsonPrimitive, + val negative: Boolean, + val digits: String, + val exponent: Int, +) : Comparable { + override fun compareTo(other: ExactJsonDecimal): Int { + if (digits == "0" && other.digits == "0") return 0 + if (negative != other.negative) return if (negative) -1 else 1 + val magnitude = compareMagnitude(other) + return if (negative) -magnitude else magnitude + } + + private fun compareMagnitude(other: ExactJsonDecimal): Int { + val decimalLength = digits.length.toLong() + exponent + val otherDecimalLength = other.digits.length.toLong() + other.exponent + if (decimalLength != otherDecimalLength) return decimalLength.compareTo(otherDecimalLength) + val width = maxOf(digits.length, other.digits.length) + repeat(width) { index -> + val left = digits.getOrElse(index) { '0' } + val right = other.digits.getOrElse(index) { '0' } + if (left != right) return left.compareTo(right) + } + return 0 + } +} + +private fun String.toExactJsonDecimalOrNull(): ExactJsonDecimal? { + if (isEmpty() || length > MAX_REPEATABLE_OBJECT_SCALAR_LENGTH) return null + val primitive = runCatching { Json.parseToJsonElement(this) as? JsonPrimitive }.getOrNull() + ?.takeUnless(JsonPrimitive::isString) + ?.takeIf { parsed -> parsed.contentOrNull == this } + ?: return null + val negative = startsWith('-') + val unsigned = if (negative) substring(1) else this + val exponentMarker = unsigned.indexOfFirst { character -> character == 'e' || character == 'E' } + val significand = if (exponentMarker < 0) unsigned else unsigned.substring(0, exponentMarker) + val declaredExponent = if (exponentMarker < 0) { + 0 + } else { + unsigned.substring(exponentMarker + 1).toIntOrNull() ?: return null + } + val decimalMarker = significand.indexOf('.') + val fractionLength = if (decimalMarker < 0) 0 else significand.length - decimalMarker - 1 + val rawDigits = significand.replace(".", "") + val firstSignificant = rawDigits.indexOfFirst { character -> character != '0' } + if (firstSignificant < 0) { + return ExactJsonDecimal(primitive, negative = false, digits = "0", exponent = 0) + } + val withoutLeadingZeros = rawDigits.substring(firstSignificant) + val trailingZeros = withoutLeadingZeros.reversed().indexOfFirst { character -> character != '0' } + .let { count -> if (count < 0) withoutLeadingZeros.length else count } + val digits = withoutLeadingZeros.dropLast(trailingZeros) + val exponent = declaredExponent.toLong() - fractionLength + trailingZeros + if (exponent !in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) return null + return ExactJsonDecimal( + primitive = primitive, + negative = negative, + digits = digits, + exponent = exponent.toInt(), + ) +} + +private fun String.structuredHumanize(): String = + replace(Regex("([a-z0-9])([A-Z])"), "$1 $2") + .replace('_', ' ') + .replace('-', ' ') + .trim() + .replaceFirstChar { character -> character.uppercase() } + .take(MAX_REPEATABLE_OBJECT_INPUT_LABEL_LENGTH) + +internal const val DESCRIPTION_ENUM_EXTENSION = "x-nextcloud-native-description-enum" +private const val MAX_REPEATABLE_OBJECT_INPUT_ITEMS = 64 +private const val MAX_REPEATABLE_OBJECT_INPUT_FIELDS = 16 +private const val MAX_REPEATABLE_OBJECT_ENUM_VALUES = 32 +private const val MAX_REPEATABLE_OBJECT_SCALAR_LENGTH = 4_096 +private const val MAX_REPEATABLE_OBJECT_INPUT_LENGTH = 256 * 1024 +private const val MAX_REPEATABLE_OBJECT_INPUT_LABEL_LENGTH = 160 +private val REPEATABLE_OBJECT_SCALAR_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "enum", + "example", + "examples", + "format", + "maxLength", + "maximum", + "minLength", + "minimum", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", + DESCRIPTION_ENUM_EXTENSION, +) +private val REPEATABLE_OBJECT_ARRAY_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "format", + "items", + "maxItems", + "minItems", + "nullable", + "readOnly", + "title", + "type", + "uniqueItems", + "writeOnly", +) +private val REPEATABLE_OBJECT_ITEM_SCHEMA_KEYS = setOf( + "\$comment", + "additionalProperties", + "deprecated", + "description", + "properties", + "readOnly", + "required", + "title", + "type", + "writeOnly", +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/NativeSchema.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/NativeSchema.kt index 2ac8b890..234a56fb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/NativeSchema.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/NativeSchema.kt @@ -62,6 +62,7 @@ data class FieldSpec( val readOnly: Boolean, val format: String? = null, val enumValues: List? = null, + val repeatableObjectInput: RepeatableObjectInputSpec? = null, ) @Serializable @@ -144,6 +145,9 @@ data class ActionSpec( val confidence: Confidence, val inputSchema: JsonElement? = null, val evidence: List = emptyList(), + val effect: ActionEffect = ActionEffect.unspecified, + /** Exact same-route GET action that can reconcile an unknown idempotent replacement result. */ + val resultRecoveryActionId: String? = null, ) @Serializable @@ -182,6 +186,39 @@ enum class ActionIntent { execute, } +/** + * Contract-derived user-visible effect of an action. + * + * HTTP methods do not describe product behavior: POST can create a record, toggle state, restore + * one, reorder a collection, or run a batch command. Keeping the effect separate lets generic + * renderers choose the right affordance and recovery behavior without knowing an app identity or + * endpoint. + */ +@Serializable +enum class ActionEffect { + unspecified, + list, + read, + create, + update, + delete, + permanentDelete, + empty, + toggle, + archive, + unarchive, + restore, + move, + copy, + reorder, + batch, + upload, + assign, + leave, + clear, + execute, +} + @Serializable enum class ActionRisk { readOnly, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt index a8f3c7d1..5119dd7d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt @@ -16,8 +16,11 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.horizontalScroll @@ -27,6 +30,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,6 +41,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -59,19 +64,25 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.Modifier import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntOffset @@ -90,17 +101,31 @@ import dev.obiente.nextcloudnative.app.design.NextcloudSpacing import dev.obiente.nextcloudnative.app.design.NextcloudTheme import dev.obiente.nextcloudnative.app.design.nextcloudCardInteractions import dev.obiente.nextcloudnative.app.design.resolveBoardDragVerticalLane +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_LIST_FORMAT import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.Confidence import dev.obiente.nextcloudnative.nativeui.model.FieldKind import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputFieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputRow +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputScalarKind +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputSpec import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import dev.obiente.nextcloudnative.nativeui.model.ViewSpec import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import kotlin.math.roundToInt fun interface NativeFileFieldPicker { @@ -111,6 +136,56 @@ fun interface NativeImageLoader { suspend fun load(relativePath: String): ImageBitmap? } +data class NativeCollectionBatchRelationLoadRequest( + val actionId: String, + val resourceId: String, + val relatedResourceIdsByField: Map, + val bindingValues: Map, + val forceRefresh: Boolean, +) { + init { + require(actionId.isNotBlank() && resourceId.isNotBlank()) + require(relatedResourceIdsByField.isNotEmpty()) + require(relatedResourceIdsByField.size <= MAX_NATIVE_COLLECTION_BATCH_RELATIONS) + require(relatedResourceIdsByField.all { (fieldId, relatedResourceId) -> + fieldId.isNotBlank() && relatedResourceId.isNotBlank() + }) + require(bindingValues.size <= MAX_NATIVE_COLLECTION_BATCH_RELATION_BINDINGS) + } +} + +data class NativeCollectionBatchRelationLoadResult( + val recordsByResourceId: Map>, + val errorsByResourceId: Map = emptyMap(), +) { + init { + require(recordsByResourceId.size <= MAX_NATIVE_COLLECTION_BATCH_RELATIONS) + require(errorsByResourceId.size <= MAX_NATIVE_COLLECTION_BATCH_RELATIONS) + require(recordsByResourceId.values.all { records -> + records.size <= MAX_NATIVE_COLLECTION_BATCH_RELATION_RECORDS && + records.map(NativeRecord::id).distinct().size == records.size + }) + require(errorsByResourceId.values.all { message -> + message.isNotBlank() && message.length <= MAX_NATIVE_COLLECTION_BATCH_RELATION_ERROR_LENGTH + }) + } +} + +fun interface NativeCollectionBatchRelationLoader { + suspend fun load( + request: NativeCollectionBatchRelationLoadRequest, + ): NativeCollectionBatchRelationLoadResult +} + +internal fun NativeDatasetContext.withCollectionBatchRelationRecords( + recordsByResourceId: Map>, +): NativeDatasetContext = copy( + // A batch picker is scoped to its own verified load. Ambient records may belong to another + // parent or an earlier form and therefore must never satisfy this dialog accidentally. + relatedRecords = recordsByResourceId, + relatedRecordPaging = emptyMap(), +) + /** * Drop-in renderer for a compiler- or adapter-produced [NativeAppSchema]. * @@ -130,15 +205,18 @@ fun GenericNativeAppScreen( onSelectRecord: ((NativeRecord) -> Unit)? = null, onOpenLink: ((String) -> Unit)? = null, filePicker: NativeFileFieldPicker? = null, - onActionSucceeded: (() -> Unit)? = null, + onActionSucceeded: ((ActionSpec) -> Unit)? = null, datasetContext: NativeDatasetContext = NativeDatasetContext(), - onInlineActionSucceeded: (() -> Unit)? = null, + onInlineActionSucceeded: ((ActionSpec) -> Unit)? = null, + showCollectionCreateAction: Boolean = false, imageLoader: NativeImageLoader? = null, onLoadMore: (() -> Unit)? = null, loadingMore: Boolean = false, loadMoreError: String? = null, audioPlayer: NativeAudioRecordPlayer? = null, mediaArtworkResolver: NativeMediaArtworkResolver? = null, + mutationReconciliationGeneration: Int = 0, + collectionBatchRelationLoader: NativeCollectionBatchRelationLoader? = null, ) { val resource = schema.resource(view.resourceId) val boardMoveReconciliation = remember(schema.app.id, view.id, resource?.id) { @@ -167,8 +245,316 @@ fun GenericNativeAppScreen( nestedBoard != null -> GenericNativeSurface.Board else -> view.genericSurface(presentedResource, presentedRecords) } + var pendingRecordFormActionToken by rememberSaveable(schema.app.id, view.id) { + mutableStateOf(null) + } + var pendingRecordCommandFormActionToken by rememberSaveable(schema.app.id, view.id) { + mutableStateOf(null) + } + var pendingRecordDeleteAction by remember(schema, view.id) { + mutableStateOf(null) + } + var pendingRecordCommandAction by remember(schema, view.id) { + mutableStateOf(null) + } + var pendingCollectionCommandActionId by rememberSaveable(schema.app.id, view.id) { + mutableStateOf(null) + } + var pendingCollectionBatchActionId by rememberSaveable(schema.app.id, view.id) { + mutableStateOf(null) + } + var pendingCollectionReorderActionId by rememberSaveable(schema.app.id, view.id) { + mutableStateOf(null) + } + val recordCommandsInFlight = remember(schema, view.id) { mutableSetOf() } + val recordCommandScope = rememberCoroutineScope() + val inlineActionSucceeded = onInlineActionSucceeded ?: onActionSucceeded + val activeMutationOwners = remember(schema.app.id) { + mutableSetOf() + } + var formMutationRecoveryToken by rememberSaveable(schema.app.id) { + mutableStateOf(null) + } + val formMutationRecovery = resolveNativeFormMutationRecoveryState( + encoded = formMutationRecoveryToken, + currentReconciliationGeneration = mutationReconciliationGeneration, + ownerStillExecuting = activeMutationOwners::contains, + ) + val normalizedFormMutationRecoveryToken = formMutationRecovery?.encode() + LaunchedEffect(normalizedFormMutationRecoveryToken, formMutationRecoveryToken) { + if (formMutationRecoveryToken != normalizedFormMutationRecoveryToken) { + formMutationRecoveryToken = normalizedFormMutationRecoveryToken + } + } + LaunchedEffect(formMutationRecovery?.owner, formMutationRecovery?.phase) { + val actionId = formMutationRecovery?.authoritativeReconciliationActionId + ?: return@LaunchedEffect + schema.action(actionId)?.let { action -> + inlineActionSucceeded?.invoke(action) + } + } + val openRecordEdit: (NativeRecord, NativeRecordFormActionPlan) -> Unit = edit@{ record, plan -> + if (formMutationRecovery?.blocksSubmission == true) return@edit + val actionResource = presentedResource ?: return@edit + pendingRecordFormActionToken = RestorableNativeRecordFormAction( + actionId = plan.action.id, + resourceId = actionResource.id, + kind = plan.kind, + recordId = record.id, + ).encode() + } + val openRecordCommandForm: (NativeRecord, NativeRecordCommandFormActionPlan) -> Unit = + commandForm@{ record, plan -> + if (formMutationRecovery?.blocksSubmission == true) return@commandForm + val actionResource = presentedResource ?: return@commandForm + pendingRecordCommandFormActionToken = RestorableNativeRecordFormAction( + actionId = plan.action.id, + resourceId = actionResource.id, + kind = NativeRecordFormActionKind.Edit, + recordId = record.id, + ).encode() + } + val openRecordDelete: (NativeRecord, NativeRecordDeleteActionPlan) -> Unit = { record, plan -> + pendingRecordDeleteAction = PendingNativeRecordDeleteAction( + plan = plan, + itemLabel = presentedResource + ?.let { resourceSpec -> nativeRecordPresentation(resourceSpec, record).title } + ?: record.id, + ) + } + val executeRecordCommand: (NativeRecord, NativeRecordCommandActionPlan) -> Unit = command@{ record, plan -> + val itemLabel = presentedResource + ?.let { resourceSpec -> nativeRecordPresentation(resourceSpec, record).title } + ?: record.id + if (plan.requiresConfirmation) { + pendingRecordCommandAction = PendingNativeRecordCommandAction( + plan = plan, + itemLabel = itemLabel, + ) + return@command + } + val executionKey = "${record.id}\u0000${plan.action.id}" + if (!recordCommandsInFlight.add(executionKey)) return@command + recordCommandScope.launch { + try { + when (val result = actionExecutor.execute(plan.request())) { + is NativeActionExecutionResult.Success -> inlineActionSucceeded?.invoke(plan.action) + is NativeActionExecutionResult.Failure -> { + if (result.outcome.requiresCommandReconciliation()) { + inlineActionSucceeded?.invoke(plan.action) + } + pendingRecordCommandAction = PendingNativeRecordCommandAction( + plan = plan, + itemLabel = itemLabel, + initialError = result.message, + initialFailureOutcome = result.outcome, + ) + } + } + } finally { + recordCommandsInFlight.remove(executionKey) + } + } + } + val collectionCreatePlan = presentedResource + ?.takeIf { showCollectionCreateAction } + ?.let { resource -> + nativeRecordActions( + schema = schema, + resource = resource, + navigationContext = datasetContext.bindingValues, + ).create + } + val openCollectionCreate: (() -> Unit)? = collectionCreatePlan?.let { plan -> + val actionResource = presentedResource + create@{ + if (formMutationRecovery?.blocksSubmission == true) return@create + pendingRecordFormActionToken = RestorableNativeRecordFormAction( + actionId = plan.action.id, + resourceId = actionResource.id, + kind = plan.kind, + recordId = null, + ).encode() + } + } + val collectionActionCapabilities = remember( + schema, + view.sourceActionId, + resource, + readyRecords, + datasetContext.bindingValues, + datasetContext.parentResourceId, + datasetContext.parentRecord, + onLoadMore, + presentedSurface, + nestedBoard, + state is NativeScreenState.Ready, + ) { + val activeReadAction = schema.action(view.sourceActionId) + if ( + state is NativeScreenState.Ready && + resource != null && + activeReadAction != null && + nestedBoard == null && + presentedSurface !in setOf(GenericNativeSurface.Detail, GenericNativeSurface.Form) + ) { + nativeCollectionActions( + schema = schema, + activeReadAction = activeReadAction, + resource = resource, + records = readyRecords, + navigationContext = datasetContext.bindingValues, + collectionComplete = onLoadMore == null, + authorityContext = datasetContext.nativeRecordAuthorityContext(schema), + ) + } else { + NativeCollectionActionCapabilities( + commands = emptyList(), + reorder = null, + batches = emptyList(), + ) + } + } + val collectionBatchPlans = collectionActionCapabilities.batches + .takeIf { readyRecords.isNotEmpty() } + .orEmpty() + val pendingCollectionCommandAction = pendingCollectionCommandActionId?.let { actionId -> + collectionActionCapabilities.commands.singleOrNull { plan -> plan.action.id == actionId } + } + val pendingCollectionBatchAction = pendingCollectionBatchActionId?.let { actionId -> + collectionBatchPlans.singleOrNull { plan -> plan.action.id == actionId } + } + val pendingCollectionReorderAction = pendingCollectionReorderActionId?.let { actionId -> + collectionActionCapabilities.reorder + ?.takeIf { plan -> plan.action.id == actionId } + } + LaunchedEffect( + pendingCollectionCommandActionId, + collectionActionCapabilities.commands.map { plan -> plan.action.id }, + ) { + if (pendingCollectionCommandActionId != null && pendingCollectionCommandAction == null) { + pendingCollectionCommandActionId = null + } + } + LaunchedEffect( + pendingCollectionBatchActionId, + collectionBatchPlans.map { plan -> plan.action.id }, + ) { + if (pendingCollectionBatchActionId != null && pendingCollectionBatchAction == null) { + pendingCollectionBatchActionId = null + } + } + val pendingCollectionBatchRecoveryOwner = pendingCollectionBatchAction?.let { plan -> + nativeCollectionBatchMutationRecoveryOwner( + appId = schema.app.id, + viewId = view.id, + actionId = plan.action.id, + resourceId = requireNotNull(resource).id, + ) + } + LaunchedEffect( + pendingCollectionReorderActionId, + collectionActionCapabilities.reorder?.action?.id, + ) { + if (pendingCollectionReorderActionId != null && pendingCollectionReorderAction == null) { + pendingCollectionReorderActionId = null + } + } + val pendingRecordFormAction = pendingRecordFormActionToken + ?.let(::decodeRestorableNativeRecordFormAction) + ?.let pending@{ saved -> + val actionResource = presentedResource + ?.takeIf { resourceSpec -> resourceSpec.id == saved.resourceId } + ?: schema.resource(saved.resourceId) + ?: return@pending null + val record = if (saved.recordId != null) { + presentedRecords.firstOrNull { candidate -> candidate.id == saved.recordId } + ?: return@pending null + } else { + null + } + val plan = nativeRecordActions( + schema = schema, + resource = actionResource, + record = record, + navigationContext = datasetContext.bindingValues, + authorityContext = datasetContext.nativeRecordAuthorityContext(schema), + ).let { capabilities -> + when (saved.kind) { + NativeRecordFormActionKind.Create -> capabilities.create + NativeRecordFormActionKind.Edit -> capabilities.edit + } + }?.takeIf { candidate -> candidate.action.id == saved.actionId } + ?: return@pending null + val mutationRecoveryOwner = nativeFormMutationRecoveryOwner( + appId = schema.app.id, + viewId = view.id, + actionId = plan.action.id, + resourceId = actionResource.id, + intent = plan.action.intent, + recordId = record?.id, + ) ?: return@pending null + PendingNativeRecordFormAction( + plan = plan, + itemLabel = record + ?.let { nativeRecordPresentation(actionResource, it).title } + ?: actionResource.name, + resource = actionResource, + datasetContext = datasetContext, + restoreKey = pendingRecordFormActionToken.orEmpty(), + mutationRecoveryOwner = mutationRecoveryOwner, + ) + } + val pendingRecordCommandFormAction = pendingRecordCommandFormActionToken + ?.let(::decodeRestorableNativeRecordFormAction) + ?.let pending@{ saved -> + val actionResource = presentedResource + ?.takeIf { resourceSpec -> resourceSpec.id == saved.resourceId } + ?: schema.resource(saved.resourceId) + ?: return@pending null + val recordId = saved.recordId ?: return@pending null + val record = presentedRecords.firstOrNull { candidate -> candidate.id == recordId } + ?: return@pending null + val plan = nativeRecordActions( + schema = schema, + resource = actionResource, + record = record, + navigationContext = datasetContext.bindingValues, + authorityContext = datasetContext.nativeRecordAuthorityContext(schema), + ).commandForms.singleOrNull { candidate -> candidate.action.id == saved.actionId } + ?: return@pending null + val mutationRecoveryOwner = nativeFormMutationRecoveryOwner( + appId = schema.app.id, + viewId = view.id, + actionId = plan.action.id, + resourceId = actionResource.id, + intent = plan.action.intent, + recordId = record.id, + ) ?: return@pending null + PendingNativeRecordCommandFormAction( + plan = plan, + itemLabel = nativeRecordPresentation(actionResource, record).title, + resource = schema.resource(plan.action.resourceId) ?: return@pending null, + datasetContext = datasetContext, + restoreKey = pendingRecordCommandFormActionToken.orEmpty(), + mutationRecoveryOwner = mutationRecoveryOwner, + ) + } + val persistentCollectionCreate = openCollectionCreate?.takeIf { + state is NativeScreenState.Ready && + presentedRecords.isNotEmpty() && + presentedSurface in setOf( + GenericNativeSurface.List, + GenericNativeSurface.Grid, + GenericNativeSurface.Table, + ) + } Surface( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .semantics { + contentDescription = "Dynamic surface action ${view.sourceActionId}" + }, color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, ) { @@ -184,25 +570,44 @@ fun GenericNativeAppScreen( ) state is NativeScreenState.Ready && presentedSurface == GenericNativeSurface.Form -> GenericNativeForm( - schema, - view, - presentedResource, - presentedRecords.firstOrNull() ?: datasetContext.parentRecord, - datasetContext, - actionExecutor, - filePicker, - onActionSucceeded, + schema = schema, + view = view, + resource = presentedResource, + initialRecord = presentedRecords.firstOrNull() ?: datasetContext.parentRecord, + datasetContext = datasetContext, + executor = actionExecutor, + filePicker = filePicker, + onActionSucceeded = onActionSucceeded, + // A standalone form has no authoritative collection to refresh in place. + // Return to its caller so that surface can reload and verify an ambiguous + // mutation result before the user retries it. + onActionOutcomeUnknown = onActionSucceeded, + mutationReconciliationGeneration = mutationReconciliationGeneration, ) state is NativeScreenState.Ready && presentedRecords.isEmpty() && view.compositeDataGrid == null && - nestedBoard == null -> - GenericRendererEmpty(presentedResource.name) + nestedBoard == null -> { + GenericRendererEmpty( + resourceId = presentedResource.id, + resourceName = presentedResource.name, + createLabel = collectionCreatePlan?.action?.label, + onCreate = openCollectionCreate, + ) + } state is NativeScreenState.Ready -> when (presentedSurface) { GenericNativeSurface.List -> GenericRecordCollection( + schema = schema, resource = presentedResource, records = presentedRecords, + datasetContext = datasetContext, + actionExecutor = actionExecutor, onSelectRecord = onSelectRecord, + onInlineActionSucceeded = inlineActionSucceeded, + onEditRecord = openRecordEdit, + onDeleteRecord = openRecordDelete, + onCommandRecord = executeRecordCommand, + onCommandFormRecord = openRecordCommandForm, imageLoader = imageLoader, ) GenericNativeSurface.Grid -> GenericRecordGrid(presentedResource, presentedRecords, onSelectRecord) @@ -261,6 +666,31 @@ fun GenericNativeAppScreen( } } } + if ( + state is NativeScreenState.Ready && + ( + persistentCollectionCreate != null || + collectionActionCapabilities.commands.isNotEmpty() || + collectionBatchPlans.isNotEmpty() || + collectionActionCapabilities.reorder != null + ) + ) { + GenericCollectionActionBar( + resourceId = requireNotNull(resource).id, + createLabel = collectionCreatePlan?.action?.label, + onCreate = persistentCollectionCreate, + commands = collectionActionCapabilities.commands, + batches = collectionBatchPlans, + reorder = collectionActionCapabilities.reorder, + onCommand = { plan -> pendingCollectionCommandActionId = plan.action.id }, + onBatch = { plan -> + if (formMutationRecovery?.blocksSubmission != true) { + pendingCollectionBatchActionId = plan.action.id + } + }, + onReorder = { plan -> pendingCollectionReorderActionId = plan.action.id }, + ) + } if (state is NativeScreenState.Ready && onLoadMore != null) { Surface( modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), @@ -298,112 +728,1815 @@ fun GenericNativeAppScreen( } } } + pendingRecordFormAction?.let { pending -> + GenericRecordActionFormDialog( + pending = pending, + schema = schema, + actionExecutor = actionExecutor, + filePicker = filePicker, + mutationRecovery = formMutationRecovery, + onMutationStarted = { owner -> + activeMutationOwners += owner + formMutationRecoveryToken = owner.begin(mutationReconciliationGeneration).encode() + }, + onMutationFinished = { owner, result -> + activeMutationOwners -= owner + val current = decodeNativeFormMutationRecoveryState(formMutationRecoveryToken) + if (current?.owner == owner) { + formMutationRecoveryToken = current.afterExecutionResult( + result = result, + currentReconciliationGeneration = mutationReconciliationGeneration, + )?.encode() + } + }, + onDismiss = { pendingRecordFormActionToken = null }, + onActionSucceeded = { action -> + pendingRecordFormActionToken = null + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingRecordCommandFormAction?.let { pending -> + GenericRecordActionFormDialog( + pending = pending, + schema = schema, + actionExecutor = actionExecutor, + filePicker = filePicker, + mutationRecovery = formMutationRecovery, + onMutationStarted = { owner -> + activeMutationOwners += owner + formMutationRecoveryToken = owner.begin(mutationReconciliationGeneration).encode() + }, + onMutationFinished = { owner, result -> + activeMutationOwners -= owner + val current = decodeNativeFormMutationRecoveryState(formMutationRecoveryToken) + if (current?.owner == owner) { + formMutationRecoveryToken = current.afterExecutionResult( + result = result, + currentReconciliationGeneration = mutationReconciliationGeneration, + )?.encode() + } + }, + onDismiss = { pendingRecordCommandFormActionToken = null }, + onActionSucceeded = { action -> + pendingRecordCommandFormActionToken = null + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingRecordDeleteAction?.let { pending -> + GenericRecordDeleteActionDialog( + pending = pending, + actionExecutor = actionExecutor, + onDismiss = { pendingRecordDeleteAction = null }, + onActionSucceeded = { action -> + pendingRecordDeleteAction = null + inlineActionSucceeded?.invoke(action) + }, + onOutcomeUnknown = { action -> + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingRecordCommandAction?.let { pending -> + GenericRecordCommandActionDialog( + pending = pending, + actionExecutor = actionExecutor, + onDismiss = { pendingRecordCommandAction = null }, + onActionSucceeded = { action -> + pendingRecordCommandAction = null + inlineActionSucceeded?.invoke(action) + }, + onOutcomeUnknown = { action -> + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingCollectionCommandAction?.let { plan -> + GenericCollectionCommandDialog( + plan = plan, + resourceName = resource?.name ?: view.title, + resourceId = requireNotNull(resource).id, + actionExecutor = actionExecutor, + onDismiss = { pendingCollectionCommandActionId = null }, + onActionSucceeded = { action -> + pendingCollectionCommandActionId = null + inlineActionSucceeded?.invoke(action) + }, + onOutcomeUnknown = { action -> + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingCollectionBatchAction?.let { plan -> + val recoveryOwner = pendingCollectionBatchRecoveryOwner ?: return@let + GenericCollectionBatchDialog( + plan = plan, + resource = requireNotNull(resource), + records = readyRecords, + schema = schema, + datasetContext = datasetContext, + actionExecutor = actionExecutor, + relationLoader = collectionBatchRelationLoader, + mutationRecovery = formMutationRecovery, + mutationRecoveryOwner = recoveryOwner, + onMutationStarted = { owner -> + activeMutationOwners += owner + formMutationRecoveryToken = owner.begin(mutationReconciliationGeneration).encode() + }, + onMutationFinished = { owner, result -> + activeMutationOwners -= owner + val current = decodeNativeFormMutationRecoveryState(formMutationRecoveryToken) + if (current?.owner == owner) { + formMutationRecoveryToken = current.afterExecutionResult( + result = result, + currentReconciliationGeneration = mutationReconciliationGeneration, + )?.encode() + } + }, + onDismiss = { pendingCollectionBatchActionId = null }, + onActionSucceeded = { action -> + pendingCollectionBatchActionId = null + inlineActionSucceeded?.invoke(action) + }, + onOutcomeUnknown = { action -> + inlineActionSucceeded?.invoke(action) + }, + ) + } + pendingCollectionReorderAction?.let { plan -> + GenericCollectionReorderDialog( + plan = plan, + resource = requireNotNull(resource), + records = readyRecords, + actionExecutor = actionExecutor, + onDismiss = { pendingCollectionReorderActionId = null }, + onActionSucceeded = { action -> + pendingCollectionReorderActionId = null + inlineActionSucceeded?.invoke(action) + }, + onOutcomeUnknown = { action -> + inlineActionSucceeded?.invoke(action) + }, + ) + } } @Composable -private fun GenericRecordTable( - schema: NativeAppSchema, - view: ViewSpec, - resource: ResourceSpec, - records: List, - datasetContext: NativeDatasetContext, - actionExecutor: NativeActionExecutor, - onSelectRecord: ((NativeRecord) -> Unit)?, - onInlineActionSucceeded: (() -> Unit)?, - modifier: Modifier = Modifier, +private fun GenericCollectionActionBar( + resourceId: String, + createLabel: String?, + onCreate: (() -> Unit)?, + commands: List, + batches: List, + reorder: NativeCollectionReorderActionPlan?, + onCommand: (NativeCollectionCommandActionPlan) -> Unit, + onBatch: (NativeCollectionBatchActionPlan) -> Unit, + onReorder: (NativeCollectionReorderActionPlan) -> Unit, ) { - val composite = view.compositeDataGrid - val columnResource = composite?.let { schema.resource(it.columnResourceId) } - val columnRecords = composite?.let { datasetContext.relatedRecords[it.columnResourceId].orEmpty() }.orEmpty() - val projection = remember(resource, records, columnResource, columnRecords, composite) { - nativeTableProjection(resource, records, columnResource, columnRecords, composite) - } - val projectedResource = projection.resource - val projectedRecords = projection.records - val fields = remember(projection) { - if (projection.composite) { - projectedResource.fields.filter { it.id in projection.projectedFieldIds } + - listOfNotNull(projectedResource.fields.firstOrNull { it.id == projection.frozenFieldId }) - } else { - nativeTableFields(projectedResource, projectedRecords) - } - }.distinctBy(FieldSpec::id) - if (fields.isEmpty()) { - GenericRecordList(projectedResource, projectedRecords, onSelectRecord, modifier) - return - } - var activeEdit by remember(schema, projection) { mutableStateOf(null) } - var editValue by remember { mutableStateOf("") } - var editError by remember { mutableStateOf(null) } - var savingEdit by remember { mutableStateOf(false) } - val editedValues = remember(schema, projection) { mutableStateMapOf() } - val scope = rememberCoroutineScope() - val actionWidth = if (onSelectRecord == null) 0.dp else 48.dp - val frozenField = fields.firstOrNull { it.id == projection.frozenFieldId } - val scrollingFields = fields.filterNot { it.id == frozenField?.id } - val horizontalState = rememberScrollState() - Column( - modifier = modifier.fillMaxSize().padding( - start = NextcloudSpacing.Large, - top = NextcloudSpacing.Medium, - end = NextcloudSpacing.Large, - bottom = NextcloudSpacing.XXLarge, - ), + var expanded by remember { mutableStateOf(false) } + val hasSecondaryActions = commands.isNotEmpty() || batches.isNotEmpty() || reorder != null + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = NextcloudSpacing.Small), - verticalAlignment = Alignment.CenterVertically, - ) { - frozenField?.let { field -> GenericTableHeaderCell(field, field.nativeTableColumnWidth()) } + BoxWithConstraints { + val compactActions = maxWidth < 520.dp Row( - modifier = Modifier.weight(1f).horizontalScroll(horizontalState), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = NextcloudSpacing.Large, vertical = NextcloudSpacing.Small), + horizontalArrangement = Arrangement.spacedBy( + NextcloudSpacing.Small, + alignment = Alignment.End, + ), verticalAlignment = Alignment.CenterVertically, ) { - scrollingFields.forEach { field -> GenericTableHeaderCell(field, field.nativeTableColumnWidth()) } - if (actionWidth > 0.dp) Box(Modifier.width(actionWidth)) - } - } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - LazyColumn(modifier = Modifier.weight(1f)) { - items(projectedRecords, key = NativeRecord::id) { record -> - Row( - modifier = Modifier - .fillMaxWidth() - .then( - onSelectRecord?.let { callback -> Modifier.clickable { callback(record) } } ?: Modifier, + onCreate?.let { create -> + Button( + onClick = create, + modifier = Modifier + .then( + when { + hasSecondaryActions -> Modifier.weight(1f) + else -> Modifier.fillMaxWidth() + }, + ) + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Create $resourceId" + }, + ) { + Icon(NextcloudIcons.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + createLabel?.takeIf(String::isNotBlank) ?: "Create item", + modifier = Modifier.padding(start = NextcloudSpacing.Small), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - .heightIn(min = 50.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - frozenField?.let { field -> - val address = NativeCellAddress(record.id, field.id) - val plan = nativeCellEditPlan(schema, resource, projection, record, field) - GenericTableValueCell( - field = field, - rawValue = editedValues[address] ?: record.presentationValue(field.id), - editPlan = plan, - width = field.nativeTableColumnWidth(), - emphasized = true, - onEdit = { - activeEdit = it.copy(originalValue = editedValues[address] ?: it.originalValue) - editValue = editedValues[address] ?: it.originalValue - editError = null + } + } + if (hasSecondaryActions) Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Open collection actions for $resourceId" }, + ) { + Icon( + NextcloudIcons.More, + contentDescription = null, + modifier = Modifier.size(18.dp), ) + if (!compactActions) { + Text( + "More actions", + modifier = Modifier.padding(start = NextcloudSpacing.Small), + ) + } } - Row( - modifier = Modifier.weight(1f).horizontalScroll(horizontalState), - verticalAlignment = Alignment.CenterVertically, + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.semantics { + contentDescription = "Collection actions for $resourceId" + }, ) { - scrollingFields.forEachIndexed { index, field -> - val address = NativeCellAddress(record.id, field.id) - val plan = nativeCellEditPlan(schema, resource, projection, record, field) - GenericTableValueCell( - field = field, - rawValue = editedValues[address] ?: record.presentationValue(field.id), - editPlan = plan, - width = field.nativeTableColumnWidth(), - emphasized = frozenField == null && index == 0, + commands.forEach { plan -> + DropdownMenuItem( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Run collection action ${plan.action.id}" + }, + text = { + Text( + plan.action.label, + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + expanded = false + onCommand(plan) + }, + ) + } + batches.forEach { plan -> + DropdownMenuItem( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Open batch action ${plan.action.id}" + }, + text = { + Text( + plan.action.label, + color = if (plan.action.risk == ActionRisk.destructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + onClick = { + expanded = false + onBatch(plan) + }, + ) + } + reorder?.let { plan -> + DropdownMenuItem( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Reorder collection for $resourceId" + }, + text = { Text(plan.action.label) }, + onClick = { + expanded = false + onReorder(plan) + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun GenericCollectionCommandDialog( + plan: NativeCollectionCommandActionPlan, + resourceName: String, + resourceId: String, + actionExecutor: NativeActionExecutor, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, + onOutcomeUnknown: (ActionSpec) -> Unit, +) { + var error by remember(plan.action.id) { mutableStateOf(null) } + var failureOutcome by remember(plan.action.id) { + mutableStateOf(null) + } + var executing by remember(plan.action.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val outcomeUnknown = failureOutcome?.requiresMutationReconciliation() == true + + AlertDialog( + onDismissRequest = { if (!executing) onDismiss() }, + title = { + Text( + if (outcomeUnknown) { + "${plan.action.label} result unknown" + } else { + "${plan.action.label} $resourceName?" + }, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + Text( + "This changes the entire collection and cannot be undone. Continue?", + ) + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (outcomeUnknown) { + Text( + "The collection is being refreshed to check the server result. " + + "Review the refreshed data before trying this action again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + dismissButton = { + TextButton(enabled = !executing, onClick = onDismiss) { + Text(if (outcomeUnknown) "Close" else "Cancel") + } + }, + confirmButton = { + if (!outcomeUnknown) { + Button( + enabled = !executing, + modifier = Modifier.semantics { + contentDescription = "Confirm collection action ${plan.action.id} for $resourceId" + }, + onClick = { + val request = runCatching { + plan.request(confirmed = true) + }.getOrElse { failure -> + error = failure.message ?: "The collection action could not be submitted." + return@Button + } + executing = true + error = null + failureOutcome = null + scope.launch { + when (val result = actionExecutor.execute(request)) { + is NativeActionExecutionResult.Success -> + onActionSucceeded(plan.action) + is NativeActionExecutionResult.Failure -> { + error = result.message + failureOutcome = result.outcome + if (result.outcome.requiresMutationReconciliation()) { + onOutcomeUnknown(plan.action) + } + } + } + executing = false + } + }, + ) { + if (executing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(plan.action.label) + } + } + } + }, + ) +} + +@Composable +private fun GenericCollectionBatchDialog( + plan: NativeCollectionBatchActionPlan, + resource: ResourceSpec, + records: List, + schema: NativeAppSchema, + datasetContext: NativeDatasetContext, + actionExecutor: NativeActionExecutor, + relationLoader: NativeCollectionBatchRelationLoader?, + mutationRecovery: NativeFormMutationRecoveryState?, + mutationRecoveryOwner: NativeFormMutationRecoveryOwner, + onMutationStarted: (NativeFormMutationRecoveryOwner) -> Unit, + onMutationFinished: (NativeFormMutationRecoveryOwner, NativeActionExecutionResult) -> Unit, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, + onOutcomeUnknown: (ActionSpec) -> Unit, +) { + val selectableRecords = remember(records, plan.selectableRecordIds) { + records.filter { record -> record.id in plan.selectableRecordIds } + } + val recordIds = remember(selectableRecords) { selectableRecords.map(NativeRecord::id) } + var selectedRecordIds by rememberSaveable(plan.action.id, recordIds) { + mutableStateOf(emptyList()) + } + var values by rememberSaveable(plan.action.id) { + mutableStateOf(initialNativeCollectionBatchDraft(plan.fields)) + } + var error by remember(plan.action.id) { mutableStateOf(null) } + var failureOutcome by remember(plan.action.id) { + mutableStateOf(null) + } + var awaitingConfirmation by remember(plan.action.id) { mutableStateOf(false) } + var executing by remember(plan.action.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val outcomeUnknown = + failureOutcome?.requiresMutationReconciliation() == true || + ( + mutationRecovery?.owner == mutationRecoveryOwner && + mutationRecovery.phase == NativeFormMutationRecoveryPhase.AwaitingReconciliation + ) + val submissionBlocked = mutationRecovery != null + val verifiedRelationsByField = remember(plan.fields, resource, schema) { + plan.fields.mapNotNull { field -> + val relatedResourceId = field.relatedResourceId ?: return@mapNotNull null + val rendererField = field.toNativeCollectionFieldSpec() + nativeRelationRelationship(rendererField, resource, schema) + ?.takeIf { relationship -> relationship.parentResourceId == relatedResourceId } + ?.let { field.id to relatedResourceId } + }.toMap() + } + val relationAvailableValues = remember(datasetContext) { + buildMap { + datasetContext.parentRecord?.values?.forEach { (fieldId, value) -> + value?.takeIf(String::isNotBlank)?.let { put(fieldId, it) } + } + datasetContext.parentRecord?.bindingContext?.forEach { (fieldId, value) -> + value.takeIf(String::isNotBlank)?.let { put(fieldId, it) } + } + putAll(datasetContext.bindingValues.filterValues(String::isNotBlank)) + } + } + val relationRequest = verifiedRelationsByField.takeIf { relations -> relations.isNotEmpty() }?.let { relations -> + NativeCollectionBatchRelationLoadRequest( + actionId = plan.action.id, + resourceId = resource.id, + relatedResourceIdsByField = relations, + bindingValues = relationAvailableValues, + forceRefresh = false, + ) + } + var relationLoadAttempt by rememberSaveable(plan.action.id) { mutableStateOf(0) } + var relationRecords by remember(plan.action.id, relationRequest) { + mutableStateOf>>(emptyMap()) + } + var relationErrors by remember(plan.action.id, relationRequest) { + mutableStateOf>(emptyMap()) + } + var relationsLoading by remember(plan.action.id, relationRequest) { + mutableStateOf(relationRequest != null) + } + LaunchedEffect(relationLoader, relationRequest, relationLoadAttempt) { + val request = relationRequest ?: run { + relationsLoading = false + return@LaunchedEffect + } + relationsLoading = true + relationErrors = emptyMap() + val requestedResourceIds = request.relatedResourceIdsByField.values.toSet() + val outcome = relationLoader?.let { loader -> + runCatching { + loader.load(request.copy(forceRefresh = relationLoadAttempt > 0)) + } + } + val result = outcome?.getOrNull() + relationRecords = result?.recordsByResourceId.orEmpty() + .filterKeys(requestedResourceIds::contains) + relationErrors = requestedResourceIds.mapNotNull { resourceId -> + when { + outcome == null -> resourceId to "No verified choice loader is available." + outcome.isFailure -> resourceId to ( + outcome.exceptionOrNull()?.message?.takeIf(String::isNotBlank) + ?: "Could not load choices." + ) + result?.errorsByResourceId?.get(resourceId) != null -> + resourceId to requireNotNull(result.errorsByResourceId[resourceId]) + resourceId !in relationRecords -> resourceId to "Could not load verified choices." + else -> null + } + }.toMap() + relationsLoading = false + } + val relationContext = datasetContext.withCollectionBatchRelationRecords(relationRecords) + val requiredRelationUnavailable = plan.fields.any { field -> + field.required && field.relatedResourceId?.let { relatedResourceId -> + relationsLoading || relatedResourceId in relationErrors || relatedResourceId !in relationRecords + } == true + } + + fun request(confirmed: Boolean): NativeActionRequest.Submit? = runCatching { + plan.request( + selectedRecordIds = selectedRecordIds, + values = nativeCollectionBatchRequestValues(plan.fields, values), + confirmed = confirmed, + ) + }.getOrElse { failure -> + error = failure.message ?: "The selected items could not be submitted." + null + } + + fun submit(confirmed: Boolean) { + if (submissionBlocked || requiredRelationUnavailable) return + val actionRequest = request(confirmed) ?: return + executing = true + error = null + failureOutcome = null + onMutationStarted(mutationRecoveryOwner) + scope.launch { + val result = actionExecutor.execute(actionRequest) + onMutationFinished(mutationRecoveryOwner, result) + when (result) { + is NativeActionExecutionResult.Success -> onActionSucceeded(plan.action) + is NativeActionExecutionResult.Failure -> { + error = result.message + failureOutcome = result.outcome + awaitingConfirmation = false + if (result.outcome.requiresMutationReconciliation()) { + onOutcomeUnknown(plan.action) + } + } + } + executing = false + } + } + + AlertDialog( + onDismissRequest = { if (!executing) onDismiss() }, + title = { + Text( + when { + outcomeUnknown -> "${plan.action.label} result unknown" + awaitingConfirmation -> "Confirm ${plan.action.label.lowercase()}" + else -> plan.action.label + }, + ) + }, + text = { + if (awaitingConfirmation) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + Text( + "${plan.action.label} will change ${selectedRecordIds.size} selected " + + "${if (selectedRecordIds.size == 1) "item" else "items"}. Continue?", + ) + if (plan.action.risk == ActionRisk.destructive) { + Text( + "This action changes server data and may not be reversible.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "${selectedRecordIds.size} of ${plan.maximumSelectionSize} selected", + style = MaterialTheme.typography.labelLarge, + ) + if (plan.minimumSelectionSize > 1) { + Text( + "Select at least ${plan.minimumSelectionSize}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row { + TextButton( + enabled = !executing && !submissionBlocked && selectedRecordIds.isNotEmpty(), + onClick = { + selectedRecordIds = emptyList() + error = null + }, + ) { + Text("Clear") + } + TextButton( + enabled = !executing && !submissionBlocked && selectedRecordIds.size < minOf( + recordIds.size, + plan.maximumSelectionSize, + ), + onClick = { + selectedRecordIds = recordIds.take(plan.maximumSelectionSize) + error = null + }, + ) { + Text( + if (recordIds.size <= plan.maximumSelectionSize) { + "Select all" + } else { + "Select ${plan.maximumSelectionSize}" + }, + ) + } + } + } + } + items(selectableRecords, key = NativeRecord::id) { record -> + val selected = record.id in selectedRecordIds + val canToggle = selected || + selectedRecordIds.size < plan.maximumSelectionSize + val itemLabel = nativeRecordPresentation(resource, record).title + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Select $itemLabel" + } + .clickable(enabled = canToggle && !executing && !submissionBlocked) { + selectedRecordIds = toggleNativeCollectionSelection( + selectedRecordIds = selectedRecordIds, + recordId = record.id, + availableRecordIds = recordIds, + maximumSelectionSize = plan.maximumSelectionSize, + ) + error = null + } + .padding(horizontal = NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = selected, + enabled = canToggle && !executing && !submissionBlocked, + onCheckedChange = null, + ) + Text( + itemLabel, + modifier = Modifier.padding(start = NextcloudSpacing.Small), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + items(plan.fields, key = NativeCollectionBatchInputField::id) { field -> + val rendererField = field.toNativeCollectionFieldSpec() + val relationship = remember(field, rendererField, resource, schema) { + nativeRelationRelationship(rendererField, resource, schema) + ?.takeIf { relation -> + relation.parentResourceId == field.relatedResourceId + } + } + if (relationship != null) { + val relatedResourceId = requireNotNull(field.relatedResourceId) + val relationOptions = nativeRelationOptions( + field = rendererField, + formResource = resource, + schema = schema, + context = relationContext, + ) + val relationError = relationErrors[relatedResourceId] + GenericRelationshipField( + field = rendererField, + value = values[field.id].orEmpty(), + options = relationOptions, + choicesLoaded = relationRecords.containsKey(relatedResourceId), + choiceSourceHasRecords = + relationRecords[relatedResourceId].orEmpty().isNotEmpty(), + choiceUnavailableReason = when { + relationsLoading || relationError != null -> + NativeRelationChoiceUnavailableReason.source + else -> nativeRelationChoiceUnavailableReason( + rendererField, + resource, + schema, + relationContext, + ) + }, + paging = null, + error = relationError, + enabled = !executing && !outcomeUnknown && !submissionBlocked && + !relationsLoading && relationError == null, + onValueChange = { value -> + values = values + (field.id to value) + error = null + }, + ) + if (relationsLoading) { + Text( + "Loading choices...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (relationError != null) { + TextButton( + enabled = !executing && !submissionBlocked, + onClick = { relationLoadAttempt += 1 }, + ) { + Text("Retry choices") + } + } + } else { + GenericFormField( + field = rendererField, + value = values[field.id].orEmpty(), + error = null, + enabled = !executing && !outcomeUnknown && !submissionBlocked, + filePicker = null, + onValueChange = { value -> + values = values + (field.id to value) + error = null + }, + ) + } + } + error?.let { message -> + item { + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (outcomeUnknown) { + item { + Text( + "The collection is being refreshed to check the server result. " + + "Review the refreshed data before trying this action again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + dismissButton = { + TextButton( + enabled = !executing, + onClick = { + if (awaitingConfirmation) { + awaitingConfirmation = false + error = null + } else { + onDismiss() + } + }, + ) { + Text( + when { + outcomeUnknown -> "Close" + awaitingConfirmation -> "Back" + else -> "Cancel" + }, + ) + } + }, + confirmButton = { + if (!outcomeUnknown) { + Button( + enabled = !executing && !submissionBlocked && !requiredRelationUnavailable, + modifier = Modifier.semantics { + contentDescription = if (awaitingConfirmation) { + "Confirm batch action ${plan.action.id} for ${resource.id}" + } else { + "Submit batch action ${plan.action.id} for ${resource.id}" + } + }, + onClick = { + when { + awaitingConfirmation -> submit(confirmed = true) + plan.requiresConfirmation -> { + if (request(confirmed = true) != null) { + error = null + awaitingConfirmation = true + } + } + else -> submit(confirmed = false) + } + }, + ) { + if (executing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(if (awaitingConfirmation) "Confirm" else plan.action.label) + } + } + } + }, + ) +} + +@Composable +private fun GenericCollectionReorderDialog( + plan: NativeCollectionReorderActionPlan, + resource: ResourceSpec, + records: List, + actionExecutor: NativeActionExecutor, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, + onOutcomeUnknown: (ActionSpec) -> Unit, +) { + val recordsById = remember(records) { records.associateBy(NativeRecord::id) } + val initialOrder = remember(records) { records.map(NativeRecord::id) } + val reorderDraftSaver = remember(initialOrder) { + nativeCollectionReorderDraftSaver(initialOrder) + } + var orderedRecordIds by rememberSaveable( + plan.action.id, + initialOrder, + stateSaver = reorderDraftSaver, + ) { + mutableStateOf(initialOrder) + } + var error by remember(plan.action.id) { mutableStateOf(null) } + var failureOutcome by remember(plan.action.id) { + mutableStateOf(null) + } + var executing by remember(plan.action.id) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val outcomeUnknown = failureOutcome?.requiresMutationReconciliation() == true + + AlertDialog( + onDismissRequest = { if (!executing) onDismiss() }, + title = { + Text( + if (outcomeUnknown) { + "${plan.action.label} result unknown" + } else { + plan.action.label + }, + ) + }, + text = { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + .semantics { + contentDescription = "Reorder collection list for ${resource.id}" + }, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall), + ) { + item { + Text( + "Move every item into the required order, then save.", + style = MaterialTheme.typography.bodyMedium, + ) + } + itemsIndexed( + items = orderedRecordIds, + key = { _, recordId -> recordId }, + ) { index, recordId -> + val record = recordsById[recordId] ?: return@itemsIndexed + val itemLabel = nativeRecordPresentation(resource, record).title + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${index + 1}. $itemLabel", + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + TextButton( + enabled = !executing && index > 0, + modifier = Modifier.semantics { + contentDescription = "Move $itemLabel up" + }, + onClick = { + orderedRecordIds = moveNativeCollectionRecord( + orderedRecordIds = orderedRecordIds, + recordId = recordId, + offset = -1, + ) + error = null + }, + ) { + Text("Up") + } + TextButton( + enabled = !executing && index < orderedRecordIds.lastIndex, + modifier = Modifier.semantics { + contentDescription = "Move $itemLabel down" + }, + onClick = { + orderedRecordIds = moveNativeCollectionRecord( + orderedRecordIds = orderedRecordIds, + recordId = recordId, + offset = 1, + ) + error = null + }, + ) { + Text("Down") + } + } + } + } + error?.let { message -> + item { + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (outcomeUnknown) { + item { + Text( + "The collection is being refreshed to check the server result. " + + "Review the refreshed order before trying this action again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + dismissButton = { + TextButton(enabled = !executing, onClick = onDismiss) { + Text(if (outcomeUnknown) "Close" else "Cancel") + } + }, + confirmButton = { + if (!outcomeUnknown) { + Button( + enabled = !executing && orderedRecordIds != initialOrder, + modifier = Modifier.semantics { + contentDescription = "Save reordered collection ${resource.id}" + }, + onClick = { + val request = runCatching { + plan.requestInOrder(orderedRecordIds) + }.getOrElse { failure -> + error = failure.message ?: "The new order could not be submitted." + return@Button + } + executing = true + error = null + failureOutcome = null + scope.launch { + when (val result = actionExecutor.execute(request)) { + is NativeActionExecutionResult.Success -> + onActionSucceeded(plan.action) + is NativeActionExecutionResult.Failure -> { + error = result.message + failureOutcome = result.outcome + if (result.outcome.requiresMutationReconciliation()) { + onOutcomeUnknown(plan.action) + } + } + } + executing = false + } + }, + ) { + if (executing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Save order") + } + } + } + }, + ) +} + +private fun nativeCollectionReorderDraftSaver( + plannedRecordIds: List, +) = Saver, List>( + save = { orderedRecordIds -> encodeNativeCollectionReorderDraft(orderedRecordIds) }, + restore = { savedRecordIds -> + restoreNativeCollectionReorderDraft(savedRecordIds, plannedRecordIds) + }, +) + +private sealed interface PendingNativeRecordActionForm { + val action: ActionSpec + val fields: List + val initialValues: Map + val itemLabel: String + val resource: ResourceSpec + val datasetContext: NativeDatasetContext + val restoreKey: String + val mutationRecoveryOwner: NativeFormMutationRecoveryOwner + val operationLabel: String + + fun request( + scalarInputValues: Map, + repeatableObjectValues: Map>, + confirmed: Boolean, + ): NativeActionRequest.Submit +} + +private data class PendingNativeRecordFormAction( + val plan: NativeRecordFormActionPlan, + override val itemLabel: String, + override val resource: ResourceSpec, + override val datasetContext: NativeDatasetContext, + override val restoreKey: String, + override val mutationRecoveryOwner: NativeFormMutationRecoveryOwner, +) : PendingNativeRecordActionForm { + override val action: ActionSpec + get() = plan.action + override val fields: List + get() = plan.fields + override val initialValues: Map + get() = plan.initialValues + override val operationLabel: String + get() = when (plan.kind) { + NativeRecordFormActionKind.Create -> "Create" + NativeRecordFormActionKind.Edit -> "Edit" + } + + override fun request( + scalarInputValues: Map, + repeatableObjectValues: Map>, + confirmed: Boolean, + ): NativeActionRequest.Submit = plan.requestWithStructuredInput( + scalarInputValues = scalarInputValues, + repeatableObjectValues = repeatableObjectValues, + confirmed = confirmed, + ) +} + +private data class PendingNativeRecordCommandFormAction( + val plan: NativeRecordCommandFormActionPlan, + override val itemLabel: String, + override val resource: ResourceSpec, + override val datasetContext: NativeDatasetContext, + override val restoreKey: String, + override val mutationRecoveryOwner: NativeFormMutationRecoveryOwner, +) : PendingNativeRecordActionForm { + override val action: ActionSpec + get() = plan.action + override val fields: List + get() = plan.fields + override val initialValues: Map + get() = plan.initialValues + override val operationLabel: String + get() = plan.action.label + + override fun request( + scalarInputValues: Map, + repeatableObjectValues: Map>, + confirmed: Boolean, + ): NativeActionRequest.Submit = plan.requestWithStructuredInput( + scalarInputValues = scalarInputValues, + repeatableObjectValues = repeatableObjectValues, + confirmed = confirmed, + ) +} + +internal data class RestorableNativeRecordFormAction( + val actionId: String, + val resourceId: String, + val kind: NativeRecordFormActionKind, + val recordId: String?, +) + +internal fun RestorableNativeRecordFormAction.encode(): String? { + if ( + actionId.isBlank() || + resourceId.isBlank() || + actionId.length > MAX_SAVED_FORM_ID_LENGTH || + resourceId.length > MAX_SAVED_FORM_ID_LENGTH || + recordId?.length?.let { it > MAX_SAVED_FORM_ID_LENGTH } == true + ) { + return null + } + return JsonArray( + listOf( + JsonPrimitive(actionId), + JsonPrimitive(resourceId), + JsonPrimitive(kind.name), + recordId?.let(::JsonPrimitive) ?: JsonNull, + ), + ).toString() +} + +internal fun decodeRestorableNativeRecordFormAction(value: String): RestorableNativeRecordFormAction? { + if (value.length > MAX_SAVED_FORM_TOKEN_LENGTH) return null + val parts = runCatching { Json.parseToJsonElement(value) }.getOrNull() as? JsonArray ?: return null + if (parts.size != 4) return null + val actionId = (parts[0] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull ?: return null + val resourceId = (parts[1] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull ?: return null + val kindName = (parts[2] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull ?: return null + val recordId = when (val record = parts[3]) { + JsonNull -> null + is JsonPrimitive -> record.takeIf(JsonPrimitive::isString)?.contentOrNull ?: return null + else -> return null + } + if ( + actionId.isBlank() || + resourceId.isBlank() || + actionId.length > MAX_SAVED_FORM_ID_LENGTH || + resourceId.length > MAX_SAVED_FORM_ID_LENGTH || + recordId?.length?.let { it > MAX_SAVED_FORM_ID_LENGTH } == true + ) { + return null + } + val kind = NativeRecordFormActionKind.entries.firstOrNull { candidate -> candidate.name == kindName } + ?: return null + return RestorableNativeRecordFormAction(actionId, resourceId, kind, recordId) +} + +internal fun encodeNativeRecordFormDraft(values: Map): List? { + if (values.size > MAX_SAVED_FORM_FIELDS) return null + var totalLength = 0 + val saved = ArrayList(values.size * 2) + values.entries.sortedBy(Map.Entry::key).forEach { (key, value) -> + if ( + key.isBlank() || + key.length > MAX_SAVED_FORM_ID_LENGTH || + value.length > MAX_SAVED_FORM_VALUE_LENGTH + ) { + return null + } + totalLength += key.length + value.length + if (totalLength > MAX_SAVED_FORM_TOTAL_LENGTH) return null + saved += key + saved += value + } + return saved +} + +internal fun decodeNativeRecordFormDraft(values: List): Map? { + if (values.size % 2 != 0 || values.size / 2 > MAX_SAVED_FORM_FIELDS) return null + val entries = linkedMapOf() + var totalLength = 0 + values.chunked(2).forEach { (key, value) -> + if ( + key.isBlank() || + key in entries || + key.length > MAX_SAVED_FORM_ID_LENGTH || + value.length > MAX_SAVED_FORM_VALUE_LENGTH + ) { + return null + } + totalLength += key.length + value.length + if (totalLength > MAX_SAVED_FORM_TOTAL_LENGTH) return null + entries[key] = value + } + return entries +} + +private fun nativeRecordFormDraftSaver(declaredFieldIds: Set) = Saver, List>( + save = { draft -> + if (draft.keys.all(declaredFieldIds::contains)) encodeNativeRecordFormDraft(draft) else null + }, + restore = { saved -> + decodeNativeRecordFormDraft(saved)?.takeIf { values -> values.keys.all(declaredFieldIds::contains) } + }, +) + +private fun nativeRepeatableObjectDraftSaver( + specs: Map, +) = Saver>, List>( + save = { draft -> encodeNativeRepeatableObjectDraft(draft, specs) }, + restore = { saved -> decodeNativeRepeatableObjectDraft(saved, specs) }, +) + +private const val MAX_SAVED_FORM_FIELDS = 64 +private const val MAX_SAVED_FORM_ID_LENGTH = 256 +private const val MAX_SAVED_FORM_VALUE_LENGTH = 64 * 1024 +private const val MAX_SAVED_FORM_TOTAL_LENGTH = 256 * 1024 +private const val MAX_SAVED_FORM_TOKEN_LENGTH = 2 * 1024 + +private data class PendingNativeRecordDeleteAction( + val plan: NativeRecordDeleteActionPlan, + val itemLabel: String, +) + +private data class PendingNativeRecordCommandAction( + val plan: NativeRecordCommandActionPlan, + val itemLabel: String, + val initialError: String? = null, + val initialFailureOutcome: NativeActionFailureOutcome? = null, +) + +@Composable +private fun GenericRecordActionFormDialog( + pending: PendingNativeRecordActionForm, + schema: NativeAppSchema, + actionExecutor: NativeActionExecutor, + filePicker: NativeFileFieldPicker?, + mutationRecovery: NativeFormMutationRecoveryState?, + onMutationStarted: (NativeFormMutationRecoveryOwner) -> Unit, + onMutationFinished: (NativeFormMutationRecoveryOwner, NativeActionExecutionResult) -> Unit, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, +) { + val scalarFields = remember(pending.fields) { + pending.fields.filter { field -> field.repeatableObjectInput == null } + } + val displayFields = remember(pending.fields, pending.resource, schema) { + nativeFormDisplayFields( + fields = pending.fields, + relationFieldIds = pending.fields + .filter { field -> nativeRelationFieldRequiresChoice(field, pending.resource, schema) } + .mapTo(linkedSetOf(), FieldSpec::id), + ) + } + val structuredSpecs = remember(pending.fields) { + pending.fields.mapNotNull { field -> + field.repeatableObjectInput?.let { spec -> field.id to spec } + }.toMap() + } + val draftSaver = remember(scalarFields) { + nativeRecordFormDraftSaver(scalarFields.mapTo(linkedSetOf(), FieldSpec::id)) + } + var values by rememberSaveable(pending.restoreKey, stateSaver = draftSaver) { + mutableStateOf( + pending.initialValues.filterKeys { fieldId -> + fieldId !in structuredSpecs + }, + ) + } + val initialStructuredDraft = remember(pending.restoreKey, structuredSpecs, pending.initialValues) { + initialNativeRepeatableObjectDraft(pending.fields, pending.initialValues) + } + val emptyStructuredDraft = remember(pending.restoreKey, structuredSpecs) { + requireNotNull(initialNativeRepeatableObjectDraft(pending.fields, emptyMap())) + } + val structuredDraftSaver = remember(structuredSpecs) { + nativeRepeatableObjectDraftSaver(structuredSpecs) + } + var repeatableObjectValues by rememberSaveable( + "${pending.restoreKey}:structured", + stateSaver = structuredDraftSaver, + ) { + mutableStateOf(initialStructuredDraft ?: emptyStructuredDraft) + } + var structuredDraftSafe by rememberSaveable(pending.restoreKey) { + mutableStateOf(initialStructuredDraft != null) + } + var error by remember(pending) { + mutableStateOf( + if (initialStructuredDraft == null) { + "The existing structured value could not be edited safely." + } else { + null + }, + ) + } + var awaitingConfirmation by rememberSaveable(pending.restoreKey) { mutableStateOf(false) } + var submitting by remember(pending) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val operation = pending.operationLabel + + fun submit(confirmed: Boolean) { + val request = runCatching { + pending.request( + scalarInputValues = values, + repeatableObjectValues = repeatableObjectValues, + confirmed = confirmed, + ) + }.getOrElse { failure -> + error = failure.message ?: "The values could not be submitted." + return + } + submitting = true + error = null + onMutationStarted(pending.mutationRecoveryOwner) + scope.launch { + val result = actionExecutor.execute(request) + onMutationFinished(pending.mutationRecoveryOwner, result) + when (result) { + is NativeActionExecutionResult.Success -> onActionSucceeded(pending.action) + is NativeActionExecutionResult.Failure -> { + error = result.message + awaitingConfirmation = false + } + } + submitting = false + } + } + val outcomeUnknown = + mutationRecovery?.owner == pending.mutationRecoveryOwner && + mutationRecovery.phase == NativeFormMutationRecoveryPhase.AwaitingReconciliation + val formRetryAllowed = mutationRecovery == null + + AlertDialog( + onDismissRequest = { if (!submitting) onDismiss() }, + title = { + Text( + if (outcomeUnknown) { + "$operation result unknown" + } else if (awaitingConfirmation) { + "Confirm ${operation.lowercase()}" + } else { + "$operation ${pending.itemLabel}" + }, + ) + }, + text = { + if (awaitingConfirmation) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + Text( + "${pending.action.label} will change server data for ${pending.itemLabel}. Continue?", + ) + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + if (pending.fields.isEmpty()) { + Text("No additional information is needed.") + } else { + displayFields.forEach { field -> + val repeatableSpec = field.repeatableObjectInput + if (repeatableSpec != null) { + GenericRepeatableObjectField( + field = field, + spec = repeatableSpec, + rows = repeatableObjectValues[field.id].orEmpty(), + enabled = !submitting && formRetryAllowed && structuredDraftSafe, + onRowsChange = { rows -> + repeatableObjectValues = repeatableObjectValues + + (field.id to rows) + error = null + }, + ) + return@forEach + } + val relationOptions = nativeRelationOptions( + field = field, + formResource = pending.resource, + schema = schema, + context = pending.datasetContext, + ) + if (nativeRelationFieldRequiresChoice(field, pending.resource, schema)) { + GenericRelationshipField( + field = field, + value = values[field.id].orEmpty(), + options = relationOptions, + choicesLoaded = nativeRelationChoicesLoaded( + field, + pending.resource, + schema, + pending.datasetContext, + ), + choiceSourceHasRecords = nativeRelationChoiceSourceHasRecords( + field, + pending.resource, + schema, + pending.datasetContext, + ), + choiceUnavailableReason = nativeRelationChoiceUnavailableReason( + field, + pending.resource, + schema, + pending.datasetContext, + ), + paging = nativeRelationPaging( + field, + pending.resource, + schema, + pending.datasetContext, + ), + error = null, + enabled = !submitting && formRetryAllowed, + onValueChange = { value -> + values = values + (field.id to value) + error = null + }, + ) + } else { + GenericFormField( + field = field, + value = values[field.id].orEmpty(), + error = null, + enabled = !submitting && formRetryAllowed, + filePicker = filePicker, + onValueChange = { value -> + values = values + (field.id to value) + error = null + }, + ) + } + } + } + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (!structuredDraftSafe && structuredSpecs.isNotEmpty()) { + OutlinedButton( + enabled = !submitting && formRetryAllowed, + onClick = { + repeatableObjectValues = emptyStructuredDraft + structuredDraftSafe = true + error = null + }, + modifier = Modifier.semantics { + contentDescription = "Reset structured fields for ${pending.action.id}" + }, + ) { + Text("Reset structured items") + } + } + if (outcomeUnknown) { + Text( + "The data is being refreshed to check the server result. " + + "Review the refreshed data before trying this action again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + dismissButton = { + TextButton( + enabled = !submitting, + onClick = { + if (awaitingConfirmation) { + awaitingConfirmation = false + error = null + } else { + onDismiss() + } + }, + ) { + Text( + when { + outcomeUnknown -> "Close" + awaitingConfirmation -> "Back" + else -> "Cancel" + }, + ) + } + }, + confirmButton = { + if (formRetryAllowed) { + Button( + enabled = !submitting && structuredDraftSafe, + onClick = { + when { + awaitingConfirmation -> submit(confirmed = true) + pending.action.requiresConfirmation -> { + val validation = runCatching { + pending.request( + scalarInputValues = values, + repeatableObjectValues = repeatableObjectValues, + confirmed = true, + ) + }.exceptionOrNull() + if (validation == null) { + error = null + awaitingConfirmation = true + } else { + error = validation.message ?: "The values could not be submitted." + } + } + else -> submit(confirmed = false) + } + }, + ) { + if (submitting) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(if (awaitingConfirmation) "Confirm" else operation) + } + } + } + }, + ) +} + +@Composable +private fun GenericRecordDeleteActionDialog( + pending: PendingNativeRecordDeleteAction, + actionExecutor: NativeActionExecutor, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, + onOutcomeUnknown: (ActionSpec) -> Unit, +) { + var error by remember(pending) { mutableStateOf(null) } + var deleting by remember(pending) { mutableStateOf(false) } + var outcomeUnknown by remember(pending) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { if (!deleting) onDismiss() }, + title = { + Text( + if (outcomeUnknown) { + "Delete result unknown" + } else { + "Delete ${pending.itemLabel}?" + }, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + Text("This removes the item from the server and cannot be undone.") + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (outcomeUnknown) { + Text( + "The collection is being refreshed to check the server result. " + + "Review the refreshed data before trying to delete this item again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + dismissButton = { + TextButton(enabled = !deleting, onClick = onDismiss) { + Text(if (outcomeUnknown) "Close" else "Cancel") + } + }, + confirmButton = { + if (!outcomeUnknown) { + Button( + enabled = !deleting, + modifier = Modifier.semantics { + contentDescription = "Confirm record delete ${pending.plan.action.id}" + }, + onClick = { + val request = pending.plan.request(confirmed = true) + deleting = true + error = null + outcomeUnknown = false + scope.launch { + when (val result = actionExecutor.execute(request)) { + is NativeActionExecutionResult.Success -> { + onActionSucceeded(pending.plan.action) + } + is NativeActionExecutionResult.Failure -> { + error = result.message + outcomeUnknown = !result.outcome.allowsGenericDeleteRetry() + if (outcomeUnknown) { + onOutcomeUnknown(pending.plan.action) + } + } + } + deleting = false + } + }, + ) { + if (deleting) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Delete") + } + } + } + }, + ) +} + +@Composable +private fun GenericRecordCommandActionDialog( + pending: PendingNativeRecordCommandAction, + actionExecutor: NativeActionExecutor, + onDismiss: () -> Unit, + onActionSucceeded: (ActionSpec) -> Unit, + onOutcomeUnknown: (ActionSpec) -> Unit, +) { + val ui = nativeRecordCommandUi(pending.plan.effect, pending.itemLabel) + var error by remember(pending) { mutableStateOf(pending.initialError) } + var failureOutcome by remember(pending) { mutableStateOf(pending.initialFailureOutcome) } + var executing by remember(pending) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val retryingDirectAction = !pending.plan.requiresConfirmation + val outcomeUnknown = failureOutcome?.requiresCommandReconciliation() == true + + AlertDialog( + onDismissRequest = { if (!executing) onDismiss() }, + title = { + Text( + if (outcomeUnknown) { + "${ui.label} result unknown" + } else if (retryingDirectAction) { + "${ui.label} failed" + } else { + requireNotNull(ui.confirmationTitle) + }, + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + ui.confirmationMessage?.takeIf { pending.plan.requiresConfirmation }?.let { message -> + Text(message) + } + error?.let { message -> + Text( + message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (outcomeUnknown) { + Text( + "The collection is being refreshed to check the server result. " + + "Review the refreshed item before trying this action again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + dismissButton = { + TextButton(enabled = !executing, onClick = onDismiss) { + Text(if (outcomeUnknown) "Close" else "Cancel") + } + }, + confirmButton = { + if (!outcomeUnknown) { + Button( + enabled = !executing, + onClick = { + executing = true + error = null + failureOutcome = null + scope.launch { + when ( + val result = actionExecutor.execute( + pending.plan.request(confirmed = pending.plan.requiresConfirmation), + ) + ) { + is NativeActionExecutionResult.Success -> { + onActionSucceeded(pending.plan.action) + } + is NativeActionExecutionResult.Failure -> { + error = result.message + failureOutcome = result.outcome + if (result.outcome.requiresCommandReconciliation()) { + onOutcomeUnknown(pending.plan.action) + } + } + } + executing = false + } + }, + ) { + if (executing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(if (retryingDirectAction) "Try again" else ui.label) + } + } + } + }, + ) +} + +@Composable +private fun GenericRecordTable( + schema: NativeAppSchema, + view: ViewSpec, + resource: ResourceSpec, + records: List, + datasetContext: NativeDatasetContext, + actionExecutor: NativeActionExecutor, + onSelectRecord: ((NativeRecord) -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, + modifier: Modifier = Modifier, +) { + val composite = view.compositeDataGrid + val columnResource = composite?.let { schema.resource(it.columnResourceId) } + val columnRecords = composite?.let { datasetContext.relatedRecords[it.columnResourceId].orEmpty() }.orEmpty() + val projection = remember(resource, records, columnResource, columnRecords, composite) { + nativeTableProjection(resource, records, columnResource, columnRecords, composite) + } + val projectedResource = projection.resource + val projectedRecords = projection.records + val fields = remember(projection) { + if (projection.composite) { + projectedResource.fields.filter { it.id in projection.projectedFieldIds } + + listOfNotNull(projectedResource.fields.firstOrNull { it.id == projection.frozenFieldId }) + } else { + nativeTableFields(projectedResource, projectedRecords) + } + }.distinctBy(FieldSpec::id) + if (fields.isEmpty()) { + GenericRecordList(projectedResource, projectedRecords, onSelectRecord, modifier) + return + } + var activeEdit by remember(schema, projection) { mutableStateOf(null) } + var editValue by remember { mutableStateOf("") } + var editError by remember { mutableStateOf(null) } + var savingEdit by remember { mutableStateOf(false) } + val editedValues = remember(schema, projection) { mutableStateMapOf() } + val scope = rememberCoroutineScope() + val actionWidth = if (onSelectRecord == null) 0.dp else 48.dp + val frozenField = fields.firstOrNull { it.id == projection.frozenFieldId } + val scrollingFields = fields.filterNot { it.id == frozenField?.id } + val horizontalState = rememberScrollState() + Column( + modifier = modifier.fillMaxSize().padding( + start = NextcloudSpacing.Large, + top = NextcloudSpacing.Medium, + end = NextcloudSpacing.Large, + bottom = NextcloudSpacing.XXLarge, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + frozenField?.let { field -> GenericTableHeaderCell(field, field.nativeTableColumnWidth()) } + Row( + modifier = Modifier.weight(1f).horizontalScroll(horizontalState), + verticalAlignment = Alignment.CenterVertically, + ) { + scrollingFields.forEach { field -> GenericTableHeaderCell(field, field.nativeTableColumnWidth()) } + if (actionWidth > 0.dp) Box(Modifier.width(actionWidth)) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + LazyColumn(modifier = Modifier.weight(1f)) { + items(projectedRecords, key = NativeRecord::id) { record -> + Row( + modifier = Modifier + .fillMaxWidth() + .then( + onSelectRecord?.let { callback -> Modifier.clickable { callback(record) } } ?: Modifier, + ) + .heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + frozenField?.let { field -> + val address = NativeCellAddress(record.id, field.id) + val plan = nativeCellEditPlan(schema, resource, projection, record, field) + GenericTableValueCell( + field = field, + rawValue = editedValues[address] ?: record.presentationValue(field.id), + editPlan = plan, + width = field.nativeTableColumnWidth(), + emphasized = true, + onEdit = { + activeEdit = it.copy(originalValue = editedValues[address] ?: it.originalValue) + editValue = editedValues[address] ?: it.originalValue + editError = null + }, + ) + } + Row( + modifier = Modifier.weight(1f).horizontalScroll(horizontalState), + verticalAlignment = Alignment.CenterVertically, + ) { + scrollingFields.forEachIndexed { index, field -> + val address = NativeCellAddress(record.id, field.id) + val plan = nativeCellEditPlan(schema, resource, projection, record, field) + GenericTableValueCell( + field = field, + rawValue = editedValues[address] ?: record.presentationValue(field.id), + editPlan = plan, + width = field.nativeTableColumnWidth(), + emphasized = frozenField == null && index == 0, onEdit = { activeEdit = it.copy(originalValue = editedValues[address] ?: it.originalValue) editValue = editedValues[address] ?: it.originalValue @@ -461,7 +2594,7 @@ private fun GenericRecordTable( is NativeActionExecutionResult.Success -> { editedValues[NativeCellAddress(plan.recordId, plan.field.id)] = editValue.trim() activeEdit = null - onInlineActionSucceeded?.invoke() + onInlineActionSucceeded?.invoke(plan.action) } is NativeActionExecutionResult.Failure -> editError = result.message } @@ -487,7 +2620,7 @@ private fun GenericTableCollection( datasetContext: NativeDatasetContext, actionExecutor: NativeActionExecutor, onSelectRecord: ((NativeRecord) -> Unit)?, - onInlineActionSucceeded: (() -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, ) { val composite = view.compositeDataGrid val columnResource = composite?.let { schema.resource(it.columnResourceId) } @@ -635,7 +2768,12 @@ private fun GenericRendererLoading(title: String) { } @Composable -private fun GenericRendererEmpty(resourceName: String) { +private fun GenericRendererEmpty( + resourceId: String, + resourceName: String, + createLabel: String? = null, + onCreate: (() -> Unit)? = null, +) { GenericCenteredState { GenericStateIcon(NextcloudIcons.Apps) Text("No ${resourceName.lowercase()} yet", style = MaterialTheme.typography.titleLarge) @@ -644,6 +2782,22 @@ private fun GenericRendererEmpty(resourceName: String) { color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) + if (onCreate != null) { + Button( + onClick = onCreate, + modifier = Modifier + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Create $resourceId" + }, + ) { + Icon(NextcloudIcons.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + createLabel?.takeIf(String::isNotBlank) ?: "Create item", + modifier = Modifier.padding(start = NextcloudSpacing.Small), + ) + } + } } } @@ -703,6 +2857,7 @@ private fun GenericRecordList( records: List, onSelectRecord: ((NativeRecord) -> Unit)?, modifier: Modifier = Modifier, + secondaryActions: (NativeRecord) -> List = { emptyList() }, ) { LazyColumn( modifier = modifier, @@ -715,7 +2870,12 @@ private fun GenericRecordList( verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { items(records, key = NativeRecord::id) { record -> - GenericCollectionCard(resource, record, onSelectRecord) + GenericCollectionCard( + resource = resource, + record = record, + onSelectRecord = onSelectRecord, + secondaryActions = secondaryActions(record), + ) } } } @@ -728,7 +2888,7 @@ private fun GenericEditableTableRecordList( records: List, onSelectRecord: ((NativeRecord) -> Unit)?, actionExecutor: NativeActionExecutor, - onInlineActionSucceeded: (() -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, modifier: Modifier = Modifier, ) { val fields = remember(projection) { @@ -830,7 +2990,7 @@ private fun GenericEditableTableRecordList( NativeCellAddress(plan.recordId, plan.field.id) ] = editValue.trim() activeEdit = null - onInlineActionSucceeded?.invoke() + onInlineActionSucceeded?.invoke(plan.action) } is NativeActionExecutionResult.Failure -> editError = result.message } @@ -852,11 +3012,20 @@ private fun GenericEditableTableRecordList( @Composable private fun GenericRecordCollection( + schema: NativeAppSchema, resource: ResourceSpec, records: List, + datasetContext: NativeDatasetContext, + actionExecutor: NativeActionExecutor, onSelectRecord: ((NativeRecord) -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, + onEditRecord: (NativeRecord, NativeRecordFormActionPlan) -> Unit, + onDeleteRecord: (NativeRecord, NativeRecordDeleteActionPlan) -> Unit, + onCommandRecord: (NativeRecord, NativeRecordCommandActionPlan) -> Unit, + onCommandFormRecord: (NativeRecord, NativeRecordCommandFormActionPlan) -> Unit, imageLoader: NativeImageLoader?, ) { + val authoritativeRecordsKey = NativeAuthoritativeRecordsKey(records) val recipes = remember(resource, records) { nativeRecipeCollectionPresentations(resource, records) } @@ -864,11 +3033,25 @@ private fun GenericRecordCollection( GenericRecipeCollection(recipes, onSelectRecord, imageLoader) return } - val tasks = remember(resource, records) { + val tasks = remember(resource, authoritativeRecordsKey) { nativeTaskCollectionPresentations(resource, records) } if (tasks != null) { - GenericTaskCollection(tasks, onSelectRecord) + GenericTaskCollection( + schema = schema, + resource = resource, + rows = tasks, + authoritativeRecordsKey = authoritativeRecordsKey, + navigationContext = datasetContext.bindingValues, + authorityContext = datasetContext.nativeRecordAuthorityContext(schema), + actionExecutor = actionExecutor, + onSelectRecord = onSelectRecord, + onActionSucceeded = onInlineActionSucceeded, + onEditRecord = onEditRecord, + onDeleteRecord = onDeleteRecord, + onCommandRecord = onCommandRecord, + onCommandFormRecord = onCommandFormRecord, + ) return } val groupware = remember(resource, records) { @@ -897,7 +3080,28 @@ private fun GenericRecordCollection( stateKey = "collection:${resource.id}", ) } - GenericRecordList(resource, records, onSelectRecord, Modifier.weight(1f)) + GenericRecordList( + resource = resource, + records = records, + onSelectRecord = onSelectRecord, + modifier = Modifier.weight(1f), + secondaryActions = { record -> + nativeRecordCardActions( + capabilities = nativeRecordActions( + schema = schema, + resource = resource, + record = record, + navigationContext = datasetContext.bindingValues, + authorityContext = datasetContext.nativeRecordAuthorityContext(schema), + ), + record = record, + onEditRecord = onEditRecord, + onDeleteRecord = onDeleteRecord, + onCommandRecord = onCommandRecord, + onCommandFormRecord = onCommandFormRecord, + ) + }, + ) } } } @@ -953,7 +3157,12 @@ private fun GenericFinanceCollection( horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), verticalAlignment = Alignment.CenterVertically, ) { - GenericResourceIcon(resource) + val presentation = nativeRecordPresentation(resource, record) + GenericResourceIcon( + resource, + presentation.iconKey, + presentation.colorArgb, + ) Text( transaction.title, modifier = Modifier.weight(1f), @@ -1099,11 +3308,106 @@ private fun GenericFinanceDetailHeader( } } +/** + * Compose effect keys use structural equality, while an authoritative refresh can legitimately + * return records equal to the previous snapshot. This key treats a newly allocated record list as + * a refresh even when its contents are unchanged. + */ +internal class NativeAuthoritativeRecordsKey( + private val records: List, +) { + override fun equals(other: Any?): Boolean = + other is NativeAuthoritativeRecordsKey && records === other.records + + override fun hashCode(): Int = 0 +} + +internal data class NativeCompletionOverride( + val completed: Boolean, + val sourceRecordsKey: NativeAuthoritativeRecordsKey, +) + +internal fun effectiveNativeCompletion( + override: NativeCompletionOverride?, + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, + authoritativeCompleted: Boolean, +): Boolean = override + ?.takeIf { candidate -> candidate.sourceRecordsKey == authoritativeRecordsKey } + ?.completed + ?: authoritativeCompleted + +internal fun MutableMap.reconcileNativeCompletionOverrides( + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, +) { + keys.filter { recordId -> + get(recordId)?.sourceRecordsKey != authoritativeRecordsKey + }.forEach(::remove) +} + +/** + * Records a completion result whose server outcome is unknown until a later authoritative refresh. + * The Boolean return value tells the UI whether it must request that refresh. + */ +internal fun MutableMap.recordNativeCompletionFailure( + recordId: String, + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, + outcome: NativeActionFailureOutcome, +): Boolean { + if (!outcome.requiresMutationReconciliation()) { + remove(recordId) + return false + } + this[recordId] = authoritativeRecordsKey + return true +} + +internal fun Map.isNativeCompletionReconciling( + recordId: String, + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, +): Boolean = get(recordId) == authoritativeRecordsKey + +internal fun MutableMap.reconcileNativeCompletionFailures( + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, +): Set { + val reconciledRecordIds = keys.filterTo(linkedSetOf()) { recordId -> + get(recordId) != authoritativeRecordsKey + } + reconciledRecordIds.forEach(::remove) + return reconciledRecordIds +} + @Composable private fun GenericTaskCollection( + schema: NativeAppSchema, + resource: ResourceSpec, rows: List>, + authoritativeRecordsKey: NativeAuthoritativeRecordsKey, + navigationContext: Map, + authorityContext: NativeRecordAuthorityContext?, + actionExecutor: NativeActionExecutor, onSelectRecord: ((NativeRecord) -> Unit)?, + onActionSucceeded: ((ActionSpec) -> Unit)?, + onEditRecord: (NativeRecord, NativeRecordFormActionPlan) -> Unit, + onDeleteRecord: (NativeRecord, NativeRecordDeleteActionPlan) -> Unit, + onCommandRecord: (NativeRecord, NativeRecordCommandActionPlan) -> Unit, + onCommandFormRecord: (NativeRecord, NativeRecordCommandFormActionPlan) -> Unit, ) { + val scope = rememberCoroutineScope() + val currentAuthoritativeRecordsKey by rememberUpdatedState(authoritativeRecordsKey) + val completionOverrides = remember(schema, resource.id) { + mutableStateMapOf() + } + val completionInProgress = remember(schema, resource.id) { mutableStateMapOf() } + val completionReconciliations = remember(schema, resource.id) { + mutableStateMapOf() + } + val completionErrors = remember(schema, resource.id) { mutableStateMapOf() } + LaunchedEffect(authoritativeRecordsKey) { + completionOverrides.reconcileNativeCompletionOverrides(authoritativeRecordsKey) + completionReconciliations + .reconcileNativeCompletionFailures(authoritativeRecordsKey) + .forEach(completionErrors::remove) + } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -1115,11 +3419,47 @@ private fun GenericTaskCollection( verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { items(rows, key = { (record, _) -> record.id }) { (record, task) -> - val interaction = onSelectRecord - ?.let { callback -> Modifier.clickable { callback(record) } } - ?: Modifier + val actions = remember(schema, resource, record, navigationContext, authorityContext) { + nativeRecordActions( + schema = schema, + resource = resource, + record = record, + navigationContext = navigationContext, + authorityContext = authorityContext, + ) + } + val completion = actions.completion + val authoritativeCompleted = completion?.currentlyCompleted ?: task.completed + val completed = effectiveNativeCompletion( + override = completionOverrides[record.id], + authoritativeRecordsKey = authoritativeRecordsKey, + authoritativeCompleted = authoritativeCompleted, + ) + val completing = completionInProgress[record.id] == true + val reconciling = completionReconciliations.isNativeCompletionReconciling( + recordId = record.id, + authoritativeRecordsKey = authoritativeRecordsKey, + ) + val secondaryActions = nativeRecordCardActions( + capabilities = actions, + record = record, + onEditRecord = onEditRecord, + onDeleteRecord = onDeleteRecord, + onCommandRecord = onCommandRecord, + onCommandFormRecord = onCommandFormRecord, + ) + var actionsExpanded by rememberSaveable(record.id) { mutableStateOf(false) } Card( - modifier = interaction.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().nextcloudCardInteractions( + onOpen = onSelectRecord?.let { callback -> { callback(record) } }, + onShowActions = if (secondaryActions.isNotEmpty()) { + { actionsExpanded = true } + } else { + null + }, + openLabel = "Open ${task.title}", + actionsLabel = "Show actions for ${task.title}", + ), colors = CardDefaults.cardColors(containerColor = NextcloudTheme.colors.appTile), shape = RoundedCornerShape(NextcloudRadii.Card), ) { @@ -1129,27 +3469,70 @@ private fun GenericTaskCollection( verticalAlignment = Alignment.CenterVertically, ) { Surface( - color = if (task.completed) { + color = if (completed) { MaterialTheme.colorScheme.primaryContainer } else { NextcloudTheme.colors.appIconContainer }, shape = MaterialTheme.shapes.extraLarge, ) { - Icon( - imageVector = if (task.completed) { - NextcloudIcons.CheckCircle - } else { - NextcloudIcons.app("tasks") - }, - contentDescription = if (task.completed) "Completed" else "Open task", - modifier = Modifier.padding(NextcloudSpacing.Small).size(24.dp), - tint = if (task.completed) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - NextcloudTheme.colors.appIcon - }, - ) + if (completion != null) { + Checkbox( + checked = completed, + enabled = !completing && !reconciling, + modifier = Modifier.semantics { + contentDescription = "Toggle completion for ${task.title}" + }, + onCheckedChange = { requested -> + completionErrors.remove(record.id) + completionInProgress[record.id] = true + scope.launch { + when ( + val result = actionExecutor.execute( + completion.request(completed = requested), + ) + ) { + is NativeActionExecutionResult.Success -> { + completionReconciliations.remove(record.id) + completionOverrides[record.id] = NativeCompletionOverride( + completed = requested, + sourceRecordsKey = authoritativeRecordsKey, + ) + onActionSucceeded?.invoke(completion.action) + } + is NativeActionExecutionResult.Failure -> { + completionErrors[record.id] = result.message + val refreshRequired = + completionReconciliations.recordNativeCompletionFailure( + recordId = record.id, + authoritativeRecordsKey = currentAuthoritativeRecordsKey, + outcome = result.outcome, + ) + if (refreshRequired) { + onActionSucceeded?.invoke(completion.action) + } + } + } + completionInProgress.remove(record.id) + } + }, + ) + } else { + Icon( + imageVector = if (completed) { + NextcloudIcons.CheckCircle + } else { + NextcloudIcons.FormatChecklist + }, + contentDescription = if (completed) "Completed" else "Open item", + modifier = Modifier.padding(NextcloudSpacing.Small).size(24.dp), + tint = if (completed) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + NextcloudTheme.colors.appIcon + }, + ) + } } Column( modifier = Modifier.weight(1f), @@ -1168,7 +3551,7 @@ private fun GenericTaskCollection( task.effortPoints?.let { "$it ${if (it == 1) "point" else "points"}" }, task.recurrenceRule?.taskRecurrenceLabel(), task.status?.takeUnless { status -> - status.equals("completed", ignoreCase = true) && task.completed + status.equals("completed", ignoreCase = true) && completed }, ).distinct().joinToString(" · ").takeIf(String::isNotBlank)?.let { metadata -> Text( @@ -1179,8 +3562,33 @@ private fun GenericTaskCollection( overflow = TextOverflow.Ellipsis, ) } + completionErrors[record.id]?.let { message -> + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (reconciling) { + Text( + "Refreshing to verify the completion result before another change.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } - if (onSelectRecord != null) { + if (secondaryActions.isNotEmpty()) { + NextcloudCardOverflow( + itemLabel = task.title, + actions = secondaryActions, + expanded = actionsExpanded, + onExpandedChange = { actionsExpanded = it }, + ) + } else if (onSelectRecord != null) { Icon( NextcloudIcons.ChevronRight, contentDescription = "Open ${task.title}", @@ -1194,6 +3602,104 @@ private fun GenericTaskCollection( } } +internal fun nativeRecordCardActions( + capabilities: NativeRecordActionCapabilities, + record: NativeRecord, + onEditRecord: (NativeRecord, NativeRecordFormActionPlan) -> Unit, + onDeleteRecord: (NativeRecord, NativeRecordDeleteActionPlan) -> Unit, + onCommandRecord: (NativeRecord, NativeRecordCommandActionPlan) -> Unit, + onCommandFormRecord: (NativeRecord, NativeRecordCommandFormActionPlan) -> Unit = { _, _ -> }, +): List = buildList { + capabilities.edit?.let { plan -> + add( + NextcloudCardAction( + label = "Edit", + semanticId = plan.action.id, + onClick = { onEditRecord(record, plan) }, + ), + ) + } + capabilities.commands.forEach { plan -> + val ui = nativeRecordCommandUi(plan.effect, record.id) + add( + NextcloudCardAction( + label = ui.label, + semanticId = plan.action.id, + destructive = ui.destructive, + onClick = { onCommandRecord(record, plan) }, + ), + ) + } + capabilities.commandForms.forEach { plan -> + add( + NextcloudCardAction( + label = plan.action.label, + semanticId = plan.action.id, + destructive = plan.action.risk == ActionRisk.destructive, + onClick = { onCommandFormRecord(record, plan) }, + ), + ) + } + capabilities.delete?.let { plan -> + add( + NextcloudCardAction( + label = "Delete", + semanticId = plan.action.id, + destructive = true, + onClick = { onDeleteRecord(record, plan) }, + ), + ) + } +} + +internal data class NativeRecordCommandUi( + val label: String, + val destructive: Boolean, + val confirmationTitle: String? = null, + val confirmationMessage: String? = null, +) + +internal fun NativeActionFailureOutcome.requiresMutationReconciliation(): Boolean = + this == NativeActionFailureOutcome.Unknown + +internal fun NativeActionFailureOutcome.requiresCommandReconciliation(): Boolean = + requiresMutationReconciliation() + +internal fun NativeActionFailureOutcome.allowsGenericFormRetry(): Boolean = + !requiresMutationReconciliation() + +internal fun NativeActionFailureOutcome.allowsGenericDeleteRetry(): Boolean = + !requiresMutationReconciliation() + +internal fun nativeRecordCommandUi( + effect: ActionEffect, + itemLabel: String, +): NativeRecordCommandUi = when (effect) { + ActionEffect.archive -> NativeRecordCommandUi(label = "Archive", destructive = false) + ActionEffect.unarchive -> NativeRecordCommandUi(label = "Unarchive", destructive = false) + ActionEffect.restore -> NativeRecordCommandUi(label = "Restore", destructive = false) + ActionEffect.copy -> NativeRecordCommandUi(label = "Copy", destructive = false) + ActionEffect.permanentDelete -> NativeRecordCommandUi( + label = "Delete permanently", + destructive = true, + confirmationTitle = "Delete $itemLabel permanently?", + confirmationMessage = "This permanently removes the item from the server and cannot be undone.", + ) + ActionEffect.clear -> NativeRecordCommandUi( + label = "Clear", + destructive = true, + confirmationTitle = "Clear $itemLabel?", + confirmationMessage = "This permanently clears the selected item and cannot be undone.", + ) + ActionEffect.leave -> NativeRecordCommandUi( + label = "Leave", + destructive = true, + confirmationTitle = "Leave $itemLabel?", + confirmationMessage = "You may lose access to this item after leaving.", + ) + else -> error("Unsupported record command effect: $effect") +} + @Composable private fun GenericGroupwareCollection( rows: List>, @@ -1811,7 +4317,7 @@ private fun GenericRecordBoard( declaredLanes: List? = null, onSelectRecord: ((NativeRecord) -> Unit)?, actionExecutor: NativeActionExecutor, - onActionSucceeded: (() -> Unit)?, + onActionSucceeded: ((ActionSpec) -> Unit)?, reconciliation: NativeBoardMoveReconciliation, ) { val discoveredLanes = remember(resource, records, declaredLanes) { @@ -1935,7 +4441,7 @@ private fun GenericRecordBoard( is NativeActionExecutionResult.Success -> { editTarget = null actionMessage = "Update accepted. Refreshing the card..." - onActionSucceeded?.invoke() + onActionSucceeded?.invoke(target.plan.action) } is NativeActionExecutionResult.Failure -> actionError = result.message } @@ -1958,7 +4464,7 @@ private fun GenericRecordBoard( beforeFingerprint = fingerprint, ) actionMessage = "Move accepted. Refreshing the board to verify it..." - onActionSucceeded?.invoke() + onActionSucceeded?.invoke(target.plan.action) } is NativeActionExecutionResult.Failure -> actionError = result.message } @@ -1975,7 +4481,7 @@ private fun GenericRecordBoard( is NativeActionExecutionResult.Success -> { createTarget = null actionMessage = "Card created in ${target.lane.title}. Refreshing the board..." - onActionSucceeded?.invoke() + onActionSucceeded?.invoke(target.action) } is NativeActionExecutionResult.Failure -> actionError = result.message } @@ -1992,7 +4498,7 @@ private fun GenericRecordBoard( is NativeActionExecutionResult.Success -> { confirmTarget = null actionMessage = "${target.plan.label} accepted. Refreshing the board..." - onActionSucceeded?.invoke() + onActionSucceeded?.invoke(target.plan.action) } is NativeActionExecutionResult.Failure -> actionError = result.message } @@ -2643,7 +5149,7 @@ private fun GenericCollectionCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), ) { - GenericResourceIcon(resource) + GenericResourceIcon(resource, presentation.iconKey, presentation.colorArgb) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Text( presentation.title, @@ -2679,16 +5185,23 @@ private fun GenericCollectionCard( } } Card( - modifier = Modifier.fillMaxWidth().nextcloudCardInteractions( - onOpen = onSelectRecord?.let { select -> { select(record) } }, - onShowActions = if (secondaryActions.isNotEmpty()) { - { actionsExpanded = true } - } else { - null - }, - openLabel = "Open ${presentation.title}", - actionsLabel = "Show actions for ${presentation.title}", - ), + modifier = Modifier + .fillMaxWidth() + .semantics { + if (onSelectRecord != null) { + contentDescription = "Open ${presentation.title}" + } + } + .nextcloudCardInteractions( + onOpen = onSelectRecord?.let { select -> { select(record) } }, + onShowActions = if (secondaryActions.isNotEmpty()) { + { actionsExpanded = true } + } else { + null + }, + openLabel = "Open ${presentation.title}", + actionsLabel = "Show actions for ${presentation.title}", + ), colors = CardDefaults.cardColors(containerColor = NextcloudTheme.colors.appTile), shape = RoundedCornerShape(NextcloudRadii.Card), content = content, @@ -2719,7 +5232,7 @@ private fun GenericRecordGrid( modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), ) { - GenericResourceIcon(resource) + GenericResourceIcon(resource, presentation.iconKey, presentation.colorArgb) Text( presentation.title, style = MaterialTheme.typography.titleMedium, @@ -2749,8 +5262,8 @@ private fun GenericRecordDetail( record: NativeRecord, datasetContext: NativeDatasetContext, actionExecutor: NativeActionExecutor, - onActionSucceeded: (() -> Unit)?, - onInlineActionSucceeded: (() -> Unit)?, + onActionSucceeded: ((ActionSpec) -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, onOpenLink: ((String) -> Unit)?, imageLoader: NativeImageLoader?, ) { @@ -2798,7 +5311,10 @@ private fun GenericRecordDetail( 1.0 } Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(NextcloudSpacing.Large), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(NextcloudSpacing.Large), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), ) { if (recipe != null) { @@ -2818,9 +5334,15 @@ private fun GenericRecordDetail( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), ) { - GenericResourceIcon(resource, large = true) + val presentation = nativeRecordPresentation(resource, record) + GenericResourceIcon( + resource, + presentation.iconKey, + presentation.colorArgb, + large = true, + ) Column(modifier = Modifier.weight(1f)) { - Text(nativeRecordPresentation(resource, record).title, style = MaterialTheme.typography.headlineSmall) + Text(presentation.title, style = MaterialTheme.typography.headlineSmall) Text( resource.name, style = MaterialTheme.typography.bodyMedium, @@ -2882,7 +5404,10 @@ private fun GenericFinanceStatisticsDashboard( dashboard: NativeFinanceDashboardPresentation, ) { Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(NextcloudSpacing.Large), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(NextcloudSpacing.Large), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), ) { Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { @@ -3153,8 +5678,8 @@ private fun GenericMailMessageDetail( message: NativeMailMessageDetailPresentation, datasetContext: NativeDatasetContext, actionExecutor: NativeActionExecutor, - onActionSucceeded: (() -> Unit)?, - onInlineActionSucceeded: (() -> Unit)?, + onActionSucceeded: ((ActionSpec) -> Unit)?, + onInlineActionSucceeded: ((ActionSpec) -> Unit)?, ) { val structured = remember(resource, record) { nativeStructuredDetail(resource, record) } val attachments = structured.sections.filter { section -> @@ -3195,9 +5720,9 @@ private fun GenericMailMessageDetail( NativeMailMessageActionKind.Delete, ) ) { - onActionSucceeded?.invoke() + onActionSucceeded?.invoke(plan.action) } else { - (onInlineActionSucceeded ?: onActionSucceeded)?.invoke() + (onInlineActionSucceeded ?: onActionSucceeded)?.invoke(plan.action) } } is NativeActionExecutionResult.Failure -> actionError = result.message @@ -3527,8 +6052,15 @@ private fun GenericStructuredOmission(count: Int) { } @Composable -private fun GenericResourceIcon(resource: ResourceSpec, large: Boolean = false) { - val icon = nativeResourceIconAppId(resource)?.let(NextcloudIcons::app) ?: when { +private fun GenericResourceIcon( + resource: ResourceSpec, + recordIconKey: String? = null, + recordColorArgb: Int? = null, + large: Boolean = false, +) { + val icon = recordIconKey?.let(NextcloudIcons::semanticOrFallback) + ?: nativeResourceIconAppId(resource)?.let(NextcloudIcons::app) + ?: when { resource.fields.any { it.kind == FieldKind.image } -> NextcloudIcons.Image resource.fields.any { it.kind == FieldKind.file } -> NextcloudIcons.File resource.fields.any { it.kind == FieldKind.date || it.kind == FieldKind.dateTime } -> NextcloudIcons.Calendar @@ -3539,7 +6071,7 @@ private fun GenericResourceIcon(resource: ResourceSpec, large: Boolean = false) Icon( icon, contentDescription = null, - tint = NextcloudTheme.colors.appIcon, + tint = recordColorArgb?.let(::Color) ?: NextcloudTheme.colors.appIcon, modifier = Modifier.padding(if (large) NextcloudSpacing.Medium else NextcloudSpacing.Small) .size(if (large) 30.dp else 24.dp), ) @@ -3603,15 +6135,30 @@ private fun GenericNativeForm( datasetContext: NativeDatasetContext, executor: NativeActionExecutor, filePicker: NativeFileFieldPicker?, - onActionSucceeded: (() -> Unit)?, + onActionSucceeded: ((ActionSpec) -> Unit)?, + onActionOutcomeUnknown: ((ActionSpec) -> Unit)?, + mutationReconciliationGeneration: Int, ) { val action = schema.action(view.sourceActionId) if (action == null || action.resourceId != resource.id) { GenericRendererError("This form has no matching schema-declared action.") return } - val formResource = remember(resource, action, initialRecord) { - resource.withObservedSettingsFormTypes(action, initialRecord) + val prefillRecord = remember( + action, + resource, + initialRecord, + datasetContext.parentResourceId, + ) { + nativeFormPrefillRecord( + action = action, + resource = resource, + record = initialRecord, + parentResourceId = datasetContext.parentResourceId, + ) + } + val formResource = remember(resource, action, prefillRecord) { + resource.withObservedSettingsFormTypes(action, prefillRecord) } val formAction = remember(action, formResource) { action.withObservedSettingsInputTypes(formResource) @@ -3626,284 +6173,838 @@ private fun GenericNativeForm( }, ) } - val coordinator = remember(formSchema, view, executor) { NativeActionCoordinator(formSchema, view, executor) } - val autoBoundValues = remember(action, initialRecord) { - nativeFormAutoBoundValues(action, formResource, initialRecord) + val currentExecutor = rememberUpdatedState(executor) + val stableExecutor = remember { + NativeActionExecutor { request -> + currentExecutor.value.execute(request) + } + } + val coordinator = remember(formSchema, view) { + NativeActionCoordinator(formSchema, view, stableExecutor) } - val initialDraft = remember(formSchema, view, formResource, initialRecord, autoBoundValues) { - initialNativeFormDraft(formResource, action, initialRecord).copy( - values = initialNativeFormDraft(formResource, action, initialRecord).values + autoBoundValues, + val bindingRecord = remember( + initialRecord, + datasetContext.parentResourceId, + datasetContext.parentRecord, + ) { + nativeFormBindingRecord( + initialRecord = initialRecord, + parentResourceId = datasetContext.parentResourceId, + parentRecord = datasetContext.parentRecord, + ) + } + val autoBinding = remember( + action, + bindingRecord, + datasetContext.parentResourceId, + datasetContext.bindingValues, + ) { + nativeFormAutoBindingResolution( + schema = schema, + action = action, + resource = formResource, + record = bindingRecord, + parentResourceId = datasetContext.parentResourceId, + navigationValues = datasetContext.bindingValues, ) } - var draft by remember(formSchema, view, formResource, initialRecord) { + val autoBoundValues = autoBinding.values + val initialDraft = remember(formSchema, view, formResource, prefillRecord, autoBoundValues) { + initialNativeFormDraft(formResource, action, prefillRecord).let { draft -> + draft.copy(values = draft.values + autoBoundValues) + } + } + var draft by remember( + formSchema, + view, + formResource, + prefillRecord, + datasetContext.parentResourceId, + datasetContext.parentRecord?.id, + autoBoundValues, + ) { mutableStateOf(initialDraft) } val scope = rememberCoroutineScope() val executionState = coordinator.state val validationErrors = (executionState as? NativeActionExecutionState.ValidationFailed)?.fieldErrors.orEmpty() val submitting = executionState is NativeActionExecutionState.Running - val fields = editableNativeFields(formResource, action).filterNot { field -> field.id in autoBoundValues } + val awaitingReconciliation = executionState is NativeActionExecutionState.AwaitingReconciliation + val submissionBlocked = submitting || awaitingReconciliation + val fields = editableNativeFields(formResource, action) + .filterNot { field -> field.id in autoBoundValues } + .let { editableFields -> + nativeFormDisplayFields( + fields = editableFields, + relationFieldIds = editableFields + .filter { field -> nativeRelationFieldRequiresChoice(field, formResource, schema) } + .mapTo(linkedSetOf(), FieldSpec::id), + ) + } + val uneditableBodyFieldIds = uneditableNativeBodyFieldIds( + action = action, + editableFields = fields, + autoBoundValues = autoBoundValues, + ) + val hasUneditableBodyFields = uneditableBodyFieldIds.isNotEmpty() val settingsWrite = action.isSettingsWrite(resource) val hasChanges = draft.hasChangesFrom(initialDraft) LaunchedEffect(executionState) { - if (executionState is NativeActionExecutionState.Succeeded) onActionSucceeded?.invoke() + when (executionState) { + is NativeActionExecutionState.Succeeded -> onActionSucceeded?.invoke(action) + is NativeActionExecutionState.AwaitingReconciliation -> onActionOutcomeUnknown?.invoke(action) + else -> Unit + } + } + + LaunchedEffect(mutationReconciliationGeneration) { + coordinator.reconcileAuthoritativeRefresh(mutationReconciliationGeneration) } Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(NextcloudSpacing.Large), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + modifier = Modifier + .fillMaxSize() + .semantics { + contentDescription = "${nativeFormTitle(view, resource, action)}; form action ${action.id}" + }, ) { - Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { - Text(nativeFormTitle(view, resource, action), style = MaterialTheme.typography.headlineSmall) - Text( - "${formResource.name} · Fields marked required must be completed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(NextcloudSpacing.Large), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + Text(nativeFormTitle(view, resource, action), style = MaterialTheme.typography.headlineSmall) + Text( + "${formResource.name} · Required fields are marked with *", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + autoBinding.error?.let { error -> + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(NextcloudIcons.Error, contentDescription = null, modifier = Modifier.size(20.dp)) + Text(error, style = MaterialTheme.typography.bodyMedium) + } + } + } + if (fields.isNotEmpty()) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Large), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + fields.forEach { field -> + val relationOptions = + nativeRelationOptions(field, formResource, schema, datasetContext) + if (nativeRelationFieldRequiresChoice(field, formResource, schema)) { + GenericRelationshipField( + field = field, + value = draft.values[field.id].orEmpty(), + options = relationOptions, + choicesLoaded = nativeRelationChoicesLoaded( + field, + formResource, + schema, + datasetContext, + ), + choiceSourceHasRecords = nativeRelationChoiceSourceHasRecords( + field, + formResource, + schema, + datasetContext, + ), + choiceUnavailableReason = nativeRelationChoiceUnavailableReason( + field, + formResource, + schema, + datasetContext, + ), + paging = nativeRelationPaging(field, formResource, schema, datasetContext), + error = validationErrors[field.id], + enabled = !submissionBlocked, + onValueChange = { value -> + coordinator.clearStatus() + draft = draft.update(field.id, value) + }, + ) + } else { + GenericFormField( + field = field, + value = draft.values[field.id].orEmpty(), + error = validationErrors[field.id], + enabled = !submissionBlocked, + filePicker = filePicker, + onValueChange = { value -> + coordinator.clearStatus() + draft = draft.update(field.id, value) + }, + ) + } + } + } + } + } else if (!hasUneditableBodyFields) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Text( + "No additional information is needed for this action.", + modifier = Modifier.padding(NextcloudSpacing.Large), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Text( + "This action needs structured information that cannot be edited safely yet.", + modifier = Modifier.padding(NextcloudSpacing.Large), + style = MaterialTheme.typography.bodyMedium, + ) + } + } } - if (fields.isNotEmpty()) { - GenericSectionHeading("Details", "Information sent with this action") - Surface( - modifier = Modifier.fillMaxWidth(), - color = NextcloudTheme.colors.appTile, - shape = RoundedCornerShape(NextcloudRadii.Card), + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding( + horizontal = NextcloudSpacing.Large, + vertical = NextcloudSpacing.Medium, + ), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { - Column( - modifier = Modifier.padding(NextcloudSpacing.Large), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), - ) { - fields.forEach { field -> - val relationOptions = remember(field, schema, datasetContext) { - nativeRelationOptions(field, schema, datasetContext) + GenericActionStatus(executionState, coordinator::clearStatus) + if (action.risk == ActionRisk.destructive) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = MaterialTheme.shapes.small, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(NextcloudIcons.Error, contentDescription = null, modifier = Modifier.size(20.dp)) + Text( + "You will be asked to confirm this destructive action.", + style = MaterialTheme.typography.bodySmall, + ) } - if (relationOptions.isNotEmpty()) { - GenericRelationPicker( - field = field, - value = draft.values[field.id].orEmpty(), - options = relationOptions, - error = validationErrors[field.id], - enabled = !submitting, - onValueChange = { value -> - coordinator.clearStatus() - draft = draft.update(field.id, value) - }, + } + } + Button( + enabled = + autoBinding.error == null && + !hasUneditableBodyFields && + !submissionBlocked && + (!settingsWrite || hasChanges), + onClick = { + scope.launch { + coordinator.submit( + values = draft.values, + reconciliationGeneration = mutationReconciliationGeneration, ) - } else { - GenericFormField( - field = field, - value = draft.values[field.id].orEmpty(), - error = validationErrors[field.id], - enabled = !submitting, - filePicker = filePicker, - onValueChange = { value -> + } + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { + contentDescription = "Submit form action ${action.id}" + }, + ) { + if (submitting) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .semantics { + contentDescription = "Action status running ${action.id}" + }, + strokeWidth = 2.dp, + ) + } else { + Text( + nativeFormSubmitLabel(resource, action), + modifier = Modifier.semantics { + contentDescription = "Activate form action ${action.id}" + }, + ) + } + } + if (settingsWrite) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (hasChanges) "Unsaved changes" else "Settings are up to date", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (hasChanges) { + TextButton( + enabled = !submissionBlocked, + onClick = { coordinator.clearStatus() - draft = draft.update(field.id, value) + draft = initialDraft }, - ) + ) { + Text("Reset changes") + } } } } } - } else { - Surface( - modifier = Modifier.fillMaxWidth(), - color = NextcloudTheme.colors.appTile, - shape = RoundedCornerShape(NextcloudRadii.Card), - ) { - Text( - "No additional information is needed for this action.", - modifier = Modifier.padding(NextcloudSpacing.Large), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } - GenericSectionHeading("Action", "Review the details before continuing") - GenericActionStatus(executionState, coordinator::clearStatus) - Button( - enabled = !submitting && (!settingsWrite || hasChanges), - onClick = { scope.launch { coordinator.submit(draft.values) } }, - modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), - ) { - if (submitting) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - Text(nativeFormSubmitLabel(resource, action)) + } + + val pending = executionState as? NativeActionExecutionState.AwaitingConfirmation + if (pending != null) { + NativeConfirmationDialog( + action = pending.request.action, + onDismiss = coordinator::cancelConfirmation, + onConfirm = { + scope.launch { + coordinator.confirm(mutationReconciliationGeneration) + } + }, + ) + } +} + +/** + * A nested form may expose an observed child row while the selected parent record carries the + * relationship identity required by the action. Use the parent for hidden relationship binding, + * while the observed row remains available separately for safe form prefilling. + */ +internal fun nativeFormBindingRecord( + initialRecord: NativeRecord?, + parentResourceId: String?, + parentRecord: NativeRecord?, +): NativeRecord? = + if (parentResourceId.isNullOrBlank()) initialRecord else parentRecord ?: initialRecord + +/** + * A selected parent record supplies relationship identities to a child create action, but its + * user-authored fields are not defaults for the new child. Creating a nested child must not copy + * the parent's title or other editable content into the new record. + */ +internal fun nativeFormPrefillRecord( + action: ActionSpec, + resource: ResourceSpec, + record: NativeRecord?, + parentResourceId: String? = null, +): NativeRecord? { + record ?: return null + if (action.intent != ActionIntent.create || parentResourceId.isNullOrBlank()) return record + val parentIdentities = parentResourceId.nativeRelationResourceIdentities() + val formIdentities = resource.id.nativeRelationResourceIdentities() + return record.takeIf { parentIdentities.intersect(formIdentities).isNotEmpty() } +} + +/** + * Resolves already-known technical identities without exposing them as text inputs. + * Destination fields remain user choices: source context is never destination intent. + */ +internal fun nativeFormAutoBoundValues( + schema: NativeAppSchema, + action: ActionSpec, + resource: ResourceSpec, + record: NativeRecord?, + parentResourceId: String? = null, +): Map = nativeFormAutoBindingResolution( + schema = schema, + action = action, + resource = resource, + record = record, + parentResourceId = parentResourceId, +).values + +internal data class NativeFormAutoBindingResolution( + val values: Map, + val error: String? = null, +) + +/** + * Resolves schema-declared technical inputs from verified navigation and record provenance. + * + * No app, endpoint, or domain vocabulary participates. Conflicting identities leave the action + * disabled instead of silently selecting one source. User-selected destinations remain visible. + */ +internal fun nativeFormAutoBindingResolution( + schema: NativeAppSchema, + action: ActionSpec, + resource: ResourceSpec, + record: NativeRecord?, + parentResourceId: String? = null, + navigationValues: Map = emptyMap(), +): NativeFormAutoBindingResolution { + val declaredBindingNames = buildSet { + addAll(action.binding.pathParameterNames) + addAll(action.binding.queryParameterNames) + addAll(action.binding.bodyFieldNames) + } + val declaredNavigationValues = navigationValues.filterKeys(declaredBindingNames::contains) + val available = when (record) { + null -> safeActionBindingValues(declaredNavigationValues) + else -> { + if (!record.actionBindingProvenanceValid) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because the selected item's identity provenance is ambiguous.", + ) } - } - if (settingsWrite) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - if (hasChanges) "Unsaved changes" else "Settings are up to date", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val recordValues = record.nativeFormDeclaredBindingValues(declaredBindingNames) + ?: return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because the selected item contains conflicting declared identities.", ) - if (hasChanges) { - TextButton( - enabled = !submitting, - onClick = { - coordinator.clearStatus() - draft = initialDraft - }, - ) { - Text("Reset changes") - } + safeActionBindingValues(recordValues, declaredNavigationValues) + ?: return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because the selected item no longer matches the navigation context.", + ) + } + } ?: return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because its navigation context is invalid.", + ) + if ( + schema.resources.count { candidate -> candidate.id == resource.id } != 1 || + schema.actions.count { candidate -> candidate.id == action.id && candidate == action } != 1 + ) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because its schema contract is ambiguous.", + ) + } + val acceptedRelationships = schema.relationships.filter { relationship -> + relationship.childResourceId == resource.id && + relationship.parentResourceId == parentResourceId && + relationship.childFieldId != null && + relationship.confidence == Confidence.verified + } + if ( + acceptedRelationships.groupBy { relationship -> relationship.childFieldId } + .any { (_, relationships) -> relationships.distinct().size > 1 } + ) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because its parent relationship is ambiguous.", + ) + } + val acceptedRelationshipFieldIds = acceptedRelationships.mapNotNullTo(mutableSetOf()) { relationship -> + relationship.childFieldId + } + val requiredInputFieldNames = ((action.inputSchema as? JsonObject)?.get("required") as? JsonArray) + ?.mapNotNull { element -> (element as? JsonPrimitive)?.contentOrNull } + .orEmpty() + .toSet() + val resolved = linkedMapOf() + if (action.intent == ActionIntent.create) { + acceptedRelationships.distinct().forEach { relationship -> + val childFieldId = requireNotNull(relationship.childFieldId) + if (childFieldId !in declaredBindingNames) return@forEach + val parentValues = record + ?.takeIf { parent -> + parent.actionSafeIdentity && parent.actionBindingProvenanceValid } + ?.safeActionBindingValues() + ?.get(relationship.parentFieldId) + ?.let(::listOf) + .orEmpty() + val exactValues = buildList { + declaredNavigationValues[childFieldId]?.takeIf(String::isNotBlank)?.let(::add) + addAll(parentValues.filter(String::isNotBlank)) + }.distinct() + if (exactValues.size > 1) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be safely linked to the selected parent because its identities conflict.", + ) } - } - if (action.risk == ActionRisk.destructive) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - shape = MaterialTheme.shapes.small, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(NextcloudIcons.Error, contentDescription = null, modifier = Modifier.size(20.dp)) - Text( - "This destructive action always requires confirmation.", - style = MaterialTheme.typography.bodySmall, + val value = exactValues.singleOrNull() + if ( + value == null && + childFieldId in ( + action.binding.pathParameterNames + + action.binding.requiredQueryParameterNames + + action.binding.requiredBodyFieldNames + + requiredInputFieldNames ) - } + ) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be linked because the selected parent identity could not be verified.", + ) + } + value?.let { resolved[childFieldId] = it } + } + } + if (action.intent != ActionIntent.create) { + val requiredBindingNames = buildSet { + addAll(action.binding.requiredPathParameterNames) + addAll(action.binding.requiredQueryParameterNames) + addAll(action.binding.requiredBodyFieldNames) + } + resource.fields.asSequence() + .filter { field -> + val normalized = field.id.nativeRelationSemanticId() + field.id in requiredBindingNames && + field.id !in acceptedRelationshipFieldIds && + normalized.length > 2 && + normalized.endsWith("id") + } + .forEach { field -> + available[field.id] + ?.takeIf(String::isNotBlank) + ?.let { value -> resolved[field.id] = value } + } + } + val technicalParameterNames = ( + action.binding.pathParameterNames + action.binding.queryParameterNames + ).distinct() + technicalParameterNames.forEach { parameterName -> + if (parameterName in resolved) return@forEach + if ( + action.intent == ActionIntent.create && + parentResourceId != null && + parameterName.nativeRelationSemanticId().isIdentityForNativeParent(parentResourceId) && + parameterName !in acceptedRelationshipFieldIds + ) { + declaredNavigationValues[parameterName] + ?.takeIf(String::isNotBlank) + ?.let { value -> resolved[parameterName] = value } + return@forEach + } + val exactCandidates = available[parameterName] + ?.takeIf(String::isNotBlank) + ?.let(::listOf) + .orEmpty() + val canonicalRecordIdentity = record + ?.takeIf { + it.actionSafeIdentity && + it.actionBindingProvenanceValid && + parameterName.nativeRelationSemanticId().isIdentityForNativeParent(resource.id) } + ?.id + ?.takeIf(String::isNotBlank) + val candidates = (exactCandidates + listOfNotNull(canonicalRecordIdentity)).distinct() + if (candidates.size > 1) { + return NativeFormAutoBindingResolution( + values = emptyMap(), + error = "This action cannot be safely linked because the required identity is ambiguous.", + ) } + candidates.singleOrNull()?.let { value -> resolved[parameterName] = value } } + return NativeFormAutoBindingResolution(values = resolved) +} - val pending = executionState as? NativeActionExecutionState.AwaitingConfirmation - if (pending != null) { - NativeConfirmationDialog( - action = pending.request.action, - onDismiss = coordinator::cancelConfirmation, - onConfirm = { scope.launch { coordinator.confirm() } }, - ) +private fun NativeRecord.nativeFormDeclaredBindingValues( + declaredBindingNames: Set, +): Map? { + val declaredSemanticNames = declaredBindingNames + .mapTo(linkedSetOf()) { name -> name.nativeRelationSemanticId() } + val declaredContext = bindingContext.filterKeys { key -> + key.nativeRelationSemanticId() in declaredSemanticNames + } + val declaredObservedValues = values.mapNotNull { (key, value) -> + val semanticKey = key.nativeRelationSemanticId() + value + ?.takeIf { + key !in structuredValues && + semanticKey in declaredSemanticNames && + semanticKey != "id" + } + ?.let { key to it } + }.toMap() + val canonicalIdentity = if (actionSafeIdentity) { + declaredBindingNames + .filter { name -> name.nativeRelationSemanticId() == "id" } + .associateWith { id } + } else { + emptyMap() } + return safeActionBindingValues( + declaredContext, + declaredObservedValues, + canonicalIdentity, + ) } -internal data class NativeRelationOption( - val value: String, - val label: String, - val supportingText: String? = null, +private fun String.nativeRelationSemanticId(): String = lowercase().filter(Char::isLetterOrDigit) + +private fun String.isIdentityForNativeParent(parentResourceId: String): Boolean { + if (length <= 2 || !endsWith("id")) return false + return dropLast(2).nativeRelationResourceIdentities() + .intersect(parentResourceId.nativeRelationResourceIdentities()) + .isNotEmpty() +} + +private fun String.nativeRelationResourceIdentities(): Set { + val normalized = nativeRelationSemanticId() + return buildSet { + add(normalized) + if (normalized.endsWith('s') && normalized.length > 1) add(normalized.dropLast(1)) + if (normalized.endsWith("ies") && normalized.length > 3) add(normalized.dropLast(3) + "y") + if ( + normalized.endsWith("ches") || + normalized.endsWith("shes") || + normalized.endsWith("sses") || + normalized.endsWith("xes") || + normalized.endsWith("zes") + ) { + add(normalized.dropLast(2)) + } + } +} + +internal fun nativeScalarRelationClearChoice(field: FieldSpec): NativeRelationOption? = + NativeRelationOption( + value = "", + label = "None", + supportingText = "Clear selection", + ).takeIf { + !field.required && + field.format !in setOf( + DYNAMIC_INTEGER_ARRAY_FORMAT, + DYNAMIC_STRING_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + ) + } + +internal data class NativeRelationOptionWindow( + val options: List, + val hasMore: Boolean, ) -/** - * Resolves already-known technical identities without exposing them as text inputs. - * Destination fields remain user choices: source context is never destination intent. - */ -internal fun nativeFormAutoBoundValues( - action: ActionSpec, - resource: ResourceSpec, - record: NativeRecord?, -): Map { - record ?: return emptyMap() - val available = record.actionBindingValues(allowUnsafeIdentity = true) - return buildMap { - editableNativeFields(resource, action).forEach { field -> - val semantic = field.id.nativeRelationSemanticId() - if ( - semantic.isDestinationRelationField() || - semantic !in setOf("accountid", "folderid", "id", "mailboxid", "messageid", "sourceid") - ) return@forEach - val value = available.entries.firstOrNull { (key, _) -> - key.nativeRelationSemanticId() == semantic - }?.value ?: when (semantic) { - "id", "messageid" -> record.id.takeIf { record.canResolveUnsafeActionIdentity() } - else -> null +internal fun nativeRelationOptionWindow( + options: List, + query: String, +): NativeRelationOptionWindow { + val boundedQuery = query.take(NATIVE_RELATION_MAX_QUERY_LENGTH) + val matches = options.asSequence() + .filter { option -> + boundedQuery.isBlank() || + option.label.contains(boundedQuery, ignoreCase = true) || + option.supportingText?.contains(boundedQuery, ignoreCase = true) == true + } + .take(NATIVE_RELATION_OPTION_WINDOW_SIZE + 1) + .toList() + return NativeRelationOptionWindow( + options = matches.take(NATIVE_RELATION_OPTION_WINDOW_SIZE), + hasMore = matches.size > NATIVE_RELATION_OPTION_WINDOW_SIZE, + ) +} + +internal fun retainSelectedNativeRelationOptions( + retained: List, + available: List, + selectedValues: Collection, +): List { + val selected = selectedValues.asSequence() + .filter(String::isNotBlank) + .distinct() + .take(NATIVE_RELATION_RETAINED_SELECTION_LIMIT) + .toSet() + if (selected.isEmpty()) return emptyList() + return (available + retained).asSequence() + .filter { option -> option.value in selected } + .distinctBy(NativeRelationOption::value) + .take(NATIVE_RELATION_RETAINED_SELECTION_LIMIT) + .toList() +} + +@Composable +private fun GenericRelationshipField( + field: FieldSpec, + value: String, + options: List, + choicesLoaded: Boolean, + choiceSourceHasRecords: Boolean, + choiceUnavailableReason: NativeRelationChoiceUnavailableReason?, + paging: NativeRelatedRecordPaging?, + error: String?, + enabled: Boolean, + onValueChange: (String) -> Unit, +) { + val displayField = field.copy(label = field.nativeRelationshipDisplayLabel()) + val clearChoice = nativeScalarRelationClearChoice(displayField) + when { + options.isEmpty() && clearChoice == null && paging == null -> + GenericUnavailableRelationField(displayField, error) + field.format in setOf(DYNAMIC_INTEGER_ARRAY_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) -> + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + GenericRelationMultiPicker( + displayField, + value, + options, + choicesLoaded, + choiceSourceHasRecords, + choiceUnavailableReason, + paging, + error, + enabled, + onValueChange, + ) + if (options.isEmpty() && choiceSourceHasRecords) { + GenericRelationUnavailableReason(displayField, choiceUnavailableReason) + } + } + else -> Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + GenericRelationPicker( + field = displayField, + value = value, + options = options, + clearChoice = clearChoice, + choicesLoaded = choicesLoaded, + choiceSourceHasRecords = choiceSourceHasRecords, + choiceUnavailableReason = choiceUnavailableReason, + paging = paging, + error = error, + enabled = enabled, + onValueChange = onValueChange, + ) + if (options.isEmpty() && choiceSourceHasRecords) { + GenericRelationUnavailableReason(displayField, choiceUnavailableReason) } - value?.takeIf(String::isNotBlank)?.let { put(field.id, it) } } } } -/** - * Builds a labeled relation picker from loaded records. Mailbox/folder move destinations and any - * future destination-folder relation can reuse this without an app-id adapter. - */ -internal fun nativeRelationOptions( +@Composable +private fun GenericRelationUnavailableReason( + field: FieldSpec, + reason: NativeRelationChoiceUnavailableReason?, +) { + Text( + reason.nativeRelationUnavailableMessage(field), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun nativeRelationPaging( field: FieldSpec, + formResource: ResourceSpec, schema: NativeAppSchema, context: NativeDatasetContext, -): List { - if (!field.id.nativeRelationSemanticId().isDestinationRelationField()) return emptyList() - val currentAccountId = context.parentRecord.nativeRelationValue("accountid") - val currentMailboxId = context.parentRecord.nativeRelationValue("mailboxid") - return context.relatedRecords.flatMap { (resourceId, records) -> - val resource = schema.resource(resourceId) ?: return@flatMap emptyList() - records.mapNotNull { record -> - val presentation = nativeMailboxPresentation(resource, record) - if (presentation.kind != NativeMailboxItemKind.Folder) return@mapNotNull null - val accountId = record.nativeRelationValue("accountid") - if (currentAccountId != null && accountId != null && currentAccountId != accountId) { - return@mapNotNull null - } - val id = record.nativeRelationValue("databaseid") - ?: record.nativeRelationValue("id") - ?: record.id.takeIf { record.canResolveUnsafeActionIdentity() } - ?: return@mapNotNull null - if (id == currentMailboxId) return@mapNotNull null - NativeRelationOption( - value = id, - label = presentation.title, - supportingText = record.nativeRelationValue("specialuse") - ?.trim('\\') - ?.takeIf { value -> !value.equals(presentation.title, ignoreCase = true) }, - ) +): NativeRelatedRecordPaging? = nativeRelationRelationship(field, formResource, schema) + ?.parentResourceId + ?.let(context.relatedRecordPaging::get) + +@Composable +private fun GenericUnavailableRelationField(field: FieldSpec, error: String?) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + Text(requiredFieldLabel(field), style = MaterialTheme.typography.labelLarge) + OutlinedButton( + onClick = {}, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { + contentDescription = "Choose ${field.id}" + }, + ) { + Text("No verified choices available", modifier = Modifier.weight(1f)) } - }.distinctBy(NativeRelationOption::value) - .sortedWith( - compareBy { option -> - when (option.label.lowercase()) { - "inbox" -> 0 - "archive", "archives" -> 1 - "sent" -> 2 - "drafts" -> 3 - "trash", "deleted" -> 4 - else -> 5 - } - }.thenBy { option -> option.label.lowercase() }, + Text( + error ?: if (field.required) { + "Create or load a server record before choosing this required value." + } else { + "No choices are available. This optional value will be left empty." + }, + style = MaterialTheme.typography.bodySmall, + color = if (error == null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, ) + } } -private fun NativeRecord?.nativeRelationValue(name: String): String? = this?.let { record -> - record.values.entries.firstOrNull { (key, _) -> - key.nativeRelationSemanticId() == name.nativeRelationSemanticId() - }?.value?.takeIf { value -> !value.isNullOrBlank() } - ?: record.displayValues.entries.firstOrNull { (key, _) -> - key.nativeRelationSemanticId() == name.nativeRelationSemanticId() - }?.value?.takeIf(String::isNotBlank) +private fun FieldSpec.nativeRelationshipDisplayLabel(): String { + val trimmed = label.trim() + return when { + trimmed.endsWith(" ids", ignoreCase = true) -> trimmed.dropLast(4) + trimmed.endsWith(" id", ignoreCase = true) -> trimmed.dropLast(3) + else -> trimmed + }.ifBlank { label } } -private fun String.nativeRelationSemanticId(): String = lowercase().filter(Char::isLetterOrDigit) - -private fun String.isDestinationRelationField(): Boolean = - endsWith("id") && ( - startsWith("dest") || - startsWith("destination") || - contains("targetfolder") || - contains("targetmailbox") - ) - @Composable private fun GenericRelationPicker( field: FieldSpec, value: String, options: List, + clearChoice: NativeRelationOption?, + choicesLoaded: Boolean, + choiceSourceHasRecords: Boolean, + choiceUnavailableReason: NativeRelationChoiceUnavailableReason?, + paging: NativeRelatedRecordPaging?, error: String?, enabled: Boolean, onValueChange: (String) -> Unit, ) { var expanded by remember(field.id) { mutableStateOf(false) } - val selected = options.firstOrNull { option -> option.value == value } + var query by rememberSaveable(field.id) { mutableStateOf("") } + var retainedSelection by remember(field.id) { + mutableStateOf>(emptyList()) + } + LaunchedEffect(value, options) { + retainedSelection = retainSelectedNativeRelationOptions( + retained = retainedSelection, + available = options, + selectedValues = listOf(value), + ) + } + val displayedOptions = remember(options, retainedSelection) { + (options + retainedSelection).distinctBy(NativeRelationOption::value) + } + val selected = displayedOptions.firstOrNull { option -> option.value == value } + val optionWindow = nativeRelationOptionWindow(displayedOptions, query) Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { Text( - field.label, + requiredFieldLabel(field), style = MaterialTheme.typography.labelLarge, color = if (error == null) { MaterialTheme.colorScheme.onSurfaceVariant @@ -3915,10 +7016,29 @@ private fun GenericRelationPicker( OutlinedButton( onClick = { expanded = true }, enabled = enabled, - modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { + contentDescription = buildString { + append("Choose ${field.id}") + append("; relation loaded ") + append(choicesLoaded) + append("; relation options ") + append(options.size) + if (options.isEmpty()) { + append("; relation reason ") + append(choiceUnavailableReason?.name ?: "unknown") + } + } + }, ) { Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.Start) { - Text(selected?.label ?: "Choose a folder") + Text( + selected?.label + ?: clearChoice?.label?.takeIf { value.isBlank() } + ?: "Select ${field.label}", + ) selected?.supportingText?.let { supportingText -> Text( supportingText, @@ -3927,10 +7047,269 @@ private fun GenericRelationPicker( ) } } + Icon( + NextcloudIcons.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + query = "" + }, + ) { + if (displayedOptions.size > NATIVE_RELATION_SEARCH_THRESHOLD) { + OutlinedTextField( + value = query, + onValueChange = { query = it.take(NATIVE_RELATION_MAX_QUERY_LENGTH) }, + modifier = Modifier.padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.XSmall, + ).widthIn(min = 280.dp).semantics { + contentDescription = "Search relations for ${field.id}" + }, + label = { Text("Search ${field.label.lowercase()}") }, + singleLine = true, + ) + GenericRelationSearchGuidance( + totalOptionCount = displayedOptions.size, + discardedChoiceCount = paging?.discardedChoiceCount ?: 0, + query = query, + window = optionWindow, + ) + } + clearChoice?.let { choice -> + DropdownMenuItem( + text = { + Column { + Text(choice.label) + choice.supportingText?.let { supportingText -> + Text( + supportingText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onValueChange(choice.value) + expanded = false + query = "" + }, + ) + if (optionWindow.options.isNotEmpty()) HorizontalDivider() + } + if (optionWindow.options.isEmpty() && paging?.loading != true) { + DropdownMenuItem( + modifier = Modifier.semantics { + contentDescription = when { + !choicesLoaded -> "Relation choices unavailable for ${field.id}" + choiceSourceHasRecords -> + "No usable relation choices for ${field.id} " + + "reason ${choiceUnavailableReason?.name ?: "unknown"}" + else -> "No relation choices for ${field.id}" + } + }, + text = { + Text( + when { + !choicesLoaded -> "${field.label} choices could not be loaded." + choiceSourceHasRecords -> + choiceUnavailableReason.nativeRelationUnavailableMessage(field) + else -> "No ${field.label.lowercase()} choices are available." + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = false, + onClick = {}, + ) + } + optionWindow.options.forEach { option -> + DropdownMenuItem( + modifier = Modifier.semantics { + contentDescription = "Choose ${field.id} relation ${option.label}" + }, + text = { + Column { + Text(option.label) + option.supportingText?.let { supportingText -> + Text( + supportingText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onValueChange(option.value) + expanded = false + query = "" + }, + ) + } + GenericRelationPagingItem(paging) + } + } + error?.let { message -> + Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } +} + +private fun NativeRelationChoiceUnavailableReason?.nativeRelationUnavailableMessage( + field: FieldSpec, +): String = when (this) { + NativeRelationChoiceUnavailableReason.unsafeIdentity -> + "The server returned ${field.label.lowercase()} records without a verified selectable identity." + NativeRelationChoiceUnavailableReason.ambiguousBinding -> + "The server returned ${field.label.lowercase()} records with conflicting identity data." + NativeRelationChoiceUnavailableReason.scopeMismatch -> + "The available ${field.label.lowercase()} records belong to a different parent." + NativeRelationChoiceUnavailableReason.invalidValue -> + "The available ${field.label.lowercase()} records do not contain a safe selectable value." + NativeRelationChoiceUnavailableReason.duplicateValue -> + "The available ${field.label.lowercase()} records contain duplicate selectable values." + else -> + "The available ${field.label.lowercase()} records cannot be selected safely." +} + +@Composable +private fun GenericRelationMultiPicker( + field: FieldSpec, + value: String, + options: List, + choicesLoaded: Boolean, + choiceSourceHasRecords: Boolean, + choiceUnavailableReason: NativeRelationChoiceUnavailableReason?, + paging: NativeRelatedRecordPaging?, + error: String?, + enabled: Boolean, + onValueChange: (String) -> Unit, +) { + var expanded by remember(field.id) { mutableStateOf(false) } + var query by rememberSaveable(field.id) { mutableStateOf("") } + val selectedValues = remember(value, field.format) { + value.nativeRelationSelectedValues(field.format) + } + var retainedSelections by remember(field.id) { + mutableStateOf>(emptyList()) + } + LaunchedEffect(selectedValues, options) { + retainedSelections = retainSelectedNativeRelationOptions( + retained = retainedSelections, + available = options, + selectedValues = selectedValues, + ) + } + val displayedOptions = remember(options, retainedSelections) { + (options + retainedSelections).distinctBy(NativeRelationOption::value) + } + val optionWindow = nativeRelationOptionWindow(displayedOptions, query) + val selectedLabels = displayedOptions.filter { option -> option.value in selectedValues } + .joinToString(", ", transform = NativeRelationOption::label) + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + Text(requiredFieldLabel(field), style = MaterialTheme.typography.labelLarge) + Box { + OutlinedButton( + onClick = { expanded = true }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { + contentDescription = buildString { + append("Choose ${field.id}") + append("; relation loaded ") + append(choicesLoaded) + append("; relation options ") + append(options.size) + if (options.isEmpty()) { + append("; relation reason ") + append(choiceUnavailableReason?.name ?: "unknown") + } + } + }, + ) { + Text( + selectedLabels.ifBlank { "Choose ${field.label.lowercase()}" }, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Icon( + NextcloudIcons.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + query = "" + }, + ) { + if (displayedOptions.size > NATIVE_RELATION_SEARCH_THRESHOLD) { + OutlinedTextField( + value = query, + onValueChange = { query = it.take(NATIVE_RELATION_MAX_QUERY_LENGTH) }, + modifier = Modifier.padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.XSmall, + ).widthIn(min = 280.dp).semantics { + contentDescription = "Search relations for ${field.id}" + }, + label = { Text("Search ${field.label.lowercase()}") }, + singleLine = true, + ) + GenericRelationSearchGuidance( + totalOptionCount = displayedOptions.size, + discardedChoiceCount = paging?.discardedChoiceCount ?: 0, + query = query, + window = optionWindow, + ) + } + if (optionWindow.options.isEmpty() && paging?.loading != true) { + DropdownMenuItem( + modifier = Modifier.semantics { + contentDescription = when { + !choicesLoaded -> "Relation choices unavailable for ${field.id}" + choiceSourceHasRecords -> + "No usable relation choices for ${field.id} " + + "reason ${choiceUnavailableReason?.name ?: "unknown"}" + else -> "No relation choices for ${field.id}" + } + }, + text = { + Text( + when { + !choicesLoaded -> "${field.label} choices could not be loaded." + choiceSourceHasRecords -> + choiceUnavailableReason.nativeRelationUnavailableMessage(field) + else -> "No ${field.label.lowercase()} choices are available." + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = false, + onClick = {}, + ) + } + optionWindow.options.forEach { option -> + val selected = option.value in selectedValues DropdownMenuItem( + modifier = Modifier.semantics { + contentDescription = "Choose ${field.id} relation ${option.label}" + }, + leadingIcon = { + Checkbox(checked = selected, onCheckedChange = null) + }, text = { Column { Text(option.label) @@ -3944,11 +7323,16 @@ private fun GenericRelationPicker( } }, onClick = { - onValueChange(option.value) - expanded = false + val updated = if (selected) { + selectedValues - option.value + } else { + selectedValues + option.value + } + onValueChange(updated.toNativeRelationArray(field.format)) }, ) } + GenericRelationPagingItem(paging) } } error?.let { message -> @@ -3957,6 +7341,143 @@ private fun GenericRelationPicker( } } +@Composable +private fun GenericRelationPagingItem(paging: NativeRelatedRecordPaging?) { + paging ?: return + if (paging.loading) { + DropdownMenuItem( + text = { Text("Loading choices...") }, + onClick = {}, + enabled = false, + ) + return + } + paging.loadMore?.let { loadMore -> + DropdownMenuItem( + text = { Text(if (paging.error == null) "Load more choices" else "Try loading more choices") }, + onClick = loadMore, + ) + } + paging.returnToFirstPage?.let { returnToFirstPage -> + DropdownMenuItem( + text = { + Column { + Text("Return to first choices") + Text( + "${paging.discardedChoiceCount} earlier choices were released to keep memory bounded.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = returnToFirstPage, + ) + } + paging.error?.let { message -> + DropdownMenuItem( + text = { + Text( + message, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = {}, + enabled = false, + ) + } +} + +@Composable +private fun GenericRelationSearchGuidance( + totalOptionCount: Int, + discardedChoiceCount: Int, + query: String, + window: NativeRelationOptionWindow, +) { + val message = when { + query.isBlank() && discardedChoiceCount > 0 -> + "Showing ${window.options.size} of $totalOptionCount current choices. " + + "$discardedChoiceCount earlier choices can be restored below." + query.isBlank() -> + "Showing the first ${window.options.size} of $totalOptionCount choices. Search to narrow the list." + window.hasMore -> + "Showing the first ${window.options.size} matches. Refine your search to see fewer choices." + window.options.isEmpty() -> "No matching choices." + else -> "${window.options.size} matching choices." + } + Text( + message, + modifier = Modifier.padding(horizontal = NextcloudSpacing.Small), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun String.nativeRelationSelectedValues(format: String?): List { + if (isBlank()) return emptyList() + val array = runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonArray + ?: return emptyList() + return array.mapNotNull { element -> + val scalar = element as? JsonPrimitive ?: return@mapNotNull null + when (format) { + DYNAMIC_INTEGER_ARRAY_FORMAT -> scalar.takeUnless(JsonPrimitive::isString)?.contentOrNull + DYNAMIC_STRING_ARRAY_FORMAT -> scalar.takeIf(JsonPrimitive::isString)?.contentOrNull + else -> null + } + }.distinct() +} + +private fun List.toNativeRelationArray(format: String?): String = when (format) { + DYNAMIC_INTEGER_ARRAY_FORMAT -> joinToString(prefix = "[", postfix = "]", separator = ",") + DYNAMIC_STRING_ARRAY_FORMAT -> JsonArray(map(::JsonPrimitive)).toString() + else -> "" +} + +private const val NATIVE_RELATION_SEARCH_THRESHOLD = 8 +private const val NATIVE_RELATION_MAX_QUERY_LENGTH = 120 +private const val NATIVE_ENUM_SEARCH_THRESHOLD = 8 +private const val NATIVE_ENUM_MAX_QUERY_LENGTH = 120 +internal const val NATIVE_RELATION_RETAINED_SELECTION_LIMIT = 64 +internal const val NATIVE_RELATION_OPTION_WINDOW_SIZE = 40 + +/** + * Presents contract fields in a human task order without changing their wire names or request + * bindings. The ordering is deliberately semantic and app-neutral: identify the record first, + * describe it next, then show supporting choices and advanced controls. + */ +internal fun nativeFormDisplayFields( + fields: List, + relationFieldIds: Set = emptySet(), +): List = + fields.withIndex() + .sortedWith( + compareBy>( + { (_, field) -> field.nativeFormDisplayPriority(field.id in relationFieldIds) }, + IndexedValue::index, + ), + ) + .map(IndexedValue::value) + +private fun FieldSpec.nativeFormDisplayPriority(relation: Boolean): Int { + val semanticId = id.lowercase().filter(Char::isLetterOrDigit) + return when { + semanticId in setOf("name", "title", "subject", "label", "displayname") -> 0 + kind == FieldKind.longText || + semanticId in setOf("description", "content", "body", "notes", "summary") -> 10 + kind == FieldKind.file || kind == FieldKind.image -> 15 + isNativeVisualIconField() -> 30 + semanticId in setOf("color", "colour") -> 31 + kind == FieldKind.enumeration -> 40 + kind in setOf(FieldKind.date, FieldKind.dateTime) -> 45 + relation -> 50 + kind == FieldKind.boolean -> 60 + hasNativeRecurrenceRuleSemantics() -> 70 + repeatableObjectInput != null || kind == FieldKind.objectValue -> 80 + else -> 20 + } +} + internal fun nativeFormTitle(view: ViewSpec, resource: ResourceSpec, action: ActionSpec): String = if (action.isSettingsWrite(resource)) "Settings" else view.title @@ -3991,6 +7512,178 @@ private fun GenericSectionHeading(title: String, supporting: String) { } } +@Composable +private fun GenericRepeatableObjectField( + field: FieldSpec, + spec: RepeatableObjectInputSpec, + rows: List, + enabled: Boolean, + onRowsChange: (List) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(requiredFieldLabel(field), style = MaterialTheme.typography.titleSmall) + Text( + "${rows.size} of ${spec.maximumItems} items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + enabled = enabled && rows.size < spec.maximumItems, + onClick = { + onRowsChange(addNativeRepeatableObjectRow(rows, spec)) + }, + modifier = Modifier.semantics { + contentDescription = "Add ${field.id} row" + }, + ) { + Icon(NextcloudIcons.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text("Add item", modifier = Modifier.padding(start = NextcloudSpacing.XSmall)) + } + } + if (rows.isEmpty()) { + Text( + "No items added.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + rows.forEachIndexed { rowIndex, row -> + Card( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "${field.id} row ${rowIndex + 1}" + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Item ${rowIndex + 1}", + style = MaterialTheme.typography.titleSmall, + ) + TextButton( + enabled = enabled && rows.size > spec.minimumItems, + onClick = { + onRowsChange( + removeNativeRepeatableObjectRow( + rows = rows, + index = rowIndex, + spec = spec, + ), + ) + }, + modifier = Modifier.semantics { + contentDescription = "Remove ${field.id} row ${rowIndex + 1}" + }, + ) { + Text("Remove") + } + } + spec.fields.forEach { itemField -> + val automationFieldId = nativeRepeatableObjectAutomationFieldId( + fieldId = field.id, + rowIndex = rowIndex, + itemFieldId = itemField.id, + ) + val explicitNull = itemField.id in row.nullFieldIds + GenericFormField( + field = itemField.toNativeRepeatableObjectFieldSpec(), + value = row.values[itemField.id].orEmpty(), + error = null, + enabled = enabled && !explicitNull, + filePicker = null, + automationFieldId = automationFieldId, + onValueChange = { value -> + onRowsChange( + updateNativeRepeatableObjectValue( + rows = rows, + rowIndex = rowIndex, + field = itemField, + value = value, + ), + ) + }, + ) + if (itemField.nullable) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Send ${itemField.label} as null" + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = explicitNull, + enabled = enabled, + onCheckedChange = { checked -> + onRowsChange( + updateNativeRepeatableObjectNull( + rows = rows, + rowIndex = rowIndex, + field = itemField, + explicitNull = checked, + ), + ) + }, + ) + Text( + "Send ${itemField.label} as null", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + } + } +} + +internal fun nativeRepeatableObjectAutomationFieldId( + fieldId: String, + rowIndex: Int, + itemFieldId: String, +): String = "$fieldId row ${rowIndex + 1} $itemFieldId" + +internal fun RepeatableObjectInputFieldSpec.toNativeRepeatableObjectFieldSpec(): FieldSpec = + FieldSpec( + id = id, + label = label, + kind = when (kind) { + RepeatableObjectInputScalarKind.String -> FieldKind.string + RepeatableObjectInputScalarKind.Integer -> FieldKind.integer + RepeatableObjectInputScalarKind.Decimal -> FieldKind.decimal + RepeatableObjectInputScalarKind.Boolean -> FieldKind.boolean + RepeatableObjectInputScalarKind.Enumeration -> FieldKind.enumeration + }, + required = required, + readOnly = false, + format = format, + enumValues = enumValues, + ) + @Composable private fun GenericFormField( field: FieldSpec, @@ -3998,6 +7691,7 @@ private fun GenericFormField( error: String?, enabled: Boolean, filePicker: NativeFileFieldPicker?, + automationFieldId: String = field.id, onValueChange: (String) -> Unit, ) { when { @@ -4020,6 +7714,9 @@ private fun GenericFormField( Switch( checked = checked, enabled = enabled, + modifier = Modifier.semantics { + contentDescription = "Toggle $automationFieldId.$key" + }, onCheckedChange = { onValueChange(updateNativeBooleanMap(value, key, it)) }, ) } @@ -4038,22 +7735,43 @@ private fun GenericFormField( Column(modifier = Modifier.weight(1f)) { Text(requiredFieldLabel(field), style = MaterialTheme.typography.bodyLarge) Text( - "Turn this option on or off", + if (value == "true") "On" else "Off", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } } - Switch(checked = value == "true", enabled = enabled, onCheckedChange = { onValueChange(it.toString()) }) + Switch( + checked = value == "true", + enabled = enabled, + modifier = Modifier.semantics { + contentDescription = "Toggle $automationFieldId" + }, + onCheckedChange = { onValueChange(it.toString()) }, + ) } - field.kind == FieldKind.enumeration -> GenericEnumField(field, value, error, enabled, onValueChange) + field.hasNativeRecurrenceRuleSemantics() -> + GenericRecurrenceRuleField( + field, + value, + error, + enabled, + automationFieldId, + onValueChange, + ) + field.kind == FieldKind.enumeration -> + GenericEnumField(field, value, error, enabled, automationFieldId, onValueChange) field.kind == FieldKind.file -> Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedTextField( value = value, onValueChange = onValueChange, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Field $automationFieldId" + }, label = { Text(requiredFieldLabel(field)) }, supportingText = error?.let { message -> { Text(message) } }, isError = error != null, @@ -4063,7 +7781,11 @@ private fun GenericFormField( OutlinedButton( enabled = enabled, onClick = { picker.requestFile(field, onValueChange) }, - modifier = Modifier.heightIn(min = 48.dp), + modifier = Modifier + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Choose file for $automationFieldId" + }, ) { Icon(NextcloudIcons.File, contentDescription = null, modifier = Modifier.size(18.dp)) Text(if (value.isBlank()) "Choose file" else "Choose another file") @@ -4073,16 +7795,28 @@ private fun GenericFormField( else -> { val multiLine = field.kind == FieldKind.longText || - field.format in setOf(DYNAMIC_STRING_LIST_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) + field.format in setOf( + DYNAMIC_INTEGER_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + DYNAMIC_STRING_ARRAY_FORMAT, + ) OutlinedTextField( value = value, onValueChange = onValueChange, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Field $automationFieldId" + }, label = { Text(requiredFieldLabel(field)) }, supportingText = when { error != null -> ({ Text(error) }) - field.format in setOf(DYNAMIC_STRING_LIST_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) -> + field.format in setOf( + DYNAMIC_INTEGER_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + DYNAMIC_STRING_ARRAY_FORMAT, + ) -> ({ Text("One value per line") }) else -> null }, @@ -4097,7 +7831,12 @@ private fun GenericFormField( }, keyboardOptions = KeyboardOptions( keyboardType = when (field.kind) { - FieldKind.integer -> KeyboardType.Number + FieldKind.integer -> + if (field.format == DYNAMIC_INTEGER_ARRAY_FORMAT) { + KeyboardType.Text + } else { + KeyboardType.Number + } FieldKind.decimal, FieldKind.currency -> KeyboardType.Decimal else -> KeyboardType.Text }, @@ -4107,6 +7846,94 @@ private fun GenericFormField( } } +@Composable +private fun GenericRecurrenceRuleField( + field: FieldSpec, + value: String, + error: String?, + enabled: Boolean, + automationFieldId: String, + onValueChange: (String) -> Unit, +) { + var expanded by remember(field.id) { mutableStateOf(false) } + var custom by remember(field.id, value) { + mutableStateOf(value.isNotBlank() && NATIVE_RECURRENCE_PRESETS.none { (_, rule) -> rule == value }) + } + val selectedLabel = NATIVE_RECURRENCE_PRESETS.firstOrNull { (_, rule) -> rule == value }?.first + ?: if (custom) "Custom rule" else "Does not repeat" + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + Text("Repeat", style = MaterialTheme.typography.labelLarge) + Box { + OutlinedButton( + onClick = { expanded = true }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Choose $automationFieldId" + }, + ) { + Text(selectedLabel, modifier = Modifier.weight(1f), textAlign = TextAlign.Start) + Icon( + NextcloudIcons.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + NATIVE_RECURRENCE_PRESETS.forEach { (label, rule) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + custom = false + expanded = false + onValueChange(rule) + }, + ) + } + DropdownMenuItem( + text = { Text("Custom rule") }, + onClick = { + custom = true + expanded = false + }, + ) + } + } + if (custom) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Field $automationFieldId" + }, + label = { Text("RFC 5545 recurrence rule") }, + placeholder = { Text("FREQ=WEEKLY;INTERVAL=2") }, + singleLine = true, + isError = error != null, + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } +} + +private fun FieldSpec.hasNativeRecurrenceRuleSemantics(): Boolean = + id.lowercase().filter(Char::isLetterOrDigit) in setOf("rrule", "recurrencerule") && + kind in setOf(FieldKind.string, FieldKind.longText) + +private val NATIVE_RECURRENCE_PRESETS = listOf( + "Does not repeat" to "", + "Every day" to "FREQ=DAILY", + "Every weekday" to "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "Every week" to "FREQ=WEEKLY", + "Every month" to "FREQ=MONTHLY", + "Every year" to "FREQ=YEARLY", +) + private fun String.dynamicSettingLabel(): String = replace('-', ' ').replace('_', ' ') .split(' ') .filter(String::isNotBlank) @@ -4118,35 +7945,150 @@ private fun GenericEnumField( value: String, error: String?, enabled: Boolean, + automationFieldId: String, onValueChange: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } + var query by rememberSaveable(field.id) { mutableStateOf("") } + val options = field.enumValues.orEmpty() + val visibleOptions = remember(options, query) { + val normalizedQuery = query.trim().lowercase() + if (normalizedQuery.isBlank()) { + options + } else { + options.filter { option -> + normalizedQuery in option.lowercase() || + normalizedQuery in option.dynamicSettingLabel().lowercase() + } + } + } Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { Text(requiredFieldLabel(field), style = MaterialTheme.typography.labelLarge) Box(modifier = Modifier.fillMaxWidth()) { OutlinedButton( onClick = { expanded = true }, enabled = enabled && !field.enumValues.isNullOrEmpty(), - modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { + contentDescription = "Choose $automationFieldId" + }, ) { - Text(value.ifBlank { "Choose an option" }, maxLines = 1, overflow = TextOverflow.Ellipsis) + value.takeIf(String::isNotBlank) + ?.takeIf { field.isNativeVisualIconField() } + ?.let(NextcloudIcons::semanticOrFallback) + ?.let { icon -> + Icon( + icon, + contentDescription = null, + modifier = Modifier.padding(end = NextcloudSpacing.Small).size(20.dp), + ) + } + value.nativeFormColorOrNull(field)?.let { color -> + NativeColorSwatch( + color, + modifier = Modifier.padding(end = NextcloudSpacing.Small), + ) + } + Text( + value.takeIf(String::isNotBlank)?.dynamicSettingLabel() ?: "Choose an option", + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + NextcloudIcons.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - field.enumValues.orEmpty().forEach { option -> + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + query = "" + }, + ) { + if (options.size > NATIVE_ENUM_SEARCH_THRESHOLD) { + OutlinedTextField( + value = query, + onValueChange = { query = it.take(NATIVE_ENUM_MAX_QUERY_LENGTH) }, + modifier = Modifier + .padding( + horizontal = NextcloudSpacing.Small, + vertical = NextcloudSpacing.XSmall, + ) + .widthIn(min = 280.dp) + .semantics { + contentDescription = "Search options for $automationFieldId" + }, + label = { Text("Search ${field.label.lowercase()}") }, + singleLine = true, + ) + } + visibleOptions.forEach { option -> + val optionIcon = option.takeIf { field.isNativeVisualIconField() } + ?.let(NextcloudIcons::semanticOrFallback) + val optionColor = option.nativeFormColorOrNull(field) DropdownMenuItem( - text = { Text(option) }, + modifier = Modifier.semantics { + contentDescription = "Choose $automationFieldId option $option" + }, + leadingIcon = if (optionIcon != null || optionColor != null) { + { + optionIcon?.let { icon -> + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) + } + optionColor?.let { color -> NativeColorSwatch(color) } + } + } else { + null + }, + text = { Text(option.dynamicSettingLabel()) }, onClick = { expanded = false + query = "" onValueChange(option) }, ) } + if (visibleOptions.isEmpty()) { + DropdownMenuItem( + text = { Text("No matching options") }, + onClick = {}, + enabled = false, + ) + } } } error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } } } +@Composable +private fun NativeColorSwatch(color: Color, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.size(20.dp), + color = color, + shape = RoundedCornerShape(5.dp), + content = {}, + ) +} + +private fun String.nativeFormColorOrNull(field: FieldSpec): Color? { + return nativeFormColorArgbOrNull(field)?.let(::Color) +} + +internal fun String.nativeFormColorArgbOrNull(field: FieldSpec): Int? { + if (field.id.lowercase().filter(Char::isLetterOrDigit) !in setOf("color", "colour")) return null + val hex = trim().removePrefix("#") + if (hex.length != 6 || !hex.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) return null + val rgb = hex.toLongOrNull(16) ?: return null + return (0xFF000000L or rgb).toInt() +} + private fun requiredFieldLabel(field: FieldSpec): String = if (field.required) "${field.label} *" else field.label @Composable @@ -4157,12 +8099,39 @@ private fun GenericActionStatus(state: NativeActionExecutionState, onDismiss: () is NativeActionExecutionState.Running, -> null is NativeActionExecutionState.ValidationFailed -> state.message + is NativeActionExecutionState.AwaitingReconciliation -> + "${state.message} Refreshing authoritative server data before this action can be tried again." is NativeActionExecutionState.Succeeded -> state.message ?: "Action completed." is NativeActionExecutionState.Failed -> state.message } ?: return - val failure = state is NativeActionExecutionState.Failed || state is NativeActionExecutionState.ValidationFailed + val failure = + state is NativeActionExecutionState.Failed || + state is NativeActionExecutionState.ValidationFailed || + state is NativeActionExecutionState.AwaitingReconciliation + val dismissible = state !is NativeActionExecutionState.AwaitingReconciliation + val statusDescription = when (state) { + is NativeActionExecutionState.ValidationFailed -> buildString { + append("Action status validation failed") + state.fieldErrors.keys.sorted().takeIf(List::isNotEmpty)?.let { fields -> + append(" fields ") + append(fields.joinToString(" ")) + } + } + is NativeActionExecutionState.AwaitingReconciliation -> "Action status awaiting reconciliation" + is NativeActionExecutionState.Succeeded -> "Action status succeeded" + is NativeActionExecutionState.Failed -> "Action status failed" + NativeActionExecutionState.Idle, + is NativeActionExecutionState.AwaitingConfirmation, + is NativeActionExecutionState.Running, + -> error("Only visible action states have a status description.") + } Surface( - modifier = Modifier.fillMaxWidth().clickable(onClick = onDismiss), + modifier = Modifier + .fillMaxWidth() + .then(if (dismissible) Modifier.clickable(onClick = onDismiss) else Modifier) + .semantics { + contentDescription = statusDescription + }, color = if (failure) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer, contentColor = if (failure) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer, shape = MaterialTheme.shapes.small, @@ -4204,6 +8173,8 @@ private fun NativeConfirmationDialog(action: ActionSpec, onDismiss: () -> Unit, internal data class NativeRecordPresentation( val title: String, val subtitle: String?, + val iconKey: String? = null, + val colorArgb: Int? = null, ) internal data class NativeDetailFieldPresentation( @@ -4224,11 +8195,13 @@ internal data class NativeStructuredDetailSection( ) internal fun nativeRecordPresentation(resource: ResourceSpec, record: NativeRecord): NativeRecordPresentation { + val iconKey = nativeRecordIconKey(resource, record) + val colorArgb = nativeRecordColorArgb(resource, record) nativeHouseholdPresentation(resource, record)?.let { presentation -> - return NativeRecordPresentation(presentation.title, presentation.subtitle) + return NativeRecordPresentation(presentation.title, presentation.subtitle, iconKey, colorArgb) } nativeGroupwarePresentation(resource, record)?.let { presentation -> - return NativeRecordPresentation(presentation.title, presentation.subtitle) + return NativeRecordPresentation(presentation.title, presentation.subtitle, iconKey, colorArgb) } val titleField = nativeRecordTitleField(resource, record) val title = titleField @@ -4254,7 +8227,44 @@ internal fun nativeRecordPresentation(resource: ResourceSpec, record: NativeReco !value.isPresentationMimeType() } } - return NativeRecordPresentation(title, subtitle) + return NativeRecordPresentation(title, subtitle, iconKey, colorArgb) +} + +internal fun nativeRecordIconKey(resource: ResourceSpec, record: NativeRecord): String? { + val declaredIconFields = resource.fields.filter(FieldSpec::isNativeVisualIconField) + if (declaredIconFields.isEmpty()) return null + val populated = declaredIconFields.mapNotNull { field -> + record.values[field.id]?.takeIf(String::isNotBlank) + } + if (populated.isEmpty()) return null + val resolved = populated.map { raw -> + raw.takeIf { value -> + value.length <= MAX_NATIVE_RECORD_ICON_KEY_LENGTH && + value.all { character -> + character.isLetterOrDigit() || character in setOf('-', '_', ' ') + } + } + ?.trim() + ?.lowercase() + ?.replace('_', '-') + ?.replace(' ', '-') + ?: return null + }.distinct() + return resolved.singleOrNull() +} + +internal fun nativeRecordColorArgb(resource: ResourceSpec, record: NativeRecord): Int? { + val declaredColorFields = resource.fields.filter { field -> + field.id.lowercase().filter(Char::isLetterOrDigit) in setOf("color", "colour") && + field.kind in setOf(FieldKind.string, FieldKind.enumeration) + } + if (declaredColorFields.isEmpty()) return null + val populated = declaredColorFields.mapNotNull { field -> + record.values[field.id] + ?.takeIf(String::isNotBlank) + ?.nativeFormColorArgbOrNull(field) + } + return populated.distinct().singleOrNull() } /** @@ -4455,7 +8465,7 @@ private fun FieldSpec.subtitlePriority(): Int { FieldKind.currency, FieldKind.decimal -> 230 FieldKind.objectValue -> 220 FieldKind.userReference -> 210 - FieldKind.integer -> 120 + FieldKind.integer -> 0 FieldKind.boolean, FieldKind.image, FieldKind.file, FieldKind.unknown -> 0 } return semantic + typed @@ -4475,5 +8485,16 @@ private fun FieldSpec.isTechnicalPresentationField(): Boolean { return normalized in setOf( "id", "uuid", "token", "etag", "href", "permissions", "permission", "capabilities", "active", "enabled", "deleted", "favorite", "favourite", "archived", "readonly", + "icon", "symbol", "color", "colour", ) || normalized.endsWith("id") } + +private fun FieldSpec.isNativeVisualIconField(): Boolean = + id.lowercase().filter(Char::isLetterOrDigit) in setOf("icon", "symbol") && + kind in setOf(FieldKind.string, FieldKind.enumeration) + +private const val MAX_NATIVE_RECORD_ICON_KEY_LENGTH = 64 +private const val MAX_NATIVE_COLLECTION_BATCH_RELATIONS = 16 +private const val MAX_NATIVE_COLLECTION_BATCH_RELATION_BINDINGS = 32 +private const val MAX_NATIVE_COLLECTION_BATCH_RELATION_RECORDS = 500 +private const val MAX_NATIVE_COLLECTION_BATCH_RELATION_ERROR_LENGTH = 1_024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererState.kt index 1909fd4d..27bf5fd9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererState.kt @@ -5,9 +5,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import dev.obiente.nextcloudnative.nativeui.model.ActionIntent import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_LIST_FORMAT import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DynamicIntegerArrayParseResult import dev.obiente.nextcloudnative.nativeui.model.FieldKind import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import dev.obiente.nextcloudnative.nativeui.model.HttpMethod @@ -15,10 +19,15 @@ import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema import dev.obiente.nextcloudnative.nativeui.model.NativeComponent import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.model.isExactDynamicIntegerArraySchema +import dev.obiente.nextcloudnative.nativeui.model.parseDynamicIntegerArrayInput +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put @@ -37,6 +46,313 @@ enum class GenericNativeSurface { Form, } +internal data class NativeRelationOption( + val value: String, + val label: String, + val supportingText: String? = null, +) + +internal enum class NativeRelationChoiceUnavailableReason { + relationship, + parentResource, + source, + sourceEmpty, + unsafeIdentity, + ambiguousBinding, + scopeMismatch, + invalidValue, + duplicateValue, +} + +/** + * Builds human-readable choices for a writable relationship field from the accepted schema graph. + * + * The relationship must identify one exact parent resource for the form resource. Loaded parent + * records must retain safe action provenance, and their declared parent-field value must resolve + * without conflict. Semantic field/resource evidence is used only to match destination-style + * fields to an already-declared relationship; it never creates a relationship or authorizes a + * mutation on its own. + */ +internal fun nativeRelationOptions( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, + context: NativeDatasetContext, +): List { + val relationship = nativeRelationRelationship(field, formResource, schema) ?: return emptyList() + val parentResource = schema.resources.singleOrNull { resource -> + resource.id == relationship.parentResourceId && + resource.confidence.isAcceptedNativeRelationshipConfidence() + } ?: return emptyList() + val parentRecords = context.relatedRecords.entries + .singleOrNull { (resourceId, _) -> resourceId == parentResource.id } + ?.value + ?: return emptyList() + val currentBindings = context.parentRecord?.safeActionBindingValues() + val candidates = parentRecords.mapNotNull { record -> + if (!record.actionSafeIdentity || !record.actionBindingProvenanceValid) return@mapNotNull null + val bindings = record.safeActionBindingValues() ?: return@mapNotNull null + if (!bindings.shareNativeRelationshipScopeWith(currentBindings, field.id, relationship.parentFieldId)) { + return@mapNotNull null + } + val value = bindings[relationship.parentFieldId] + ?.takeIf(String::isSafeNativeRelationOptionValue) + ?: return@mapNotNull null + if ( + field.hasNativeDestinationRelationshipSemantics() && + currentBindings?.matchesNativeRelationshipSource( + value = value, + destinationFieldId = field.id, + parentResource = parentResource, + ) == true + ) { + return@mapNotNull null + } + NativeRelationOption( + value = value, + label = parentResource.nativeRelationshipRecordLabel(record), + supportingText = parentResource.name, + ) + } + val values = candidates.groupingBy(NativeRelationOption::value).eachCount() + if (values.any { (_, count) -> count > 1 }) return emptyList() + return candidates.sortedWith( + compareBy { option -> option.label.lowercase() } + .thenBy(NativeRelationOption::value), + ) +} + +internal fun nativeRelationChoiceUnavailableReason( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, + context: NativeDatasetContext, +): NativeRelationChoiceUnavailableReason? { + val relationship = nativeRelationRelationship(field, formResource, schema) + ?: return NativeRelationChoiceUnavailableReason.relationship + val parentResource = schema.resources.singleOrNull { resource -> + resource.id == relationship.parentResourceId && + resource.confidence.isAcceptedNativeRelationshipConfidence() + } ?: return NativeRelationChoiceUnavailableReason.parentResource + val parentRecords = context.relatedRecords.entries + .singleOrNull { (resourceId, _) -> resourceId == parentResource.id } + ?.value + ?: return NativeRelationChoiceUnavailableReason.source + if (parentRecords.isEmpty()) return NativeRelationChoiceUnavailableReason.sourceEmpty + val actionSafe = parentRecords.filter { record -> + record.actionSafeIdentity && record.actionBindingProvenanceValid + } + if (actionSafe.isEmpty()) return NativeRelationChoiceUnavailableReason.unsafeIdentity + val bound = actionSafe.mapNotNull { record -> + record.safeActionBindingValues()?.let { bindings -> record to bindings } + } + if (bound.isEmpty()) return NativeRelationChoiceUnavailableReason.ambiguousBinding + val currentBindings = context.parentRecord?.safeActionBindingValues() + val scoped = bound.filter { (_, bindings) -> + bindings.shareNativeRelationshipScopeWith( + currentBindings, + field.id, + relationship.parentFieldId, + ) + } + if (scoped.isEmpty()) return NativeRelationChoiceUnavailableReason.scopeMismatch + val values = scoped.mapNotNull { (_, bindings) -> + bindings[relationship.parentFieldId] + ?.takeIf(String::isSafeNativeRelationOptionValue) + } + if (values.isEmpty()) return NativeRelationChoiceUnavailableReason.invalidValue + if (values.groupingBy { value -> value }.eachCount().any { (_, count) -> count > 1 }) { + return NativeRelationChoiceUnavailableReason.duplicateValue + } + return null +} + +internal fun nativeRelationFieldRequiresChoice( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, +): Boolean = nativeRelationRelationship(field, formResource, schema) != null || + field.isOpaqueNativeRelationshipIdentity() + +internal fun nativeRelationChoicesLoaded( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, + context: NativeDatasetContext, +): Boolean = nativeRelationRelationship(field, formResource, schema) + ?.parentResourceId + ?.let(context.relatedRecords::containsKey) + ?: false + +internal fun nativeRelationChoiceSourceHasRecords( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, + context: NativeDatasetContext, +): Boolean = nativeRelationRelationship(field, formResource, schema) + ?.parentResourceId + ?.let { resourceId -> context.relatedRecords[resourceId].orEmpty().isNotEmpty() } + ?: false + +internal fun nativeRelationRelationship( + field: FieldSpec, + formResource: ResourceSpec, + schema: NativeAppSchema, +): dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec? { + if (field.readOnly) return null + val acceptedRelationships = schema.relationships + .filter { relationship -> + relationship.childResourceId == formResource.id && + relationship.childFieldId != null && + relationship.confidence.isAcceptedNativeRelationshipConfidence() + } + .distinct() + val exactRelationships = acceptedRelationships.filter { relationship -> + relationship.childFieldId == field.id + } + val relationship = when { + exactRelationships.size == 1 -> exactRelationships.single() + exactRelationships.size > 1 -> return null + else -> { + val targetIdentities = field.nativeRelationshipTargetIdentities() + acceptedRelationships.filter { candidate -> + val parent = schema.resources.singleOrNull { resource -> + resource.id == candidate.parentResourceId + } ?: return@filter false + targetIdentities.intersect(parent.nativeRelationshipResourceIdentities()).isNotEmpty() + }.singleOrNull() ?: return null + } + } + return relationship +} + +private fun Confidence.isAcceptedNativeRelationshipConfidence(): Boolean = + this == Confidence.high || this == Confidence.verified + +private fun FieldSpec.nativeRelationshipTargetIdentities(): Set = buildSet { + sequenceOf(id, label).forEach { source -> + val normalized = source.lowercase().filter(Char::isLetterOrDigit) + .removePrefix("destination") + .removePrefix("target") + .removePrefix("dest") + .removeSuffix("ids") + .removeSuffix("id") + if (normalized.isNotBlank()) addAll(normalized.nativeRelationshipWordIdentities()) + } +} + +private fun FieldSpec.isOpaqueNativeRelationshipIdentity(): Boolean { + val normalized = id.lowercase().filter(Char::isLetterOrDigit) + return when { + normalized == "id" -> false + normalized.endsWith("ids") -> + format in setOf(DYNAMIC_INTEGER_ARRAY_FORMAT, DYNAMIC_STRING_ARRAY_FORMAT) + else -> normalized.endsWith("id") && kind in setOf( + FieldKind.integer, + FieldKind.string, + FieldKind.userReference, + ) + } +} + +private fun ResourceSpec.nativeRelationshipResourceIdentities(): Set = buildSet { + addAll(id.nativeRelationshipWordIdentities()) + addAll(name.nativeRelationshipWordIdentities()) +} + +private fun String.nativeRelationshipWordIdentities(): Set { + val normalized = lowercase().filter(Char::isLetterOrDigit) + return buildSet { + if (normalized.isNotBlank()) add(normalized) + if (normalized.endsWith('s') && normalized.length > 1) add(normalized.dropLast(1)) + if (normalized.endsWith("ies") && normalized.length > 3) add(normalized.dropLast(3) + "y") + if ( + normalized.endsWith("ches") || + normalized.endsWith("shes") || + normalized.endsWith("sses") || + normalized.endsWith("xes") || + normalized.endsWith("zes") + ) { + add(normalized.dropLast(2)) + } + } +} + +private fun Map.shareNativeRelationshipScopeWith( + currentBindings: Map?, + childFieldId: String, + parentFieldId: String, +): Boolean { + currentBindings ?: return true + val excluded = setOf( + "id", + childFieldId.nativeRelationshipBindingKey(), + parentFieldId.nativeRelationshipBindingKey(), + ) + return entries + .filter { (key, _) -> + val normalized = key.nativeRelationshipBindingKey() + normalized.endsWith("id") && normalized !in excluded + } + .all { (key, value) -> + currentBindings.entries.firstOrNull { (currentKey, _) -> + currentKey.nativeRelationshipBindingKey() == key.nativeRelationshipBindingKey() + }?.value?.let { currentValue -> currentValue == value } ?: true + } +} + +private fun ResourceSpec.nativeRelationshipRecordLabel(record: NativeRecord): String { + val preferredField = fields + .map { field -> + field to when (field.id.nativeRelationshipBindingKey()) { + "displayname" -> 600 + "name", "title", "subject" -> 500 + "label", "summary" -> 400 + else -> 0 + } + } + .filter { (_, score) -> score > 0 } + .maxByOrNull { (_, score) -> score } + ?.first + return preferredField + ?.let { field -> record.presentationValue(field.id)?.takeIf(String::isNotBlank) } + ?: record.id +} + +private fun String.nativeRelationshipBindingKey(): String = lowercase().filter(Char::isLetterOrDigit) + +private fun FieldSpec.hasNativeDestinationRelationshipSemantics(): Boolean = + sequenceOf(id, label) + .map(String::nativeRelationshipBindingKey) + .any { value -> + value.startsWith("destination") || + value.startsWith("target") || + value.startsWith("dest") + } + +private fun Map.matchesNativeRelationshipSource( + value: String, + destinationFieldId: String, + parentResource: ResourceSpec, +): Boolean { + val destinationKey = destinationFieldId.nativeRelationshipBindingKey() + val parentIdentities = parentResource.nativeRelationshipResourceIdentities() + return any { (key, candidateValue) -> + val normalizedKey = key.nativeRelationshipBindingKey() + candidateValue == value && + normalizedKey != destinationKey && + normalizedKey.removeSuffix("id") + .nativeRelationshipWordIdentities() + .intersect(parentIdentities) + .isNotEmpty() + } +} + +private fun String.isSafeNativeRelationOptionValue(): Boolean = + isNotBlank() && length <= 256 && none { character -> + character == '/' || character == '\\' || character.isISOControl() + } + fun ViewSpec.genericSurface(): GenericNativeSurface = when (component) { NativeComponent.mediaGrid -> GenericNativeSurface.Grid NativeComponent.mailbox -> GenericNativeSurface.Mailbox @@ -163,6 +479,7 @@ internal fun nativeTableFields( if (maximumColumns <= 0) return emptyList() val populated = resource.fields.filter { field -> field.kind !in setOf(FieldKind.objectValue, FieldKind.image, FieldKind.unknown) && + !field.isNativeVisualPresentationField() && records.any { record -> !record.presentationValue(field.id).isNullOrBlank() } } val preferredIds = listOf("name", "title", "displayName", "subject", "description") @@ -350,6 +667,18 @@ private fun NativeRecord.nativeFormValue(field: FieldSpec): String? { ?.mapNotNull { item -> (item as? NativeStructuredValue.Scalar)?.value } if (!values.isNullOrEmpty()) return values.joinToString("\n") } + if (field.format == DYNAMIC_INTEGER_ARRAY_FORMAT) { + val list = structuredValues[field.id] as? NativeStructuredValue.ListValue + if (list != null) { + if (list.omittedItems != 0) return null + val values = list.items.map { item -> + val scalar = item as? NativeStructuredValue.Scalar ?: return null + if (scalar.kind != NativeStructuredScalarKind.number) return null + scalar.value?.toLongOrNull() ?: return null + } + return JsonArray(values.map(::JsonPrimitive)).toString() + } + } return presentationValue(field.id) } @@ -466,7 +795,22 @@ fun validateNativeForm( ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } .orEmpty() .toSet() - val normalized = fields.associate { field -> field.id to values[field.id].orEmpty().trim() } + val normalizedEditableValues = fields.associate { field -> + field.id to values[field.id].orEmpty().trim() + } + val declaredBindingNames = buildSet { + addAll(action.binding.pathParameterNames) + addAll(action.binding.queryParameterNames) + addAll(action.binding.bodyFieldNames) + } + val normalized = buildMap { + putAll(normalizedEditableValues) + declaredBindingNames + .filterNot { name -> containsKey(name) } + .forEach { name -> + values[name]?.trim()?.let { value -> put(name, value) } + } + } val errors = buildMap { fields.forEach { field -> val value = normalized.getValue(field.id) @@ -474,6 +818,20 @@ fun validateNativeForm( val error = when { required && value.isBlank() -> "${field.label} is required." value.isBlank() -> null + field.format == DYNAMIC_INTEGER_ARRAY_FORMAT && + !action.hasExactDynamicIntegerArrayBodyField(field.id) -> + "This integer list does not have an exact contract schema." + field.format == DYNAMIC_INTEGER_ARRAY_FORMAT -> { + when ( + val parsed = parseDynamicIntegerArrayInput( + value, + action.dynamicIntegerArrayBodySchema(field.id), + ) + ) { + is DynamicIntegerArrayParseResult.Valid -> null + is DynamicIntegerArrayParseResult.Invalid -> parsed.message + } + } field.kind == FieldKind.integer && value.toLongOrNull() == null -> "Enter a whole number." field.kind in setOf(FieldKind.decimal, FieldKind.currency) && !value.isPlainDecimal() -> "Enter a valid number." @@ -488,25 +846,114 @@ fun validateNativeForm( } if (error != null) put(field.id, error) } + ( + action.binding.requiredPathParameterNames + + action.binding.requiredQueryParameterNames + ).distinct().forEach { parameterName -> + if (normalized[parameterName].isNullOrBlank()) { + put(parameterName, "$parameterName is required.") + } + } } return NativeFormValidation(normalized, errors) } fun editableNativeFields(resource: ResourceSpec, action: ActionSpec): List { val properties = (action.inputSchema as? JsonObject)?.get("properties") as? JsonObject - return resource.fields.filter { field -> - (!field.readOnly || action.binding.allowsObservedBodyFields) && - (!action.binding.allowsObservedBodyFields || !field.hasSensitiveSettingSemantics()) && - (field.kind !in setOf(FieldKind.objectValue, FieldKind.image, FieldKind.unknown) || - field.format in setOf( - SETTINGS_BOOLEAN_MAP_FORMAT, - DYNAMIC_STRING_LIST_FORMAT, - DYNAMIC_STRING_ARRAY_FORMAT, - )) && - (action.binding.allowsObservedBodyFields || properties == null || field.id in properties) + val requiredByInput = (action.inputSchema as? JsonObject) + ?.get("required") + .let { it as? JsonArray } + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + .orEmpty() + .toSet() + return resource.fields + .filter { field -> + ( + !field.readOnly || + action.binding.allowsObservedBodyFields || + properties?.containsKey(field.id) == true + ) && + (!action.binding.allowsObservedBodyFields || !field.hasSensitiveSettingSemantics()) && + !action.hasExactServerManagedBodyFieldEvidence(field.id) && + action.hasSupportedDynamicArrayBodyField(field) && + ( + field.format != DYNAMIC_INTEGER_ARRAY_FORMAT || + action.hasExactDynamicIntegerArrayBodyField(field.id) + ) && + (field.kind !in setOf(FieldKind.objectValue, FieldKind.image, FieldKind.unknown) || + field.format in setOf( + SETTINGS_BOOLEAN_MAP_FORMAT, + DYNAMIC_INTEGER_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + DYNAMIC_STRING_ARRAY_FORMAT, + )) && + (action.binding.allowsObservedBodyFields || properties == null || field.id in properties) + } + .map { field -> + if (properties == null || action.binding.allowsObservedBodyFields) { + field + } else { + field.copy(required = field.id in requiredByInput) + } + } +} + +private fun ActionSpec.hasExactDynamicIntegerArrayBodyField(fieldId: String): Boolean { + return dynamicIntegerArrayBodySchema(fieldId).isExactDynamicIntegerArraySchema() +} + +private fun ActionSpec.dynamicIntegerArrayBodySchema(fieldId: String): JsonElement? { + val properties = (binding.bodySchema as? JsonObject)?.get("properties") as? JsonObject + ?: return null + return properties[fieldId] +} + +private fun ActionSpec.hasSupportedDynamicArrayBodyField(field: FieldSpec): Boolean { + val properties = (binding.bodySchema as? JsonObject)?.get("properties") as? JsonObject + ?: return true + val property = properties[field.id] as? JsonObject ?: return true + if ((property["type"] as? JsonPrimitive)?.contentOrNull != "array") return true + val itemType = ((property["items"] as? JsonObject)?.get("type") as? JsonPrimitive)?.contentOrNull + val format = (property["format"] as? JsonPrimitive)?.contentOrNull + return when (field.format) { + DYNAMIC_INTEGER_ARRAY_FORMAT -> property.isExactDynamicIntegerArraySchema() + DYNAMIC_STRING_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + -> itemType == "string" && format == field.format + else -> false } } +/** + * Returns declared body inputs that neither have a safe native editor nor an exact contextual + * binding. A mutation with any such field must be withheld: silently sending a partial or empty + * body can replace structured server state with defaults. + */ +internal fun uneditableNativeBodyFieldIds( + action: ActionSpec, + editableFields: List, + autoBoundValues: Map, +): Set { + val represented = editableFields.mapTo(mutableSetOf(), FieldSpec::id) + autoBoundValues.keys + return action.binding.bodyFieldNames + .filterNotTo(linkedSetOf()) { fieldId -> + fieldId in represented || action.isOmissibleServerManagedBodyField(fieldId) + } +} + +private fun ActionSpec.isOmissibleServerManagedBodyField(fieldId: String): Boolean { + if (fieldId in binding.requiredBodyFieldNames) return false + return hasExactServerManagedBodyFieldEvidence(fieldId) +} + +private fun ActionSpec.hasExactServerManagedBodyFieldEvidence(fieldId: String): Boolean { + val property = ((binding.bodySchema as? JsonObject)?.get("properties") as? JsonObject) + ?.get(fieldId) as? JsonObject + ?: return false + return (property["readOnly"] as? JsonPrimitive)?.booleanOrNull == true || + (property["x-nextcloud-native-server-managed"] as? JsonPrimitive)?.booleanOrNull == true +} + /** * Open observed-settings bodies are learned from a successful read, not a declared write schema. * Credential-like values therefore remain display-only even if an app accidentally includes them @@ -532,6 +979,7 @@ private fun FieldSpec.hasSensitiveSettingSemantics(): Boolean { */ internal fun FieldSpec.isSafeNativeDetailField(resource: ResourceSpec): Boolean { if (hasSensitiveSettingSemantics()) return false + if (isNativeVisualPresentationField()) return false val resourceIdentity = (resource.id + resource.name).lowercase().filter(Char::isLetterOrDigit) if (!resourceIdentity.contains("account")) return true val fieldIdentity = (id + label).lowercase().filter(Char::isLetterOrDigit) @@ -539,6 +987,9 @@ internal fun FieldSpec.isSafeNativeDetailField(resource: ResourceSpec): Boolean ACCOUNT_INTERNAL_DETAIL_PREFIXES.none(fieldIdentity::startsWith) } +private fun FieldSpec.isNativeVisualPresentationField(): Boolean = + id.lowercase().filter(Char::isLetterOrDigit) in setOf("icon", "symbol", "color", "colour") + private val ACCOUNT_INTERNAL_DETAIL_FIELDS = setOf( "authmethod", "authentication", @@ -600,7 +1051,19 @@ fun buildNativeSubmitRequest( ) { return NativeRequestBuildResult.Invalid("The declared action cannot submit this form.") } - val validation = validateNativeForm(resource, action, values) + if ( + uneditableNativeBodyFieldIds( + action = action, + editableFields = editableNativeFields(resource, action), + autoBoundValues = emptyMap(), + ).isNotEmpty() + ) { + return NativeRequestBuildResult.Invalid( + "This action contains structured fields that cannot be submitted safely yet.", + ) + } + val aliasedValues = action.resolveNativeCreateParentAlias(values) + val validation = validateNativeForm(resource, action, aliasedValues) if (!validation.isValid) { return NativeRequestBuildResult.Invalid("Check the highlighted fields.", validation.errors) } @@ -609,13 +1072,51 @@ fun buildNativeSubmitRequest( ) } +private fun ActionSpec.resolveNativeCreateParentAlias(values: Map): Map { + if ( + intent != ActionIntent.create || + "id" in binding.bodyFieldNames || + "id" in binding.queryParameterNames + ) { + return values + } + val requiredPathNames = ( + binding.pathParameterNames + binding.requiredPathParameterNames + ).distinct() + val parameterName = requiredPathNames.singleOrNull() ?: return values + if (values[parameterName]?.isNotBlank() == true) return values + val parentId = values["id"]?.takeIf(String::isNotBlank) ?: return values + if (!binding.isProvenNativeCreateParentAlias(parameterName)) return values + return values + (parameterName to parentId) +} + +private fun ApiBinding.isProvenNativeCreateParentAlias(parameterName: String): Boolean { + if (!parameterName.endsWith("Id", ignoreCase = true) || parameterName.length <= 2) return false + val parentResourceId = parameterName.dropLast(2) + val segments = path.substringBefore('?').split('/').filter(String::isNotBlank) + val placeholder = "{$parameterName}" + return segments.indices.any { index -> + segments[index] == placeholder && + index > 0 && + segments[index - 1].sameDynamicResourceAs(parentResourceId) + } +} + fun interface NativeActionExecutor { suspend fun execute(request: NativeActionRequest): NativeActionExecutionResult } +enum class NativeActionFailureOutcome { + Rejected, + Unknown, +} + sealed interface NativeActionExecutionResult { data class Success(val message: String? = null) : NativeActionExecutionResult - data class Failure(val message: String) : NativeActionExecutionResult + data class Failure( + val message: String, + val outcome: NativeActionFailureOutcome = NativeActionFailureOutcome.Unknown, + ) : NativeActionExecutionResult } sealed interface NativeActionExecutionState { @@ -623,6 +1124,14 @@ sealed interface NativeActionExecutionState { data class ValidationFailed(val message: String, val fieldErrors: Map) : NativeActionExecutionState data class AwaitingConfirmation(val request: NativeActionRequest.Submit) : NativeActionExecutionState data class Running(val request: NativeActionRequest.Submit) : NativeActionExecutionState + data class AwaitingReconciliation( + val message: String, + val reconciliationGeneration: Int, + ) : NativeActionExecutionState { + init { + require(reconciliationGeneration >= 0) + } + } data class Succeeded(val message: String?) : NativeActionExecutionState data class Failed(val message: String) : NativeActionExecutionState } @@ -635,7 +1144,16 @@ class NativeActionCoordinator( var state: NativeActionExecutionState by mutableStateOf(NativeActionExecutionState.Idle) private set - suspend fun submit(values: Map) { + suspend fun submit( + values: Map, + reconciliationGeneration: Int = 0, + ) { + if ( + state is NativeActionExecutionState.Running || + state is NativeActionExecutionState.AwaitingReconciliation + ) { + return + } val built = buildNativeSubmitRequest(schema, view, values, confirmed = false) when (built) { is NativeRequestBuildResult.Invalid -> { @@ -646,15 +1164,15 @@ class NativeActionCoordinator( if (request.action.needsExplicitConfirmation()) { state = NativeActionExecutionState.AwaitingConfirmation(request) } else { - execute(request) + execute(request, reconciliationGeneration) } } } } - suspend fun confirm() { + suspend fun confirm(reconciliationGeneration: Int = 0) { val pending = (state as? NativeActionExecutionState.AwaitingConfirmation)?.request ?: return - execute(pending.copy(confirmed = true)) + execute(pending.copy(confirmed = true), reconciliationGeneration) } fun cancelConfirmation() { @@ -662,20 +1180,44 @@ class NativeActionCoordinator( } fun clearStatus() { - if (state !is NativeActionExecutionState.Running) state = NativeActionExecutionState.Idle + if ( + state !is NativeActionExecutionState.Running && + state !is NativeActionExecutionState.AwaitingReconciliation + ) { + state = NativeActionExecutionState.Idle + } + } + + fun reconcileAuthoritativeRefresh(reconciliationGeneration: Int) { + val pending = state as? NativeActionExecutionState.AwaitingReconciliation ?: return + if (reconciliationGeneration > pending.reconciliationGeneration) { + state = NativeActionExecutionState.Idle + } } - private suspend fun execute(request: NativeActionRequest.Submit) { + private suspend fun execute( + request: NativeActionRequest.Submit, + reconciliationGeneration: Int, + ) { state = NativeActionExecutionState.Running(request) state = try { when (val result = executor.execute(request)) { is NativeActionExecutionResult.Success -> NativeActionExecutionState.Succeeded(result.message) - is NativeActionExecutionResult.Failure -> NativeActionExecutionState.Failed(result.message) + is NativeActionExecutionResult.Failure -> when (result.outcome) { + NativeActionFailureOutcome.Rejected -> NativeActionExecutionState.Failed(result.message) + NativeActionFailureOutcome.Unknown -> NativeActionExecutionState.AwaitingReconciliation( + message = result.message, + reconciliationGeneration = reconciliationGeneration, + ) + } } } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Throwable) { - NativeActionExecutionState.Failed(failure.message ?: "The action failed.") + NativeActionExecutionState.AwaitingReconciliation( + message = failure.message ?: "The action result could not be confirmed.", + reconciliationGeneration = reconciliationGeneration, + ) } } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardActions.kt index 9ddc6ff1..83988fe6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardActions.kt @@ -143,11 +143,20 @@ internal fun nativeBoardCardActionPlan( record: NativeRecord, lanes: List, ): NativeBoardCardActionPlan { - if (!record.canResolveUnsafeActionIdentity()) return NativeBoardCardActionPlan(edit = null, move = null) - val laneContext = lanes.firstOrNull { lane -> + val selectedLane = lanes.firstOrNull { lane -> lane.records.any { laneRecord -> laneRecord.id == record.id } - }?.contextValues.orEmpty() - val bindingValues = laneContext + record.actionBindingValues(allowUnsafeIdentity = true) + } + if (selectedLane?.actionBindingProvenanceValid == false) { + return NativeBoardCardActionPlan(edit = null, move = null) + } + val laneContext = selectedLane?.contextValues.orEmpty().filterKeys { key -> + val semantic = key.boardSemanticId() + semantic != "id" && (semantic.endsWith("id") || semantic in BOARD_LANE_BODY_FIELD_NAMES) + } + val recordBindingValues = record.safeActionBindingValues() + ?: return NativeBoardCardActionPlan(edit = null, move = null) + val bindingValues = safeActionBindingValues(laneContext, recordBindingValues) + ?: return NativeBoardCardActionPlan(edit = null, move = null) val actions = schema.actions.filter { action -> action.resourceId.sameBoardActionResource(resource.id) && action.risk == ActionRisk.mutating && @@ -177,13 +186,7 @@ internal fun nativeBoardLaneCreatePlan( resource: ResourceSpec, lane: NativeBoardLane, ): NativeBoardCreatePlan? { - val bindingValues = lane.contextValues + mapOf( - "laneId" to lane.key, - "stackId" to lane.key, - "columnId" to lane.key, - "listId" to lane.key, - "stageId" to lane.key, - ) + if (!lane.actionBindingProvenanceValid) return null return schema.actions.mapNotNull { action -> if ( !action.resourceId.sameBoardActionResource(resource.id) || @@ -194,6 +197,15 @@ internal fun nativeBoardLaneCreatePlan( ) { return@mapNotNull null } + val laneAliases = ( + action.binding.pathParameterNames + + action.binding.queryParameterNames + + action.binding.bodyFieldNames + ) + .filter { name -> name.boardSemanticId() in BOARD_LANE_BODY_FIELD_NAMES } + .associateWith { lane.key } + val bindingValues = safeActionBindingValues(lane.contextValues, laneAliases) + ?: return@mapNotNull null val titleField = action.binding.bodyFieldNames .mapNotNull { name -> BOARD_CREATE_TITLE_FIELDS[name.boardSemanticId()]?.let { priority -> name to priority } @@ -204,6 +216,9 @@ internal fun nativeBoardLaneCreatePlan( val descriptionField = action.binding.bodyFieldNames.singleOrNull { it.boardSemanticId() in BOARD_CREATE_DESCRIPTION_FIELDS } + if (action.binding.hasFlatBoardActionCollision(listOfNotNull(titleField, descriptionField))) { + return@mapNotNull null + } val available = bindingValues.keys + titleField + listOfNotNull(descriptionField) if (!action.binding.canResolveExactBoardValues(available, action.resourceId)) return@mapNotNull null val semantic = "${ @@ -240,7 +255,12 @@ private fun nativeBoardDirectActions( val candidates = schema.actions.mapNotNull { action -> if (!action.resourceId.sameBoardActionResource(resource.id)) return@mapNotNull null val kind = action.boardDirectActionKind() ?: return@mapNotNull null - val resolvedBindingValues = action.withBoardIdentityAliases(bindingValues, record.id) + val resolvedBindingValues = action.withBoardIdentityAliases( + values = bindingValues, + recordId = record.id, + canonicalIdentityAvailable = record.actionSafeIdentity, + ) + ?: return@mapNotNull null if ( kind == NativeBoardDirectActionKind.Delete && (action.risk != ActionRisk.destructive || action.binding.method != HttpMethod.DELETE) @@ -256,6 +276,9 @@ private fun nativeBoardDirectActions( if (action.binding.bodyFieldNames.any { it.boardSemanticId() !in BOARD_DIRECT_IDENTITY_FIELDS }) { return@mapNotNull null } + if (action.binding.hasFlatBoardActionCollision(action.binding.bodyFieldNames)) { + return@mapNotNull null + } if (!action.binding.canResolveExactBoardValues(resolvedBindingValues.keys, action.resourceId)) { return@mapNotNull null } @@ -293,20 +316,24 @@ private fun nativeBoardDirectActions( private fun ActionSpec.withBoardIdentityAliases( values: Map, recordId: String, -): Map = buildMap { - putAll(values) - val declaredNames = binding.pathParameterNames + binding.queryParameterNames + binding.bodyFieldNames - declaredNames.forEach { name -> - val normalized = name.boardSemanticId() - val stem = normalized.removeSuffix("id") - if ( - normalized.endsWith("id") && - stem.sameBoardActionResource(resourceId) && - keys.none { existing -> existing.boardSemanticId() == normalized } - ) { - put(name, recordId) + canonicalIdentityAvailable: Boolean, +): Map? { + val aliases = buildMap { + if (!canonicalIdentityAvailable) return@buildMap + val declaredNames = binding.pathParameterNames + binding.queryParameterNames + binding.bodyFieldNames + declaredNames.forEach { name -> + val normalized = name.boardSemanticId() + val stem = normalized.removeSuffix("id") + if ( + normalized.endsWith("id") && + stem.sameBoardActionResource(resourceId) && + keys.none { existing -> existing.boardSemanticId() == normalized } + ) { + put(name, recordId) + } } } + return safeActionBindingValues(values, aliases) } private fun ActionSpec.boardDirectActionKind(): NativeBoardDirectActionKind? { @@ -366,8 +393,11 @@ private fun ActionSpec.toBoardEditCandidate( NativeBoardEditableField(field, bodyName) } if (editableFields.isEmpty()) return null + if (binding.hasFlatBoardActionCollision(editableFields.map(NativeBoardEditableField::bodyFieldName))) { + return null + } val available = bindingValues.keys + editableFields.map(NativeBoardEditableField::bodyFieldName) - if (!binding.canResolveRequiredBoardValues(available)) return null + if (!binding.canResolveExactBoardValues(available, resourceId)) return null val initialValues = editableFields.associate { editable -> editable.field.id to bindingValues.valueForBoardName(editable.field.id).orEmpty() } @@ -392,6 +422,7 @@ private fun ActionSpec.toBoardMoveCandidate( if (intent !in setOf(ActionIntent.update, ActionIntent.execute)) return null val laneField = binding.bodyFieldNames.singleOrNull { it.boardSemanticId() in BOARD_LANE_BODY_FIELD_NAMES } ?: return null + if (binding.hasFlatBoardActionCollision(listOf(laneField))) return null val currentLane = record.values.entries.firstOrNull { it.key.boardSemanticId() in BOARD_LANE_BODY_FIELD_NAMES }?.value?.trim()?.takeIf(String::isNotBlank) ?: return null @@ -402,7 +433,7 @@ private fun ActionSpec.toBoardMoveCandidate( .toList() if (targets.isEmpty()) return null val available = bindingValues.keys + laneField - if (!binding.canResolveRequiredBoardValues(available)) return null + if (!binding.canResolveExactBoardValues(available, resourceId)) return null val semantic = "$id $label ${binding.operationId} ${binding.path}".boardSemanticWords() val rank = when { "move" in semantic -> 500 @@ -416,19 +447,6 @@ private fun ActionSpec.toBoardMoveCandidate( ) } -private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.canResolveRequiredBoardValues( - available: Set, -): Boolean { - val normalized = available.mapTo(mutableSetOf()) { it.boardSemanticId() } - fun String.resolvable(): Boolean { - val name = boardSemanticId() - return name in normalized || name.endsWith("id") && "id" in normalized - } - return requiredPathParameterNames.all(String::resolvable) && - requiredQueryParameterNames.all(String::resolvable) && - requiredBodyFieldNames.all(String::resolvable) -} - private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.canResolveExactBoardValues( available: Set, actionResourceId: String, @@ -447,6 +465,17 @@ private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.canResolveExac requiredBodyFieldNames.all { it.boardSemanticId() in normalized } } +/** + * NativeActionRequest currently carries one flat value map. A submitted body field therefore + * cannot safely have a different value from a path or query parameter with the same semantic name. + */ +private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.hasFlatBoardActionCollision( + submittedBodyFields: Collection, +): Boolean { + val routeFields = (pathParameterNames + queryParameterNames).mapTo(mutableSetOf(), String::boardSemanticId) + return submittedBodyFields.any { it.boardSemanticId() in routeFields } +} + private fun List>.singleHighestOrNull(): T? { val highest = maxOfOrNull(RankedBoardPlan::rank) ?: return null return filter { it.rank == highest }.singleOrNull()?.plan diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiState.kt new file mode 100644 index 00000000..45a116c0 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiState.kt @@ -0,0 +1,184 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +internal fun toggleNativeCollectionSelection( + selectedRecordIds: List, + recordId: String, + availableRecordIds: List, + maximumSelectionSize: Int, +): List { + if (maximumSelectionSize < 1 || recordId !in availableRecordIds) return selectedRecordIds + val available = availableRecordIds.distinct() + val selected = selectedRecordIds + .asSequence() + .filter(available::contains) + .distinct() + .take(maximumSelectionSize) + .toMutableSet() + if (!selected.remove(recordId) && selected.size < maximumSelectionSize) { + selected += recordId + } + return available.filter(selected::contains) +} + +internal fun moveNativeCollectionRecord( + orderedRecordIds: List, + recordId: String, + offset: Int, +): List { + if (offset !in setOf(-1, 1) || orderedRecordIds.distinct().size != orderedRecordIds.size) { + return orderedRecordIds + } + val fromIndex = orderedRecordIds.indexOf(recordId) + val toIndex = fromIndex + offset + if (fromIndex < 0 || toIndex !in orderedRecordIds.indices) return orderedRecordIds + return orderedRecordIds.toMutableList().apply { + this[fromIndex] = this[toIndex] + this[toIndex] = recordId + } +} + +/** + * Returns a bounded snapshot that is safe to place in Android saved instance state. + * + * Reorder plans are already capped at 500 records, but the saver repeats that boundary so a + * malformed or stale restored value cannot inflate the activity state independently of a plan. + */ +internal fun encodeNativeCollectionReorderDraft( + orderedRecordIds: List, +): List? = orderedRecordIds + .takeIf { recordIds -> + recordIds.size in 2..MAX_SAVED_COLLECTION_REORDER_RECORDS && + recordIds.distinct().size == recordIds.size && + recordIds.all { recordId -> + recordId.isNotBlank() && + recordId.length <= MAX_SAVED_COLLECTION_REORDER_ID_LENGTH && + recordId.none(Char::isISOControl) + } + } + ?.toList() + +/** Restores only a complete permutation of the identities in the current reorder plan. */ +internal fun restoreNativeCollectionReorderDraft( + savedRecordIds: List, + plannedRecordIds: List, +): List { + val planned = encodeNativeCollectionReorderDraft(plannedRecordIds) ?: return plannedRecordIds + val saved = encodeNativeCollectionReorderDraft(savedRecordIds) ?: return planned + return saved.takeIf { recordIds -> recordIds.toSet() == planned.toSet() } ?: planned +} + +internal fun NativeCollectionBatchInputField.toNativeCollectionFieldSpec(): FieldSpec = + FieldSpec( + id = id, + label = id.nativeCollectionFieldLabel(), + kind = when { + enumValues != null -> FieldKind.enumeration + kind == NativeCollectionBatchInputKind.Boolean && required -> FieldKind.boolean + kind == NativeCollectionBatchInputKind.Boolean -> FieldKind.enumeration + kind == NativeCollectionBatchInputKind.Integer || + kind == NativeCollectionBatchInputKind.IntegerArray -> FieldKind.integer + kind == NativeCollectionBatchInputKind.Decimal -> FieldKind.decimal + else -> FieldKind.string + }, + required = required, + readOnly = false, + format = when (kind) { + NativeCollectionBatchInputKind.IntegerArray -> DYNAMIC_INTEGER_ARRAY_FORMAT + NativeCollectionBatchInputKind.StringArray -> DYNAMIC_STRING_ARRAY_FORMAT + else -> null + }, + enumValues = when { + kind == NativeCollectionBatchInputKind.Boolean && !required -> + NATIVE_COLLECTION_BATCH_BOOLEAN_OPTIONS + else -> enumValues + }, + ) + +internal fun initialNativeCollectionBatchDraft( + fields: List, +): Map = fields + .filter { field -> field.required && field.kind == NativeCollectionBatchInputKind.Boolean } + .associate { field -> field.id to "false" } + +internal fun nativeCollectionBatchRequestValues( + fields: List, + draft: Map, +): Map = buildMap { + fields.forEach { field -> + val value = draft[field.id] ?: return@forEach + if ( + field.kind == NativeCollectionBatchInputKind.Boolean && + !field.required && + value == NATIVE_COLLECTION_BATCH_UNCHANGED_VALUE + ) { + return@forEach + } + if (value.isBlank() && !field.required) return@forEach + put( + field.id, + when (field.kind) { + NativeCollectionBatchInputKind.IntegerArray -> + if (field.relatedResourceId != null) value.trim() else value.toNativeCollectionIntegerArray() + NativeCollectionBatchInputKind.StringArray -> + if (field.relatedResourceId != null) value.trim() else value.toNativeCollectionStringArray() + else -> value.trim() + }, + ) + } +} + +internal const val NATIVE_COLLECTION_BATCH_UNCHANGED_VALUE = "unchanged" + +private val NATIVE_COLLECTION_BATCH_BOOLEAN_OPTIONS = listOf( + NATIVE_COLLECTION_BATCH_UNCHANGED_VALUE, + "true", + "false", +) + +private const val MAX_SAVED_COLLECTION_REORDER_RECORDS = 500 +private const val MAX_SAVED_COLLECTION_REORDER_ID_LENGTH = 256 + +private fun String.toNativeCollectionIntegerArray(): String { + val values = lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .map { value -> + value.toLongOrNull() + ?: error("Enter one whole number per line.") + } + .toList() + return JsonArray(values.map(::JsonPrimitive)).toString() +} + +private fun String.toNativeCollectionStringArray(): String { + val values = lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + return JsonArray(values.map(::JsonPrimitive)).toString() +} + +private fun String.nativeCollectionFieldLabel(): String = + replace('-', ' ') + .replace('_', ' ') + .fold(StringBuilder()) { label, character -> + if ( + character.isUpperCase() && + label.isNotEmpty() && + label.last() != ' ' + ) { + label.append(' ') + } + label.append(character) + } + .toString() + .split(' ') + .filter(String::isNotBlank) + .joinToString(" ") { word -> word.lowercase().replaceFirstChar(Char::uppercaseChar) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt new file mode 100644 index 00000000..33cebe38 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt @@ -0,0 +1,1222 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs +import dev.obiente.nextcloudnative.template.scanBracedTemplate +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +/** + * Collection-scoped capabilities for a future generic collection toolbar. + * + * Planning is deliberately separate from rendering. The planner accepts only the active verified + * collection read, its exact resource, the currently loaded records, and the request context that + * produced that collection. It never uses an app identity or operation name to authorize a write. + */ +internal data class NativeCollectionActionCapabilities( + val commands: List, + val reorder: NativeCollectionReorderActionPlan?, + val batches: List, +) + +internal data class NativeCollectionCommandActionPlan( + val action: ActionSpec, + val effect: ActionEffect, + private val bindingValues: Map, +) { + val requiresConfirmation: Boolean + get() = action.requiresConfirmation + + fun request(confirmed: Boolean): NativeActionRequest.Submit { + require(!requiresConfirmation || confirmed) { + "This collection command requires explicit confirmation." + } + return NativeActionRequest.Submit( + action = action, + values = bindingValues, + confirmed = confirmed, + ) + } +} + +internal data class NativeCollectionOrderedIdentity( + val recordId: String, + val order: Long, +) + +internal data class NativeCollectionReorderActionPlan( + val action: ActionSpec, + val identityFieldId: String, + val orderFieldId: String, + val maximumOrderSize: Int, + private val bodyFieldId: String, + private val identitySchema: NativeCollectionIdentitySchema, + private val orderSchema: NativeCollectionIntegerSchema, + private val derivedOrderValues: List, + private val availableRecordIds: Set, + private val bindingValues: Map, +) { + /** + * Builds the default request a generic reorder UI should use. + * + * Numeric positions are derived by the planner from the verified integer schema. Callers only + * provide record identity order and never need to invent app-specific gaps or base values. + */ + fun requestInOrder(recordIdsInOrder: List): NativeActionRequest.Submit { + require(recordIdsInOrder.size == derivedOrderValues.size) { + "A reordered collection must contain every planned position." + } + return request( + recordIdsInOrder.mapIndexed { index, recordId -> + NativeCollectionOrderedIdentity( + recordId = recordId, + order = derivedOrderValues[index], + ) + }, + ) + } + + fun request(ordered: List): NativeActionRequest.Submit { + require(ordered.size in 2..maximumOrderSize) { + "A reordered collection must contain a bounded complete order." + } + require(ordered.size == derivedOrderValues.size) { + "A reordered collection must contain every planned position." + } + require(ordered.map(NativeCollectionOrderedIdentity::recordId).toSet() == availableRecordIds) { + "A reordered collection must contain every active record exactly once." + } + require(ordered.map(NativeCollectionOrderedIdentity::recordId).distinct().size == ordered.size) { + "A reordered collection contains duplicate record identities." + } + require(ordered.zipWithNext().all { (first, second) -> first.order < second.order }) { + "A reordered collection requires strictly increasing order values." + } + val payload = JsonArray( + ordered.map { entry -> + buildJsonObject { + put(identityFieldId, identitySchema.jsonValue(entry.recordId)) + put(orderFieldId, orderSchema.jsonValue(entry.order.toString())) + } + }, + ) + return NativeActionRequest.Submit( + action = action, + values = bindingValues + (bodyFieldId to payload.toString()), + confirmed = false, + ) + } +} + +internal enum class NativeCollectionBatchInputKind { + Boolean, + Integer, + Decimal, + String, + IntegerArray, + StringArray, +} + +internal data class NativeCollectionBatchInputField( + val id: String, + val kind: NativeCollectionBatchInputKind, + val required: Boolean, + val nullable: Boolean, + val enumValues: List?, + val relatedResourceId: String? = null, +) + +internal data class NativeCollectionBatchActionPlan( + val action: ActionSpec, + val selectionFieldId: String, + val fields: List, + val minimumSelectionSize: Int, + val maximumSelectionSize: Int, + private val selectionSchema: NativeCollectionIdentityArraySchema, + private val fieldSchemas: Map, + val selectableRecordIds: Set, + private val bindingValues: Map, +) { + val requiresConfirmation: Boolean + get() = action.requiresConfirmation + + fun request( + selectedRecordIds: List, + values: Map = emptyMap(), + confirmed: Boolean = false, + ): NativeActionRequest.Submit { + require(selectedRecordIds.size in minimumSelectionSize..maximumSelectionSize) { + "A batch selection must be non-empty and bounded." + } + require(selectedRecordIds.distinct().size == selectedRecordIds.size) { + "A batch selection contains duplicate record identities." + } + require(selectedRecordIds.all(selectableRecordIds::contains)) { + "A batch selection must contain only active collection records." + } + require(values.keys.all(fieldSchemas::containsKey)) { + "The batch action contains an undeclared input." + } + fields.filter(NativeCollectionBatchInputField::required).forEach { field -> + require(values.containsKey(field.id)) { "${field.id} is required." } + } + require(!requiresConfirmation || confirmed) { + "This batch action requires explicit confirmation." + } + val normalizedInputs = values.mapValues { (fieldId, value) -> + requireNotNull(fieldSchemas[fieldId]).canonicalValue(value) + } + return NativeActionRequest.Submit( + action = action, + values = bindingValues + + normalizedInputs + + (selectionFieldId to selectionSchema.jsonValue(selectedRecordIds).toString()), + confirmed = confirmed, + ) + } +} + +/** + * Plans collection-wide commands without treating arbitrary record actions as toolbar actions. + * + * [collectionComplete] authorizes reorder only. Batch actions may operate on a bounded selected + * subset of a paged collection, while an order payload must represent the complete active order. + */ +internal fun nativeCollectionActions( + schema: NativeAppSchema, + activeReadAction: ActionSpec, + resource: ResourceSpec, + records: List, + navigationContext: Map, + collectionComplete: Boolean, + authorityContext: NativeRecordAuthorityContext? = null, +): NativeCollectionActionCapabilities { + val empty = NativeCollectionActionCapabilities(emptyList(), null, emptyList()) + val authoritativeResource = schema.resources + .filter { candidate -> candidate.id == resource.id } + .singleOrNull() + ?: return empty + if (!schema.hasExactActiveCollection(activeReadAction, authoritativeResource)) return empty + val context = safeNativeCollectionContext( + readBinding = activeReadAction.binding, + navigationContext = navigationContext, + ) ?: return empty + val recordIds = safeNativeCollectionRecordIds( + resource = authoritativeResource, + records = records, + navigationContext = context, + ) ?: return empty + if (recordIds.size > MAX_COLLECTION_CONTEXT_RECORDS) return empty + + val candidates = schema.actions.filter { action -> + action.id != activeReadAction.id && + action.resourceId == authoritativeResource.id && + action.confidence.isSafeNativeCollectionConfidence() && + schema.actions.count { candidate -> candidate.id == action.id } == 1 + } + + val commands = candidates + .mapNotNull { action -> + action.nativeCollectionCommandPlan( + activeReadAction = activeReadAction, + resource = authoritativeResource, + context = context, + ) + } + .groupBy { plan -> plan.effect to plan.action.collectionRouteIdentity() } + .values + .mapNotNull(List::singleOrNull) + .sortedWith(compareBy({ it.effect.ordinal }, { it.action.label })) + + val reorder = if ( + collectionComplete && + recordIds.size in 2..MAX_COLLECTION_REORDER_RECORDS + ) { + candidates.mapNotNull { action -> + val permittedRecordIds = records + .filter { record -> + record.permitsNativeCollectionMutation( + action, + authoritativeResource, + authorityContext, + ) + } + .mapTo(linkedSetOf(), NativeRecord::id) + if (permittedRecordIds != recordIds) return@mapNotNull null + action.nativeCollectionReorderPlan( + activeReadAction = activeReadAction, + resource = authoritativeResource, + recordIds = recordIds, + context = context, + ) + }.singleOrNull() + } else { + null + } + + val batches = candidates.mapNotNull { action -> + val permittedRecordIds = records + .filter { record -> + record.permitsNativeCollectionMutation( + action, + authoritativeResource, + authorityContext, + ) + } + .mapTo(linkedSetOf(), NativeRecord::id) + action.nativeCollectionBatchPlan( + schema = schema, + resource = authoritativeResource, + recordIds = permittedRecordIds, + context = context, + ) + } + .groupBy { plan -> plan.action.collectionRouteIdentity() } + .values + .mapNotNull(List::singleOrNull) + .sortedBy { plan -> plan.action.label } + + return NativeCollectionActionCapabilities( + commands = commands, + reorder = reorder, + batches = batches, + ) +} + +private fun NativeRecord.permitsNativeCollectionMutation( + action: ActionSpec, + resource: ResourceSpec, + authorityContext: NativeRecordAuthorityContext?, +): Boolean { + // Batch and reorder are transport shapes rather than underlying permission categories. + // Ordinary collection mutations require edit authority, while destructive batches affect each + // selected record as a deletion would and therefore require delete authority. + val permissionAction = when { + action.effect == ActionEffect.batch && action.risk == ActionRisk.destructive -> + action.copy( + intent = ActionIntent.delete, + effect = ActionEffect.delete, + ) + action.effect in setOf(ActionEffect.batch, ActionEffect.reorder) -> + action.copy( + intent = ActionIntent.update, + effect = ActionEffect.update, + ) + else -> action + } + return permits(permissionAction, resource, authorityContext) +} + +private fun NativeAppSchema.hasExactActiveCollection( + activeReadAction: ActionSpec, + resource: ResourceSpec, +): Boolean { + if (resources.count { candidate -> candidate.id == resource.id } != 1) return false + if (resources.single { candidate -> candidate.id == resource.id } != resource) return false + if (actions.count { candidate -> candidate.id == activeReadAction.id } != 1) return false + if (actions.single { candidate -> candidate.id == activeReadAction.id } != activeReadAction) return false + return activeReadAction.resourceId == resource.id && + activeReadAction.binding.method == HttpMethod.GET && + activeReadAction.intent == ActionIntent.list && + activeReadAction.risk == ActionRisk.readOnly && + !activeReadAction.requiresConfirmation && + activeReadAction.confidence.isSafeNativeCollectionConfidence() && + activeReadAction.binding.bodyFieldNames.isEmpty() && + activeReadAction.binding.bodySchema == null +} + +private fun safeNativeCollectionContext( + readBinding: ApiBinding, + navigationContext: Map, +): Map? { + val safeContext = navigationContext.takeIf { values -> + values.size <= MAX_COLLECTION_CONTEXT_VALUES && + values.all { (name, value) -> + name.isSafeNativeCollectionBindingName() && + value.isSafeNativeCollectionBindingValue() + } + } ?: return null + val merged = safeActionBindingValues(safeContext) ?: return null + readBinding.resolveNativeCollectionBindings(merged) ?: return null + return merged +} + +private fun safeNativeCollectionRecordIds( + resource: ResourceSpec, + records: List, + navigationContext: Map, +): Set? { + if (records.map(NativeRecord::id).distinct().size != records.size) return null + val safe = LinkedHashSet(records.size) + records.forEach { record -> + if ( + !record.actionSafeIdentity || + !record.actionBindingProvenanceValid || + !record.effectiveNativeResourceId(resource.id).sameDynamicResourceAs(resource.id) || + !record.id.isSafeNativeCollectionIdentity() + ) { + return null + } + if (safeActionBindingValues(record.bindingContext, navigationContext) == null) return null + // actionSafeIdentity already proves that record.id came from the contract-selected record + // identity. A parent collection may legitimately have been loaded through a generic + // `{id}` route parameter whose value differs from the child record identity; treating that + // parent binding as the child's canonical ID would incorrectly suppress every action. + if (!safe.add(record.id)) return null + } + return safe +} + +private fun ActionSpec.nativeCollectionCommandPlan( + activeReadAction: ActionSpec, + resource: ResourceSpec, + context: Map, +): NativeCollectionCommandActionPlan? { + if ( + effect !in setOf(ActionEffect.empty, ActionEffect.clear) || + intent !in setOf(ActionIntent.execute, ActionIntent.delete) || + risk != ActionRisk.destructive || + !requiresConfirmation || + binding.method !in COLLECTION_COMMAND_METHODS || + !binding.isSameNativeCollectionRouteAs(activeReadAction.binding, context) || + binding.bodyFieldNames.isNotEmpty() || + binding.requiredBodyFieldNames.isNotEmpty() || + binding.bodySchema != null || + binding.bodyContentType != null || + binding.allowsObservedBodyFields || + binding.queryParameterNames.isNotEmpty() || + binding.requiredQueryParameterNames.isNotEmpty() || + binding.hasSelfResourcePathIdentity(resource) + ) { + return null + } + val bindings = binding.resolveNativeCollectionBindings(context) ?: return null + return NativeCollectionCommandActionPlan( + action = this, + effect = effect, + bindingValues = bindings, + ) +} + +private fun ActionSpec.nativeCollectionReorderPlan( + activeReadAction: ActionSpec, + resource: ResourceSpec, + recordIds: Set, + context: Map, +): NativeCollectionReorderActionPlan? { + if ( + effect != ActionEffect.reorder || + intent != ActionIntent.execute || + risk != ActionRisk.mutating || + requiresConfirmation || + binding.method !in COLLECTION_BODY_COMMAND_METHODS || + binding.queryParameterNames.isNotEmpty() || + binding.requiredQueryParameterNames.isNotEmpty() || + binding.allowsObservedBodyFields || + !binding.isDirectChildRouteOf(activeReadAction.binding, resource, context) + ) { + return null + } + val properties = binding.exactNativeCollectionBodyProperties() ?: return null + if (properties.size != 1) return null + val bodyField = properties.entries.single() + val arraySchema = NativeCollectionReorderArraySchema.create( + schema = bodyField.value, + resource = resource, + ) ?: return null + val maximumOrderSize = minOf( + MAX_COLLECTION_REORDER_RECORDS, + arraySchema.maximumItems ?: MAX_COLLECTION_REORDER_RECORDS, + ) + if ( + recordIds.size < (arraySchema.minimumItems ?: 0) || + recordIds.size > maximumOrderSize + ) { + return null + } + val derivedOrderValues = arraySchema.orderSchema.monotonicValues(recordIds.size) ?: return null + val bindings = binding.resolveNativeCollectionBindings(context) ?: return null + return NativeCollectionReorderActionPlan( + action = this, + identityFieldId = arraySchema.identityFieldId, + orderFieldId = arraySchema.orderFieldId, + maximumOrderSize = maximumOrderSize, + bodyFieldId = bodyField.key, + identitySchema = arraySchema.identitySchema, + orderSchema = arraySchema.orderSchema, + derivedOrderValues = derivedOrderValues, + availableRecordIds = recordIds, + bindingValues = bindings, + ) +} + +private fun ActionSpec.nativeCollectionBatchPlan( + schema: NativeAppSchema, + resource: ResourceSpec, + recordIds: Set, + context: Map, +): NativeCollectionBatchActionPlan? { + if ( + effect != ActionEffect.batch || + intent != ActionIntent.execute || + !hasProperNativeCollectionConfirmation() || + binding.method !in COLLECTION_BODY_COMMAND_METHODS || + binding.queryParameterNames.isNotEmpty() || + binding.requiredQueryParameterNames.isNotEmpty() || + binding.allowsObservedBodyFields || + binding.hasSelfResourcePathIdentity(resource) + ) { + return null + } + val properties = binding.exactNativeCollectionBodyProperties() ?: return null + val selectionCandidates = properties.mapNotNull { (fieldId, schema) -> + fieldId.takeIf { candidate -> candidate.isSelfResourceSelectionField(resource) } + ?.let { + NativeCollectionIdentityArraySchema.create(schema) + ?.let { selection -> fieldId to selection } + } + } + val (selectionFieldId, selectionSchema) = selectionCandidates.singleOrNull() ?: return null + val declaredFieldSchemas = properties + .filterKeys { fieldId -> fieldId != selectionFieldId } + .mapValues { (_, schema) -> NativeCollectionBatchValueSchema.create(schema) ?: return null } + val schemaRequired = binding.bodySchema.requiredNativeCollectionProperties() ?: return null + if (declaredFieldSchemas.any { (fieldId, fieldSchema) -> + fieldId in schemaRequired && fieldSchema.nullable + } + ) { + // The generic batch draft cannot distinguish an explicit JSON null from omission. A + // required nullable field therefore cannot be submitted without inventing a value. + return null + } + // Optional nullable inputs remain omitted until the generic batch draft has an explicit null + // state. Keeping them out of both the UI and accepted request values prevents a blank value + // from being misrepresented as either an empty scalar or an absent clear operation. + val fieldSchemas = declaredFieldSchemas.filterValues { fieldSchema -> !fieldSchema.nullable } + val fields = fieldSchemas.map { (fieldId, fieldSchema) -> + NativeCollectionBatchInputField( + id = fieldId, + kind = fieldSchema.kind, + required = fieldId in schemaRequired, + nullable = fieldSchema.nullable, + enumValues = fieldSchema.enumValues, + relatedResourceId = schema.verifiedNativeCollectionRelatedResourceId( + resource = resource, + fieldId = fieldId, + ), + ) + } + val minimumSelectionSize = maxOf(1, selectionSchema.minimumItems ?: 1) + val maximumSelectionSize = minOf( + MAX_COLLECTION_BATCH_SELECTION, + selectionSchema.maximumItems ?: MAX_COLLECTION_BATCH_SELECTION, + ) + if ( + minimumSelectionSize > maximumSelectionSize || + recordIds.size < minimumSelectionSize + ) { + return null + } + val bindings = binding.resolveNativeCollectionBindings(context) ?: return null + return NativeCollectionBatchActionPlan( + action = this, + selectionFieldId = selectionFieldId, + fields = fields, + minimumSelectionSize = minimumSelectionSize, + maximumSelectionSize = maximumSelectionSize, + selectionSchema = selectionSchema, + fieldSchemas = fieldSchemas, + selectableRecordIds = recordIds, + bindingValues = bindings, + ) +} + +private fun NativeAppSchema.verifiedNativeCollectionRelatedResourceId( + resource: ResourceSpec, + fieldId: String, +): String? { + val candidates = relationships.asSequence() + .filter { relationship -> + relationship.confidence == Confidence.verified && + relationship.childResourceId.sameDynamicResourceAs(resource.id) && + relationship.childFieldId == fieldId + } + .map { relationship -> relationship.parentResourceId } + .distinct() + .filter { parentResourceId -> + resources.count { candidate -> + candidate.id.sameDynamicResourceAs(parentResourceId) + } == 1 + } + .toList() + return candidates.singleOrNull() +} + +private fun ActionSpec.hasProperNativeCollectionConfirmation(): Boolean = when (risk) { + ActionRisk.readOnly -> false + ActionRisk.mutating -> !requiresConfirmation + ActionRisk.destructive -> requiresConfirmation +} + +private fun ApiBinding.exactNativeCollectionBodyProperties(): JsonObject? { + if ( + bodyContentType?.substringBefore(';')?.trim()?.lowercase() != "application/json" || + bodyFieldNames.isEmpty() || + bodyFieldNames.distinct().size != bodyFieldNames.size || + requiredBodyFieldNames.any { field -> field !in bodyFieldNames } + ) { + return null + } + val body = bodySchema as? JsonObject ?: return null + if (!body.isExactNativeCollectionObjectSchema(NATIVE_COLLECTION_BODY_SCHEMA_KEYS)) return null + val properties = body["properties"] as? JsonObject ?: return null + if (properties.keys != bodyFieldNames.toSet()) return null + val required = body.requiredNativeCollectionProperties() ?: return null + if (required != requiredBodyFieldNames.toSet()) return null + return properties +} + +private fun JsonElement?.requiredNativeCollectionProperties(): Set? { + val body = this as? JsonObject ?: return null + val required = body["required"] ?: return emptySet() + val array = required as? JsonArray ?: return null + val values = array.mapNotNull { element -> + (element as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + } + val distinct = values.toSet() + return distinct.takeIf { + values.size == array.size && distinct.size == values.size + } +} + +private fun ApiBinding.resolveNativeCollectionBindings( + context: Map, +): Map? { + val pathNames = (pathParameterNames + requiredPathParameterNames).distinct() + if ( + pathParameterNames.distinct().size != pathParameterNames.size || + requiredPathParameterNames.distinct().size != requiredPathParameterNames.size || + requiredPathParameterNames.any { name -> name !in pathParameterNames } || + queryParameterNames.distinct().size != queryParameterNames.size || + requiredQueryParameterNames.distinct().size != requiredQueryParameterNames.size || + requiredQueryParameterNames.any { name -> name !in queryParameterNames } || + path.placeholderNames() != pathNames.toSet() + ) { + return null + } + val resolved = linkedMapOf() + pathNames.forEach { name -> + val value = context[name] + ?: context["id"].takeIf { + pathNames.size == 1 && + isProvenSingleCollectionParentIdentityAlias(name) + } + ?.takeIf(String::isSafeNativeCollectionPathValue) + ?: return null + resolved[name] = value + } + requiredQueryParameterNames.forEach { name -> + val value = context[name] + ?.takeIf(String::isSafeNativeCollectionBindingValue) + ?: return null + resolved[name] = value + } + return resolved +} + +private fun ApiBinding.isProvenSingleCollectionParentIdentityAlias(parameterName: String): Boolean { + if (!parameterName.endsWith("Id", ignoreCase = true) || parameterName.length <= 2) return false + val parentResourceId = parameterName.dropLast(2) + val segments = path.substringBefore('?').split('/').filter(String::isNotBlank) + val placeholder = "{$parameterName}" + return segments.indices.any { index -> + segments[index] == placeholder && + index > 0 && + segments[index - 1].sameDynamicResourceAs(parentResourceId) + } +} + +private fun ApiBinding.isDirectChildRouteOf( + readBinding: ApiBinding, + resource: ResourceSpec, + context: Map, +): Boolean { + if (hasSelfResourcePathIdentity(resource)) return false + val readSegments = readBinding.resolvedNativeCollectionPathSegments(context) ?: return false + val actionSegments = resolvedNativeCollectionPathSegments(context) ?: return false + return actionSegments.size == readSegments.size + 1 && + actionSegments.dropLast(1) == readSegments && + path.trimEnd('/').substringAfterLast('/').let { segment -> + '{' !in segment && '}' !in segment + } +} + +private fun ApiBinding.isSameNativeCollectionRouteAs( + other: ApiBinding, + context: Map, +): Boolean = + resolvedNativeCollectionPathSegments(context) == + other.resolvedNativeCollectionPathSegments(context) + +private fun ApiBinding.resolvedNativeCollectionPathSegments( + context: Map, +): List? { + val bindings = resolveNativeCollectionBindings(context) ?: return null + return path.substringBefore('?') + .trimEnd('/') + .split('/') + .map { segment -> + if (segment.startsWith('{') && segment.endsWith('}')) { + val name = segment.substring(1, segment.lastIndex) + bindings[name] ?: return null + } else { + if ('{' in segment || '}' in segment) return null + segment + } + } +} + +private fun ApiBinding.hasSelfResourcePathIdentity(resource: ResourceSpec): Boolean = + (pathParameterNames + requiredPathParameterNames) + .distinct() + .any { name -> name.isSelfResourceIdentityField(resource) } + +private fun String.isSelfResourceSelectionField(resource: ResourceSpec): Boolean { + val normalized = nativeCollectionSemanticId() + if (normalized in GENERIC_COLLECTION_SELECTION_FIELD_IDS) return true + if (!normalized.endsWith("ids") || normalized.length <= 3) return false + return normalized.dropLast(3).sameDynamicResourceAs(resource.id) +} + +private fun String.isSelfResourceIdentityField(resource: ResourceSpec): Boolean { + val normalized = nativeCollectionSemanticId() + if (normalized == "id") return true + if (!normalized.endsWith("id") || normalized.length <= 2) return false + return normalized.dropLast(2).sameDynamicResourceAs(resource.id) +} + +private fun ActionSpec.collectionRouteIdentity(): String = + "${binding.method}:${binding.path}:${binding.bodyFieldNames.sorted()}" + +private data class NativeCollectionReorderArraySchema( + val identityFieldId: String, + val orderFieldId: String, + val identitySchema: NativeCollectionIdentitySchema, + val orderSchema: NativeCollectionIntegerSchema, + val minimumItems: Int?, + val maximumItems: Int?, +) { + companion object { + fun create( + schema: JsonElement, + resource: ResourceSpec, + ): NativeCollectionReorderArraySchema? { + val array = schema as? JsonObject ?: return null + if (!array.isExactNativeCollectionArraySchema()) return null + val item = array["items"] as? JsonObject ?: return null + if (!item.isExactNativeCollectionObjectSchema(NATIVE_COLLECTION_ITEM_OBJECT_SCHEMA_KEYS)) return null + val properties = item["properties"] as? JsonObject ?: return null + if (properties.size != 2) return null + val required = item.requiredNativeCollectionProperties() ?: return null + if (required != properties.keys) return null + val identityCandidates = properties.mapNotNull { (fieldId, fieldSchema) -> + fieldId.takeIf { candidate -> + candidate.nativeCollectionSemanticId() == "id" || + candidate.isSelfResourceIdentityField(resource) + }?.let { + NativeCollectionIdentitySchema.create(fieldSchema) + ?.let { identity -> fieldId to identity } + } + } + val (identityFieldId, identitySchema) = identityCandidates.singleOrNull() ?: return null + val orderCandidates = properties + .filterKeys { fieldId -> fieldId != identityFieldId } + .mapNotNull { (fieldId, fieldSchema) -> + fieldId.takeIf { candidate -> + candidate.nativeCollectionSemanticId() in COLLECTION_ORDER_FIELD_IDS + }?.let { + NativeCollectionIntegerSchema.create(fieldSchema) + ?.let { order -> fieldId to order } + } + } + val (orderFieldId, orderSchema) = orderCandidates.singleOrNull() ?: return null + return NativeCollectionReorderArraySchema( + identityFieldId = identityFieldId, + orderFieldId = orderFieldId, + identitySchema = identitySchema, + orderSchema = orderSchema, + minimumItems = array.nonNegativeInt("minItems"), + maximumItems = array.nonNegativeInt("maxItems"), + ) + } + } +} + +internal data class NativeCollectionIdentityArraySchema( + val identity: NativeCollectionIdentitySchema, + val minimumItems: Int?, + val maximumItems: Int?, +) { + fun jsonValue(values: List): JsonArray { + minimumItems?.let { minimum -> require(values.size >= minimum) } + maximumItems?.let { maximum -> require(values.size <= maximum) } + return JsonArray(values.map(identity::jsonValue)) + } + + companion object { + fun create(schema: JsonElement): NativeCollectionIdentityArraySchema? { + val array = schema as? JsonObject ?: return null + if (!array.isExactNativeCollectionArraySchema()) return null + val identity = NativeCollectionIdentitySchema.create(array["items"]) ?: return null + return NativeCollectionIdentityArraySchema( + identity = identity, + minimumItems = array.nonNegativeInt("minItems"), + maximumItems = array.nonNegativeInt("maxItems"), + ) + } + } +} + +internal sealed interface NativeCollectionIdentitySchema { + fun jsonValue(value: String): JsonPrimitive + + data class IntegerIdentity( + val schema: NativeCollectionIntegerSchema, + ) : NativeCollectionIdentitySchema { + override fun jsonValue(value: String): JsonPrimitive = schema.jsonValue(value) + } + + data class StringIdentity( + val minimumLength: Int?, + val maximumLength: Int?, + val enumValues: Set?, + ) : NativeCollectionIdentitySchema { + override fun jsonValue(value: String): JsonPrimitive { + require(value.isSafeNativeCollectionIdentity()) + minimumLength?.let { minimum -> require(value.length >= minimum) } + maximumLength?.let { maximum -> require(value.length <= maximum) } + enumValues?.let { allowed -> require(value in allowed) } + return JsonPrimitive(value) + } + } + + companion object { + fun create(schema: JsonElement?): NativeCollectionIdentitySchema? { + val scalar = schema as? JsonObject ?: return null + if (!scalar.keys.all(NATIVE_COLLECTION_IDENTITY_SCHEMA_KEYS::contains)) return null + return when (scalar.string("type")) { + "integer" -> NativeCollectionIntegerSchema.create(scalar)?.let(::IntegerIdentity) + "string" -> { + val minimum = scalar.nonNegativeInt("minLength") + val maximum = scalar.nonNegativeInt("maxLength") + if ( + !scalar.hasOptionalString("format") || + !scalar.hasOptionalBoolean("nullable") || + !scalar.hasOptionalNonNegativeInt("minLength") || + !scalar.hasOptionalNonNegativeInt("maxLength") || + (minimum != null && maximum != null && minimum > maximum) + ) { + return null + } + val enumValues = scalar.stringEnumValues() + if ("enum" in scalar && enumValues == null) return null + StringIdentity( + minimumLength = minimum, + maximumLength = maximum, + enumValues = enumValues, + ) + } + else -> null + } + } + } +} + +internal data class NativeCollectionIntegerSchema( + val minimum: Long?, + val maximum: Long?, +) { + fun jsonValue(value: String): JsonPrimitive { + val parsed = value.toLongOrNull() ?: error("A collection identity must be a whole number.") + minimum?.let { lower -> require(parsed >= lower) } + maximum?.let { upper -> require(parsed <= upper) } + return JsonPrimitive(parsed) + } + + fun monotonicValues(count: Int): List? { + if (count <= 0) return null + val lastOffset = (count - 1).toLong() + + fun valuesFrom(start: Long): List? { + if (start > Long.MAX_VALUE - lastOffset) return null + val end = start + lastOffset + if (minimum?.let { start < it } == true || maximum?.let { end > it } == true) return null + return List(count) { index -> start + index.toLong() } + } + + valuesFrom(0L)?.let { return it } + minimum?.let { lower -> valuesFrom(lower)?.let { return it } } + maximum?.let { upper -> + if (upper < Long.MIN_VALUE + lastOffset) return null + valuesFrom(upper - lastOffset)?.let { return it } + } + return null + } + + companion object { + fun create(schema: JsonElement?): NativeCollectionIntegerSchema? { + val scalar = schema as? JsonObject ?: return null + if ( + scalar.string("type") != "integer" || + !scalar.keys.all(NATIVE_COLLECTION_INTEGER_SCHEMA_KEYS::contains) || + !scalar.hasOptionalString("format") || + !scalar.hasOptionalBoolean("nullable") || + !scalar.hasOptionalLong("minimum") || + !scalar.hasOptionalLong("maximum") + ) { + return null + } + val minimum = scalar.long("minimum") + val maximum = scalar.long("maximum") + if (minimum != null && maximum != null && minimum > maximum) return null + return NativeCollectionIntegerSchema(minimum, maximum) + } + } +} + +internal data class NativeCollectionBatchValueSchema( + val kind: NativeCollectionBatchInputKind, + val nullable: Boolean, + val enumValues: List?, + val canonicalValue: (String) -> String, +) { + companion object { + fun create(schema: JsonElement): NativeCollectionBatchValueSchema? { + val property = schema as? JsonObject ?: return null + val nullable = property.boolean("nullable") == true + val stringEnumValues = property.stringEnumValues() + if ("enum" in property && stringEnumValues == null) return null + val enumValues = stringEnumValues?.toList() + return when (property.string("type")) { + "boolean" -> { + if ( + !property.keys.all(NATIVE_COLLECTION_BOOLEAN_SCHEMA_KEYS::contains) || + !property.hasOptionalBoolean("nullable") + ) { + return null + } + NativeCollectionBatchValueSchema( + kind = NativeCollectionBatchInputKind.Boolean, + nullable = nullable, + enumValues = null, + ) { value -> + value.toBooleanStrictOrNull()?.toString() + ?: error("A batch boolean must be true or false.") + } + } + "integer" -> { + val integer = NativeCollectionIntegerSchema.create(property) ?: return null + NativeCollectionBatchValueSchema( + kind = NativeCollectionBatchInputKind.Integer, + nullable = nullable, + enumValues = enumValues, + ) { value -> integer.jsonValue(value).content } + } + "number" -> { + if ( + !property.keys.all(NATIVE_COLLECTION_NUMBER_SCHEMA_KEYS::contains) || + !property.hasOptionalString("format") || + !property.hasOptionalBoolean("nullable") || + !property.hasOptionalDouble("minimum") || + !property.hasOptionalDouble("maximum") + ) { + return null + } + val minimum = property.double("minimum") + val maximum = property.double("maximum") + if (minimum != null && maximum != null && minimum > maximum) return null + NativeCollectionBatchValueSchema( + kind = NativeCollectionBatchInputKind.Decimal, + nullable = nullable, + enumValues = enumValues, + ) { value -> + val parsed = value.toDoubleOrNull()?.takeIf(Double::isFinite) + ?: error("A batch number must be finite.") + minimum?.let { lower -> require(parsed >= lower) } + maximum?.let { upper -> require(parsed <= upper) } + parsed.toString() + } + } + "string" -> { + if ( + !property.keys.all(NATIVE_COLLECTION_STRING_SCHEMA_KEYS::contains) || + !property.hasOptionalString("format") || + !property.hasOptionalBoolean("nullable") || + !property.hasOptionalNonNegativeInt("minLength") || + !property.hasOptionalNonNegativeInt("maxLength") + ) { + return null + } + val minimum = property.nonNegativeInt("minLength") + val maximum = property.nonNegativeInt("maxLength") + if (minimum != null && maximum != null && minimum > maximum) return null + NativeCollectionBatchValueSchema( + kind = NativeCollectionBatchInputKind.String, + nullable = nullable, + enumValues = enumValues, + ) { value -> + require(value.length <= MAX_COLLECTION_INPUT_LENGTH && value.none(Char::isISOControl)) + minimum?.let { lower -> require(value.length >= lower) } + maximum?.let { upper -> require(value.length <= upper) } + enumValues?.let { allowed -> require(value in allowed) } + value + } + } + "array" -> { + val array = NativeCollectionIdentityArraySchema.create(property) ?: return null + val itemType = (property["items"] as? JsonObject)?.string("type") + val kind = when (itemType) { + "integer" -> NativeCollectionBatchInputKind.IntegerArray + "string" -> NativeCollectionBatchInputKind.StringArray + else -> return null + } + NativeCollectionBatchValueSchema( + kind = kind, + nullable = nullable, + enumValues = null, + ) { value -> + val parsed = runCatching { Json.parseToJsonElement(value) }.getOrNull() as? JsonArray + ?: error("A batch array must be a JSON array.") + val scalars = parsed.map { element -> + val primitive = element as? JsonPrimitive + ?: error("A batch array must contain scalar identities.") + if (itemType == "string") { + primitive.takeIf(JsonPrimitive::isString)?.contentOrNull + } else { + primitive.takeUnless(JsonPrimitive::isString)?.contentOrNull + } ?: error("A batch array identity has the wrong type.") + } + require(scalars.distinct().size == scalars.size) + array.jsonValue(scalars).toString() + } + } + else -> null + } + } + } +} + +private fun JsonObject.isExactNativeCollectionArraySchema(): Boolean = + string("type") == "array" && + keys.all(NATIVE_COLLECTION_ARRAY_SCHEMA_KEYS::contains) && + this["items"] != null && + hasOptionalString("format") && + hasOptionalBoolean("nullable") && + hasOptionalBoolean("uniqueItems") && + hasOptionalNonNegativeInt("minItems") && + hasOptionalNonNegativeInt("maxItems") && + ( + nonNegativeInt("minItems") == null || + nonNegativeInt("maxItems") == null || + requireNotNull(nonNegativeInt("minItems")) <= requireNotNull(nonNegativeInt("maxItems")) + ) + +private fun JsonObject.isExactNativeCollectionObjectSchema(allowedKeys: Set): Boolean = + string("type") == "object" && + keys.all(allowedKeys::contains) && + this["properties"] is JsonObject && + ("additionalProperties" !in this || boolean("additionalProperties") == false) + +private fun String.isSafeNativeCollectionBindingName(): Boolean = + length in 1..128 && all { character -> + character.isLetterOrDigit() || character == '_' || character == '-' || character == '.' + } + +private fun String.isSafeNativeCollectionBindingValue(): Boolean = + isNotBlank() && + length <= MAX_COLLECTION_CONTEXT_VALUE_LENGTH && + none { character -> character.isISOControl() || character == '\\' } + +private fun String.isSafeNativeCollectionPathValue(): Boolean = + isSafeNativeCollectionBindingValue() && '/' !in this + +private fun String.isSafeNativeCollectionIdentity(): Boolean = + isNotBlank() && + length <= MAX_COLLECTION_IDENTITY_LENGTH && + none { character -> character.isISOControl() || character == '/' || character == '\\' } + +private fun String.placeholderNames(): Set? { + val scan = substringBefore('?').scanBracedTemplate() + if (scan.malformed) return null + val names = scan.tokens.map { token -> token.name } + if (names.distinct().size != names.size) return null + if (names.any { name -> !name.isSafeNativeCollectionTemplateName() }) return null + return names.toSet() +} + +private fun String.isSafeNativeCollectionTemplateName(): Boolean = + length in 1..128 && + first().isLetter() && + all { character -> + character.isLetterOrDigit() || character == '_' || character == '-' || character == '.' + } + +private fun Confidence.isSafeNativeCollectionConfidence(): Boolean = + this == Confidence.high || this == Confidence.verified + +private fun String.nativeCollectionSemanticId(): String = lowercase().filter(Char::isLetterOrDigit) + +private fun JsonObject.string(name: String): String? = + (this[name] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + +private fun JsonObject.boolean(name: String): Boolean? = + (this[name] as? JsonPrimitive)?.booleanOrNull + +private fun JsonObject.long(name: String): Long? = + (this[name] as? JsonPrimitive)?.longOrNull + +private fun JsonObject.double(name: String): Double? = + (this[name] as? JsonPrimitive)?.doubleOrNull + +private fun JsonObject.nonNegativeInt(name: String): Int? = + long(name)?.takeIf { value -> value in 0..Int.MAX_VALUE }?.toInt() + +private fun JsonObject.hasOptionalString(name: String): Boolean = + name !in this || string(name) != null + +private fun JsonObject.hasOptionalBoolean(name: String): Boolean = + name !in this || boolean(name) != null + +private fun JsonObject.hasOptionalLong(name: String): Boolean = + name !in this || long(name) != null + +private fun JsonObject.hasOptionalDouble(name: String): Boolean = + name !in this || double(name)?.isFinite() == true + +private fun JsonObject.hasOptionalNonNegativeInt(name: String): Boolean = + name !in this || nonNegativeInt(name) != null + +private fun JsonObject.stringEnumValues(): Set? { + val values = this["enum"] ?: return null + val array = values as? JsonArray ?: return null + val strings = array.mapNotNull { element -> + (element as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + } + return strings.toSet().takeIf { strings.size == array.size && strings.distinct().size == strings.size } +} + +private val COLLECTION_COMMAND_METHODS = setOf(HttpMethod.POST, HttpMethod.DELETE) +private val COLLECTION_BODY_COMMAND_METHODS = setOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH) +private val GENERIC_COLLECTION_SELECTION_FIELD_IDS = setOf("ids", "recordids", "selectedids") +private val COLLECTION_ORDER_FIELD_IDS = setOf( + "index", + "order", + "ordinal", + "position", + "rank", + "sequence", + "sortorder", +) +private val NATIVE_COLLECTION_BODY_SCHEMA_KEYS = setOf( + "type", + "properties", + "required", + "additionalProperties", + "description", + "title", +) +private val NATIVE_COLLECTION_ITEM_OBJECT_SCHEMA_KEYS = setOf( + "type", + "properties", + "required", + "additionalProperties", + "description", + "title", +) +private val NATIVE_COLLECTION_ARRAY_SCHEMA_KEYS = setOf( + "type", + "items", + "format", + "nullable", + "default", + "description", + "title", + "minItems", + "maxItems", + "uniqueItems", +) +private val NATIVE_COLLECTION_IDENTITY_SCHEMA_KEYS = setOf( + "type", + "format", + "nullable", + "default", + "description", + "title", + "enum", + "minimum", + "maximum", + "minLength", + "maxLength", +) +private val NATIVE_COLLECTION_INTEGER_SCHEMA_KEYS = setOf( + "type", + "format", + "nullable", + "default", + "description", + "title", + "minimum", + "maximum", +) +private val NATIVE_COLLECTION_BOOLEAN_SCHEMA_KEYS = setOf( + "type", + "nullable", + "default", + "description", + "title", +) +private val NATIVE_COLLECTION_NUMBER_SCHEMA_KEYS = setOf( + "type", + "format", + "nullable", + "default", + "description", + "title", + "minimum", + "maximum", +) +private val NATIVE_COLLECTION_STRING_SCHEMA_KEYS = setOf( + "type", + "format", + "nullable", + "default", + "description", + "title", + "enum", + "minLength", + "maxLength", +) +private const val MAX_COLLECTION_CONTEXT_VALUES = 64 +private const val MAX_COLLECTION_CONTEXT_VALUE_LENGTH = 4_096 +private const val MAX_COLLECTION_CONTEXT_RECORDS = 5_000 +private const val MAX_COLLECTION_REORDER_RECORDS = 500 +private const val MAX_COLLECTION_BATCH_SELECTION = 256 +private const val MAX_COLLECTION_IDENTITY_LENGTH = 256 +private const val MAX_COLLECTION_INPUT_LENGTH = 8_192 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt index 25405e9a..79fe2ab0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt @@ -25,6 +25,8 @@ internal data class NativeBoardLane( val records: List, /** Stable parent/lane values retained for exact contract-backed card creation. */ val contextValues: Map = emptyMap(), + /** False when lane context was retained for display but is ambiguous for mutations. */ + val actionBindingProvenanceValid: Boolean = true, ) internal data class NativeChartPoint( @@ -117,11 +119,7 @@ internal fun nativeCellEditPlan( val cell = projection.cellsByRecord[record.id]?.get(field.id) ?: return null if (cell.valueShape == NativeCellValueShape.complex) return null if (cell.valueShape == NativeCellValueShape.nullValue && cell.declaredKind == null) return null - val preserved = buildMap { - record.values.forEach { (key, value) -> if (value != null) put(key, value) } - if (record.canResolveUnsafeActionIdentity()) putIfAbsent("id", record.id) - putAll(cell.contextValues) - } + val preserved = record.safeActionBindingValues(contextualValues = cell.contextValues) ?: return null val candidates = schema.actions.mapNotNull { action -> if (!action.resourceId.sameTabularResourceShape(resource.id) || action.intent != ActionIntent.update || action.risk != ActionRisk.mutating || action.binding.method !in setOf(HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.POST) @@ -142,9 +140,10 @@ internal fun nativeCellEditPlan( action.binding.bodyFieldNames.singleOrNull { it.semanticCellFieldId() in CELL_VALUE_BODY_FIELD_IDS } else -> return@mapNotNull null } ?: return@mapNotNull null + if (action.binding.hasFlatCellActionCollision(valueField)) return@mapNotNull null val available = preserved.keys + valueField - if (!action.binding.requiredPathParameterNames.all { it.canResolveFrom(available) } || - !action.binding.requiredQueryParameterNames.all { it.canResolveFrom(available) } || + if (!action.binding.requiredPathParameterNames.all { it.canResolveFrom(available, action.resourceId) } || + !action.binding.requiredQueryParameterNames.all { it.canResolveFrom(available, action.resourceId) } || !action.binding.requiredBodyFieldNames.all { it in available } ) return@mapNotNull null val rank = when { @@ -187,9 +186,29 @@ internal fun validateNativeCellEdit(field: FieldSpec, value: String): String? { data class NativeDatasetContext( val parentResourceId: String? = null, val parentRecord: NativeRecord? = null, + /** + * Exact values already resolved by descriptor-driven navigation. + * + * These values let an empty child collection still expose safe create actions without + * guessing a parent identity from a route name or an application-specific convention. + */ + val bindingValues: Map = emptyMap(), val relatedRecords: Map> = emptyMap(), + val relatedRecordPaging: Map = emptyMap(), ) +data class NativeRelatedRecordPaging( + val loading: Boolean = false, + val error: String? = null, + val discardedChoiceCount: Int = 0, + val loadMore: (() -> Unit)? = null, + val returnToFirstPage: (() -> Unit)? = null, +) { + init { + require(discardedChoiceCount >= 0) + } +} + internal data class HydratedNativeDataset( val resource: ResourceSpec, val records: List, @@ -278,15 +297,14 @@ internal fun expandNestedBoardDataset( val laneTitle = laneRecord.presentationValue("title") ?: laneRecord.presentationValue("name") ?: laneRecord.id + val laneContext = laneRecord.safeActionBindingValues() NativeBoardLane( key = laneRecord.id, title = laneTitle, records = childRecords.filter { (child, _) -> child.laneRecord.id == laneRecord.id } .map { (_, record) -> record }, - contextValues = laneRecord.actionBindingValues(allowUnsafeIdentity = true) + mapOf( - "laneId" to laneRecord.id, - "stackId" to laneRecord.id, - ), + contextValues = laneContext.orEmpty(), + actionBindingProvenanceValid = laneContext != null, ) } return HydratedNativeDataset( @@ -330,6 +348,18 @@ private fun NestedBoardChild.toNativeBoardRecord( val inheritedContext = laneRecord.values.filterKeys { key -> key.semanticFieldId().endsWith("id") && key.semanticFieldId() != "id" } + val parentBindingContext = laneRecord.safeActionBindingValues() + ?.filterKeys { key -> + key.semanticFieldId().endsWith("id") && key.semanticFieldId() != "id" + } + val childBindingContext = parentBindingContext?.let { parentValues -> + safeActionBindingValues( + parentValues, + mapOf( + laneField to (scalarValues[laneField] ?: laneRecord.id), + ), + ) + } val nestedStructures = value.entries.mapNotNull { entry -> entry.value.takeIf { nested -> nested !is NativeStructuredValue.Scalar }?.let { nested -> entry.key to nested } }.toMap() @@ -343,6 +373,8 @@ private fun NestedBoardChild.toNativeBoardRecord( ephemeralFields = value.entries.map { entry -> entry.toNestedBoardField(declaredWritableIds) }, actionSafeIdentity = identityEntry?.key?.semanticFieldId() in declaredIdentityIds, structuredValues = nestedStructures, + bindingContext = childBindingContext.orEmpty(), + actionBindingProvenanceValid = childBindingContext != null, ) } @@ -550,12 +582,15 @@ internal fun nativeBoardLanes( ?.value ?.takeIf { it.boardLanePriority() > 0 && records.any { record -> !record.values[it.id].isNullOrBlank() } } ?: return listOf( - NativeBoardLane( + records.sharedBoardLaneContext().let { sharedContext -> + NativeBoardLane( "all", resource.name, sortBoardRecords(resource, records), - records.firstOrNull()?.actionBindingValues(allowUnsafeIdentity = true).orEmpty(), - ), + sharedContext.orEmpty(), + actionBindingProvenanceValid = sharedContext != null, + ) + }, ) val orderedKeys = records.map { it.values[laneField.id].orEmpty().trim().ifBlank { UNASSIGNED_LANE } }.distinct() @@ -563,6 +598,7 @@ internal fun nativeBoardLanes( val laneRecords = records.filter { it.values[laneField.id].orEmpty().trim().ifBlank { UNASSIGNED_LANE } == key } + val sharedContext = laneRecords.sharedBoardLaneContext(laneField.id) NativeBoardLane( key = key, title = laneRecords.firstNotNullOfOrNull { it.displayValues[laneField.id] } @@ -571,11 +607,36 @@ internal fun nativeBoardLanes( resource, laneRecords, ), - contextValues = laneRecords.firstOrNull()?.actionBindingValues(allowUnsafeIdentity = true).orEmpty(), + contextValues = sharedContext.orEmpty(), + actionBindingProvenanceValid = sharedContext != null, ) } } +private fun List.sharedBoardLaneContext(laneFieldId: String? = null): Map? { + if (isEmpty()) return emptyMap() + val resolved = map { record -> + record.safeActionBindingValues() ?: return null + } + val candidateSemantics = resolved.first().keys + .map(String::semanticFieldId) + .filterTo(mutableSetOf()) { semantic -> + semantic != "id" && (semantic.endsWith("id") || semantic == laneFieldId?.semanticFieldId()) + } + val sharedSemantics = candidateSemantics.filterTo(mutableSetOf()) { semantic -> + resolved.all { values -> + values.filterKeys { key -> key.semanticFieldId() == semantic } + .values + .distinct() + .singleOrNull() != null + } && + resolved.mapNotNull { values -> + values.entries.firstOrNull { (key, _) -> key.semanticFieldId() == semantic }?.value + }.distinct().size == 1 + } + return resolved.first().filterKeys { key -> key.semanticFieldId() in sharedSemantics } +} + /** * Finds a useful aggregate and optional categorical breakdown for arbitrary record collections. * Only declared numeric fields with successfully parsed values participate, so identifiers and @@ -695,6 +756,7 @@ private fun FieldSpec.measurePriority(): Int { "quantity", "count" -> 500 else -> 0 } + if (semantic <= 0) return 0 return semantic + when (kind) { FieldKind.currency -> 300 FieldKind.decimal -> 200 @@ -828,8 +890,14 @@ private fun List.mostSpecificCellKind(): FieldKind = when { private fun projectedCellId(sourceFieldId: String, key: String): String = "$sourceFieldId.$key" -private fun String.canResolveFrom(available: Set): Boolean = - this in available || length > 2 && endsWith("Id", ignoreCase = true) && "id" in available +private fun String.canResolveFrom(available: Set, actionResourceId: String): Boolean { + val normalizedAvailable = available.mapTo(mutableSetOf(), String::semanticCellFieldId) + val normalizedName = semanticCellFieldId() + if (normalizedName in normalizedAvailable) return true + if ("id" !in normalizedAvailable || !normalizedName.endsWith("id")) return false + return normalizedName in TABULAR_RECORD_IDENTITY_FIELD_IDS || + normalizedName.removeSuffix("id").sameTabularResourceShape(actionResourceId) +} /** * A projected cell can use a generic payload name such as `data` only when the route is explicitly @@ -840,6 +908,13 @@ private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.declaresCellId (pathParameterNames + queryParameterNames + bodyFieldNames) .any { it.semanticCellFieldId() in CELL_IDENTITY_FIELD_IDS } +private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.hasFlatCellActionCollision( + submittedBodyField: String, +): Boolean { + val submitted = submittedBodyField.semanticCellFieldId() + return (pathParameterNames + queryParameterNames).any { it.semanticCellFieldId() == submitted } +} + private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.bodyFieldExplicitlyAcceptsObject( fieldName: String, ): Boolean { @@ -1027,6 +1102,7 @@ private val CELL_IDENTITY_FIELD_IDS = setOf( "cellid", ) private val CELL_VALUE_BODY_FIELD_IDS = setOf("data", "content", "payload", "cellvalue") +private val TABULAR_RECORD_IDENTITY_FIELD_IDS = setOf("id", "rowid", "recordid", "itemid", "entryid") private val SUPPORTED_INLINE_BODY_TYPES = setOf("application/json", "application/x-www-form-urlencoded") private val IDENTITY_FIELD_IDS = setOf("id", "uuid", "token") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecovery.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecovery.kt new file mode 100644 index 00000000..d97cf04e --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecovery.kt @@ -0,0 +1,230 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +internal enum class NativeFormMutationKind { + Create, + Update, + Command, + CollectionBatch, +} + +internal enum class NativeFormMutationRecoveryPhase { + InFlight, + AwaitingReconciliation, +} + +/** + * Stable contract identity for one generic form or collection-batch submission. + * + * The owner deliberately contains only schema identities and the canonical target record. It is + * independent of any installed app vocabulary and is safe to persist in saved state. + */ +internal data class NativeFormMutationRecoveryOwner( + val appId: String, + val viewId: String, + val actionId: String, + val resourceId: String, + val kind: NativeFormMutationKind, + val recordId: String?, +) { + init { + listOf(appId, viewId, actionId, resourceId).forEach { value -> + require(value.isNotBlank() && value.length <= NATIVE_FORM_RECOVERY_ID_LIMIT) + } + require(recordId?.length?.let { length -> length <= NATIVE_FORM_RECOVERY_ID_LIMIT } != false) + require( + kind !in setOf(NativeFormMutationKind.Create, NativeFormMutationKind.CollectionBatch) || + recordId == null, + ) + require( + kind !in setOf(NativeFormMutationKind.Update, NativeFormMutationKind.Command) || + !recordId.isNullOrBlank(), + ) + } +} + +internal fun nativeCollectionBatchMutationRecoveryOwner( + appId: String, + viewId: String, + actionId: String, + resourceId: String, +): NativeFormMutationRecoveryOwner? = runCatching { + NativeFormMutationRecoveryOwner( + appId = appId, + viewId = viewId, + actionId = actionId, + resourceId = resourceId, + kind = NativeFormMutationKind.CollectionBatch, + recordId = null, + ) +}.getOrNull() + +internal data class NativeFormMutationRecoveryState( + val owner: NativeFormMutationRecoveryOwner, + val phase: NativeFormMutationRecoveryPhase, + val reconciliationGeneration: Int, +) { + init { + require(reconciliationGeneration >= 0) + } + + val blocksSubmission: Boolean + get() = true + + val authoritativeReconciliationActionId: String? + get() = owner.actionId.takeIf { + phase == NativeFormMutationRecoveryPhase.AwaitingReconciliation + } + + fun afterLifecycleRestore( + ownerStillExecuting: Boolean, + currentReconciliationGeneration: Int = reconciliationGeneration, + ): NativeFormMutationRecoveryState = + if (phase == NativeFormMutationRecoveryPhase.InFlight && !ownerStillExecuting) { + copy( + phase = NativeFormMutationRecoveryPhase.AwaitingReconciliation, + reconciliationGeneration = maxOf( + reconciliationGeneration, + currentReconciliationGeneration, + ), + ) + } else { + this + } + + fun afterExecutionResult( + result: NativeActionExecutionResult, + currentReconciliationGeneration: Int = reconciliationGeneration, + ): NativeFormMutationRecoveryState? = + when (result) { + is NativeActionExecutionResult.Success -> null + is NativeActionExecutionResult.Failure -> when (result.outcome) { + NativeActionFailureOutcome.Rejected -> null + NativeActionFailureOutcome.Unknown -> + copy( + phase = NativeFormMutationRecoveryPhase.AwaitingReconciliation, + reconciliationGeneration = maxOf( + reconciliationGeneration, + currentReconciliationGeneration, + ), + ) + } + } + + fun afterAuthoritativeReconciliation( + currentGeneration: Int, + ): NativeFormMutationRecoveryState? = + takeUnless { + phase == NativeFormMutationRecoveryPhase.AwaitingReconciliation && + currentGeneration > reconciliationGeneration + } +} + +internal fun NativeFormMutationRecoveryOwner.begin( + reconciliationGeneration: Int, +): NativeFormMutationRecoveryState = NativeFormMutationRecoveryState( + owner = this, + phase = NativeFormMutationRecoveryPhase.InFlight, + reconciliationGeneration = reconciliationGeneration, +) + +internal fun nativeFormMutationRecoveryOwner( + appId: String, + viewId: String, + actionId: String, + resourceId: String, + intent: ActionIntent, + recordId: String?, +): NativeFormMutationRecoveryOwner? { + val kind = when (intent) { + ActionIntent.create -> NativeFormMutationKind.Create + ActionIntent.update -> NativeFormMutationKind.Update + ActionIntent.execute -> NativeFormMutationKind.Command + else -> return null + } + return runCatching { + NativeFormMutationRecoveryOwner( + appId = appId, + viewId = viewId, + actionId = actionId, + resourceId = resourceId, + kind = kind, + recordId = recordId, + ) + }.getOrNull() +} + +internal fun NativeFormMutationRecoveryState.encode(): String? { + val encoded = JsonArray( + listOf( + JsonPrimitive(owner.appId), + JsonPrimitive(owner.viewId), + JsonPrimitive(owner.actionId), + JsonPrimitive(owner.resourceId), + JsonPrimitive(owner.kind.name), + owner.recordId?.let(::JsonPrimitive) ?: JsonNull, + JsonPrimitive(phase.name), + JsonPrimitive(reconciliationGeneration), + ), + ).toString() + return encoded.takeIf { it.length <= NATIVE_FORM_RECOVERY_TOKEN_LIMIT } +} + +internal fun decodeNativeFormMutationRecoveryState( + encoded: String?, +): NativeFormMutationRecoveryState? { + if (encoded == null || encoded.length > NATIVE_FORM_RECOVERY_TOKEN_LIMIT) return null + val parts = runCatching { Json.parseToJsonElement(encoded) }.getOrNull() as? JsonArray ?: return null + if (parts.size != 8) return null + fun string(index: Int): String? = + (parts[index] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + + val recordId = when (val record = parts[5]) { + JsonNull -> null + is JsonPrimitive -> record.takeIf(JsonPrimitive::isString)?.contentOrNull ?: return null + else -> return null + } + val generation = (parts[7] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.contentOrNull + ?.toIntOrNull() + ?: return null + return runCatching { + NativeFormMutationRecoveryState( + owner = NativeFormMutationRecoveryOwner( + appId = string(0) ?: return null, + viewId = string(1) ?: return null, + actionId = string(2) ?: return null, + resourceId = string(3) ?: return null, + kind = NativeFormMutationKind.entries.firstOrNull { kind -> kind.name == string(4) } + ?: return null, + recordId = recordId, + ), + phase = NativeFormMutationRecoveryPhase.entries.firstOrNull { phase -> + phase.name == string(6) + } ?: return null, + reconciliationGeneration = generation, + ) + }.getOrNull() +} + +internal fun resolveNativeFormMutationRecoveryState( + encoded: String?, + currentReconciliationGeneration: Int, + ownerStillExecuting: (NativeFormMutationRecoveryOwner) -> Boolean, +): NativeFormMutationRecoveryState? { + val saved = decodeNativeFormMutationRecoveryState(encoded) ?: return null + return saved.afterLifecycleRestore( + ownerStillExecuting = ownerStillExecuting(saved.owner), + currentReconciliationGeneration = currentReconciliationGeneration, + ).afterAuthoritativeReconciliation(currentReconciliationGeneration) +} + +private const val NATIVE_FORM_RECOVERY_ID_LIMIT = 256 +private const val NATIVE_FORM_RECOVERY_TOKEN_LIMIT = 3 * 1024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt index 451ec028..769aef43 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt @@ -152,7 +152,7 @@ private fun resolveMailActionTarget( * observed `id`, UUID, name, or protocol identifiers are never accepted. */ private fun NativeRecord.mailActionBindingValues(action: ActionSpec): Map? { - if (actionSafeIdentity) return actionBindingValues(allowUnsafeIdentity = true) + if (actionSafeIdentity) return safeActionBindingValues(allowUnsafeIdentity = true) if (!canResolveUnsafeActionIdentity()) return null if (action.evidence.none { evidence -> evidence.source in setOf(EvidenceSource.verifiedAppPackage, EvidenceSource.appStoreLinkedSourceTag) @@ -167,7 +167,8 @@ private fun NativeRecord.mailActionBindingValues(action: ActionSpec): Map value.toLongOrNull()?.let { it > 0 } == true } ?: return null - return actionBindingValues(allowUnsafeIdentity = true) + ("id" to databaseId) + val provenanceValues = safeActionBindingValues(allowUnsafeIdentity = false) ?: return null + return safeActionBindingValues(provenanceValues, mapOf("id" to databaseId)) } private data class RankedMailPlan( @@ -184,6 +185,7 @@ private fun ActionSpec.toBooleanStateCandidate( ): RankedMailPlan? { val matchingFields = binding.bodyFieldNames.filter { it.mailSemanticId() in directFieldNames } val fieldName = matchingFields.singleOrNull() ?: return null + if (binding.hasFlatMailActionCollision(fieldName)) return null val semantic = "$id $label ${binding.operationId} ${binding.path} $fieldName".mailSemanticWords() if (semantic.none { it in MAIL_STATE_ACTION_WORDS }) return null val wireState = when (fieldName.mailSemanticId()) { @@ -191,7 +193,7 @@ private fun ActionSpec.toBooleanStateCandidate( else -> desiredState.toString() } val available = bindingValues.keys + fieldName - if (!binding.canResolveRequiredMailValues(available)) return null + if (!binding.canResolveRequiredMailValues(available, resourceId)) return null val rank = when { "mark" in semantic -> 500 "set" in semantic -> 450 @@ -218,8 +220,9 @@ private fun ActionSpec.toArchiveCandidate( if (semantic.none { it in MAIL_MOVE_ACTION_WORDS }) return null val destinationFields = binding.bodyFieldNames.filter { it.mailSemanticId() in MAIL_DESTINATION_FIELDS } val destinationField = destinationFields.singleOrNull() ?: return null + if (binding.hasFlatMailActionCollision(destinationField)) return null val available = bindingValues.keys + destinationField - if (!binding.canResolveRequiredMailValues(available)) return null + if (!binding.canResolveRequiredMailValues(available, resourceId)) return null val rank = when { "archive" in semantic -> 600 "move" in semantic -> 550 @@ -248,9 +251,10 @@ private fun ActionSpec.toFlagsMapStateCandidate( .filter { field -> field.mailSemanticId() in setOf("flags", "flagchanges", "states") } .singleOrNull() ?: return null + if (binding.hasFlatMailActionCollision(fieldName)) return null val semantic = "$id $label ${binding.operationId} ${binding.path} $fieldName".mailSemanticWords() if (semantic.none { it in MAIL_STATE_ACTION_WORDS } || "flags" !in semantic && "flag" !in semantic) return null - if (!binding.canResolveRequiredMailValues(bindingValues.keys + fieldName)) return null + if (!binding.canResolveRequiredMailValues(bindingValues.keys + fieldName, resourceId)) return null return RankedMailPlan( plan = NativeMailMessageActionPlan( kind = kind, @@ -268,7 +272,7 @@ private fun ActionSpec.toDeleteCandidate(bindingValues: Map): Ra } val semantic = "$id $label ${binding.operationId} ${binding.path}".mailSemanticWords() if (semantic.none { it in MAIL_DELETE_ACTION_WORDS }) return null - if (!binding.canResolveRequiredMailValues(bindingValues.keys)) return null + if (!binding.canResolveRequiredMailValues(bindingValues.keys, resourceId)) return null return RankedMailPlan( plan = NativeMailMessageActionPlan( kind = NativeMailMessageActionKind.Delete, @@ -284,6 +288,13 @@ private fun ActionSpec.toDeleteCandidate(bindingValues: Map): Ra ) } +private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.hasFlatMailActionCollision( + submittedBodyField: String, +): Boolean { + val submitted = submittedBodyField.mailSemanticId() + return (pathParameterNames + queryParameterNames).any { it.mailSemanticId() == submitted } +} + private fun NativeDatasetContext.uniqueArchiveMailboxId( schema: NativeAppSchema, message: NativeRecord, @@ -342,11 +353,14 @@ private fun NativeRecord.isArchiveMailbox(): Boolean { private fun dev.obiente.nextcloudnative.nativeui.model.ApiBinding.canResolveRequiredMailValues( available: Set, + actionResourceId: String, ): Boolean { val normalized = available.mapTo(mutableSetOf(), String::mailSemanticId) fun String.resolvable(): Boolean { val name = mailSemanticId() - return name in normalized || name.endsWith("id") && "id" in normalized + if (name in normalized) return true + if ("id" !in normalized || !name.endsWith("id")) return false + return name.removeSuffix("id").sameMailActionResource(actionResourceId) } return requiredPathParameterNames.all(String::resolvable) && requiredQueryParameterNames.all(String::resolvable) && diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActions.kt new file mode 100644 index 00000000..8fa899eb --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActions.kt @@ -0,0 +1,1757 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_LIST_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DynamicIntegerArrayParseResult +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputRow +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.isExactDynamicIntegerArraySchema +import dev.obiente.nextcloudnative.nativeui.model.parseDynamicIntegerArrayInput +import dev.obiente.nextcloudnative.nativeui.model.repeatableObjectInputSpec +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs +import dev.obiente.nextcloudnative.template.scanBracedTemplate +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +internal data class NativeRecordActionCapabilities( + val create: NativeRecordFormActionPlan?, + val edit: NativeRecordFormActionPlan?, + val delete: NativeRecordDeleteActionPlan?, + val completion: NativeRecordCompletionActionPlan?, + val commands: List, + val commandForms: List, +) + +internal data class NativeRecordAuthorityContext( + val parentResource: ResourceSpec, + val parentRecord: NativeRecord, +) + +internal fun NativeDatasetContext.nativeRecordAuthorityContext( + schema: NativeAppSchema, +): NativeRecordAuthorityContext? { + val resourceId = parentResourceId ?: return null + val record = parentRecord + ?.takeIf { it.actionBindingProvenanceValid } + ?: return null + val resource = schema.resources.singleOrNull { candidate -> candidate.id == resourceId } + ?: return null + return NativeRecordAuthorityContext(resource, record) +} + +internal enum class NativeRecordFormActionKind { + Create, + Edit, +} + +internal data class NativeRecordFormActionPlan( + val kind: NativeRecordFormActionKind, + val action: ActionSpec, + val fields: List, + val initialValues: Map, + private val bindingValues: Map, +) { + fun request( + inputValues: Map, + confirmed: Boolean = false, + ): NativeActionRequest.Submit { + require(kind != NativeRecordFormActionKind.Edit || action.binding.method != HttpMethod.PUT) { + "Replacement PUT record edits require authoritative completeness and precondition evidence." + } + require(!action.requiresConfirmation || confirmed) { + "This action requires explicit confirmation." + } + val declaredFields = fields.associateBy(FieldSpec::id) + require(inputValues.keys.all(declaredFields::containsKey)) { + "The action contains an undeclared input." + } + fields.forEach { field -> + val supplied = inputValues[field.id] + require(!field.required || supplied?.trim()?.isNotEmpty() == true) { + "${field.label} is required." + } + supplied?.let { value -> + require(field.acceptsRecordActionValue(value)) { + "${field.label} has an invalid value." + } + } + } + return NativeActionRequest.Submit( + action = action, + values = bindingValues + inputValues.mapValues { (_, value) -> value.trim() }, + confirmed = confirmed, + ) + } + + fun requestWithStructuredInput( + scalarInputValues: Map, + repeatableObjectValues: Map>, + confirmed: Boolean = false, + ): NativeActionRequest.Submit = request( + inputValues = fields.encodeRepeatableObjectInput( + scalarInputValues = scalarInputValues, + repeatableObjectValues = repeatableObjectValues, + ), + confirmed = confirmed, + ) +} + +internal data class NativeRecordDeleteActionPlan( + val action: ActionSpec, + private val bindingValues: Map, +) { + fun request(confirmed: Boolean): NativeActionRequest.Submit { + require(confirmed) { "Delete requires explicit confirmation." } + return NativeActionRequest.Submit( + action = action, + values = bindingValues, + confirmed = true, + ) + } +} + +internal data class NativeRecordCommandActionPlan( + val action: ActionSpec, + val effect: ActionEffect, + private val bindingValues: Map, +) { + val requiresConfirmation: Boolean + get() = action.requiresConfirmation + + fun request(confirmed: Boolean = false): NativeActionRequest.Submit { + require(!requiresConfirmation || confirmed) { + "This action requires explicit confirmation." + } + return NativeActionRequest.Submit( + action = action, + values = bindingValues, + confirmed = confirmed, + ) + } +} + +internal data class NativeRecordCommandFormActionPlan( + val action: ActionSpec, + val effect: ActionEffect, + val fields: List, + val initialValues: Map, + private val bindingValues: Map, + private val fieldSchemas: Map, +) { + val requiresConfirmation: Boolean + get() = action.requiresConfirmation + + fun request( + inputValues: Map, + confirmed: Boolean = false, + ): NativeActionRequest.Submit { + require(!requiresConfirmation || confirmed) { + "This action requires explicit confirmation." + } + val declaredFields = fields.associateBy(FieldSpec::id) + require(inputValues.keys.all(declaredFields::containsKey)) { + "The action contains an undeclared input." + } + fields.forEach { field -> + val supplied = inputValues[field.id] + require(!field.required || supplied?.trim()?.isNotEmpty() == true) { + "${field.label} is required." + } + supplied?.let { value -> + require(field.acceptsRecordCommandFormValue(value, fieldSchemas[field.id])) { + "${field.label} has an invalid value." + } + } + } + return NativeActionRequest.Submit( + action = action, + values = bindingValues + inputValues.mapValues { (_, value) -> value.trim() }, + confirmed = confirmed, + ) + } + + fun requestWithStructuredInput( + scalarInputValues: Map, + repeatableObjectValues: Map>, + confirmed: Boolean = false, + ): NativeActionRequest.Submit = request( + inputValues = fields.encodeRepeatableObjectInput( + scalarInputValues = scalarInputValues, + repeatableObjectValues = repeatableObjectValues, + ), + confirmed = confirmed, + ) +} + +internal data class NativeRecordCompletionActionPlan( + val action: ActionSpec, + val field: FieldSpec, + val currentlyCompleted: Boolean, + val completedWireValue: String, + val incompleteWireValue: String, + val kind: NativeRecordCompletionActionKind, + private val bindingValues: Map, +) { + fun request( + completed: Boolean, + confirmed: Boolean = false, + ): NativeActionRequest.Submit { + require(!action.requiresConfirmation || confirmed) { + "This action requires explicit confirmation." + } + if (kind == NativeRecordCompletionActionKind.Toggle) { + require(completed != currentlyCompleted) { + "The requested completion state is already active." + } + } + return NativeActionRequest.Submit( + action = action, + values = if (kind == NativeRecordCompletionActionKind.Toggle) { + bindingValues + } else { + bindingValues + ( + field.id to if (completed) completedWireValue else incompleteWireValue + ) + }, + confirmed = confirmed, + ) + } +} + +internal enum class NativeRecordCompletionActionKind { + SetValue, + Toggle, +} + +/** + * Plans ordinary record mutations from the verified schema and the active navigation context. + * + * No app identity, route vocabulary, or product-specific resource name participates in selection. + * A capability is withheld when more than one action is equally valid or any required identity + * cannot be bound exactly. Completion is deliberately narrower than a named execute action: it is + * exposed only when one declared update field can represent both completed and incomplete states. + */ +internal fun nativeRecordActions( + schema: NativeAppSchema, + resource: ResourceSpec, + record: NativeRecord? = null, + navigationContext: Map = emptyMap(), + authorityContext: NativeRecordAuthorityContext? = null, +): NativeRecordActionCapabilities { + val authoritativeResource = schema.resources + .filter { candidate -> candidate.id == resource.id } + .singleOrNull() + ?: return emptyNativeRecordActionCapabilities() + if (record?.actionBindingProvenanceValid == false) { + return emptyNativeRecordActionCapabilities() + } + val createSources = nativeRecordBindingSources( + resource = authoritativeResource, + record = record, + navigationContext = navigationContext, + includeRecordValues = false, + ) ?: return emptyNativeRecordActionCapabilities() + val recordSources = nativeRecordBindingSources( + resource = authoritativeResource, + record = record, + navigationContext = navigationContext, + includeRecordValues = true, + ) ?: return emptyNativeRecordActionCapabilities() + val resourceActions = schema.actions.filter { action -> + action.resourceId == authoritativeResource.id && + action.confidence.isSafeRecordActionConfidence() + } + val permittedRecordActions = if (record == null) { + resourceActions + } else { + resourceActions.filter { action -> + record.permits(action, authoritativeResource, authorityContext) + } + } + val permittedRecordCommandFormActions = if (record == null) { + emptyList() + } else { + schema.actions.filter { action -> + action.confidence.isSafeRecordActionConfidence() && + action.binding.singleRecordPathIdentityName(authoritativeResource) != null && + record.permits(action, authoritativeResource, authorityContext) + } + } + val verifiedRelationshipFieldIds = schema.relationships + .asSequence() + .filter { relationship -> + // High-confidence inferred relationships remain useful to relation pickers, but only + // verified relationship evidence may hide and silently bind a write field. + relationship.confidence == Confidence.verified && + relationship.childResourceId.sameDynamicResourceAs(authoritativeResource.id) + } + .mapNotNull { relationship -> relationship.childFieldId } + .toSet() + val contextualSources = safeActionBindingValues( + navigationContext, + record?.bindingContext.orEmpty(), + ) ?: return emptyNativeRecordActionCapabilities() + val completionSemantics = authoritativeResource.uniqueRecordCompletionSemantics( + allowReadOnly = permittedRecordActions.any { action -> action.effect == ActionEffect.toggle }, + ) + + val create = resourceActions.mapNotNull { action -> + action.recordFormPlan( + kind = NativeRecordFormActionKind.Create, + resource = authoritativeResource, + record = null, + sources = createSources, + contextualSources = contextualSources, + relationshipFieldIds = verifiedRelationshipFieldIds, + completionFieldId = completionSemantics?.field?.id, + ) + }.singleOrNull() + + val edit = if (record?.actionSafeIdentity == true && !record.hasNativeDeletedState()) { + permittedRecordActions.mapNotNull { action -> + action.recordFormPlan( + kind = NativeRecordFormActionKind.Edit, + resource = authoritativeResource, + record = record, + sources = recordSources, + contextualSources = contextualSources, + relationshipFieldIds = verifiedRelationshipFieldIds, + completionFieldId = completionSemantics?.field?.id, + ) + }.singleOrNull() + } else { + null + } + + val delete = if (record?.actionSafeIdentity == true && !record.hasNativeDeletedState()) { + permittedRecordActions.mapNotNull { action -> + action.recordDeletePlan(authoritativeResource, record, recordSources) + }.singleOrNull() + } else { + null + } + + val completion = if ( + record?.actionSafeIdentity == true && + !record.hasNativeDeletedState() && + completionSemantics != null + ) { + permittedRecordActions.mapNotNull { action -> + action.recordCompletionPlan( + resource = authoritativeResource, + record = record, + sources = recordSources, + semantics = completionSemantics, + ) + }.singleOrNull() + } else { + null + } + val commands = if (record?.actionSafeIdentity == true) { + permittedRecordActions + .mapNotNull { action -> + action.recordCommandPlan( + resource = authoritativeResource, + record = record, + sources = recordSources, + ) + } + .groupBy(NativeRecordCommandActionPlan::effect) + .mapNotNull { (_, candidates) -> candidates.singleOrNull() } + .sortedBy { plan -> RECORD_COMMAND_EFFECT_ORDER.indexOf(plan.effect) } + } else { + emptyList() + } + val commandForms = if (record?.actionSafeIdentity == true) { + permittedRecordCommandFormActions + .mapNotNull { action -> + val fieldResource = schema.resources.singleOrNull { candidate -> + candidate.id == action.resourceId + } ?: return@mapNotNull null + action.recordCommandFormPlan( + identityResource = authoritativeResource, + fieldResource = fieldResource, + record = record, + sources = recordSources, + ) + } + .groupBy(NativeRecordCommandFormActionPlan::effect) + .mapNotNull { (_, candidates) -> candidates.singleOrNull() } + .sortedBy { plan -> RECORD_COMMAND_FORM_EFFECT_ORDER.indexOf(plan.effect) } + } else { + emptyList() + } + + return NativeRecordActionCapabilities( + create = create, + edit = edit, + delete = delete, + completion = completion, + commands = commands, + commandForms = commandForms, + ) +} + +private fun ActionSpec.recordFormPlan( + kind: NativeRecordFormActionKind, + resource: ResourceSpec, + record: NativeRecord?, + sources: Map, + contextualSources: Map, + relationshipFieldIds: Set, + completionFieldId: String?, +): NativeRecordFormActionPlan? { + val expectedIntent = when (kind) { + NativeRecordFormActionKind.Create -> ActionIntent.create + NativeRecordFormActionKind.Edit -> ActionIntent.update + } + if ( + intent != expectedIntent || + !kind.acceptsEffect(effect) || + risk != ActionRisk.mutating || + binding.method !in kind.allowedMethods() || + !binding.hasSafeRecordActionBody() || + binding.hasOverlappingRecordActionChannels() + ) { + return null + } + + val declaredFields = binding.bodyFieldNames.mapNotNull { bodyName -> + resource.fields.singleOrNull { field -> + field.id == bodyName && + !field.readOnly && + field.isSupportedRecordFormField(binding.bodyFieldSchema(bodyName)) + }?.copy(required = bodyName in binding.requiredBodyFieldNames) + } + val contextualRelationshipValues = if (kind == NativeRecordFormActionKind.Create) { + declaredFields.mapNotNull { field -> + field.takeIf { candidate -> candidate.id in relationshipFieldIds } + ?.let { candidate -> + contextualSources[candidate.id] + ?.takeIf(String::isSafeRecordBodyValue) + ?.let { value -> candidate.id to value } + } + }.toMap() + } else { + emptyMap() + } + val fields = declaredFields.filterNot { field -> field.id in contextualRelationshipValues } + if (fields.map(FieldSpec::id).distinct().size != fields.size) return null + if (kind == NativeRecordFormActionKind.Edit && fields.isEmpty()) return null + if (kind == NativeRecordFormActionKind.Edit && binding.method == HttpMethod.PUT) { + // NativeRecord currently proves selected-record identity and binding provenance, but it + // cannot prove that a collection row is a complete replacement representation or carry an + // applicable version/ETag precondition. Even a present nullable field is dropped from the + // string-valued initial draft, so allowing a generic PUT edit could still clear omitted + // server state. Semantic whole-resource commands use their separately gated command plan. + return null + } + if ( + kind == NativeRecordFormActionKind.Edit && + completionFieldId != null && + fields.all { field -> field.id == completionFieldId } + ) { + return null + } + + val resolved = binding.resolveRecordActionBindings( + resource = resource, + record = record, + sources = sources, + userInputNames = fields.mapTo(mutableSetOf(), FieldSpec::id), + ) ?: return null + val completeBindings = safeActionBindingValues(resolved, contextualRelationshipValues) ?: return null + val representedBodyFieldIds = buildSet { + addAll(fields.map(FieldSpec::id)) + addAll(completeBindings.keys) + } + if (binding.bodyFieldNames.any { fieldId -> fieldId !in representedBodyFieldIds }) return null + return NativeRecordFormActionPlan( + kind = kind, + action = this, + fields = fields, + initialValues = if (record == null) { + emptyMap() + } else { + fields.mapNotNull { field -> + record.values[field.id]?.let { value -> field.id to value } + }.toMap() + }, + bindingValues = completeBindings, + ) +} + +private fun ActionSpec.recordCommandFormPlan( + identityResource: ResourceSpec, + fieldResource: ResourceSpec, + record: NativeRecord, + sources: Map, +): NativeRecordCommandFormActionPlan? { + if ( + !record.canApplyNativeRecordEffect(effect) || + !hasRecordCommandFormSemantics() || + binding.method !in RECORD_COMMAND_METHODS || + binding.bodyFieldNames.isEmpty() || + binding.allowsObservedBodyFields || + !binding.hasSafeRecordActionBody() || + binding.hasOverlappingRecordActionChannels() || + binding.singleRecordPathIdentityName(identityResource) == null || + risk == ActionRisk.readOnly || + (risk == ActionRisk.destructive && !requiresConfirmation) + ) { + return null + } + val bodyFieldNames = binding.bodyFieldNames + if (bodyFieldNames.distinct().size != bodyFieldNames.size) return null + val bodySchemas = bodyFieldNames.associateWith(binding::bodyFieldSchema) + val fields = bodyFieldNames.mapNotNull { bodyName -> + fieldResource.fields.singleOrNull { field -> + field.id == bodyName && + !field.readOnly && + field.isSupportedRecordCommandFormField(bodySchemas[bodyName]) + }?.copy(required = bodyName in binding.requiredBodyFieldNames) + } + if (fields.size != bodyFieldNames.size || fields.isEmpty()) return null + val resolved = binding.resolveRecordActionBindings( + resource = identityResource, + record = record, + sources = sources, + userInputNames = fields.mapTo(mutableSetOf(), FieldSpec::id), + ) ?: return null + val representedBodyFieldIds = fields.mapTo(mutableSetOf(), FieldSpec::id) + if (binding.bodyFieldNames.any { fieldId -> fieldId !in representedBodyFieldIds }) return null + return NativeRecordCommandFormActionPlan( + action = this, + effect = effect, + fields = fields, + initialValues = fields.mapNotNull { field -> + record.values[field.id]?.let { value -> field.id to value } + }.toMap(), + bindingValues = resolved, + fieldSchemas = bodySchemas, + ) +} + +private fun ActionSpec.hasRecordCommandFormSemantics(): Boolean = when (effect) { + ActionEffect.assign -> intent in setOf(ActionIntent.update, ActionIntent.execute) + ActionEffect.update -> + intent == ActionIntent.update && + binding.method == HttpMethod.PUT && + resultRecoveryActionId != null + ActionEffect.copy, + ActionEffect.move, + ActionEffect.execute, + ActionEffect.unspecified, + -> intent == ActionIntent.execute + else -> false +} + +private fun ActionSpec.recordDeletePlan( + resource: ResourceSpec, + record: NativeRecord, + sources: Map, +): NativeRecordDeleteActionPlan? { + if ( + intent != ActionIntent.delete || + effect !in setOf(ActionEffect.unspecified, ActionEffect.delete) || + risk != ActionRisk.destructive || + binding.method != HttpMethod.DELETE || + !requiresConfirmation + ) { + return null + } + if (binding.singleRecordPathIdentityName(resource) == null) return null + val resolved = binding.resolveRecordActionBindings( + resource = resource, + record = record, + sources = sources, + userInputNames = emptySet(), + ) ?: return null + return NativeRecordDeleteActionPlan(this, resolved) +} + +private fun ActionSpec.recordCommandPlan( + resource: ResourceSpec, + record: NativeRecord, + sources: Map, +): NativeRecordCommandActionPlan? { + if (!record.canApplyNativeRecordEffect(effect)) return null + val safeEffect = when (effect) { + in REVERSIBLE_RECORD_COMMAND_EFFECTS -> { + intent == ActionIntent.execute && + risk == ActionRisk.mutating && + !requiresConfirmation + } + in DESTRUCTIVE_RECORD_COMMAND_EFFECTS -> { + intent in setOf(ActionIntent.execute, ActionIntent.delete) && + risk == ActionRisk.destructive && + requiresConfirmation + } + else -> false + } + if ( + !safeEffect || + binding.method !in RECORD_COMMAND_METHODS || + binding.hasOverlappingRecordActionChannels() || + !binding.hasSafeRecordActionBody() || + binding.allowsObservedBodyFields || + binding.bodyFieldNames.isNotEmpty() || + binding.bodyContentType != null + ) { + return null + } + val resolved = binding.resolveRecordCommandBindings( + resource = resource, + record = record, + sources = sources, + ) ?: return null + return NativeRecordCommandActionPlan( + action = this, + effect = effect, + bindingValues = resolved, + ) +} + +private fun NativeRecord.canApplyNativeRecordEffect(effect: ActionEffect): Boolean { + val deleted = hasNativeDeletedState() + val archived = hasNativeArchivedState() + return when (effect) { + ActionEffect.archive -> !deleted && !archived + ActionEffect.unarchive -> !deleted && archived + ActionEffect.restore, + ActionEffect.permanentDelete, + -> deleted + ActionEffect.copy -> !deleted + else -> true + } +} + +private fun NativeRecord.hasNativeDeletedState(): Boolean = hasNativeRecordState( + setOf("deleted", "deletedat", "isdeleted", "removedat", "trashed", "trashedat"), +) + +private fun NativeRecord.hasNativeArchivedState(): Boolean = hasNativeRecordState( + setOf("archived", "archivedat", "isarchived"), +) + +private fun NativeRecord.hasNativeRecordState(fieldIds: Set): Boolean = values.any { (key, value) -> + key.lowercase().filter(Char::isLetterOrDigit) in fieldIds && + value?.trim()?.let { state -> + state.isNotEmpty() && + !state.equals("false", ignoreCase = true) && + state != "0" && + !state.equals("null", ignoreCase = true) + } == true +} + +private fun ActionSpec.recordCompletionPlan( + resource: ResourceSpec, + record: NativeRecord, + sources: Map, + semantics: NativeRecordCompletionSemantics, +): NativeRecordCompletionActionPlan? { + if (effect == ActionEffect.toggle) { + return recordToggleCompletionPlan( + resource = resource, + record = record, + sources = sources, + semantics = semantics, + ) + } + if ( + intent != ActionIntent.update || + risk != ActionRisk.mutating || + requiresConfirmation || + // Set-value completion intentionally emits a partial body. ApiBinding cannot currently + // prove both a complete authoritative replacement and a version/ETag precondition, so an + // ordinary PUT must not be treated as an inline completion capability. + binding.method != HttpMethod.PATCH || + !binding.hasSafeRecordActionBody() || + binding.hasOverlappingRecordActionChannels() || + binding.bodyFieldNames.count { it == semantics.field.id } != 1 + ) { + return null + } + val currentValue = record.values[semantics.field.id] ?: return null + val currentlyCompleted = semantics.read(currentValue) ?: return null + val resolved = binding.resolveRecordActionBindings( + resource = resource, + record = record, + sources = sources, + userInputNames = setOf(semantics.field.id), + ) ?: return null + return NativeRecordCompletionActionPlan( + action = this, + field = semantics.field, + currentlyCompleted = currentlyCompleted, + completedWireValue = semantics.completedWireValue, + incompleteWireValue = semantics.incompleteWireValue, + kind = NativeRecordCompletionActionKind.SetValue, + bindingValues = resolved, + ) +} + +private fun ActionSpec.recordToggleCompletionPlan( + resource: ResourceSpec, + record: NativeRecord, + sources: Map, + semantics: NativeRecordCompletionSemantics, +): NativeRecordCompletionActionPlan? { + val identityParameterNames = ( + binding.pathParameterNames + + binding.requiredPathParameterNames + ).distinct().filter { name -> name.isRecordIdentityNameFor(resource) } + if ( + intent != ActionIntent.execute || + risk != ActionRisk.mutating || + requiresConfirmation || + binding.method !in RECORD_TOGGLE_METHODS || + identityParameterNames.size != 1 || + identityParameterNames.none { name -> + "{$name}" in binding.path.substringBefore('?').split('/') + } || + binding.bodyFieldNames.isNotEmpty() || + binding.requiredBodyFieldNames.isNotEmpty() || + binding.queryParameterNames.isNotEmpty() || + binding.requiredQueryParameterNames.isNotEmpty() + ) { + return null + } + val currentValue = record.values[semantics.field.id] ?: return null + val currentlyCompleted = semantics.read(currentValue) ?: return null + val resolved = binding.resolveRecordActionBindings( + resource = resource, + record = record, + sources = sources, + userInputNames = emptySet(), + ) ?: return null + return NativeRecordCompletionActionPlan( + action = this, + field = semantics.field, + currentlyCompleted = currentlyCompleted, + completedWireValue = semantics.completedWireValue, + incompleteWireValue = semantics.incompleteWireValue, + kind = NativeRecordCompletionActionKind.Toggle, + bindingValues = resolved, + ) +} + +private fun ApiBinding.resolveRecordActionBindings( + resource: ResourceSpec, + record: NativeRecord?, + sources: Map, + userInputNames: Set, +): Map? { + if (!hasExactRecordBindingDeclaration()) return null + val resolved = linkedMapOf() + val requiredPathNames = (pathParameterNames + requiredPathParameterNames).distinct() + requiredPathNames.forEach { name -> + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordPathValue) + ?: sources["id"].takeIf { + isProvenSingleParentIdentityAlias(name) && + !name.isRecordIdentityNameFor(resource) + } + ?.takeIf(String::isSafeRecordPathValue) + ?: return null + resolved[name] = value + } + requiredQueryParameterNames.distinct().forEach { name -> + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordQueryValue) + ?: return null + resolved[name] = value + } + requiredBodyFieldNames.distinct().forEach { name -> + if (name in userInputNames) return@forEach + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordBodyValue) + ?: return null + resolved[name] = value + } + return resolved +} + +private fun ApiBinding.resolveRecordCommandBindings( + resource: ResourceSpec, + record: NativeRecord, + sources: Map, +): Map? { + if (!hasExactRecordBindingDeclaration()) return null + val pathNames = (pathParameterNames + requiredPathParameterNames).distinct() + val queryNames = (queryParameterNames + requiredQueryParameterNames).distinct() + val bodyNames = (bodyFieldNames + requiredBodyFieldNames).distinct() + val channels = listOf(pathNames, queryNames, bodyNames) + .flatMap { names -> names.map(String::recordSemanticId) } + if (channels.size != channels.distinct().size) return null + + if (singleRecordPathIdentityName(resource) == null) return null + + val resolved = linkedMapOf() + pathNames.forEach { name -> + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordPathValue) + ?: sources["id"].takeIf { + isProvenSingleParentIdentityAlias(name) && + !name.isRecordIdentityNameFor(resource) + } + ?.takeIf(String::isSafeRecordPathValue) + ?: return null + resolved[name] = value + } + queryNames.forEach { name -> + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordQueryValue) + ?: return null + resolved[name] = value + } + bodyNames.forEach { name -> + val value = resolveRecordActionValue(name, resource, record, sources) + ?.takeIf(String::isSafeRecordBodyValue) + ?: return null + resolved[name] = value + } + return resolved.takeIf { values -> + values.size == pathNames.size + queryNames.size + bodyNames.size + } +} + +private fun ApiBinding.hasExactRecordBindingDeclaration(): Boolean { + val channels = listOf( + pathParameterNames to requiredPathParameterNames, + queryParameterNames to requiredQueryParameterNames, + bodyFieldNames to requiredBodyFieldNames, + ) + if (channels.any { (declared, required) -> + declared.distinct().size != declared.size || + required.distinct().size != required.size || + required.any { name -> name !in declared } || + (declared + required).any { name -> !name.isSafeRecordBindingName() } + } + ) { + return false + } + val scan = path.substringBefore('?').scanBracedTemplate() + if (scan.malformed) return false + val placeholders = scan.tokens.map { token -> token.name } + return placeholders.distinct().size == placeholders.size && + placeholders.toSet() == pathParameterNames.toSet() +} + +private fun ApiBinding.singleRecordPathIdentityName(resource: ResourceSpec): String? { + val pathNames = (pathParameterNames + requiredPathParameterNames).distinct() + val identityName = pathNames.singleOrNull { name -> + name.isRecordIdentityNameFor(resource) + } ?: return null + return identityName.takeIf { name -> + "{$name}" in path.substringBefore('?').split('/') + } +} + +private fun ApiBinding.isProvenSingleParentIdentityAlias(parameterName: String): Boolean { + if (!parameterName.endsWith("Id", ignoreCase = true) || parameterName.length <= 2) return false + val parentResourceId = parameterName.dropLast(2) + val segments = path.substringBefore('?').split('/').filter(String::isNotBlank) + val placeholder = "{$parameterName}" + return segments.indices.any { index -> + segments[index] == placeholder && + index > 0 && + segments[index - 1].sameDynamicResourceAs(parentResourceId) + } +} + +private fun resolveRecordActionValue( + name: String, + resource: ResourceSpec, + record: NativeRecord?, + sources: Map, +): String? { + if ( + record?.actionSafeIdentity == true && + name.isRecordIdentityNameFor(resource) + ) { + return record.id + } + return sources[name] +} + +private fun nativeRecordBindingSources( + resource: ResourceSpec, + record: NativeRecord?, + navigationContext: Map, + includeRecordValues: Boolean, +): Map? { + if (record?.actionBindingProvenanceValid == false) return null + fun safeSource(source: Map): Map? = + source.takeIf { values -> + values.all { (name, value) -> + name.isSafeRecordBindingName() && value.isSafeRecordBodyValue() + } + } + + val sources = mutableListOf>() + sources += safeSource(navigationContext) ?: return null + sources += safeSource(record?.bindingContext.orEmpty()) ?: return null + if (includeRecordValues && record != null) { + val observed = linkedMapOf() + resource.fields.forEach { field -> + record.values[field.id]?.let { value -> + val canonicalIdentityValue = + record.actionSafeIdentity && + ( + field.id.isRecordIdentityNameFor(resource) || + value == record.id + ) + if (!canonicalIdentityValue) { + if (!field.id.isSafeRecordBindingName() || !value.isSafeRecordBodyValue()) return null + observed[field.id] = value + } + } + } + sources += observed + } + return safeActionBindingValues(*sources.toTypedArray()) +} + +private data class NativeRecordCompletionSemantics( + val field: FieldSpec, + val completedWireValue: String, + val incompleteWireValue: String, +) { + fun read(value: String): Boolean? = when (field.kind) { + FieldKind.boolean -> when (value.trim().lowercase()) { + "true", "1" -> true + "false", "0" -> false + else -> null + } + FieldKind.enumeration -> when (value) { + completedWireValue -> true + incompleteWireValue -> false + else -> null + } + else -> null + } +} + +private fun ResourceSpec.uniqueRecordCompletionSemantics( + allowReadOnly: Boolean = false, +): NativeRecordCompletionSemantics? { + val candidates = fields.mapNotNull { field -> + if (field.readOnly && !allowReadOnly) return@mapNotNull null + val score = field.recordCompletionScore() + if (score == 0) return@mapNotNull null + val semantics = when (field.kind) { + FieldKind.boolean -> if ( + !field.requiresIndependentNativeTaskEvidence() || + hasIndependentNativeTaskEvidence(field) + ) { + NativeRecordCompletionSemantics(field, "true", "false") + } else { + null + } + FieldKind.enumeration -> { + val values = field.enumValues.orEmpty() + val completed = values.singleOrNull { + it.recordSemanticId() in RECORD_COMPLETED_VALUES + } + val incomplete = values.singleOrNull { + it.recordSemanticId() in RECORD_INCOMPLETE_VALUES + } + if (completed == null || incomplete == null) null else { + NativeRecordCompletionSemantics(field, completed, incomplete) + } + } + else -> null + } ?: return@mapNotNull null + semantics to score + }.sortedByDescending { (_, score) -> score } + val best = candidates.firstOrNull() ?: return null + return best.first.takeIf { candidates.drop(1).none { (_, score) -> score == best.second } } +} + +private fun FieldSpec.recordCompletionScore(): Int { + if (kind !in setOf(FieldKind.boolean, FieldKind.enumeration)) return 0 + val id = id.recordSemanticId() + val label = label.recordSemanticId() + return when { + id in RECORD_COMPLETION_FIELD_NAMES -> 400 + label in RECORD_COMPLETION_FIELD_NAMES -> 300 + else -> 0 + } +} + +private fun NativeRecordFormActionKind.allowedMethods(): Set = when (this) { + NativeRecordFormActionKind.Create -> setOf(HttpMethod.POST) + NativeRecordFormActionKind.Edit -> RECORD_UPDATE_METHODS +} + +private fun NativeRecordFormActionKind.acceptsEffect(effect: ActionEffect): Boolean = when (this) { + NativeRecordFormActionKind.Create -> effect in setOf(ActionEffect.unspecified, ActionEffect.create) + NativeRecordFormActionKind.Edit -> effect in setOf(ActionEffect.unspecified, ActionEffect.update) +} + +private fun ApiBinding.hasSafeRecordActionBody(): Boolean = + bodyFieldNames.isEmpty() || bodyContentType?.substringBefore(';')?.trim()?.lowercase() in + RECORD_ACTION_BODY_CONTENT_TYPES + +private fun ApiBinding.hasOverlappingRecordActionChannels(): Boolean { + val routeParameters = ( + pathParameterNames + + requiredPathParameterNames + + queryParameterNames + + requiredQueryParameterNames + ).mapTo(hashSetOf(), String::recordSemanticId) + return bodyFieldNames.any { bodyName -> bodyName.recordSemanticId() in routeParameters } +} + +private fun ApiBinding.bodyFieldSchema(fieldId: String): JsonElement? = + ((bodySchema as? JsonObject)?.get("properties") as? JsonObject)?.get(fieldId) + +private fun FieldSpec.isSupportedRecordFormField(schema: JsonElement?): Boolean = + if (repeatableObjectInput != null) { + kind == FieldKind.objectValue && repeatableObjectInput == schema.repeatableObjectInputSpec() + } else { + kind in RECORD_ACTION_FIELD_KINDS + } + +private fun FieldSpec.isSupportedRecordCommandFormField(schema: JsonElement?): Boolean { + repeatableObjectInput?.let { structured -> + return kind == FieldKind.objectValue && structured == schema.repeatableObjectInputSpec() + } + if (kind !in RECORD_ACTION_FIELD_KINDS) return false + return when (format) { + DYNAMIC_INTEGER_ARRAY_FORMAT -> + kind == FieldKind.integer && schema.isExactDynamicIntegerArraySchema() + DYNAMIC_STRING_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + -> kind in setOf(FieldKind.string, FieldKind.longText) && + schema.isExactStringArraySchema(format) + else -> schema.isCompatibleRecordCommandScalarSchema(this) + } +} + +private fun List.encodeRepeatableObjectInput( + scalarInputValues: Map, + repeatableObjectValues: Map>, +): Map { + require(scalarInputValues.keys.intersect(repeatableObjectValues.keys).isEmpty()) { + "An input cannot be supplied as both scalar and structured." + } + val fieldsById = associateBy(FieldSpec::id) + require(scalarInputValues.keys.all(fieldsById::containsKey)) { + "The action contains an undeclared scalar input." + } + require(repeatableObjectValues.keys.all(fieldsById::containsKey)) { + "The action contains an undeclared structured input." + } + require(scalarInputValues.keys.all { fieldId -> + fieldsById.getValue(fieldId).repeatableObjectInput == null + }) { + "A structured input cannot be supplied as opaque text." + } + return scalarInputValues + repeatableObjectValues.mapValues { (fieldId, rows) -> + val spec = fieldsById.getValue(fieldId).repeatableObjectInput + ?: error("A scalar input cannot be supplied as structured rows.") + spec.encode(rows) + } +} + +private data class RecordCommandScalarSchema( + val type: String, + val format: String?, + val enumValues: List?, + val minimum: String?, + val maximum: String?, + val minimumLength: Int?, + val maximumLength: Int?, +) { + fun accepts(value: String): Boolean { + return when (type) { + "string" -> { + if (minimumLength?.let { value.length < it } == true) return false + if (maximumLength?.let { value.length > it } == true) return false + enumValues?.contains(value) != false + } + "integer" -> { + val parsed = value.toLongOrNull() ?: return false + if (minimum?.toLongOrNull()?.let { parsed < it } == true) return false + if (maximum?.toLongOrNull()?.let { parsed > it } == true) return false + true + } + "number" -> { + val parsed = value.toDoubleOrNull()?.takeIf(Double::isFinite) ?: return false + if (minimum?.toDoubleOrNull()?.let { parsed < it } == true) return false + if (maximum?.toDoubleOrNull()?.let { parsed > it } == true) return false + true + } + "boolean" -> value.toBooleanStrictOrNull() != null + else -> false + } + } + + companion object { + fun create(element: JsonElement?): RecordCommandScalarSchema? { + val schema = element as? JsonObject ?: return null + if (!schema.keys.all(RECORD_COMMAND_SCALAR_SCHEMA_KEYS::contains)) return null + val type = schema.schemaString("type") ?: return null + if (type !in RECORD_COMMAND_SCALAR_TYPES) return null + if (!schema.hasOptionalSchemaString("format")) return null + if (!schema.hasOptionalSchemaBoolean("nullable")) return null + val enumValues = schema.schemaStringEnum() + if ("enum" in schema && (type != "string" || enumValues == null)) return null + + val minimum = schema.schemaNumber("minimum") + val maximum = schema.schemaNumber("maximum") + if ( + (type in setOf("integer", "number") && + (!schema.hasOptionalSchemaNumber("minimum") || + !schema.hasOptionalSchemaNumber("maximum"))) || + (type !in setOf("integer", "number") && + ("minimum" in schema || "maximum" in schema)) + ) { + return null + } + if ( + type == "integer" && + (minimum?.toLongOrNull() == null && minimum != null || + maximum?.toLongOrNull() == null && maximum != null) + ) { + return null + } + if ( + minimum != null && + maximum != null && + when (type) { + "integer" -> minimum.toLong() > maximum.toLong() + "number" -> minimum.toDouble() > maximum.toDouble() + else -> false + } + ) { + return null + } + + val minimumLength = schema.schemaNonNegativeInt("minLength") + val maximumLength = schema.schemaNonNegativeInt("maxLength") + if ( + (type == "string" && + (!schema.hasOptionalSchemaNonNegativeInt("minLength") || + !schema.hasOptionalSchemaNonNegativeInt("maxLength"))) || + (type != "string" && ("minLength" in schema || "maxLength" in schema)) || + (minimumLength != null && maximumLength != null && minimumLength > maximumLength) + ) { + return null + } + return RecordCommandScalarSchema( + type = type, + format = schema.schemaString("format"), + enumValues = enumValues, + minimum = minimum, + maximum = maximum, + minimumLength = minimumLength, + maximumLength = maximumLength, + ) + } + } +} + +private fun JsonElement?.isCompatibleRecordCommandScalarSchema(field: FieldSpec): Boolean { + val schema = RecordCommandScalarSchema.create(this) ?: return false + if (field.format != schema.format) return false + return when (schema.type) { + "string" -> when { + schema.enumValues != null -> + field.kind == FieldKind.enumeration && + field.enumValues == schema.enumValues + schema.format == "date" -> field.kind == FieldKind.date + schema.format == "date-time" -> field.kind == FieldKind.dateTime + else -> field.kind in RECORD_COMMAND_STRING_FIELD_KINDS + } + "integer" -> field.kind == FieldKind.integer + "number" -> field.kind in setOf(FieldKind.decimal, FieldKind.currency) + "boolean" -> field.kind == FieldKind.boolean + else -> false + } +} + +private fun JsonElement?.acceptsExactRecordCommandScalar( + value: String, + field: FieldSpec, +): Boolean { + val schema = RecordCommandScalarSchema.create(this) ?: return false + return isCompatibleRecordCommandScalarSchema(field) && schema.accepts(value) +} + +private fun JsonElement?.isExactStringArraySchema(expectedFormat: String): Boolean { + val objectSchema = this as? JsonObject ?: return false + if ((objectSchema["type"] as? JsonPrimitive)?.contentOrNull != "array") return false + if ((objectSchema["format"] as? JsonPrimitive)?.contentOrNull != expectedFormat) return false + if (objectSchema.keys.any { key -> key !in RECORD_COMMAND_STRING_ARRAY_SCHEMA_KEYS }) return false + val items = objectSchema["items"] as? JsonObject ?: return false + return (items["type"] as? JsonPrimitive)?.contentOrNull == "string" && + items.keys.all { key -> key in RECORD_COMMAND_STRING_ARRAY_ITEM_SCHEMA_KEYS } +} + +private fun String.isRecordIdentityNameFor(resource: ResourceSpec): Boolean { + val name = recordSemanticId() + if (name == "id") return true + if (!name.endsWith("id") || name.length <= 2) return false + val stem = name.dropLast(2) + return listOf(resource.id, resource.name).any { alias -> + stem.sameDynamicResourceAs(alias) + } +} + +private fun FieldSpec.acceptsRecordActionValue(value: String): Boolean { + val normalized = value.trim() + if ( + normalized.length > MAX_RECORD_BODY_VALUE_LENGTH || + normalized.any(Char::isUnsafeRecordTextControl) + ) { + return false + } + if (normalized.isEmpty()) return !required + repeatableObjectInput?.let { structured -> + return runCatching { structured.canonicalJson(normalized) }.isSuccess + } + return when (kind) { + FieldKind.integer -> normalized.toLongOrNull() != null + FieldKind.decimal, + FieldKind.currency, + -> normalized.toDoubleOrNull() != null + FieldKind.boolean -> normalized in setOf("true", "false") + FieldKind.enumeration -> enumValues?.contains(normalized) == true + else -> true + } +} + +private fun FieldSpec.acceptsRecordCommandFormValue( + value: String, + schema: JsonElement?, +): Boolean { + val normalized = value.trim() + if ( + normalized.length > MAX_RECORD_BODY_VALUE_LENGTH || + normalized.any(Char::isUnsafeRecordTextControl) + ) { + return false + } + if (normalized.isEmpty()) return !required + return when (format) { + DYNAMIC_INTEGER_ARRAY_FORMAT -> + parseDynamicIntegerArrayInput(normalized, schema) is DynamicIntegerArrayParseResult.Valid + DYNAMIC_STRING_ARRAY_FORMAT, + DYNAMIC_STRING_LIST_FORMAT, + -> normalized.isSafeRecordStringArray() + else -> { + repeatableObjectInput?.let { structured -> + return runCatching { structured.canonicalJson(normalized) }.isSuccess + } + schema.acceptsExactRecordCommandScalar(normalized, this) + } + } +} + +private fun String.isSafeRecordStringArray(): Boolean { + val array = runCatching { Json.parseToJsonElement(this) }.getOrNull() as? JsonArray ?: return false + if (array.size > MAX_RECORD_COMMAND_ARRAY_ITEMS) return false + return array.all { element -> + val value = (element as? JsonPrimitive) + ?.takeIf { primitive -> primitive.isString } + ?.contentOrNull + ?: return@all false + value.length <= MAX_RECORD_BODY_VALUE_LENGTH && value.none(Char::isUnsafeRecordTextControl) + } +} + +private fun JsonObject.schemaString(name: String): String? = + (this[name] as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + +private fun JsonObject.schemaBoolean(name: String): Boolean? = + (this[name] as? JsonPrimitive)?.booleanOrNull + +private fun JsonObject.schemaNumber(name: String): String? = + (this[name] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.contentOrNull + ?.takeIf { value -> value.toDoubleOrNull()?.isFinite() == true } + +private fun JsonObject.schemaNonNegativeInt(name: String): Int? = + (this[name] as? JsonPrimitive) + ?.takeUnless(JsonPrimitive::isString) + ?.longOrNull + ?.takeIf { value -> value in 0..Int.MAX_VALUE } + ?.toInt() + +private fun JsonObject.hasOptionalSchemaString(name: String): Boolean = + name !in this || schemaString(name) != null + +private fun JsonObject.hasOptionalSchemaBoolean(name: String): Boolean = + name !in this || schemaBoolean(name) != null + +private fun JsonObject.hasOptionalSchemaNumber(name: String): Boolean = + name !in this || schemaNumber(name) != null + +private fun JsonObject.hasOptionalSchemaNonNegativeInt(name: String): Boolean = + name !in this || schemaNonNegativeInt(name) != null + +private fun JsonObject.schemaStringEnum(): List? { + val array = this["enum"] as? JsonArray ?: return null + val values = array.mapNotNull { element -> + (element as? JsonPrimitive)?.takeIf(JsonPrimitive::isString)?.contentOrNull + } + return values.takeIf { + values.isNotEmpty() && + values.size == array.size && + values.distinct().size == values.size + } +} + +private fun String.recordSemanticId(): String = lowercase().filter(Char::isLetterOrDigit) + +private fun String.isSafeRecordBindingName(): Boolean = + length in 1..128 && all { character -> + character.isLetterOrDigit() || character == '_' || character == '-' || character == '.' + } + +private fun String.isSafeRecordPathValue(): Boolean = + isNotBlank() && + length <= MAX_RECORD_IDENTITY_VALUE_LENGTH && + none { character -> + character == '/' || character == '\\' || character.isISOControl() + } + +private fun String.isSafeRecordQueryValue(): Boolean = + isNotBlank() && + length <= MAX_RECORD_QUERY_VALUE_LENGTH && + none(Char::isISOControl) + +private fun String.isSafeRecordBodyValue(): Boolean = + length <= MAX_RECORD_BODY_VALUE_LENGTH && none(Char::isUnsafeRecordTextControl) + +private fun Char.isUnsafeRecordTextControl(): Boolean = + isISOControl() && this !in setOf('\n', '\r', '\t') + +private fun Confidence.isSafeRecordActionConfidence(): Boolean = + this == Confidence.high || this == Confidence.verified + +/** + * Honors exact record-level capability fields and affirmative parent authority. Endpoint existence + * is never permission evidence: selected-record mutations fail closed when both sources are absent + * or unscoped. A declared capability whose current-record value is absent or unparseable is unknown + * and cannot authorize a write. Explicit denials also cannot be overridden by parent authority. + */ +internal fun NativeRecord.permits( + action: ActionSpec, + resource: ResourceSpec, + authorityContext: NativeRecordAuthorityContext?, +): Boolean { + val capabilityEntries = resource.fields.mapNotNull { field -> + val semanticId = field.id.lowercase().filter(Char::isLetterOrDigit) + if ( + semanticId !in setOf( + "isadmin", + "readonly", + "writable", + "canwrite", + "canedit", + "canupdate", + "candelete", + ) + ) { + return@mapNotNull null + } + semanticId to field.id + } + if (capabilityEntries.groupingBy { entry -> entry.first }.eachCount().any { (_, count) -> count != 1 }) { + return false + } + val capabilityFields = capabilityEntries.toMap() + + fun declaredCapability(id: String): Boolean? { + val fieldId = capabilityFields[id] ?: return null + return values[fieldId]?.nativeCapabilityBooleanOrNull() + } + + val selectedAdminEvidence = if ("isadmin" in capabilityFields) { + when (declaredCapability("isadmin")) { + true -> NativeAuthorityEvidence.Allowed + false -> NativeAuthorityEvidence.Absent + null -> NativeAuthorityEvidence.Denied + } + } else { + NativeAuthorityEvidence.Absent + } + val generalWriteEvidence = buildList { + if ("readonly" in capabilityFields) { + add( + if (declaredCapability("readonly") == false) { + NativeAuthorityEvidence.Allowed + } else { + NativeAuthorityEvidence.Denied + }, + ) + } + listOf("writable", "canwrite").forEach { id -> + if (id in capabilityFields) { + add( + if (declaredCapability(id) == true) { + NativeAuthorityEvidence.Allowed + } else { + NativeAuthorityEvidence.Denied + }, + ) + } + } + } + if ( + selectedAdminEvidence == NativeAuthorityEvidence.Denied || + NativeAuthorityEvidence.Denied in generalWriteEvidence + ) { + return false + } + + val deletion = action.isRecordDeletion() + val scopedCapabilities = setOf("canedit", "canupdate", "candelete") + .filter(capabilityFields::containsKey) + val currentEvidence = if (deletion) { + if ("candelete" !in scopedCapabilities) { + NativeAuthorityEvidence.Absent + } else if (declaredCapability("candelete") == true) { + NativeAuthorityEvidence.Allowed + } else { + NativeAuthorityEvidence.Denied + } + } else { + val applicable = setOf("canedit", "canupdate").filter(scopedCapabilities::contains) + when { + action.intent !in setOf(ActionIntent.update, ActionIntent.execute) -> + NativeAuthorityEvidence.Allowed + applicable.isEmpty() -> NativeAuthorityEvidence.Absent + applicable.all { id -> declaredCapability(id) == true } -> + NativeAuthorityEvidence.Allowed + else -> NativeAuthorityEvidence.Denied + } + } + return when (currentEvidence) { + NativeAuthorityEvidence.Allowed -> true + NativeAuthorityEvidence.Denied, + NativeAuthorityEvidence.Ambiguous, + -> false + NativeAuthorityEvidence.Absent, + NativeAuthorityEvidence.Unscoped, + -> when { + selectedAdminEvidence == NativeAuthorityEvidence.Allowed -> true + authorityContext?.authorityEvidence(action, resource) == NativeAuthorityEvidence.Allowed -> true + scopedCapabilities.isNotEmpty() -> false + deletion -> false + action.intent in setOf(ActionIntent.update, ActionIntent.execute) -> + NativeAuthorityEvidence.Allowed in generalWriteEvidence + else -> false + } + } +} + +private enum class NativeAuthorityEvidence { + Allowed, + Denied, + Absent, + Ambiguous, + Unscoped, +} + +private fun ActionSpec.isRecordDeletion(): Boolean = when (effect) { + ActionEffect.delete, + ActionEffect.permanentDelete, + -> true + ActionEffect.clear, + ActionEffect.leave, + -> false + else -> intent == ActionIntent.delete +} + +private fun NativeRecordAuthorityContext.authorityEvidence( + action: ActionSpec, + resource: ResourceSpec, +): NativeAuthorityEvidence { + if (!parentRecord.actionBindingProvenanceValid) return NativeAuthorityEvidence.Denied + val adminFields = parentResource.fields.filter { field -> + field.id.recordSemanticId() == "isadmin" && field.kind == FieldKind.boolean + } + if (adminFields.size > 1) return NativeAuthorityEvidence.Ambiguous + adminFields.singleOrNull()?.let { field -> + when (parentRecord.values[field.id]?.nativeCapabilityBooleanOrNull()) { + true -> return NativeAuthorityEvidence.Allowed + false -> Unit + null -> return NativeAuthorityEvidence.Denied + } + } + + val permissionFields = parentResource.fields.filter { field -> + field.id.recordSemanticId() == "permissions" && field.kind == FieldKind.objectValue + } + if (permissionFields.isEmpty()) { + return if (adminFields.isEmpty()) { + NativeAuthorityEvidence.Unscoped + } else { + NativeAuthorityEvidence.Absent + } + } + if (permissionFields.size != 1) return NativeAuthorityEvidence.Ambiguous + val permissionField = permissionFields.single() + val permissions = parentRecord.structuredValues[permissionField.id] as? NativeStructuredValue.ObjectValue + ?: return NativeAuthorityEvidence.Denied + if (permissions.omittedEntries > 0) return NativeAuthorityEvidence.Ambiguous + val capabilityIds = action.authorityCapabilityIds(resource) + if (capabilityIds.isEmpty()) return NativeAuthorityEvidence.Denied + val matches = permissions.entries.filter { entry -> + entry.key.recordSemanticId() in capabilityIds + } + if (matches.isEmpty()) return NativeAuthorityEvidence.Absent + if (matches.size != 1) return NativeAuthorityEvidence.Ambiguous + val scalar = matches.single().value as? NativeStructuredValue.Scalar + ?: return NativeAuthorityEvidence.Denied + if (scalar.kind != NativeStructuredScalarKind.boolean) return NativeAuthorityEvidence.Denied + return when (scalar.value?.nativeCapabilityBooleanOrNull()) { + true -> NativeAuthorityEvidence.Allowed + false -> NativeAuthorityEvidence.Denied + null -> NativeAuthorityEvidence.Denied + } +} + +private fun ActionSpec.authorityCapabilityIds(resource: ResourceSpec): Set { + val verbs = when { + isRecordDeletion() -> setOf("delete", "remove") + effect == ActionEffect.create || intent == ActionIntent.create -> setOf("create", "add") + effect == ActionEffect.assign -> setOf("assign", "update") + effect == ActionEffect.move -> setOf("move", "update") + effect == ActionEffect.copy -> setOf("copy", "create") + intent == ActionIntent.update -> setOf("edit", "update", "write") + intent == ActionIntent.execute -> setOf(effect.name.recordSemanticId(), "execute") + else -> emptySet() + } + if (verbs.isEmpty()) return emptySet() + val resourceNames = listOf(resource.id, resource.name) + val routeNouns = binding.path + .substringBefore('?') + .split('/') + .filter { segment -> + segment.isNotBlank() && !(segment.startsWith('{') && segment.endsWith('}')) + } + .filter { segment -> + resourceNames.any { name -> segment.sameDynamicResourceAs(name) } + } + .flatMapTo(linkedSetOf()) { segment -> segment.recordAuthorityNounVariants() } + if (routeNouns.isEmpty()) return emptySet() + val resourceNouns = resourceNames.flatMapTo(linkedSetOf(), String::recordAuthorityNounVariants) + val nouns = routeNouns.intersect(resourceNouns) + return verbs.flatMapTo(linkedSetOf()) { verb -> + nouns.map { noun -> "can$verb$noun" } + } +} + +private fun String.recordAuthorityNounVariants(): Set { + val words = lowercase() + .split(Regex("[^a-z0-9]+")) + .filter(String::isNotBlank) + val bases = buildSet { + words.joinToString("").takeIf(String::isNotBlank)?.let(::add) + words.lastOrNull()?.let(::add) + } + return buildSet { + bases.forEach { normalized -> + add(normalized) + when { + normalized.endsWith("ies") && normalized.length > 3 -> + add(normalized.dropLast(3) + "y") + normalized.endsWith("ses") || + normalized.endsWith("xes") || + normalized.endsWith("zes") || + normalized.endsWith("ches") || + normalized.endsWith("shes") -> { + add(normalized.dropLast(2)) + add(normalized.dropLast(1)) + } + normalized.endsWith('s') && !normalized.endsWith("ss") -> + add(normalized.dropLast(1)) + else -> add("${normalized}s") + } + } + } +} + +private fun String.nativeCapabilityBooleanOrNull(): Boolean? = when (trim().lowercase()) { + "true", "1", "yes" -> true + "false", "0", "no" -> false + else -> null +} + +private fun emptyNativeRecordActionCapabilities() = NativeRecordActionCapabilities( + create = null, + edit = null, + delete = null, + completion = null, + commands = emptyList(), + commandForms = emptyList(), +) + +private val RECORD_UPDATE_METHODS = setOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH) +private val RECORD_TOGGLE_METHODS = setOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH) +private val RECORD_COMMAND_METHODS = setOf( + HttpMethod.POST, + HttpMethod.PUT, + HttpMethod.PATCH, + HttpMethod.DELETE, +) +private val REVERSIBLE_RECORD_COMMAND_EFFECTS = setOf( + ActionEffect.archive, + ActionEffect.unarchive, + ActionEffect.restore, + ActionEffect.copy, +) +private val DESTRUCTIVE_RECORD_COMMAND_EFFECTS = setOf( + ActionEffect.permanentDelete, + ActionEffect.clear, + ActionEffect.leave, +) +private val RECORD_COMMAND_EFFECT_ORDER = listOf( + ActionEffect.archive, + ActionEffect.unarchive, + ActionEffect.restore, + ActionEffect.copy, + ActionEffect.clear, + ActionEffect.leave, + ActionEffect.permanentDelete, +) +private val RECORD_COMMAND_FORM_EFFECT_ORDER = listOf( + ActionEffect.copy, + ActionEffect.move, + ActionEffect.assign, + ActionEffect.update, + ActionEffect.execute, + ActionEffect.unspecified, +) +private val RECORD_ACTION_BODY_CONTENT_TYPES = setOf( + "application/json", + "application/x-www-form-urlencoded", +) +private val RECORD_ACTION_FIELD_KINDS = setOf( + FieldKind.string, + FieldKind.longText, + FieldKind.integer, + FieldKind.decimal, + FieldKind.boolean, + FieldKind.date, + FieldKind.dateTime, + FieldKind.currency, + FieldKind.userReference, + FieldKind.enumeration, +) +private val RECORD_COMMAND_STRING_FIELD_KINDS = setOf( + FieldKind.string, + FieldKind.longText, + FieldKind.currency, + FieldKind.userReference, +) +private val RECORD_COMMAND_SCALAR_TYPES = setOf( + "string", + "integer", + "number", + "boolean", +) +private val RECORD_COMMAND_SCALAR_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "enum", + "example", + "examples", + "format", + "maxLength", + "maximum", + "minLength", + "minimum", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", + "x-nextcloud-native-wire-name", +) +private val RECORD_COMMAND_STRING_ARRAY_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "format", + "items", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", + "x-nextcloud-native-wire-name", +) +private val RECORD_COMMAND_STRING_ARRAY_ITEM_SCHEMA_KEYS = setOf( + "\$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "nullable", + "readOnly", + "title", + "type", + "writeOnly", +) +private val RECORD_COMPLETION_FIELD_NAMES = setOf( + "complete", + "completed", + "done", + "finished", + "iscomplete", + "iscompleted", + "isdone", + "status", + "state", +) +private val RECORD_COMPLETED_VALUES = setOf( + "closed", + "complete", + "completed", + "done", + "finished", +) +private val RECORD_INCOMPLETE_VALUES = setOf( + "active", + "incomplete", + "new", + "open", + "pending", + "todo", +) +private const val MAX_RECORD_IDENTITY_VALUE_LENGTH = 256 +private const val MAX_RECORD_QUERY_VALUE_LENGTH = 2_048 +private const val MAX_RECORD_BODY_VALUE_LENGTH = 65_536 +private const val MAX_RECORD_COMMAND_ARRAY_ITEMS = 256 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiState.kt new file mode 100644 index 00000000..7f41f905 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiState.kt @@ -0,0 +1,248 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputFieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputRow +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputScalarKind +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputSpec +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal fun initialNativeRepeatableObjectDraft( + fields: List, + initialValues: Map, +): Map>? = buildMap { + fields.forEach { field -> + val spec = field.repeatableObjectInput ?: return@forEach + val rows = initialValues[field.id]?.let { encoded -> + spec.decodeNativeRepeatableObjectRows(encoded) ?: return null + } ?: List(spec.minimumItems) { spec.emptyNativeRepeatableObjectRow() } + put(field.id, rows) + } +} + +internal fun addNativeRepeatableObjectRow( + rows: List, + spec: RepeatableObjectInputSpec, +): List = + if (rows.size >= spec.maximumItems) rows else rows + spec.emptyNativeRepeatableObjectRow() + +internal fun removeNativeRepeatableObjectRow( + rows: List, + index: Int, + spec: RepeatableObjectInputSpec, +): List = + if (rows.size <= spec.minimumItems || index !in rows.indices) { + rows + } else { + rows.filterIndexed { rowIndex, _ -> rowIndex != index } + } + +internal fun updateNativeRepeatableObjectValue( + rows: List, + rowIndex: Int, + field: RepeatableObjectInputFieldSpec, + value: String, +): List { + if (rowIndex !in rows.indices || value.length > MAX_NATIVE_REPEATABLE_OBJECT_SCALAR_LENGTH) { + return rows + } + val updatedValues = rows[rowIndex].values.toMutableMap().apply { + if (value.isBlank() && field.kind != RepeatableObjectInputScalarKind.Boolean) { + remove(field.id) + } else { + put(field.id, value) + } + } + val updatedNullFieldIds = rows[rowIndex].nullFieldIds - field.id + return rows.toMutableList().apply { + this[rowIndex] = RepeatableObjectInputRow(updatedValues, updatedNullFieldIds) + } +} + +internal fun updateNativeRepeatableObjectNull( + rows: List, + rowIndex: Int, + field: RepeatableObjectInputFieldSpec, + explicitNull: Boolean, +): List { + if (rowIndex !in rows.indices || !field.nullable) return rows + val row = rows[rowIndex] + val updatedValues = if (explicitNull) row.values - field.id else row.values + val updatedNullFieldIds = if (explicitNull) { + row.nullFieldIds + field.id + } else { + row.nullFieldIds - field.id + } + return rows.toMutableList().apply { + this[rowIndex] = RepeatableObjectInputRow(updatedValues, updatedNullFieldIds) + } +} + +internal fun encodeNativeRepeatableObjectDraft( + values: Map>, + specs: Map, +): List? { + if ( + values.keys != specs.keys || + values.size > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELDS + ) { + return null + } + val encoded = values.mapValues { (fieldId, rows) -> + encodeNativeRepeatableObjectDraftRows(rows, specs.getValue(fieldId)) ?: return null + } + var totalLength = 0 + val saved = ArrayList(encoded.size * 2) + encoded.entries.sortedBy(Map.Entry::key).forEach { (fieldId, draft) -> + if ( + fieldId.isBlank() || + fieldId.length > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELD_ID_LENGTH || + draft.length > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_LENGTH + ) { + return null + } + totalLength += fieldId.length + draft.length + if (totalLength > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_LENGTH) return null + saved += fieldId + saved += draft + } + return saved +} + +internal fun decodeNativeRepeatableObjectDraft( + saved: List, + specs: Map, +): Map>? { + if ( + saved.size % 2 != 0 || + saved.size / 2 > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELDS + ) { + return null + } + val encoded = linkedMapOf() + var totalLength = 0 + saved.chunked(2).forEach { (fieldId, draft) -> + if ( + fieldId.isBlank() || + fieldId in encoded || + fieldId.length > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELD_ID_LENGTH || + draft.length > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_LENGTH + ) { + return null + } + totalLength += fieldId.length + draft.length + if (totalLength > MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_LENGTH) return null + encoded[fieldId] = draft + } + if (encoded.keys != specs.keys) return null + return buildMap { + encoded.forEach { (fieldId, value) -> + val rows = decodeNativeRepeatableObjectDraftRows(value, specs.getValue(fieldId)) + ?: return null + put(fieldId, rows) + } + } +} + +private fun encodeNativeRepeatableObjectDraftRows( + rows: List, + spec: RepeatableObjectInputSpec, +): String? { + if (rows.size > spec.maximumItems) return null + val declaredFieldIds = spec.fields.mapTo(linkedSetOf(), RepeatableObjectInputFieldSpec::id) + if ( + rows.any { row -> + row.values.keys.any { fieldId -> fieldId !in declaredFieldIds } || + row.nullFieldIds.any { fieldId -> fieldId !in declaredFieldIds } || + row.values.keys.any(row.nullFieldIds::contains) || + row.nullFieldIds.any { fieldId -> + spec.fields.single { field -> field.id == fieldId }.nullable.not() + } || + row.values.values.any { value -> + value.length > MAX_NATIVE_REPEATABLE_OBJECT_SCALAR_LENGTH + } + } + ) { + return null + } + return JsonArray( + rows.map { row -> + JsonObject( + buildMap { + row.values.forEach { (fieldId, value) -> put(fieldId, JsonPrimitive(value)) } + row.nullFieldIds.forEach { fieldId -> put(fieldId, JsonNull) } + }, + ) + }, + ).toString() +} + +private fun decodeNativeRepeatableObjectDraftRows( + encoded: String, + spec: RepeatableObjectInputSpec, +): List? { + val rows = runCatching { Json.parseToJsonElement(encoded) }.getOrNull() as? JsonArray + ?: return null + if (rows.size > spec.maximumItems) return null + val declaredFieldIds = spec.fields.mapTo(linkedSetOf(), RepeatableObjectInputFieldSpec::id) + return rows.map { element -> + val row = element as? JsonObject ?: return null + if (row.keys.any { fieldId -> fieldId !in declaredFieldIds }) return null + val values = linkedMapOf() + val nullFieldIds = linkedSetOf() + row.forEach { (fieldId, value) -> + if (value is JsonNull) { + val field = spec.fields.single { field -> field.id == fieldId } + if (!field.nullable) return null + nullFieldIds += fieldId + } else { + val primitive = value as? JsonPrimitive ?: return null + val draft = primitive.takeIf(JsonPrimitive::isString)?.content + ?.takeIf { candidate -> + candidate.length <= MAX_NATIVE_REPEATABLE_OBJECT_SCALAR_LENGTH + } + ?: return null + values[fieldId] = draft + } + } + RepeatableObjectInputRow(values, nullFieldIds) + } +} + +private fun RepeatableObjectInputSpec.emptyNativeRepeatableObjectRow(): RepeatableObjectInputRow = + RepeatableObjectInputRow( + fields.mapNotNull { field -> + field.takeIf { + it.required && it.kind == RepeatableObjectInputScalarKind.Boolean + }?.let { it.id to "false" } + }.toMap(), + ) + +private fun RepeatableObjectInputSpec.decodeNativeRepeatableObjectRows( + encoded: String, +): List? = runCatching { + canonicalJson(encoded).mapIndexed { rowIndex, element -> + val item = element as JsonObject + val nullFieldIds = item.entries + .filter { (_, value) -> value is JsonNull } + .mapTo(linkedSetOf()) { (fieldId, _) -> fieldId } + RepeatableObjectInputRow( + values = fields.mapNotNull { field -> + item[field.id]?.let { value -> + if (value is JsonNull) return@let null + field.id to field.wireValue(value, rowIndex) + } + }.toMap(), + nullFieldIds = nullFieldIds, + ) + } +}.getOrNull() + +private const val MAX_NATIVE_REPEATABLE_OBJECT_SCALAR_LENGTH = 4_096 +private const val MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELDS = 64 +private const val MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_FIELD_ID_LENGTH = 256 +private const val MAX_NATIVE_REPEATABLE_OBJECT_DRAFT_LENGTH = 256 * 1_024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt index 81cb8bc4..f90477eb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt @@ -2,6 +2,8 @@ package dev.obiente.nextcloudnative.nativeui.runtime import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -103,6 +105,29 @@ internal data class NativeGroupwarePresentation( val allDay: Boolean, ) +private data class NativeTaskCompletionSemantics( + val field: FieldSpec, + val completedWireValue: String, + val incompleteWireValue: String, +) { + fun read(record: NativeRecord): Boolean? { + val rawValue = record.values[field.id]?.trim() ?: return null + return when (field.kind) { + FieldKind.boolean -> when (rawValue.lowercase()) { + "true", "1" -> true + "false", "0" -> false + else -> null + } + FieldKind.enumeration -> when (rawValue) { + completedWireValue -> true + incompleteWireValue -> false + else -> null + } + else -> null + } + } +} + internal enum class NativeHouseholdItemKind { Household, Member, @@ -513,9 +538,15 @@ internal fun nativeGroupwarePresentation( ): NativeGroupwarePresentation? { val values = NativeSemanticValues(record) val words = semanticTokens(resource.id, resource.name) + val typedCompletion = resource.uniqueNativeTaskCompletionSemantics() + ?.takeIf { semantics -> + semantics.read(record) != null && + values.hasAny("summary", "title", "name", "displayname", "label") + } val taskShape = values.hasAny( - "due", "duedate", "percentcomplete", "completed", "priority", "relatedto", - ) + "assignee", "assignedto", "due", "duedate", "percentcomplete", "priority", + "relatedto", + ) || typedCompletion != null val completionShape = values.hasAny("worktime", "completedat", "donetimestamp") && values.hasAny("member", "assignee", "user") val eventShape = values.hasAny("dtstart", "start", "startdate") && @@ -537,16 +568,21 @@ internal fun nativeGroupwarePresentation( val end = values.string("dtend", "end", "enddate") val due = values.string("due", "duedate") val status = values.string("status", "state") + ?: typedCompletion + ?.takeIf { semantics -> semantics.field.kind == FieldKind.enumeration } + ?.let { semantics -> record.values[semantics.field.id] } val recurrence = values.string("rrule", "recurrencerule", "recurrenceid") ?: values.string("repeat") val recurring = recurrence?.isRecurringTaskRule() == true val completionPercent = values.int("percentcomplete", "completionpercent", "progress") ?.coerceIn(0, 100) val completedValue = values.string("completedat") - val completed = values.boolean("completed", "done") == true || + val completed = typedCompletion?.read(record) ?: ( + values.boolean("completed", "done") == true || status.equals("completed", ignoreCase = true) || completionPercent == 100 || !completedValue.isNullOrBlank() + ) val organization = values.string("org", "organization", "company") val email = values.person("email", "emails", "mail") val phone = values.string("tel", "phone", "telephone", "phones") @@ -564,7 +600,7 @@ internal fun nativeGroupwarePresentation( NativeGroupwareItemKind.Contact -> values.string("fn", "formattedname", "displayname", "name") ?: email ?: phone ?: record.id NativeGroupwareItemKind.Event, NativeGroupwareItemKind.Task -> - values.string("summary", "title", "name") ?: record.id + values.string("summary", "title", "name", "displayname", "label") ?: record.id } val subtitle = when (kind) { NativeGroupwareItemKind.Contact -> listOfNotNull( @@ -626,13 +662,17 @@ internal fun nativeTaskCollectionPresentations( records: List, ): List>? { if (records.isEmpty()) return null + val typedCompletion = resource.uniqueNativeTaskCompletionSemantics() return records.map { record -> val values = NativeSemanticValues(record) if (!values.hasAny( "assignee", "assignedto", "completed", "completedat", "done", "donetimestamp", "due", "duedate", "effort", "percentcomplete", "points", "priority", "repeat", "rrule", "status", "worktime", - ) + ) && ( + typedCompletion?.read(record) == null || + !values.hasAny("summary", "title", "name", "displayname", "label") + ) ) return null val presentation = nativeGroupwarePresentation(resource, record) ?.takeIf { it.kind == NativeGroupwareItemKind.Task } @@ -641,6 +681,61 @@ internal fun nativeTaskCollectionPresentations( } } +/** + * Uses the declared field type and a standard completion concept to recognize reversible item + * state without relying on an app or endpoint identifier. Enumeration values must prove both a + * completed and an incomplete state; an arbitrary boolean such as `favorite` is not enough. + */ +private fun ResourceSpec.uniqueNativeTaskCompletionSemantics(): NativeTaskCompletionSemantics? { + val candidates = fields.mapNotNull { field -> + if (field.taskCompletionFieldScore() == 0) return@mapNotNull null + when (field.kind) { + FieldKind.boolean -> if ( + !field.requiresIndependentNativeTaskEvidence() || + hasIndependentNativeTaskEvidence(field) + ) { + NativeTaskCompletionSemantics( + field = field, + completedWireValue = "true", + incompleteWireValue = "false", + ) + } else { + null + } + FieldKind.enumeration -> { + val values = field.enumValues.orEmpty() + val completed = values.singleOrNull { value -> + value.semanticKey() in TASK_COMPLETED_VALUES + } + val incomplete = values.singleOrNull { value -> + value.semanticKey() in TASK_INCOMPLETE_VALUES + } + if (completed == null || incomplete == null) null else { + NativeTaskCompletionSemantics( + field = field, + completedWireValue = completed, + incompleteWireValue = incomplete, + ) + } + } + else -> null + }?.let { semantics -> semantics to field.taskCompletionFieldScore() } + }.sortedByDescending { (_, score) -> score } + val best = candidates.firstOrNull() ?: return null + return best.first.takeIf { candidates.drop(1).none { (_, score) -> score == best.second } } +} + +private fun FieldSpec.taskCompletionFieldScore(): Int { + if (kind !in setOf(FieldKind.boolean, FieldKind.enumeration)) return 0 + val id = id.semanticKey() + val label = label.semanticKey() + return when { + id in TASK_COMPLETION_FIELD_NAMES -> 2 + label in TASK_COMPLETION_FIELD_NAMES -> 1 + else -> 0 + } +} + /** * Promotes homogeneous contact or event collections into dedicated native surfaces. * @@ -984,3 +1079,29 @@ private val TASK_WORDS = setOf( "assignment", "assignments", "chore", "chores", "duty", "duties", "rota", "rotas", "task", "tasks", "todo", "todos", "vtodo", ) +private val TASK_COMPLETION_FIELD_NAMES = setOf( + "complete", + "completed", + "done", + "finished", + "iscomplete", + "iscompleted", + "isdone", + "status", + "state", +) +private val TASK_COMPLETED_VALUES = setOf( + "closed", + "complete", + "completed", + "done", + "finished", +) +private val TASK_INCOMPLETE_VALUES = setOf( + "active", + "incomplete", + "new", + "open", + "pending", + "todo", +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeTaskSemantics.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeTaskSemantics.kt new file mode 100644 index 00000000..1e6ffdd8 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeTaskSemantics.kt @@ -0,0 +1,85 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec + +/** + * Independent structural evidence required before a boolean field can acquire task-completion + * meaning. Boolean values only describe two states; their field label does not prove those states + * mean incomplete and completed. + */ +internal fun ResourceSpec.hasIndependentNativeTaskEvidence( + completionField: FieldSpec, +): Boolean { + val resourceTokens = listOf(id, name) + .flatMap(String::nativeTaskSemanticTokens) + .toSet() + if (resourceTokens.any(NATIVE_TASK_RESOURCE_WORDS::contains)) return true + + return fields + .asSequence() + .filterNot { field -> field.id == completionField.id } + .any { field -> + field.id.nativeTaskSemanticKey() in NATIVE_TASK_STRUCTURAL_FIELD_NAMES || + field.label.nativeTaskSemanticKey() in NATIVE_TASK_STRUCTURAL_FIELD_NAMES + } +} + +internal fun FieldSpec.requiresIndependentNativeTaskEvidence(): Boolean = + id.nativeTaskSemanticKey() in AMBIGUOUS_TASK_COMPLETION_FIELD_NAMES || + label.nativeTaskSemanticKey() in AMBIGUOUS_TASK_COMPLETION_FIELD_NAMES + +private fun String.nativeTaskSemanticTokens(): List = + buildString(length + 4) { + this@nativeTaskSemanticTokens.forEachIndexed { index, character -> + if ( + index > 0 && + character.isUpperCase() && + this@nativeTaskSemanticTokens[index - 1].isLowerCase() + ) { + append(' ') + } + append(if (character.isLetterOrDigit()) character.lowercaseChar() else ' ') + } + } + .split(' ') + .filter(String::isNotBlank) + +private fun String.nativeTaskSemanticKey(): String = + lowercase().filter(Char::isLetterOrDigit) + +private val NATIVE_TASK_RESOURCE_WORDS = setOf( + "assignment", + "assignments", + "chore", + "chores", + "duty", + "duties", + "rota", + "rotas", + "task", + "tasks", + "todo", + "todos", + "vtodo", +) + +private val NATIVE_TASK_STRUCTURAL_FIELD_NAMES = setOf( + "assignedto", + "assignee", + "assigneeid", + "completionpercent", + "deadline", + "due", + "duedate", + "effort", + "effortpoints", + "parenttask", + "parenttaskid", + "percentcomplete", + "points", + "priority", + "relatedto", +) + +private val AMBIGUOUS_TASK_COMPLETION_FIELD_NAMES = setOf("state", "status") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt index b25d2f43..3744e090 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt @@ -21,6 +21,8 @@ data class NativeRecord( * not response data, and therefore never render as fields. */ val bindingContext: Map = emptyMap(), + /** False when display data was retained after detecting ambiguous action provenance. */ + val actionBindingProvenanceValid: Boolean = true, ) @Serializable @@ -66,16 +68,90 @@ internal const val NATIVE_SYNTHETIC_RESOURCE_FIELD = "__nextcloud_native_resourc internal fun NativeRecord.effectiveNativeResourceId(fallback: String): String = values[NATIVE_SYNTHETIC_RESOURCE_FIELD]?.takeIf(String::isNotBlank) ?: fallback -/** Values allowed to cross from a selected read record into action/path/query binding. */ -internal fun NativeRecord.actionBindingValues(allowUnsafeIdentity: Boolean = false): Map = buildMap { - putAll(bindingContext) - this@actionBindingValues.values.forEach { (key, value) -> - if (key !in structuredValues) value?.let { put(key, it) } +/** + * Values allowed to cross from a selected read record into action/path/query binding. + * + * This compatibility helper deliberately returns no values when provenance is ambiguous. Mutation + * planners should use [safeActionBindingValues] directly so an unavailable action remains explicit. + */ +internal fun NativeRecord.actionBindingValues(allowUnsafeIdentity: Boolean = false): Map = + safeActionBindingValues(allowUnsafeIdentity).orEmpty() + +/** + * Resolves values for a mutation without silently choosing between conflicting provenance. + * + * [bindingContext] contains the exact request and navigation identities used to load the record. + * Response values and additional semantic context may confirm those identities, but may not + * replace them. A response field literally named `id` is display/protocol data when [id] is a + * different contract-selected backing identity, so only the canonical identity is exported under + * the generic `id` key. Callers must keep an action unavailable when this returns `null`. + */ +internal fun NativeRecord.safeActionBindingValues( + allowUnsafeIdentity: Boolean = false, + contextualValues: Map = emptyMap(), +): Map? { + if (!actionBindingProvenanceValid) return null + val usableCanonicalIdentity = + actionSafeIdentity || (allowUnsafeIdentity && canResolveUnsafeActionIdentity()) + val authoritativeContext = safeActionBindingValues(bindingContext, contextualValues) ?: return null + val contextualIdentityValues = authoritativeContext.entries + .filter { (key, _) -> key.actionBindingSemanticKey() == ACTION_BINDING_CANONICAL_ID } + .map { (_, value) -> value } + .distinct() + if ( + usableCanonicalIdentity && + contextualIdentityValues.any { contextualIdentity -> contextualIdentity != id } + ) { + return null } - // The parser may deliberately choose a contract-declared backing identity such as - // `databaseId` over a protocol/display field also named `id`. The canonical safe identity must - // therefore win when an action uses a conventional `{id}` parameter. - if (actionSafeIdentity || (allowUnsafeIdentity && canResolveUnsafeActionIdentity())) put("id", id) + val contextWithoutGenericIdentity = authoritativeContext.filterKeys { key -> + key.actionBindingSemanticKey() != ACTION_BINDING_CANONICAL_ID + } + val observedValues = values.mapNotNull { (key, value) -> + value + ?.takeIf { + key !in structuredValues && + key.actionBindingSemanticKey() != ACTION_BINDING_CANONICAL_ID + } + ?.let { key to it } + }.toMap() + val canonicalIdentity = when { + usableCanonicalIdentity -> mapOf("id" to id) + contextualIdentityValues.size == 1 -> mapOf("id" to contextualIdentityValues.single()) + else -> emptyMap() + } + return safeActionBindingValues( + contextWithoutGenericIdentity, + observedValues, + canonicalIdentity, + ) +} + +/** + * Merges flat action values only when every semantic key has one exact value. + * + * Exact spellings are retained because the request executor binds declared parameter names + * literally. Normalized aliases such as `parentId` and `parent_id` may coexist only when they + * confirm the same value. + */ +internal fun safeActionBindingValues( + vararg sources: Map, +): Map? { + val semanticValues = linkedMapOf() + val merged = linkedMapOf() + sources.forEach { source -> + source.forEach { (key, value) -> + val semanticKey = key.actionBindingSemanticKey() + if (semanticKey.isBlank()) return null + val existingSemanticValue = semanticValues[semanticKey] + if (existingSemanticValue != null && existingSemanticValue != value) return null + val existingExactValue = merged[key] + if (existingExactValue != null && existingExactValue != value) return null + semanticValues.putIfAbsent(semanticKey, value) + merged.putIfAbsent(key, value) + } + } + return merged } internal fun NativeRecord.canResolveUnsafeActionIdentity(): Boolean { @@ -100,6 +176,10 @@ private fun String.isSafeDynamicActionValue(): Boolean = character == '/' || character == '\\' || character.isISOControl() } +private fun String.actionBindingSemanticKey(): String = lowercase().filter(Char::isLetterOrDigit) + +private const val ACTION_BINDING_CANONICAL_ID = "id" + /** Adds bounded response-only fields to a renderer-local copy of the resource. */ internal fun ResourceSpec.withEphemeralDisplayFields(records: List): ResourceSpec { val declaredIds = fields.mapTo(mutableSetOf()) { it.id.lowercase() } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUiTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUiTest.kt index 09e953f7..1012d068 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUiTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUiTest.kt @@ -1,13 +1,19 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect import dev.obiente.nextcloudnative.nativeui.model.ActionRisk import dev.obiente.nextcloudnative.nativeui.model.ActionSpec import dev.obiente.nextcloudnative.nativeui.model.ApiBinding import dev.obiente.nextcloudnative.nativeui.model.Confidence import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionFailureOutcome import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class DynamicActionUiTest { @Test @@ -36,6 +42,42 @@ class DynamicActionUiTest { ) } + @Test + fun `semantic effects keep distinct labels and confirmations`() { + val permanentDelete = action( + "delete-permanently", + ActionIntent.delete, + ActionRisk.destructive, + HttpMethod.DELETE, + ).copy(effect = ActionEffect.permanentDelete) + val clear = action( + "clear-image", + ActionIntent.execute, + ActionRisk.destructive, + HttpMethod.DELETE, + ).copy(effect = ActionEffect.clear) + + assertEquals("Delete permanently", dynamicHeaderActionLabel(permanentDelete, "Delete")) + assertEquals("Clear", dynamicHeaderActionLabel(clear, "Delete")) + assertEquals( + DynamicActionUiMode.ConfirmDirectly, + dynamicActionUiMode(clear, editableFieldCount = 0), + ) + assertEquals("Clear photo?", dynamicDirectActionTitle(clear, "photo")) + assertEquals("Clear", dynamicDirectActionConfirmLabel(clear)) + } + + @Test + fun `unknown direct mutation requires reconciliation and cannot be retried immediately`() { + val rejected = dynamicDirectActionFailurePolicy(NativeActionFailureOutcome.Rejected) + val unknown = dynamicDirectActionFailurePolicy(NativeActionFailureOutcome.Unknown) + + assertTrue(rejected.retryAllowed) + assertFalse(rejected.requiresReconciliation) + assertFalse(unknown.retryAllowed) + assertTrue(unknown.requiresReconciliation) + } + @Test fun `quick actions prioritize create and edit ahead of destructive operations`() { assertEquals( @@ -49,6 +91,269 @@ class DynamicActionUiTest { ) } + @Test + fun `verified upload targets only its active read surface with complete bindings`() { + val upload = action( + "upload-image", + ActionIntent.execute, + ActionRisk.mutating, + HttpMethod.POST, + ).copy( + resourceId = "photos", + effect = ActionEffect.upload, + confidence = Confidence.verified, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/houses/{houseId}/photos", + operationId = "upload-image", + pathParameterNames = listOf("houseId"), + requiredPathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("image"), + requiredBodyFieldNames = listOf("image"), + bodyContentType = "multipart/form-data", + ), + ) + val form = view( + id = "upload-image.form", + resourceId = "photos", + component = NativeComponent.form, + sourceActionId = upload.id, + ) + val active = view( + id = "photos.grid", + resourceId = "photos", + component = NativeComponent.mediaGrid, + sourceActionId = "list-photos", + ) + val read = action( + "list-photos", + ActionIntent.list, + ActionRisk.readOnly, + HttpMethod.GET, + ).copy( + resourceId = "photos", + confidence = Confidence.verified, + ) + + assertTrue( + dynamicContextualFormTargetsActiveSurface( + action = upload, + formView = form, + activeView = active, + activeReadAction = read, + plannedBindingValues = mapOf("houseId" to "house-7"), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = true, + uniqueTargetResource = true, + ), + ) + assertFalse( + dynamicContextualFormTargetsActiveSurface( + action = upload, + formView = form, + activeView = active, + activeReadAction = read, + plannedBindingValues = emptyMap(), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = true, + uniqueTargetResource = true, + ), + ) + assertFalse( + dynamicContextualFormTargetsActiveSurface( + action = upload, + formView = form, + activeView = active, + activeReadAction = read, + plannedBindingValues = mapOf("houseId" to "house-7"), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + } + + @Test + fun `context bound singleton update requires its unique active verified detail read`() { + val update = action( + "update-preferences", + ActionIntent.update, + ActionRisk.mutating, + HttpMethod.PATCH, + ).copy( + resourceId = "preferences", + confidence = Confidence.verified, + binding = ApiBinding( + method = HttpMethod.PATCH, + path = "/houses/{houseId}/preferences", + operationId = "update-preferences", + pathParameterNames = listOf("houseId"), + requiredPathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("enabled"), + requiredBodyFieldNames = listOf("enabled"), + bodyContentType = "application/json", + ), + ) + val form = view( + id = "preferences.form", + resourceId = "preferences", + component = NativeComponent.form, + sourceActionId = update.id, + ) + val active = view( + id = "preferences.detail", + resourceId = "preferences", + component = NativeComponent.detail, + sourceActionId = "read-preferences", + ) + val read = action( + "read-preferences", + ActionIntent.read, + ActionRisk.readOnly, + HttpMethod.GET, + ).copy( + resourceId = "preferences", + confidence = Confidence.verified, + binding = ApiBinding( + method = HttpMethod.GET, + path = "/houses/{houseId}/preferences", + operationId = "read-preferences", + pathParameterNames = listOf("houseId"), + requiredPathParameterNames = listOf("houseId"), + ), + ) + + assertTrue( + dynamicContextualFormTargetsActiveSurface( + action = update, + formView = form, + activeView = active, + activeReadAction = read, + plannedBindingValues = mapOf("houseId" to "house-7"), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + assertFalse( + dynamicContextualFormTargetsActiveSurface( + action = update, + formView = form, + activeView = active.copy(component = NativeComponent.collectionList), + activeReadAction = read, + plannedBindingValues = mapOf("houseId" to "house-7"), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + assertFalse( + dynamicContextualFormTargetsActiveSurface( + action = update, + formView = form, + activeView = active, + activeReadAction = read.copy(resourceId = "other"), + plannedBindingValues = mapOf("houseId" to "house-7"), + selectedRecordResourceId = "houses", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + } + + @Test + fun `ordinary record update visibility still follows selected record identity`() { + val update = action( + "update-recipe", + ActionIntent.update, + ActionRisk.mutating, + HttpMethod.PATCH, + ) + val detail = view( + id = "recipe.detail", + resourceId = "recipes", + component = NativeComponent.detail, + sourceActionId = "read-recipe", + confidence = Confidence.high, + ) + + assertTrue( + dynamicContextualFormTargetsActiveSurface( + action = update, + formView = detail.copy( + id = "recipe.form", + component = NativeComponent.form, + sourceActionId = update.id, + ), + activeView = detail, + activeReadAction = null, + plannedBindingValues = emptyMap(), + selectedRecordResourceId = "recipes", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + assertFalse( + dynamicContextualFormTargetsActiveSurface( + action = update, + formView = detail.copy( + id = "recipe.form", + component = NativeComponent.form, + sourceActionId = update.id, + ), + activeView = detail.copy(resourceId = "other"), + activeReadAction = null, + plannedBindingValues = emptyMap(), + selectedRecordResourceId = "other", + selectedCollectionState = null, + hasEditableFileField = false, + uniqueTargetResource = true, + ), + ) + } + + @Test + fun `state collection reads are recognized without app or operation identifiers`() { + val trash = action( + "arbitrary-read", + ActionIntent.list, + ActionRisk.readOnly, + HttpMethod.GET, + ).copy(binding = ApiBinding(HttpMethod.GET, "/api/workspaces/{workspaceId}/records/trash", "read")) + val active = trash.copy(binding = trash.binding.copy(path = "/api/workspaces/{workspaceId}/records")) + val mutation = trash.copy( + intent = ActionIntent.delete, + binding = trash.binding.copy(method = HttpMethod.DELETE), + ) + + assertEquals("trash", dynamicCollectionState(trash)) + assertEquals(null, dynamicCollectionState(active)) + assertEquals(null, dynamicCollectionState(mutation)) + } + + @Test + fun `duplicate state destinations include their resource names`() { + assertEquals( + "Checklist Trash", + dynamicSecondaryDestinationLabel("Trash", "Checklist", duplicate = true), + ) + assertEquals( + "Trash", + dynamicSecondaryDestinationLabel("Trash", "Checklist", duplicate = false), + ) + assertEquals( + "Trash", + dynamicSecondaryDestinationLabel("Trash", "Trash", duplicate = true), + ) + } + private fun action( id: String, intent: ActionIntent, @@ -64,4 +369,19 @@ class DynamicActionUiTest { requiresConfirmation = risk == ActionRisk.destructive, confidence = Confidence.high, ) + + private fun view( + id: String, + resourceId: String, + component: NativeComponent, + sourceActionId: String, + confidence: Confidence = Confidence.verified, + ) = ViewSpec( + id = id, + title = id, + resourceId = resourceId, + component = component, + sourceActionId = sourceActionId, + confidence = confidence, + ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionDestinationSelectionTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionDestinationSelectionTest.kt new file mode 100644 index 00000000..4e716359 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionDestinationSelectionTest.kt @@ -0,0 +1,193 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_APP_DESCRIPTOR_VERSION +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DynamicCollectionDestinationSelectionTest { + @Test + fun `process restoration reuses last known contract identity in read only mode`() { + val plan = planDynamicContractResume( + liveServerVersion = null, + lastKnownServerVersion = "32.0.5", + lastKnownInstalledAppVersion = "0.23.0", + ) + + assertEquals("32.0.5", plan.serverVersion) + assertEquals("0.23.0", plan.installedAppVersionHint) + assertFalse(plan.serverVersionVerified) + } + + @Test + fun `live server identity supersedes the restored version and enables verification`() { + val plan = planDynamicContractResume( + liveServerVersion = "33.0.0", + lastKnownServerVersion = "32.0.5", + lastKnownInstalledAppVersion = "0.23.0", + ) + + assertEquals("33.0.0", plan.serverVersion) + assertEquals("0.23.0", plan.installedAppVersionHint) + assertTrue(plan.serverVersionVerified) + } + + @Test + fun `metadata fallback demotes a retained verified contract to last known read only`() { + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("workspace", "Workspace", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test"), + ) + val cached = DynamicDescriptorDiscovery( + descriptor = descriptor, + sourcePath = "signed-contract.json", + acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, + versionStatus = DynamicContractVersionStatus.VerifiedCurrent, + ) + val fallback = DynamicDescriptorDiscovery( + descriptor = descriptor.copy( + app = descriptor.app.copy(version = "metadata"), + ), + sourcePath = null, + acquisition = DynamicDescriptorAcquisition.MetadataFallback, + versionStatus = DynamicContractVersionStatus.VerifiedCurrent, + ) + + val resolved = resolveDynamicContractRediscovery(cached, fallback) + + assertSame(descriptor, resolved.descriptor) + assertEquals(DynamicDescriptorAcquisition.SignedAppStorePackage, resolved.acquisition) + assertEquals(DynamicContractVersionStatus.LastKnownReadOnly, resolved.versionStatus) + } + + @Test + fun `discovery exception downgrades cached write authority`() { + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("workspace", "Workspace", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test"), + ) + val cached = DynamicDescriptorDiscovery( + descriptor = descriptor, + sourcePath = "signed-contract.json", + acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, + versionStatus = DynamicContractVersionStatus.VerifiedCurrent, + ) + + val retained = retainedDynamicContractAfterDiscoveryFailure(cached) + + assertEquals(DynamicContractVersionStatus.LastKnownReadOnly, retained?.versionStatus) + assertSame(descriptor, retained?.descriptor) + assertEquals(cached.acquisition, retained?.acquisition) + assertEquals(null, retainedDynamicContractAfterDiscoveryFailure(null)) + } + + @Test + fun `last known read only schema exposes reads but no mutation actions or form views`() { + val read = action("list-records", HttpMethod.GET, ActionIntent.list, ActionRisk.readOnly) + val create = action("create-record", HttpMethod.POST, ActionIntent.create, ActionRisk.mutating) + val delete = action("delete-record", HttpMethod.DELETE, ActionIntent.delete, ActionRisk.destructive) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("workspace", "Workspace", "1"), + confidence = Confidence.verified, + actions = listOf(read, create, delete), + views = listOf( + view("records.list", read.id, NativeComponent.collectionList), + view("records.create", create.id, NativeComponent.form), + view("records.delete", delete.id, NativeComponent.form), + ), + ) + + val readOnly = schema.forDynamicContractVersion(DynamicContractVersionStatus.LastKnownReadOnly) + + assertEquals(listOf(read.id), readOnly.actions.map(ActionSpec::id)) + assertEquals(listOf("records.list"), readOnly.views.map(ViewSpec::id)) + assertEquals( + schema, + schema.forDynamicContractVersion(DynamicContractVersionStatus.VerifiedCurrent), + ) + } + + @Test + fun `switching a root collection clears stale hierarchy context`() { + val mutableParameters = mutableMapOf("houseId" to "house-2") + + val plan = planDynamicCollectionDestinationSelection( + isTopLevelDestination = true, + destinationPathParameterValues = mutableParameters, + ) + mutableParameters["houseId"] = "stale" + + assertTrue(plan.clearHierarchyContext) + assertEquals(mapOf("houseId" to "house-2"), plan.pathParameterValues) + } + + @Test + fun `switching a contextual collection preserves its selected parent`() { + val plan = planDynamicCollectionDestinationSelection( + isTopLevelDestination = false, + destinationPathParameterValues = mapOf( + "houseId" to "house-2", + "checklistId" to "checklist-9", + ), + ) + + assertFalse(plan.clearHierarchyContext) + assertEquals( + mapOf( + "houseId" to "house-2", + "checklistId" to "checklist-9", + ), + plan.pathParameterValues, + ) + } + + private fun action( + id: String, + method: HttpMethod, + intent: ActionIntent, + risk: ActionRisk, + ) = ActionSpec( + id = id, + label = id, + resourceId = "records", + binding = ApiBinding( + method = method, + path = "/records", + operationId = id, + ), + intent = intent, + risk = risk, + requiresConfirmation = risk == ActionRisk.destructive, + confidence = Confidence.verified, + ) + + private fun view( + id: String, + actionId: String, + component: NativeComponent, + ) = ViewSpec( + id = id, + title = id, + resourceId = "records", + component = component, + sourceActionId = actionId, + confidence = Confidence.verified, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationsTest.kt new file mode 100644 index 00000000..5f34f680 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelationsTest.kt @@ -0,0 +1,613 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.runtime.NativeCollectionBatchRelationLoadResult +import dev.obiente.nextcloudnative.nativeui.runtime.NativeDatasetContext +import dev.obiente.nextcloudnative.nativeui.runtime.withCollectionBatchRelationRecords +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class DynamicFormRelationsTest { + @Test + fun `form relationships preload one fully bound active collection read`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/workspaces/{workspaceId}/categories", + requiredPathNames = listOf("workspaceId"), + ), + action( + "category-trash", + "categories", + "/api/workspaces/{workspaceId}/categories/trash", + requiredPathNames = listOf("workspaceId"), + ), + ), + ) + + assertEquals( + listOf(DynamicFormRelationLoadPlan("categories", "category-index")), + dynamicFormRelationLoadPlans(schema, form, mapOf("workspaceId" to "7")), + ) + } + + @Test + fun `collection batch relationships get an exact verified load request independent of form view`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/workspaces/{workspaceId}/categories", + requiredPathNames = listOf("workspaceId"), + ), + ), + ) + + val request = dynamicCollectionBatchRelationLoadRequests( + schema = schema, + childResourceId = "entries", + relatedFieldIds = setOf("categoryId"), + availableValues = mapOf("workspaceId" to "workspace-7"), + ).single() + + assertEquals("categories", request.plan.resourceId) + assertEquals("category-index", request.plan.actionId) + assertEquals(mapOf("workspaceId" to "workspace-7"), request.cacheKey.bindingValues) + assertTrue( + dynamicCollectionBatchRelationLoadRequests( + schema = schema, + childResourceId = "entries", + relatedFieldIds = setOf("unrelatedId"), + availableValues = mapOf("workspaceId" to "workspace-7"), + ).isEmpty(), + ) + assertTrue( + dynamicCollectionBatchRelationLoadRequests( + schema = schema, + childResourceId = "entries", + relatedFieldIds = setOf("categoryId"), + availableValues = emptyMap(), + ).isEmpty(), + ) + } + + @Test + fun `collection batch relation scope replaces ambient records and enforces its memory bound`() { + val stale = NativeRecord("stale", mapOf("id" to "stale", "name" to "Wrong house")) + val fresh = NativeRecord("fresh", mapOf("id" to "fresh", "name" to "Current house")) + val context = NativeDatasetContext( + bindingValues = mapOf("houseId" to "house-7"), + relatedRecords = mapOf( + "categories" to listOf(stale), + "unrelated" to listOf(stale), + ), + relatedRecordPaging = mapOf( + "categories" to dev.obiente.nextcloudnative.nativeui.runtime.NativeRelatedRecordPaging(), + ), + ) + + val scoped = context.withCollectionBatchRelationRecords( + mapOf("categories" to listOf(fresh)), + ) + + assertEquals(mapOf("categories" to listOf(fresh)), scoped.relatedRecords) + assertTrue(scoped.relatedRecordPaging.isEmpty()) + assertEquals(context.bindingValues, scoped.bindingValues) + assertFailsWith { + NativeCollectionBatchRelationLoadResult( + recordsByResourceId = mapOf( + "categories" to (1..501).map { index -> + NativeRecord("category-$index", mapOf("id" to "category-$index")) + }, + ), + ) + } + } + + @Test + fun `generic relation route identity prefers its uniquely qualified retained parent`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/houses/{id}/categories", + requiredPathNames = listOf("id"), + ), + ), + ) + + val request = dynamicFormRelationLoadRequests( + schema = schema, + formView = form, + availableValues = mapOf( + "id" to "selected-list-7", + "houseId" to "retained-house-3", + ), + ).single() + + assertEquals(mapOf("id" to "retained-house-3"), request.cacheKey.bindingValues) + assertEquals( + mapOf( + "id" to "retained-house-3", + "houseId" to "retained-house-3", + "page" to "2", + ), + dynamicFormRelationRuntimeValues( + request = request, + availableValues = mapOf( + "id" to "selected-list-7", + "houseId" to "retained-house-3", + ), + additionalValues = mapOf("page" to "2"), + ), + ) + } + + @Test + fun `ambiguous qualified aliases never fall back to an unrelated direct identity`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/houses/{id}/categories", + requiredPathNames = listOf("id"), + ), + ), + ) + + assertTrue( + dynamicFormRelationLoadRequests( + schema = schema, + formView = form, + availableValues = mapOf( + "id" to "selected-list-7", + "houseId" to "retained-house-3", + "housesId" to "conflicting-house-4", + ), + ).isEmpty(), + ) + } + + @Test + fun `oversized relation response keeps its later choices within the strict scope bound`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/categories", + requiredPathNames = emptyList(), + ), + ), + ) + val request = dynamicFormRelationLoadRequests(schema, form, emptyMap()).single() + val records = (1..700).map { index -> + NativeRecord( + id = "category-$index", + values = mapOf("id" to "category-$index", "name" to "Category $index"), + ) + } + + val state = DynamicFormRelationCacheState().loadSucceeded(request, records) + val cached = state.relatedRecords(listOf(request)) + .getValue("categories") + + assertEquals(MAX_DYNAMIC_FORM_RELATION_RECORDS, cached.size) + assertTrue(cached.none { record -> record.id == "category-200" }) + assertTrue(cached.any { record -> record.id == "category-201" }) + assertTrue(cached.any { record -> record.id == "category-500" }) + assertTrue(cached.any { record -> record.id == "category-501" }) + assertTrue(cached.any { record -> record.id == "category-700" }) + assertEquals(200, state.discardedRecordCount(request)) + } + + @Test + fun `relation pagination progressively preserves later pages and declared continuation`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/categories", + requiredPathNames = emptyList(), + ), + ), + ) + val request = dynamicFormRelationLoadRequests(schema, form, emptyMap()).single() + val pagination = DynamicPaginationSpec( + parameterName = "page", + mode = DynamicPaginationMode.PageNumber, + expectedPageSize = 50, + ) + + val firstPage = DynamicFormRelationCacheState().loadSucceeded( + request = request, + records = records(1..50), + pagination = pagination, + ) + assertEquals("2", firstPage.continuation(request)?.nextRequestValue) + + val secondPage = firstPage.appendPageSucceeded(request, records(46..95)) + val secondPageRecords = secondPage.relatedRecords(listOf(request)).getValue("categories") + assertEquals(95, secondPageRecords.size) + assertEquals(secondPageRecords.size, secondPageRecords.map(NativeRecord::id).distinct().size) + assertTrue(secondPageRecords.any { record -> record.id == "category-75" }) + assertEquals("3", secondPage.continuation(request)?.nextRequestValue) + + val finalPage = secondPage.appendPageSucceeded(request, records(96..107)) + assertEquals(107, finalPage.relatedRecords(listOf(request)).getValue("categories").size) + assertEquals(null, finalPage.continuation(request)) + } + + @Test + fun `relation pagination remains reachable beyond the bounded cache window`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/categories", + requiredPathNames = emptyList(), + ), + ), + ) + val request = dynamicFormRelationLoadRequests(schema, form, emptyMap()).single() + val pagination = DynamicPaginationSpec( + parameterName = "page", + mode = DynamicPaginationMode.PageNumber, + expectedPageSize = 50, + ) + val firstPage = DynamicFormRelationCacheState().loadSucceeded( + request = request, + records = records(1..50), + pagination = pagination, + ) + val beyondFormerLimit = (2..11).fold(firstPage) { state, pageNumber -> + val first = ((pageNumber - 1) * 50) + 1 + state.appendPageSucceeded(request, records(first..(first + 49))) + } + val cached = beyondFormerLimit.relatedRecords(listOf(request)).getValue("categories") + + assertEquals(MAX_DYNAMIC_FORM_RELATION_RECORDS, cached.size) + assertTrue(cached.none { record -> record.id == "category-50" }) + assertTrue(cached.any { record -> record.id == "category-51" }) + assertTrue(cached.any { record -> record.id == "category-501" }) + assertTrue(cached.any { record -> record.id == "category-550" }) + assertEquals("12", beyondFormerLimit.continuation(request)?.nextRequestValue) + assertEquals(50, beyondFormerLimit.discardedRecordCount(request)) + + val restarted = beyondFormerLimit.loadSucceeded( + request = request, + records = records(1..50), + pagination = pagination, + ) + assertTrue( + restarted.relatedRecords(listOf(request)) + .getValue("categories") + .any { record -> record.id == "category-1" }, + ) + assertEquals(0, restarted.discardedRecordCount(request)) + assertEquals("2", restarted.continuation(request)?.nextRequestValue) + } + + @Test + fun `unbound or unrelated reads do not become form lookup dependencies`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.high, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/workspaces/{workspaceId}/categories", + requiredPathNames = listOf("workspaceId"), + ), + ), + ) + + assertEquals(emptyList(), dynamicFormRelationLoadPlans(schema, form, emptyMap())) + assertEquals( + emptyList(), + dynamicFormRelationLoadPlans( + schema.copy( + actions = schema.actions.map { action -> + if (action.id == "entry-create") { + action.copy(binding = action.binding.copy(bodyFieldNames = listOf("name"))) + } else { + action + } + }, + ), + form, + mapOf("workspaceId" to "7"), + ), + ) + } + + @Test + fun `relation cache identity follows exact declared hierarchy bindings`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/workspaces/{workspaceId}/categories", + requiredPathNames = listOf("workspaceId"), + ), + ), + ) + val firstRequest = dynamicFormRelationLoadRequests( + schema, + form, + mapOf("workspaceId" to "workspace-7", "unrelated" to "ignored"), + ).single() + val sameScope = dynamicFormRelationLoadRequests( + schema, + form, + mapOf("workspaceId" to "workspace-7"), + ).single() + val nextParent = dynamicFormRelationLoadRequests( + schema, + form, + mapOf("workspaceId" to "workspace-8"), + ).single() + val records = listOf(NativeRecord("category-1", mapOf("name" to "First"))) + val cached = DynamicFormRelationCacheState().loadSucceeded(firstRequest, records) + val staleGenericRecords = mapOf( + "categories" to listOf(NativeRecord("stale", mapOf("name" to "Wrong parent"))), + "unrelated" to listOf(NativeRecord("other", mapOf("name" to "Keep me"))), + ) + + assertEquals(firstRequest.cacheKey, sameScope.cacheKey) + assertNotEquals(firstRequest.cacheKey, nextParent.cacheKey) + assertEquals(mapOf("categories" to records), cached.relatedRecords(listOf(sameScope))) + assertTrue(cached.relatedRecords(listOf(nextParent)).isEmpty()) + assertEquals(listOf(nextParent), cached.pendingRequests(listOf(nextParent))) + assertEquals( + mapOf("unrelated" to staleGenericRecords.getValue("unrelated")), + cached.datasetRelatedRecords(staleGenericRecords, listOf(nextParent)), + ) + assertEquals( + mapOf( + "unrelated" to staleGenericRecords.getValue("unrelated"), + "categories" to records, + ), + cached.datasetRelatedRecords(staleGenericRecords, listOf(sameScope)), + ) + } + + @Test + fun `relation failures stay visible until targeted retry and cache state remains bounded`() { + val form = view("entries.create", "entries", NativeComponent.form, "entry-create") + val schema = schema( + form = form, + relationship = ResourceRelationshipSpec( + "categories", + "entries", + "id", + "categoryId", + Confidence.verified, + ), + reads = listOf( + action( + "category-index", + "categories", + "/api/workspaces/{workspaceId}/categories", + requiredPathNames = listOf("workspaceId"), + ), + ), + ) + val request = dynamicFormRelationLoadRequests( + schema, + form, + mapOf("workspaceId" to "workspace-7"), + ).single() + val failed = DynamicFormRelationCacheState().loadFailed(request) + val staleGenericRecords = mapOf( + "categories" to listOf(NativeRecord("stale", mapOf("name" to "Wrong parent"))), + ) + + assertEquals(listOf(request), failed.failedRequests(listOf(request))) + assertTrue(failed.pendingRequests(listOf(request)).isEmpty()) + assertTrue(failed.datasetRelatedRecords(staleGenericRecords, listOf(request)).isEmpty()) + + val retrying = failed.retry(listOf(request)) + + assertTrue(retrying.failedRequests(listOf(request)).isEmpty()) + assertEquals(listOf(request), retrying.pendingRequests(listOf(request))) + + val bounded = (1..24).fold(DynamicFormRelationCacheState()) { state, index -> + val scoped = request.copy( + cacheKey = request.cacheKey.copy( + bindingValues = mapOf("workspaceId" to "workspace-$index"), + ), + ) + state.loadSucceeded( + scoped, + listOf(NativeRecord("category-$index", mapOf("name" to "Category $index"))), + ).loadFailed( + scoped.copy( + cacheKey = scoped.cacheKey.copy( + bindingValues = mapOf("workspaceId" to "failed-$index"), + ), + ), + ) + } + + assertEquals(16, bounded.recordsByKey.size) + assertEquals(16, bounded.failedKeys.size) + } + + private fun schema( + form: ViewSpec, + relationship: ResourceRelationshipSpec, + reads: List, + ): NativeAppSchema { + val create = ActionSpec( + id = "entry-create", + label = "Create entry", + resourceId = "entries", + binding = ApiBinding( + method = HttpMethod.POST, + path = "/api/workspaces/{workspaceId}/entries", + operationId = "entry-create", + requiredPathParameterNames = listOf("workspaceId"), + bodyFieldNames = listOf("name", "categoryId"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + return NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("example", "Example", "1"), + confidence = Confidence.verified, + views = listOf(form) + reads.map { action -> + view("${action.id}.list", action.resourceId, NativeComponent.collectionList, action.id) + }, + actions = listOf(create) + reads, + relationships = listOf(relationship), + ) + } + + private fun action( + id: String, + resourceId: String, + path: String, + requiredPathNames: List, + ) = ActionSpec( + id = id, + label = id, + resourceId = resourceId, + binding = ApiBinding( + method = HttpMethod.GET, + path = path, + operationId = id, + requiredPathParameterNames = requiredPathNames, + ), + intent = ActionIntent.list, + risk = ActionRisk.readOnly, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + + private fun view( + id: String, + resourceId: String, + component: NativeComponent, + sourceActionId: String, + ) = ViewSpec( + id = id, + title = id, + resourceId = resourceId, + component = component, + sourceActionId = sourceActionId, + confidence = Confidence.verified, + ) + + private fun records(range: IntRange): List = range.map { index -> + NativeRecord( + id = "category-$index", + values = mapOf("id" to "category-$index", "name" to "Category $index"), + ) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMultipartContractTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMultipartContractTest.kt new file mode 100644 index 00000000..d70c75eb --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMultipartContractTest.kt @@ -0,0 +1,391 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.AdvertisedOpenApi +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptorCompiler +import dev.obiente.nextcloudnative.nativeui.model.DynamicDiscoveryInput +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.OpenApiTrust +import dev.obiente.nextcloudnative.nativeui.model.validationErrors +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionFailureOutcome +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DynamicMultipartContractTest { + @Test + fun `signed description converts declared scalar fields into a bounded multipart body`() { + val descriptor = compile(SIGNED_DESCRIPTION_CONTRACT, OpenApiTrust.nextcloudSignedAppPackage) + val action = descriptor.actions.single { it.id == "upload-photo" } + val body = assertNotNull(action.binding.body) + val schema = body.schema as JsonObject + val properties = schema["properties"] as JsonObject + val image = properties["image"] as JsonObject + + assertEquals("multipart/form-data", body.contentType) + assertEquals("binary", (image["format"] as JsonPrimitive).contentOrNull) + assertEquals("image/*", (image["contentMediaType"] as JsonPrimitive).contentOrNull) + assertEquals( + FieldKind.file, + descriptor.forms.single { it.actionId == action.id }.fields.single { it.fieldId == "image" }.kind, + ) + assertEquals(setOf("folderId", "caption", "image"), properties.keys) + + val itemImage = assertNotNull( + descriptor.actions.single { it.id == "upload-item-image" }.binding.body, + ) + val itemProperties = (itemImage.schema as JsonObject)["properties"] as JsonObject + assertEquals("multipart/form-data", itemImage.contentType) + assertEquals(setOf("image"), itemProperties.keys) + } + + @Test + fun `unsigned descriptions cannot change JSON transport semantics`() { + val descriptor = compile(SIGNED_DESCRIPTION_CONTRACT, OpenApiTrust.sameOriginAdvertisement) + val body = descriptor.actions.single { it.id == "upload-photo" }.binding.body + val properties = (body?.schema as? JsonObject)?.get("properties") as? JsonObject + val explicit = descriptor.actions.single { it.id == "upload-document" } + + assertEquals("application/json", body?.contentType) + assertTrue(properties?.containsKey("image") == false) + assertEquals("multipart/form-data", explicit.binding.body?.contentType) + assertEquals( + FieldKind.file, + descriptor.forms.single { it.actionId == explicit.id }.fields.single { it.fieldId == "document" }.kind, + ) + } + + @Test + fun `multipart request retains picker capability and scalar parts without raw local data`() { + val descriptor = compile(SIGNED_DESCRIPTION_CONTRACT, OpenApiTrust.nextcloudSignedAppPackage) + val action = descriptor.actions.single { it.id == "upload-photo" } + val selected = localUploadFile( + selectionId = "0123456789abcdef", + displayName = "pantry test.png", + mimeType = "image/png", + sizeBytes = 1234, + ) + + val request = buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = mapOf( + "image" to encodeDynamicLocalUploadSelection(selected), + "folderId" to "7", + "caption" to "A safe caption", + ), + ) + val multipart = assertNotNull(request.multipartBody) + + assertNull(request.body) + assertNull(request.contentType) + assertEquals("image", multipart.fileFieldName) + assertEquals(selected, multipart.file) + assertEquals( + listOf(MultipartTextField("caption", "A safe caption"), MultipartTextField("folderId", "7")), + multipart.textFields.sortedBy(MultipartTextField::name), + ) + } + + @Test + fun `multipart picker capability is retained for rejection retry and released after success`() { + val selected = localUploadFile( + selectionId = "0123456789abcdef", + displayName = "pantry test.png", + mimeType = "image/png", + sizeBytes = 1234, + ) + val released = mutableListOf() + val rejected = NativeActionExecutionResult.Failure( + message = "The server rejected the upload.", + outcome = NativeActionFailureOutcome.Rejected, + ) + + repeat(2) { + releaseMultipartUploadAfterSuccess(rejected, selected, released::add) + } + + assertTrue(released.isEmpty()) + + releaseMultipartUploadAfterSuccess( + NativeActionExecutionResult.Success("Uploaded."), + selected, + released::add, + ) + + assertEquals(listOf(selected), released) + } + + @Test + fun `multipart request rejects typed paths and contracts with multiple binary fields`() { + val descriptor = compile(SIGNED_DESCRIPTION_CONTRACT, OpenApiTrust.nextcloudSignedAppPackage) + val action = descriptor.actions.single { it.id == "upload-photo" } + + assertFailsWith { + buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = mapOf("image" to "/tmp/pantry.png"), + ) + } + + val ambiguous = compile(AMBIGUOUS_MULTIPART_CONTRACT, OpenApiTrust.sameOriginAdvertisement) + val ambiguousAction = ambiguous.actions.single { it.id == "upload-two-files" } + assertFailsWith { + buildDynamicApiRequest( + descriptor = ambiguous, + action = ambiguousAction, + values = emptyMap(), + ) + } + } + + @Test + fun `optional multipart file operations are withheld without fileless transport support`() { + val descriptor = compile(OPTIONAL_MULTIPART_CONTRACT, OpenApiTrust.sameOriginAdvertisement) + + assertTrue(descriptor.actions.none { it.id == "update-attachment" }) + assertTrue(descriptor.forms.none { it.actionId == "update-attachment" }) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `runtime rejects hand built optional multipart file contracts before decoding a selection`() { + val descriptor = compile(SIGNED_DESCRIPTION_CONTRACT, OpenApiTrust.sameOriginAdvertisement) + val requiredAction = descriptor.actions.single { it.id == "upload-document" } + val requiredBody = assertNotNull(requiredAction.binding.body) + val schema = requiredBody.schema as JsonObject + val optionalAction = requiredAction.copy( + binding = requiredAction.binding.copy( + body = requiredBody.copy(schema = JsonObject(schema - "required")), + ), + ) + val optionalDescriptor = descriptor.copy( + actions = descriptor.actions.map { action -> + if (action.id == optionalAction.id) optionalAction else action + }, + ) + + val failure = assertFailsWith { + buildDynamicApiRequest( + descriptor = optionalDescriptor, + action = optionalAction, + values = emptyMap(), + ) + } + + assertEquals("Optional multipart file fields are not supported.", failure.message) + } + + @Test + fun `wildcard response with exact binary schema is not compiled as a JSON layout`() { + val descriptor = compile(BINARY_PREVIEW_CONTRACT, OpenApiTrust.sameOriginAdvertisement) + val preview = descriptor.actions.single { it.id == "get-preview" } + + assertTrue(descriptor.layouts.none { it.sourceActionId == preview.id }) + } + + private fun compile(contract: String, trust: OpenApiTrust) = + DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("example", "Example", "1.0.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/apps/example/api"), + ), + advertisedOpenApi = AdvertisedOpenApi( + documentUrl = "/apps/example/openapi.json", + document = Json.parseToJsonElement(contract), + trust = trust, + ), + ), + ) + + private companion object { + val SIGNED_DESCRIPTION_CONTRACT = """ + { + "openapi": "3.0.3", + "paths": { + "/apps/example/api/items/{id}/image": { + "post": { + "operationId": "upload-item-image", + "summary": "Upload item image", + "description": "Expects a multipart/form-data request with the image file in a field named **image**.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { "204": { "description": "Uploaded" } } + } + }, + "/apps/example/api/documents": { + "post": { + "operationId": "upload-document", + "summary": "Upload document", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "type": "string", + "format": "binary", + "contentMediaType": "application/pdf" + } + } + } + } + } + }, + "responses": { "204": { "description": "Uploaded" } } + } + }, + "/apps/example/api/photos": { + "post": { + "operationId": "upload-photo", + "summary": "Upload photo", + "description": "Expects a multipart/form-data request with the image file in a field named **image**. The optional folderId and caption may be sent as additional form fields.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "folderId": { "type": "integer" }, + "caption": { "type": "string" } + } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "id": { "type": "integer" } } + } + } + } + } + } + } + } + } + } + """.trimIndent() + + val AMBIGUOUS_MULTIPART_CONTRACT = """ + { + "openapi": "3.0.3", + "paths": { + "/apps/example/api/files": { + "post": { + "operationId": "upload-two-files", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "first": { "type": "string", "format": "binary" }, + "second": { "type": "string", "format": "binary" } + } + } + } + } + }, + "responses": { "204": { "description": "Uploaded" } } + } + } + } + } + """.trimIndent() + + val OPTIONAL_MULTIPART_CONTRACT = """ + { + "openapi": "3.0.3", + "paths": { + "/apps/example/api/attachments/{id}": { + "post": { + "operationId": "update-attachment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["caption"], + "properties": { + "attachment": { "type": "string", "format": "binary" }, + "caption": { "type": "string" } + } + } + } + } + }, + "responses": { "204": { "description": "Updated" } } + } + } + } + } + """.trimIndent() + + val BINARY_PREVIEW_CONTRACT = """ + { + "openapi": "3.0.3", + "paths": { + "/apps/example/api/previews/{id}": { + "get": { + "operationId": "get-preview", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "content": { + "*/*": { + "schema": { "type": "string", "format": "binary" } + } + } + } + } + } + } + } + } + """.trimIndent() + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefreshTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefreshTest.kt new file mode 100644 index 00000000..e6b09215 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefreshTest.kt @@ -0,0 +1,334 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.CompositeDataGridSpec +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DynamicMutationRefreshTest { + @Test + fun `child creation refreshes its full declared parent child component`() { + val schema = schema( + resources = listOf("houses", "checklists", "items", "accounts"), + actions = listOf(mutation("create-checklist", "checklists", ActionIntent.create)), + relationships = listOf( + relationship("houses", "checklists"), + relationship("checklists", "items"), + ), + views = listOf( + view("houses-view", "houses"), + view("checklists-view", "checklists"), + view("items-view", "items"), + view("accounts-view", "accounts"), + ), + ) + + val plan = schema.planDynamicMutationRefresh( + action = schema.actions.single(), + selectedRecordResourceId = "houses", + ) + + requireNotNull(plan) + assertEquals(setOf("houses", "checklists", "items"), plan.affectedResourceIds) + assertEquals(setOf("houses-view", "checklists-view", "items-view"), plan.affectedViewIds) + assertEquals( + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible, + plan.selectedRecordReconciliation, + ) + assertTrue(plan.invalidateAllAppScreenSnapshots) + assertTrue(plan.reloadVisibleView) + } + + @Test + fun `deleting the selected target clears only that deleted selection`() { + val schema = schema( + resources = listOf("collections", "entries"), + actions = listOf( + mutation( + id = "delete-entry", + resourceId = "entries", + intent = ActionIntent.delete, + effect = ActionEffect.delete, + ), + ), + relationships = listOf(relationship("collections", "entries")), + ) + + assertEquals( + DynamicSelectedRecordReconciliation.ClearDeletedSelection, + schema.planDynamicMutationRefresh(schema.actions.single(), "entries") + ?.selectedRecordReconciliation, + ) + assertEquals( + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible, + schema.planDynamicMutationRefresh(schema.actions.single(), "collections") + ?.selectedRecordReconciliation, + ) + } + + @Test + fun `concrete effects exhaustively control selected record lifetime across mutation intents`() { + val mutationIntents = listOf( + ActionIntent.create, + ActionIntent.update, + ActionIntent.delete, + ActionIntent.execute, + ) + val recordRemovingEffects = setOf( + ActionEffect.delete, + ActionEffect.permanentDelete, + ActionEffect.leave, + ) + + ActionEffect.entries.forEach { effect -> + mutationIntents.forEach { intent -> + val action = mutation( + id = "reconcile-${effect.name}-${intent.name}", + resourceId = "entries", + intent = intent, + effect = effect, + ) + val schema = schema( + resources = listOf("entries"), + actions = listOf(action), + ) + val reconciliation = schema.planDynamicMutationRefresh(action, "entries") + ?.selectedRecordReconciliation + val expected = when { + effect in recordRemovingEffects -> + DynamicSelectedRecordReconciliation.ClearDeletedSelection + effect == ActionEffect.unspecified && intent == ActionIntent.delete -> + DynamicSelectedRecordReconciliation.ClearDeletedSelection + else -> + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible + } + + assertEquals( + expected, + reconciliation, + "effect=$effect intent=$intent", + ) + } + } + } + + @Test + fun `affected related records are discarded while unrelated records stay available`() { + val schema = schema( + resources = listOf("projects", "tasks", "accounts"), + actions = listOf(mutation("update-task", "tasks", ActionIntent.update)), + relationships = listOf(relationship("projects", "tasks")), + ) + val plan = requireNotNull(schema.planDynamicMutationRefresh(schema.actions.single(), null)) + val reconciled = plan.discardAffectedRelatedRecords( + mapOf( + "projects" to listOf(NativeRecord("project-1", emptyMap())), + "tasks" to listOf(NativeRecord("task-1", emptyMap())), + "accounts" to listOf(NativeRecord("account-1", emptyMap())), + ), + ) + + assertEquals(setOf("accounts"), reconciled.keys) + assertEquals("account-1", reconciled.getValue("accounts").single().id) + } + + @Test + fun `composite grid contracts connect every participating resource`() { + val composite = CompositeDataGridSpec( + parentResourceId = "catalogs", + columnResourceId = "attributes", + rowResourceId = "entries", + columnSourceActionId = "list-attributes", + rowSourceActionId = "list-entries", + columnIdentityFieldId = "id", + columnAliasFieldId = null, + columnTitleFieldId = "name", + columnTypeFieldId = null, + columnOrderFieldId = null, + rowCellMapFieldId = "values", + ) + val schema = schema( + resources = listOf("catalogs", "attributes", "entries", "accounts"), + actions = listOf(mutation("update-entry", "entries", ActionIntent.update)), + views = listOf( + view("catalog-table", "entries", composite), + view("accounts-view", "accounts"), + ), + ) + + val plan = requireNotNull(schema.planDynamicMutationRefresh(schema.actions.single(), "catalogs")) + + assertEquals(setOf("catalogs", "attributes", "entries"), plan.affectedResourceIds) + assertEquals(setOf("catalog-table"), plan.affectedViewIds) + assertEquals( + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible, + plan.selectedRecordReconciliation, + ) + } + + @Test + fun `resource matching remains exact and does not pull semantic lookalikes`() { + val schema = schema( + resources = listOf("item", "items", "item-notes"), + actions = listOf(mutation("update-item", "item", ActionIntent.update)), + relationships = listOf(relationship("items", "item-notes")), + views = listOf( + view("item-view", "item"), + view("items-view", "items"), + view("notes-view", "item-notes"), + ), + ) + + val plan = requireNotNull(schema.planDynamicMutationRefresh(schema.actions.single(), "items")) + + assertEquals(setOf("item"), plan.affectedResourceIds) + assertEquals(setOf("item-view"), plan.affectedViewIds) + assertEquals(DynamicSelectedRecordReconciliation.Keep, plan.selectedRecordReconciliation) + } + + @Test + fun `dangling relationship evidence cannot fabricate affected resources`() { + val schema = schema( + resources = listOf("parents", "children"), + actions = listOf(mutation("update-child", "children", ActionIntent.update)), + relationships = listOf( + relationship("parents", "children"), + relationship("children", "missing-resource"), + ), + ) + + val plan = requireNotNull(schema.planDynamicMutationRefresh(schema.actions.single(), null)) + + assertEquals(setOf("parents", "children"), plan.affectedResourceIds) + assertFalse("missing-resource" in plan.affectedResourceIds) + } + + @Test + fun `missing and read only actions do not produce mutation refresh plans`() { + val schema = schema( + resources = listOf("entries"), + actions = listOf( + ActionSpec( + id = "list-entries", + label = "List entries", + resourceId = "entries", + binding = ApiBinding(HttpMethod.GET, "/entries", "listEntries"), + intent = ActionIntent.list, + risk = ActionRisk.readOnly, + requiresConfirmation = false, + confidence = Confidence.verified, + ), + ), + ) + + val readAction = schema.actions.single() + assertNull( + schema.planDynamicMutationRefresh( + readAction.copy(id = "missing"), + null, + ), + ) + assertNull(schema.planDynamicMutationRefresh(readAction, null)) + } + + @Test + fun `modified action copy cannot impersonate the canonical schema mutation`() { + val schema = schema( + resources = listOf("entries"), + actions = listOf(mutation("update-entry", "entries", ActionIntent.update)), + ) + val canonical = schema.actions.single() + + assertNull( + schema.planDynamicMutationRefresh( + canonical.copy(resourceId = "other"), + null, + ), + ) + } + + private fun schema( + resources: List, + actions: List, + relationships: List = emptyList(), + views: List = emptyList(), + ): NativeAppSchema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("example", "Example", "1"), + confidence = Confidence.verified, + resources = resources.map { id -> + ResourceSpec(id, id, Confidence.verified) + }, + actions = actions, + relationships = relationships, + views = views, + ) + + private fun mutation( + id: String, + resourceId: String, + intent: ActionIntent, + effect: ActionEffect = ActionEffect.unspecified, + ): ActionSpec = ActionSpec( + id = id, + label = id, + resourceId = resourceId, + binding = ApiBinding( + method = when (intent) { + ActionIntent.delete -> HttpMethod.DELETE + ActionIntent.create -> HttpMethod.POST + else -> HttpMethod.PATCH + }, + path = "/$resourceId/{id}", + operationId = id, + pathParameterNames = listOf("id"), + requiredPathParameterNames = listOf("id"), + ), + intent = intent, + risk = if (intent == ActionIntent.delete) ActionRisk.destructive else ActionRisk.mutating, + requiresConfirmation = intent == ActionIntent.delete, + confidence = Confidence.verified, + effect = effect, + ) + + private fun relationship( + parentResourceId: String, + childResourceId: String, + ): ResourceRelationshipSpec = ResourceRelationshipSpec( + parentResourceId = parentResourceId, + childResourceId = childResourceId, + parentFieldId = "id", + childFieldId = "${parentResourceId}Id", + confidence = Confidence.verified, + ) + + private fun view( + id: String, + resourceId: String, + composite: CompositeDataGridSpec? = null, + ): ViewSpec = ViewSpec( + id = id, + title = id, + resourceId = resourceId, + component = if (composite == null) NativeComponent.collectionList else NativeComponent.dataTable, + sourceActionId = "read-$resourceId", + confidence = Confidence.verified, + compositeDataGrid = composite, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt index 39e0b615..f108fd6e 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt @@ -1,8 +1,13 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_APP_DESCRIPTOR_VERSION +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.time.Duration.Companion.minutes @@ -74,6 +79,60 @@ class DynamicNativeMemoryCacheTest { assertEquals("1", cache.screen(key)?.records?.single()?.id) } + @Test + fun `last known contract remains available but is never considered fresh`() { + val cache = DynamicNativeMemoryCache() + val discovery = DynamicDescriptorDiscovery( + descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("pantry", "Pantry", "0.23.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/ocs/v2.php/apps/pantry"), + ), + ), + sourcePath = "signed-package/openapi.json", + acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, + versionStatus = DynamicContractVersionStatus.LastKnownReadOnly, + ) + + cache.storeDiscovery(session, "pantry", discovery) + + assertEquals(discovery, cache.discovery(session, "pantry")) + assertFalse(cache.isDiscoveryFresh(session, "pantry")) + } + + @Test + fun `mutation invalidation removes only screens for the exact account and app`() { + val cache = DynamicNativeMemoryCache() + val target = dynamicScreenCacheKey(session, "pantry", "items.list", null, emptyMap()) + val siblingView = dynamicScreenCacheKey(session, "pantry", "lists.list", null, emptyMap()) + val otherApp = dynamicScreenCacheKey(session, "tasks", "tasks.list", null, emptyMap()) + val otherAccount = dynamicScreenCacheKey( + session.copy(loginName = "bob"), + "pantry", + "items.list", + null, + emptyMap(), + ) + listOf(target, siblingView, otherApp, otherAccount).forEachIndexed { index, key -> + cache.storeScreen( + key, + DynamicScreenSnapshot( + records = listOf(NativeRecord(index.toString(), mapOf("id" to index.toString()))), + relatedRecords = emptyMap(), + ), + ) + } + + cache.invalidateScreens(session, "pantry") + + assertNull(cache.screen(target)) + assertNull(cache.screen(siblingView)) + assertEquals("2", cache.screen(otherApp)?.records?.single()?.id) + assertEquals("3", cache.screen(otherAccount)?.records?.single()?.id) + } + @Test fun `dynamic response identity is stable across query order and contains no credentials`() { val first = NextcloudApiRequest( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntimeTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntimeTest.kt index 9d73f236..0870a78c 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntimeTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntimeTest.kt @@ -7,8 +7,11 @@ import dev.obiente.nextcloudnative.nativeui.model.AuthKind import dev.obiente.nextcloudnative.nativeui.model.AuthRequirement import dev.obiente.nextcloudnative.nativeui.model.Confidence import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_APP_DESCRIPTOR_VERSION +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.DynamicAction import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicField import dev.obiente.nextcloudnative.nativeui.model.DynamicHttpBinding import dev.obiente.nextcloudnative.nativeui.model.DynamicResource import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy @@ -21,10 +24,13 @@ import dev.obiente.nextcloudnative.nativeui.model.OcsMetadata import dev.obiente.nextcloudnative.nativeui.model.ParameterSource import dev.obiente.nextcloudnative.nativeui.model.Provenance import dev.obiente.nextcloudnative.nativeui.model.ProvenanceKind +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionExecutionResult +import dev.obiente.nextcloudnative.nativeui.runtime.NativeActionFailureOutcome import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredScalarKind import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredValue import dev.obiente.nextcloudnative.nativeui.runtime.actionBindingValues +import dev.obiente.nextcloudnative.nativeui.runtime.safeActionBindingValues import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -34,10 +40,199 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue class DynamicNativeRuntimeTest { + @Test + fun `nested collection read qualifies a generic parent identity from its declared response`() { + val action = readAction().copy( + resourceId = "categories", + responseFieldIds = listOf("id", "houseId", "name"), + binding = readAction().binding.copy( + path = "/apps/example/api/houses/{id}/categories", + pathParameters = listOf( + HttpParameter( + name = "id", + required = true, + schema = JsonObject(mapOf("type" to JsonPrimitive("integer"))), + source = ParameterSource.runtimeContext, + ), + ), + ), + ) + + val context = dynamicReadBindingContext( + action = action, + values = mapOf("id" to "house-7"), + runtimeContext = emptyMap(), + ) + + assertEquals(mapOf("houseId" to "house-7"), context) + val category = NativeRecord( + id = "category-11", + values = mapOf( + "id" to "category-11", + "houseId" to "house-7", + "name" to "Fruit", + ), + bindingContext = context, + ) + assertEquals("category-11", category.actionBindingValues()["id"]) + assertEquals("house-7", category.actionBindingValues()["houseId"]) + assertNull( + category.copy( + values = category.values + ("houseId" to "different-house"), + ).safeActionBindingValues(), + ) + } + + @Test + fun `generic identity is not requalified without exact nested response evidence`() { + val base = readAction() + val parameter = HttpParameter( + name = "id", + required = true, + schema = JsonObject(mapOf("type" to JsonPrimitive("string"))), + source = ParameterSource.runtimeContext, + ) + val terminal = base.copy( + responseFieldIds = listOf("id", "houseId"), + binding = base.binding.copy( + path = "/apps/example/api/categories/{id}", + pathParameters = listOf(parameter), + ), + ) + val undeclaredParent = terminal.copy( + responseFieldIds = listOf("id", "name"), + binding = terminal.binding.copy(path = "/apps/example/api/houses/{id}/categories"), + ) + val ambiguousParent = terminal.copy( + responseFieldIds = listOf("id", "houseId", "housesId"), + binding = terminal.binding.copy(path = "/apps/example/api/houses/{id}/categories"), + ) + val unrelatedNestedResource = terminal.copy( + resourceId = "items", + binding = terminal.binding.copy(path = "/apps/example/api/houses/{id}/categories"), + ) + + listOf(terminal, undeclaredParent, ambiguousParent, unrelatedNestedResource).forEach { action -> + assertEquals( + mapOf("id" to "value-7"), + dynamicReadBindingContext( + action = action, + values = mapOf("id" to "value-7"), + runtimeContext = emptyMap(), + ), + ) + } + } + + @Test + fun `last known contracts allow reads but fail closed for mutations`() { + assertTrue(DynamicContractVersionStatus.LastKnownReadOnly.allows(readAction().risk)) + assertFalse(DynamicContractVersionStatus.LastKnownReadOnly.allows(createAction().risk)) + assertTrue(DynamicContractVersionStatus.VerifiedCurrent.allows(createAction().risk)) + } + + @Test + fun `declared OCS mutation accepts explicit successful metadata`() { + val result = response( + """{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK"},"data":{"id":7}}}""", + ).toDynamicActionExecutionResult(createAction()) + + assertEquals( + "Create item completed.", + assertIs(result).message, + ) + } + + @Test + fun `declared OCS mutation rejects HTTP 200 failure with sanitized message`() { + val result = response( + """{"ocs":{"meta":{"status":"failure","statuscode":997,"message":"Current user is not\nauthorized"},"data":[]}}""", + ).toDynamicActionExecutionResult(createAction()) + val failure = assertIs(result) + + assertEquals( + "The server rejected Create item: Current user is not authorized.", + failure.message, + ) + assertEquals(NativeActionFailureOutcome.Rejected, failure.outcome) + } + + @Test + fun `declared OCS mutation rejects a non success status code`() { + val result = response( + """{"ocs":{"meta":{"status":"ok","statuscode":403,"message":"Permission denied"},"data":[]}}""", + ).toDynamicActionExecutionResult(createAction()) + + assertEquals( + "The server rejected Create item: Permission denied.", + assertIs(result).message, + ) + } + + @Test + fun `declared OCS mutation fails closed on contradictory metadata`() { + val result = response( + """{"ocs":{"meta":{"status":"failure","statuscode":100,"message":"Contradictory result"},"data":[]}}""", + ).toDynamicActionExecutionResult(createAction()) + + assertIs(result) + } + + @Test + fun `declared OCS mutation fails closed on malformed metadata without exposing its body`() { + val secretBody = """{"ocs":{"meta":{"status":"ok","message":"token=must-not-leak"},"data":[]}}""" + val result = response(secretBody).toDynamicActionExecutionResult(createAction()) + val failure = assertIs(result) + val message = failure.message + + assertEquals( + "The server returned invalid OCS metadata for Create item.", + message, + ) + assertEquals(NativeActionFailureOutcome.Unknown, failure.outcome) + assertFalse(message.contains("must-not-leak")) + } + + @Test + fun `mutation HTTP failures distinguish rejection from an unknown outcome`() { + val rejected = response( + body = """{"error":"conflict"}""", + status = 409, + ).toDynamicActionExecutionResult(createAction()) + val unavailable = response( + body = """{"error":"unavailable"}""", + status = 503, + ).toDynamicActionExecutionResult(createAction()) + + assertEquals( + NativeActionFailureOutcome.Rejected, + assertIs(rejected).outcome, + ) + assertEquals( + NativeActionFailureOutcome.Unknown, + assertIs(unavailable).outcome, + ) + } + + @Test + fun `ordinary non OCS mutation preserves HTTP 2xx success behavior`() { + val action = createAction().copy( + binding = createAction().binding.copy(ocs = null), + ) + val result = response("not an OCS envelope", contentType = "text/plain") + .toDynamicActionExecutionResult(action) + + assertEquals( + "Create item completed.", + assertIs(result).message, + ) + } + @Test fun dynamicArtworkRequestsStayInsideTheActiveAppNamespace() { val request = dynamicAppAssetRequest( @@ -216,6 +411,178 @@ class DynamicNativeRuntimeTest { assertFalse("undeclared" in body) } + @Test + fun `pantry checklist create binds house path separately from its JSON body`() { + val action = DynamicAction( + id = "checklist-create-list", + label = "Create a checklist in a house", + resourceId = "lists", + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + binding = DynamicHttpBinding( + method = HttpMethod.POST, + path = "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists", + pathParameters = listOf( + HttpParameter( + name = "houseId", + required = true, + schema = json.parseToJsonElement("""{"type":"integer","format":"int64"}"""), + source = ParameterSource.resourceField, + ), + ), + body = HttpBody( + contentType = "application/json", + required = true, + schema = json.parseToJsonElement( + """ + { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "description": {"type": "string", "nullable": true}, + "icon": {"type": "string", "nullable": true}, + "color": {"type": "string", "nullable": true} + } + } + """.trimIndent(), + ), + ), + auth = listOf(AuthRequirement("basicAuth", AuthKind.basic)), + ocs = OcsMetadata( + apiRequestHeader = true, + responseDataPointer = "/ocs/data", + responseMetaPointer = "/ocs/meta", + formatQueryParameter = "format", + ), + ), + confidence = Confidence.high, + provenance = listOf( + Provenance( + kind = ProvenanceKind.verifiedAppPackage, + source = "pantry-0.23.0", + detail = "Verified signed Pantry OpenAPI contract", + ), + ), + ) + val descriptor = descriptor(action).copy( + app = AppIdentity("pantry", "Pantry", "0.23.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/ocs/v2.php/apps/pantry/api"), + ), + ) + + val request = buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = mapOf( + "houseId" to "7", + "name" to "Groceries", + "description" to "Weekly shop", + "undeclared" to "must-not-leak", + ), + ) + + assertEquals("/ocs/v2.php/apps/pantry/api/houses/7/lists", request.relativePath) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + assertEquals(setOf("name", "description"), body.keys) + assertEquals(JsonPrimitive("Groceries"), body["name"]) + assertEquals(JsonPrimitive("Weekly shop"), body["description"]) + assertFalse("houseId" in body) + assertFalse("undeclared" in body) + } + + @Test + fun `blank optional nullable strings are omitted without erasing intentional empty strings`() { + val action = createAction( + bodySchema = + """ + { + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string"}, + "content": {"type": "string"}, + "color": {"type": "string", "nullable": true} + } + } + """.trimIndent(), + ) + val descriptor = descriptor(action) + val sharedValues = mapOf( + "projectId" to "7", + "limit" to "25", + "title" to "Sandbox note", + "content" to "", + "color" to "", + ) + val request = buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = sharedValues, + runtimeContext = mapOf("owner" to "sandbox"), + ) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + + assertEquals(setOf("title", "content"), body.keys) + assertEquals(JsonPrimitive("Sandbox note"), body["title"]) + assertEquals(JsonPrimitive(""), body["content"]) + assertFalse("color" in body) + + val coloredRequest = buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = sharedValues + ("color" to "#A1B2C3"), + runtimeContext = mapOf("owner" to "sandbox"), + ) + val coloredBody = json.parseToJsonElement( + requireNotNull(coloredRequest.body).decodeToString(), + ) as JsonObject + assertEquals(JsonPrimitive("#A1B2C3"), coloredBody["color"]) + + assertFailsWith { + buildDynamicApiRequest( + descriptor = descriptor, + action = action, + values = sharedValues + ("title" to ""), + runtimeContext = mapOf("owner" to "sandbox"), + ) + } + } + + @Test + fun `openapi 31 nullable string union also omits a blank optional value`() { + val action = createAction( + bodySchema = + """ + { + "type": "object", + "required": ["title"], + "properties": { + "title": {"type": "string"}, + "color": {"type": ["string", "null"]} + } + } + """.trimIndent(), + ) + val request = buildDynamicApiRequest( + descriptor = descriptor(action), + action = action, + values = mapOf( + "projectId" to "7", + "limit" to "25", + "title" to "Sandbox note", + "color" to "", + ), + runtimeContext = mapOf("owner" to "sandbox"), + ) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + + assertEquals(setOf("title"), body.keys) + } + @Test fun `collection reads request a useful initial page when a typed limit is optional`() { val action = readAction().copy( @@ -236,6 +603,102 @@ class DynamicNativeRuntimeTest { assertEquals("50", request.queryParameters["limit"]) } + @Test + fun `initial collection page size honors declared defaults and inclusive bounds`() { + data class Case( + val name: String, + val schema: String, + val expected: String?, + ) + + listOf( + Case( + name = "limit", + schema = """{"type":"integer","default":12,"minimum":1,"maximum":20}""", + expected = "12", + ), + Case( + name = "pageSize", + schema = """{"type":"integer","maximum":20}""", + expected = "20", + ), + Case( + name = "perPage", + schema = """{"type":"number","minimum":75,"maximum":100}""", + expected = "75", + ), + Case( + name = "maxResults", + schema = """{"type":"integer","minimum":501}""", + expected = null, + ), + Case( + name = "limit", + schema = """{"type":"integer","default":25,"maximum":20}""", + expected = null, + ), + Case( + name = "limit", + schema = """{"type":"number","default":12.5,"minimum":1,"maximum":20}""", + expected = null, + ), + ).forEach { case -> + val action = readAction().copy( + binding = readAction().binding.copy( + queryParameters = listOf( + HttpParameter( + name = case.name, + required = false, + schema = json.parseToJsonElement(case.schema), + source = ParameterSource.userInput, + ), + ), + ), + ) + + val request = buildDynamicApiRequest(descriptor(action), action, values = emptyMap()) + + assertEquals(case.expected, request.queryParameters[case.name], case.toString()) + } + } + + @Test + fun `pagination expectation uses the same schema derived page size as the initial request`() { + fun action(pageSizeSchema: String): DynamicAction = readAction().copy( + binding = readAction().binding.copy( + queryParameters = listOf( + HttpParameter( + name = "page_size", + required = false, + schema = json.parseToJsonElement(pageSizeSchema), + source = ParameterSource.userInput, + ), + HttpParameter( + name = "page", + required = false, + schema = json.parseToJsonElement("""{"type":"integer"}"""), + source = ParameterSource.userInput, + ), + ), + ), + ) + + val bounded = action("""{"type":"integer","default":18,"minimum":5,"maximum":20}""") + assertEquals( + "18", + buildDynamicApiRequest(descriptor(bounded), bounded, values = emptyMap()) + .queryParameters["page_size"], + ) + assertEquals(18, requireNotNull(bounded.dynamicPaginationSpec()).expectedPageSize) + + val unsafe = action("""{"type":"integer","minimum":501}""") + assertNull( + buildDynamicApiRequest(descriptor(unsafe), unsafe, values = emptyMap()) + .queryParameters["page_size"], + ) + assertNull(requireNotNull(unsafe.dynamicPaginationSpec()).expectedPageSize) + } + @Test fun `typed optional page parameter enables reusable page number pagination`() { val action = readAction().copy( @@ -518,6 +981,316 @@ class DynamicNativeRuntimeTest { ) } + @Test + fun `exact declared integer arrays encode bounded JSON numbers and omit blank optional values`() { + val bodySchema = json.parseToJsonElement( + """{ + "type":"object", + "additionalProperties":false, + "properties":{ + "requiredIds":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + }, + "optionalIds":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + } + }, + "required":["requiredIds"] + }""", + ) + val action = readAction().copy( + id = "assignments.update", + resourceId = "assignments", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/assignments", + body = HttpBody("application/json", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified assignment setter"), + ), + ) + + val request = buildDynamicApiRequest( + descriptor(action), + action, + values = mapOf( + "requiredIds" to "[7,-2,7,9223372036854775807]", + "optionalIds" to " ", + "undeclaredIds" to "[99]", + ), + ) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + + assertEquals(setOf("requiredIds"), body.keys) + assertEquals( + JsonArray( + listOf( + JsonPrimitive(7), + JsonPrimitive(-2), + JsonPrimitive(7), + JsonPrimitive(Long.MAX_VALUE), + ), + ), + body["requiredIds"], + ) + } + + @Test + fun `form encoded arrays fail closed while scalar siblings remain supported`() { + val bodySchema = json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + }, + "label":{"type":"string"} + } + }""", + ) + val action = readAction().copy( + id = "assignments.form.update", + resourceId = "assignments", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.POST, + path = "/ocs/v2.php/apps/example/api/assignments", + body = HttpBody("application/x-www-form-urlencoded", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified assignment setter"), + ), + ) + + val scalarRequest = buildDynamicApiRequest( + descriptor(action), + action, + values = mapOf("label" to "One"), + ) + assertEquals("label=One", requireNotNull(scalarRequest.body).decodeToString()) + + assertFailsWith { + buildDynamicApiRequest( + descriptor(action), + action, + values = mapOf("ids" to "[1,2]", "label" to "One"), + ) + } + } + + @Test + fun `integer array encoding enforces every supported declared constraint`() { + val bodySchema = json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{ + "type":"integer", + "minimum":2, + "exclusiveMaximum":12, + "multipleOf":2 + }, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT", + "minItems":2, + "maxItems":3, + "uniqueItems":true + } + }, + "required":["ids"] + }""", + ) + val action = readAction().copy( + id = "assignments.update", + resourceId = "assignments", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/assignments", + body = HttpBody("application/json", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified assignment setter"), + ), + ) + + val request = buildDynamicApiRequest( + descriptor(action), + action, + values = mapOf("ids" to "[2,4,10]"), + ) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + assertEquals( + JsonArray(listOf(JsonPrimitive(2), JsonPrimitive(4), JsonPrimitive(10))), + body["ids"], + ) + + listOf( + "[]", + "[2]", + "[2,4,6,8]", + "[2,2]", + "[0,2]", + "[2,12]", + "[2,3]", + ).forEach { value -> + assertFailsWith(value) { + buildDynamicApiRequest(descriptor(action), action, values = mapOf("ids" to value)) + } + } + } + + @Test + fun `blank optional typed scalars are omitted while blank strings remain explicit`() { + val bodySchema = json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "categoryId":{"type":"integer","nullable":true}, + "amount":{"type":"number","nullable":true}, + "enabled":{"type":"boolean"}, + "description":{"type":"string"} + } + }""", + ) + val action = readAction().copy( + id = "entries.create", + resourceId = "entries", + intent = ActionIntent.create, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.POST, + path = "/ocs/v2.php/apps/example/api/entries", + body = HttpBody("application/json", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified entry create"), + ), + ) + + val request = buildDynamicApiRequest( + descriptor(action), + action, + values = mapOf( + "categoryId" to "", + "amount" to " ", + "enabled" to "", + "description" to "", + ), + ) + val body = json.parseToJsonElement(requireNotNull(request.body).decodeToString()) as JsonObject + + assertEquals(setOf("description"), body.keys) + assertEquals(JsonPrimitive(""), body["description"]) + } + + @Test + fun `integer array encoding rejects malformed wrong-type overflowing and oversized inputs`() { + val bodySchema = json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + } + }, + "required":["ids"] + }""", + ) + val action = readAction().copy( + id = "assignments.update", + resourceId = "assignments", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.PUT, + body = HttpBody("application/json", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified assignment setter"), + ), + ) + val tooMany = (0..256).joinToString(prefix = "[", postfix = "]") + listOf( + "1\n2", + """[1,"2"]""", + "[1.5]", + "[true]", + """[{"id":1}]""", + "[9223372036854775808]", + tooMany, + ).forEach { value -> + assertFailsWith(value) { + buildDynamicApiRequest(descriptor(action), action, values = mapOf("ids" to value)) + } + } + } + + @Test + fun `integer-looking arrays without an exact declared integer format fail closed`() { + fun requestFor(propertySchema: String) { + val bodySchema = json.parseToJsonElement( + """{"type":"object","properties":{"ids":$propertySchema},"required":["ids"]}""", + ) + val action = readAction().copy( + id = "assignments.update", + resourceId = "assignments", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + binding = readAction().binding.copy( + method = HttpMethod.PUT, + body = HttpBody("application/json", true, bodySchema), + ), + provenance = listOf( + Provenance(ProvenanceKind.verifiedAppPackage, "signed-package", "Verified assignment setter"), + ), + ) + assertFailsWith { + buildDynamicApiRequest(descriptor(action), action, values = mapOf("ids" to "[1,2]")) + } + } + + requestFor("""{"type":"array","items":{"type":"integer"}}""") + requestFor( + """{ + "type":"array", + "items":{"type":"string"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + }""", + ) + requestFor("""{"type":"array","items":{"type":"number"}}""") + requestFor("""{"type":"array"}""") + requestFor( + """{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT", + "contains":{"const":1} + }""", + ) + requestFor( + """{ + "type":"array", + "items":{"type":"integer","enum":[1,2]}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + }""", + ) + } + @Test fun `verified observed settings serialize only bounded boolean maps as objects`() { val bodySchema = json.parseToJsonElement( @@ -1434,6 +2207,50 @@ class DynamicNativeRuntimeTest { assertTrue(records.none(NativeRecord::actionSafeIdentity)) } + @Test + fun `write-only resource identity fields cannot authorize records from a read response`() = runBlocking { + val read = readAction().copy(responseFieldIds = listOf("title")) + val base = descriptor(read) + val descriptor = base.copy( + resources = listOf( + base.resources.single().copy( + fields = listOf( + DynamicField( + id = "id", + label = "ID", + kind = FieldKind.string, + required = true, + readOnly = false, + nullable = false, + multiple = false, + confidence = Confidence.high, + ), + DynamicField( + id = "title", + label = "Title", + kind = FieldKind.string, + required = true, + readOnly = false, + nullable = false, + multiple = false, + confidence = Confidence.high, + ), + ), + ), + ), + ) + + val record = executeDynamicReadWithFallback(descriptor, read.id) { + response( + """{"ocs":{"meta":{"status":"ok","statuscode":100},"data":[{"id":"write-only","title":"Visible"}]}}""", + ) + }.single() + + assertEquals("write-only", record.id) + assertEquals("Visible", record.values["title"]) + assertFalse(record.actionSafeIdentity) + } + @Test fun preferredReadUsesHiddenFallbackOnlyAfterFailureOrEmptyResult() = runBlocking { val fallback = readAction().copy( @@ -1581,6 +2398,103 @@ class DynamicNativeRuntimeTest { ) } + @Test + fun `typed object rows are canonicalized and exact get recovery is executable`() { + val provenance = listOf( + Provenance( + ProvenanceKind.verifiedAppPackage, + "signed package", + "Verified signed contract", + ), + ) + val pathParameter = HttpParameter( + name = "itemId", + required = true, + schema = json.parseToJsonElement("""{"type":"integer"}"""), + source = ParameterSource.resourceField, + ) + val path = "/ocs/v2.php/apps/example/api/items/{itemId}/share" + val read = readAction().copy( + id = "item-shares.read", + intent = ActionIntent.read, + binding = readAction().binding.copy( + path = path, + pathParameters = listOf(pathParameter), + queryParameters = emptyList(), + body = null, + ), + provenance = provenance, + ) + val bodySchema = json.parseToJsonElement( + """ + { + "type":"object", + "required":["shares"], + "properties":{ + "shares":{ + "type":"array", + "format":"$DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT", + "minItems":1, + "maxItems":4, + "items":{ + "type":"object", + "additionalProperties":false, + "required":["uid","permission"], + "properties":{ + "uid":{"type":"string","minLength":1}, + "permission":{"type":"string","enum":["view","edit"]} + } + } + } + } + } + """.trimIndent(), + ) + val replacement = read.copy( + id = "item-share", + label = "Share item", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + resultRecoveryActionId = read.id, + binding = read.binding.copy( + method = HttpMethod.PUT, + body = HttpBody("application/json", true, bodySchema), + ), + ) + val descriptor = descriptor(replacement).copy(actions = listOf(read, replacement)) + + val request = buildDynamicApiRequest( + descriptor, + replacement, + values = mapOf( + "itemId" to "7", + "shares" to """[ { "permission":"edit", "uid":"alice" } ]""", + ), + ) + assertEquals( + """{"shares":[{"uid":"alice","permission":"edit"}]}""", + requireNotNull(request.body).decodeToString(), + ) + assertFailsWith { + buildDynamicApiRequest( + descriptor, + replacement, + values = mapOf( + "itemId" to "7", + "shares" to """[{"uid":"alice","permission":"owner"}]""", + ), + ) + } + + val recovery = buildDynamicResultRecoveryRequest( + descriptor = descriptor, + mutation = replacement, + values = mapOf("itemId" to "7"), + ) + assertEquals("/ocs/v2.php/apps/example/api/items/7/share", recovery?.relativePath) + assertEquals(NextcloudApiMethod.GET, recovery?.method) + } + @Test fun `signed operational sync route becomes a bounded native recovery request`() { val sync = readAction().copy( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationHistoryPersistenceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationHistoryPersistenceTest.kt new file mode 100644 index 00000000..600a0c2c --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationHistoryPersistenceTest.kt @@ -0,0 +1,124 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredScalarKind +import dev.obiente.nextcloudnative.nativeui.runtime.NativeStructuredValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DynamicNavigationHistoryPersistenceTest { + @Test + fun `saved history is bounded and excludes complete record payloads`() { + val largePayload = "private-record-payload-".repeat(2_000) + val history = (0 until MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY + 7).map { index -> + DynamicNavigationSnapshot( + viewId = "view-$index", + resourceId = "resource-$index", + record = NativeRecord( + id = "record-$index", + values = mapOf("description" to largePayload), + displayValues = mapOf("rendered" to largePayload), + ephemeralFields = listOf( + FieldSpec( + id = "ephemeral", + label = largePayload, + kind = FieldKind.string, + required = false, + readOnly = true, + ), + ), + structuredValues = mapOf( + "nested" to NativeStructuredValue.Scalar( + value = largePayload, + kind = NativeStructuredScalarKind.string, + ), + ), + bindingContext = mapOf("completeBinding" to largePayload), + ), + recordResourceId = "resource-$index", + pathParameterValues = mapOf("parentId" to "parent-$index"), + ) + } + + val saved = saveDynamicNavigationHistory(history) + val encoded = Json.encodeToString(saved) + + assertEquals(MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY, saved.size) + assertEquals("view-7", saved.first().viewId) + assertEquals("view-22", saved.last().viewId) + assertFalse(encoded.contains("private-record-payload")) + assertFalse(encoded.contains("completeBinding")) + assertTrue(encoded.length < 16_000) + } + + @Test + fun `restored history keeps navigation identity but cannot authorize writes`() { + val saved = listOf( + SavedDynamicNavigationSnapshot( + viewId = "items.detail", + resourceId = "items", + recordId = "item-9", + recordResourceId = "items", + pathParameterValues = mapOf("collectionId" to "collection-4"), + ), + ) + + val restored = restoreDynamicNavigationHistory(saved).single() + + assertEquals("items.detail", restored.viewId) + assertEquals("items", restored.resourceId) + assertEquals("item-9", restored.record?.id) + assertFalse(requireNotNull(restored.record).actionSafeIdentity) + assertTrue(restored.record.values.isEmpty()) + assertTrue(restored.record.bindingContext.isEmpty()) + assertEquals(mapOf("collectionId" to "collection-4"), restored.pathParameterValues) + } + + @Test + fun `unsafe or oversized saved locations are omitted instead of partially truncated`() { + val valid = DynamicNavigationSnapshot( + viewId = "items.list", + resourceId = "items", + record = null, + recordResourceId = null, + pathParameterValues = emptyMap(), + ) + val oversizedContext = valid.copy( + viewId = "items.children", + pathParameterValues = (0..8).associate { index -> "parent$index" to index.toString() }, + ) + val oversizedView = valid.copy(viewId = "v".repeat(129)) + + assertEquals( + listOf("items.list"), + saveDynamicNavigationHistory(listOf(oversizedContext, oversizedView, valid)) + .map(SavedDynamicNavigationSnapshot::viewId), + ) + } + + @Test + fun `restored history is bounded and rejects oversized serialized context`() { + val history = (0 until MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY + 4).map { index -> + SavedDynamicNavigationSnapshot( + viewId = "view-$index", + resourceId = "items", + ) + } + SavedDynamicNavigationSnapshot( + viewId = "items.children", + resourceId = "items", + pathParameterValues = mapOf("parentId" to "x".repeat(257)), + ) + + val restored = restoreDynamicNavigationHistory(history) + + assertEquals(MAX_SAVED_DYNAMIC_NAVIGATION_HISTORY - 1, restored.size) + assertEquals("view-5", restored.first().viewId) + assertEquals("view-19", restored.last().viewId) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationParameterInheritanceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationParameterInheritanceTest.kt index ee8f1479..ad02ae9a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationParameterInheritanceTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNavigationParameterInheritanceTest.kt @@ -16,8 +16,22 @@ import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue class DynamicNavigationParameterInheritanceTest { + @Test + fun `context records with several destinations open a section menu`() { + assertFalse(shouldShowDynamicContextDestinationMenu(emptyList())) + assertFalse(shouldShowDynamicContextDestinationMenu(listOf("items"))) + assertFalse(shouldShowDynamicContextDestinationMenu(listOf("items", "items"))) + assertTrue(shouldShowDynamicContextDestinationMenu(listOf("items", "notes"))) + assertTrue( + shouldShowDynamicContextDestinationMenu( + listOf("items", "notes", "photos", "preferences"), + ), + ) + } + @Test fun exactCollectionDetailBeatsEarlierSingularRandomResource() { fun action( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicParentRuntimeValuesTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicParentRuntimeValuesTest.kt new file mode 100644 index 00000000..61fb4e35 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicParentRuntimeValuesTest.kt @@ -0,0 +1,109 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DynamicParentRuntimeValuesTest { + @Test + fun `selected parent supplies exact declared child action path identity`() { + val values = assertNotNull(selectedDynamicRecordRuntimeValues( + record = NativeRecord( + id = "7", + values = mapOf("id" to "7", "name" to "Home"), + actionSafeIdentity = true, + ), + resourceId = "collections", + parameterNames = listOf("collectionId"), + )) + + assertEquals("7", values["id"]) + assertEquals("7", values["collectionId"]) + } + + @Test + fun `selected parent does not satisfy an unrelated path identity`() { + val values = assertNotNull(selectedDynamicRecordRuntimeValues( + record = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + actionSafeIdentity = true, + ), + resourceId = "collections", + parameterNames = listOf("entryId"), + )) + + assertFalse("entryId" in values) + } + + @Test + fun `conflicting restored parent identity fails closed`() { + val values = selectedDynamicRecordRuntimeValues( + record = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + bindingContext = mapOf("collectionId" to "11"), + actionSafeIdentity = true, + ), + resourceId = "collections", + parameterNames = listOf("collectionId"), + ) + + assertNull(values) + } + + @Test + fun `ambiguous selected record provenance fails closed`() { + val values = selectedDynamicRecordRuntimeValues( + record = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + actionSafeIdentity = true, + actionBindingProvenanceValid = false, + ), + resourceId = "collections", + parameterNames = listOf("collectionId"), + ) + + assertNull(values) + } + + @Test + fun `form binding receives only declared technical runtime values`() { + val values = dynamicDatasetBindingValues( + component = NativeComponent.form, + declaredParameterNames = listOf("collectionId", "sort"), + selectedPathParameterValues = mapOf( + "id" to "unrelated-parent-id", + "sort" to "custom", + ), + runtimeValues = mapOf( + "id" to "7", + "collectionId" to "7", + "name" to "Shared home", + "sort" to "stale", + ), + ) + + assertEquals( + mapOf("collectionId" to "7", "sort" to "custom"), + values, + ) + } + + @Test + fun `collection binding keeps its exact navigation context`() { + val values = dynamicDatasetBindingValues( + component = NativeComponent.collectionList, + declaredParameterNames = listOf("collectionId"), + selectedPathParameterValues = mapOf("collectionId" to "7"), + runtimeValues = mapOf("collectionId" to "8"), + ) + + assertEquals(mapOf("collectionId" to "7"), values) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenarioTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenarioTest.kt new file mode 100644 index 00000000..72a568c0 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenarioTest.kt @@ -0,0 +1,178 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.app.design.NextcloudPresentation +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRelationOptionWindow +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRelationOptions +import dev.obiente.nextcloudnative.nativeui.runtime.nativeFormDisplayFields +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRecordActions +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRecordPresentation +import dev.obiente.nextcloudnative.nativeui.runtime.nativeScalarRelationClearChoice +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MarketingDynamicUiCaptureScenarioTest { + @Test + fun `synthetic dynamic visual QA is paired across desktop and phone`() { + val desktop = MarketingCaptureScenario.AdaptiveApp + val phone = MarketingCaptureScenario.AdaptiveAppMobile + val phoneCollection = MarketingCaptureScenario.AdaptiveAppCollectionMobile + val phoneContextMenu = MarketingCaptureScenario.AdaptiveAppContextMenuMobile + + assertEquals(NextcloudPresentation.Desktop, desktop.presentation) + assertEquals(NextcloudPresentation.Adaptive, phone.presentation) + assertEquals("Nested collection and semantic form", desktop.surface) + assertEquals(desktop.surface, phone.surface) + assertEquals("Synthetic visual QA", desktop.state) + assertEquals(desktop.state, phone.state) + assertEquals(MarketingCapturePurpose.Showcase, desktop.purpose) + assertEquals(MarketingCapturePurpose.StateCoverage, phone.purpose) + assertEquals(1_440 to 900, desktop.width to desktop.height) + assertEquals(1_080 to 1_800, phone.width to phone.height) + assertEquals(NextcloudPresentation.Adaptive, phoneCollection.presentation) + assertEquals("Nested collection actions", phoneCollection.surface) + assertEquals(1_080 to 1_800, phoneCollection.width to phoneCollection.height) + assertEquals(NextcloudPresentation.Adaptive, phoneContextMenu.presentation) + assertEquals("Context workspace menu", phoneContextMenu.surface) + } + + @Test + fun `fixture declares every intended generic visual QA state`() { + assertEquals( + setOf( + MarketingDynamicUiFeature.ContractIdentity, + MarketingDynamicUiFeature.RecordVisualIdentity, + MarketingDynamicUiFeature.NestedCollection, + MarketingDynamicUiFeature.EnumField, + MarketingDynamicUiFeature.OptionalRelationClear, + MarketingDynamicUiFeature.LargeRelationSearch, + MarketingDynamicUiFeature.BooleanControl, + MarketingDynamicUiFeature.RecurrenceControl, + MarketingDynamicUiFeature.SemanticForm, + MarketingDynamicUiFeature.StaleMutationSuppression, + MarketingDynamicUiFeature.RelationRetry, + ), + marketingDynamicUiFixture.features, + ) + assertEquals(3, marketingDynamicUiFixture.breadcrumbs.size) + assertEquals(240, marketingDynamicUiFixture.relationOptionCount) + assertTrue(marketingDynamicUiFixture.appName.isNotBlank()) + assertTrue(marketingDynamicUiFixture.description.isNotBlank()) + assertTrue(marketingDynamicUiFixture.iconText.isNotBlank()) + } + + @Test + fun `fixture drives real generic field and relationship semantics`() { + val resource = marketingDynamicWorkItemsResource + val relationField = resource.fields.single { it.id == "groupId" } + val options = nativeRelationOptions( + field = relationField, + formResource = resource, + schema = marketingDynamicUiSchema, + context = marketingDynamicDatasetContext, + ) + val window = nativeRelationOptionWindow(options, query = "") + val create = assertNotNull(marketingDynamicUiSchema.action("work-items.create")) + val collectionCreate = assertNotNull( + nativeRecordActions( + schema = marketingDynamicUiSchema, + resource = resource, + ).create, + ) + + assertEquals(ActionIntent.create, create.intent) + assertEquals(create.id, collectionCreate.action.id) + assertEquals( + listOf( + FieldKind.string, + FieldKind.string, + FieldKind.string, + FieldKind.string, + FieldKind.enumeration, + FieldKind.string, + FieldKind.boolean, + FieldKind.string, + ), + resource.fields.map { it.kind }, + ) + assertEquals( + listOf("planned", "in-progress", "ready"), + resource.fields.single { it.id == "status" }.enumValues, + ) + assertFalse(relationField.required) + assertEquals("None", nativeScalarRelationClearChoice(relationField)?.label) + assertEquals(marketingDynamicUiFixture.relationOptionCount, options.size) + assertEquals(40, window.options.size) + assertTrue(window.hasMore) + assertEquals("Garden team", options.first().label) + assertTrue(resource.fields.any { it.id == "rrule" }) + assertEquals( + listOf("garden", "calendar", "tools"), + marketingDynamicWorkItemRecords.map { record -> + nativeRecordPresentation(resource, record).iconKey + }, + ) + assertEquals( + listOf( + "Prepare a clear layout for volunteers.", + "Check availability for the next work day.", + "Verify the shared tools and supplies list.", + ), + marketingDynamicWorkItemRecords.map { record -> + nativeRecordPresentation(resource, record).subtitle + }, + ) + assertTrue(marketingDynamicWorkItemRecords.all { record -> + nativeRecordPresentation(resource, record).colorArgb != null + }) + } + + @Test + fun `semantic forms present identity content choices and advanced controls in task order`() { + val editableFields = marketingDynamicWorkItemsResource.fields.filterNot { it.readOnly } + + assertEquals( + listOf("title", "status", "groupId", "sendReminders", "rrule"), + nativeFormDisplayFields( + fields = editableFields, + relationFieldIds = setOf("groupId"), + ).map { field -> field.id }, + ) + } + + @Test + fun `fixture remains neutral deterministic and offline`() { + val fixtureText = buildList { + add(marketingDynamicUiFixture.appName) + add(marketingDynamicUiFixture.description) + addAll(marketingDynamicUiFixture.breadcrumbs) + marketingDynamicWorkItemRecords.flatMapTo(this) { record -> + record.values.values.filterNotNull() + } + marketingDynamicRelatedGroupRecords.flatMapTo(this) { record -> + record.values.values.filterNotNull() + } + }.joinToString("\n").lowercase() + val forbidden = listOf( + "http://", + "https://", + "@", + "/" + "home/", + "pantry", + "nextcloud deck", + "personal", + ) + + forbidden.forEach { token -> + assertFalse(token in fixtureText, "Synthetic fixture must not contain '$token'.") + } + assertTrue(marketingDynamicUiSchema.app.id.startsWith("synthetic-")) + assertTrue(marketingDynamicUiSchema.actions.all { action -> + "://" !in action.binding.path + }) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingProductionCaptureTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingProductionCaptureTest.kt index da742f36..e1dbd77b 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingProductionCaptureTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MarketingProductionCaptureTest.kt @@ -20,9 +20,10 @@ class MarketingProductionCaptureTest { } @Test - fun `dynamic table captures cover desktop and compact viewports`() { + fun `dynamic visual QA captures cover desktop and compact viewports`() { val desktop = MarketingCaptureScenario.AdaptiveApp val mobile = MarketingCaptureScenario.AdaptiveAppMobile + val mobileCollection = MarketingCaptureScenario.AdaptiveAppCollectionMobile assertEquals("desktop", desktop.platform) assertEquals("wide", desktop.viewport) @@ -33,6 +34,8 @@ class MarketingProductionCaptureTest { assertEquals(1_080, mobile.width) assertEquals(1_800, mobile.height) assertTrue(mobile.density > 1f) + assertEquals("mobile", mobileCollection.platform) + assertEquals("phone-portrait", mobileCollection.viewport) assertFalse(shouldUseCompactTableRecordList(widthDp = 1_440f)) assertFalse(shouldUseCompactTableRecordList(widthDp = 900f)) assertTrue(shouldUseCompactTableRecordList(widthDp = 412f)) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt new file mode 100644 index 00000000..c5af9621 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt @@ -0,0 +1,439 @@ +package dev.obiente.nextcloudnative.app.design + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NextcloudCollectionNavigatorTest { + @Test + fun `navigation stays hidden when there is no choice`() { + NextcloudCollectionNavigationHost.entries.forEach { host -> + listOf(0, 1).forEach { destinationCount -> + assertEquals( + NextcloudCollectionNavigationMode.Hidden, + resolveNextcloudCollectionNavigationMode(host, 1_400, destinationCount), + ) + } + } + } + + @Test + fun `adaptive Android switches from drawer to rail at shared breakpoint`() { + assertEquals( + NextcloudCollectionNavigationMode.Drawer, + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.AdaptiveAndroid, + NextcloudWorkspaceBreakpoints.AdaptiveRailDp - 1, + 2, + ), + ) + assertEquals( + NextcloudCollectionNavigationMode.Rail, + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.AdaptiveAndroid, + NextcloudWorkspaceBreakpoints.AdaptiveRailDp, + 2, + ), + ) + } + + @Test + fun `desktop switches from rail to sidebar at shared breakpoint`() { + assertEquals( + NextcloudCollectionNavigationMode.Rail, + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.Desktop, + NextcloudWorkspaceBreakpoints.DesktopSidebarDp - 1, + 3, + ), + ) + assertEquals( + NextcloudCollectionNavigationMode.Sidebar, + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.Desktop, + NextcloudWorkspaceBreakpoints.DesktopSidebarDp, + 3, + ), + ) + } + + @Test + fun `compact drawer keeps menu in the leading slot at every depth`() { + assertEquals( + NextcloudCollectionLeadingControl.Menu, + resolveNextcloudCollectionLeadingControl( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = false, + ), + ) + assertEquals( + NextcloudCollectionLeadingControl.Menu, + resolveNextcloudCollectionLeadingControl( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = true, + ), + ) + listOf( + NextcloudCollectionNavigationMode.Hidden, + NextcloudCollectionNavigationMode.Rail, + NextcloudCollectionNavigationMode.Sidebar, + ).forEach { mode -> + assertEquals( + NextcloudCollectionLeadingControl.Back, + resolveNextcloudCollectionLeadingControl( + mode = mode, + hasHierarchyBack = false, + ), + ) + } + } + + @Test + fun `nested compact route keeps drawer access in its stable leading slot`() { + assertEquals( + NextcloudCollectionLeadingControl.Menu, + resolveNextcloudCollectionLeadingControl( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = true, + ), + ) + assertFalse( + shouldShowNextcloudCollectionTrailingNavigation( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = true, + ), + ) + assertFalse( + shouldShowNextcloudCollectionTrailingNavigation( + mode = NextcloudCollectionNavigationMode.Drawer, + hasHierarchyBack = false, + ), + ) + assertFalse( + shouldShowNextcloudCollectionTrailingNavigation( + mode = NextcloudCollectionNavigationMode.Rail, + hasHierarchyBack = true, + ), + ) + } + + @Test + fun `drawer and sidebar give long destination labels a second line`() { + assertEquals( + 2, + resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Drawer, + ), + ) + assertEquals( + 2, + resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Sidebar, + ), + ) + assertEquals( + 1, + resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Rail, + ), + ) + assertEquals( + 1, + resolveNextcloudCollectionDestinationLabelMaxLines( + NextcloudCollectionNavigationMode.Hidden, + ), + ) + } + + @Test + fun `model copies destinations and resolves selection`() { + val mutableDestinations = mutableListOf( + NextcloudCollectionDestination("all", "All items", 4), + NextcloudCollectionDestination("assigned", "Assigned to me"), + ) + val model = NextcloudCollectionNavigationModel.create(mutableDestinations, "assigned") + + mutableDestinations.clear() + + assertEquals(2, model.destinations.size) + assertEquals("assigned", model.selectedDestination?.id) + } + + @Test + fun `empty model permits only an empty selection`() { + assertNull(NextcloudCollectionNavigationModel.create(emptyList(), null).selectedDestination) + assertFailsWith { + NextcloudCollectionNavigationModel.create(emptyList(), "missing") + } + } + + @Test + fun `model permits no primary selection while a contextual view is active`() { + val destinations = listOf( + NextcloudCollectionDestination("today", "Today"), + NextcloudCollectionDestination("upcoming", "Upcoming"), + ) + + val model = NextcloudCollectionNavigationModel.create(destinations, null) + + assertNull(model.selectedDestinationId) + assertNull(model.selectedDestination) + } + + @Test + fun `model rejects missing and duplicate identities`() { + val destinations = listOf( + NextcloudCollectionDestination("today", "Today"), + NextcloudCollectionDestination("upcoming", "Upcoming"), + ) + assertFailsWith { + NextcloudCollectionNavigationModel.create(destinations, "missing") + } + assertFailsWith { + NextcloudCollectionNavigationModel.create( + listOf( + NextcloudCollectionDestination("today", "Today"), + NextcloudCollectionDestination("today", "Also today"), + ), + "today", + ) + } + } + + @Test + fun `keyboard movement follows destination order and wraps at the ends`() { + val model = NextcloudCollectionNavigationModel.create( + destinations = listOf( + NextcloudCollectionDestination("all", "All"), + NextcloudCollectionDestination("open", "Open"), + NextcloudCollectionDestination("done", "Done"), + ), + selectedDestinationId = "open", + ) + + assertEquals( + "done", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "open", + move = NextcloudCollectionNavigationMove.Next, + )?.id, + ) + assertEquals( + "all", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "done", + move = NextcloudCollectionNavigationMove.Next, + )?.id, + ) + assertEquals( + "done", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "all", + move = NextcloudCollectionNavigationMove.Previous, + )?.id, + ) + assertEquals( + "all", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "open", + move = NextcloudCollectionNavigationMove.First, + )?.id, + ) + assertEquals( + "done", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "open", + move = NextcloudCollectionNavigationMove.Last, + )?.id, + ) + } + + @Test + fun `keyboard movement from a contextual view focuses without inventing selection`() { + val model = NextcloudCollectionNavigationModel.create( + destinations = listOf( + NextcloudCollectionDestination("all", "All"), + NextcloudCollectionDestination("open", "Open"), + ), + selectedDestinationId = null, + ) + + assertEquals( + "all", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = null, + move = NextcloudCollectionNavigationMove.Next, + )?.id, + ) + assertEquals( + "open", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = null, + move = NextcloudCollectionNavigationMove.Previous, + )?.id, + ) + assertNull(model.selectedDestination) + } + + @Test + fun `keyboard movement tolerates stale focus and empty destinations`() { + val model = NextcloudCollectionNavigationModel.create( + destinations = listOf( + NextcloudCollectionDestination("all", "All"), + NextcloudCollectionDestination("open", "Open"), + ), + selectedDestinationId = "open", + ) + assertEquals( + "all", + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = "removed", + move = NextcloudCollectionNavigationMove.Next, + )?.id, + ) + assertNull( + resolveNextcloudCollectionKeyboardDestination( + model = NextcloudCollectionNavigationModel.create(emptyList(), null), + focusedDestinationId = null, + move = NextcloudCollectionNavigationMove.Next, + ), + ) + } + + @Test + fun `large dynamic destination sets retain full keyboard reachability`() { + val destinations = List(10_000) { index -> + NextcloudCollectionDestination( + id = "destination-$index", + label = "Destination $index", + ) + } + val model = NextcloudCollectionNavigationModel.create( + destinations = destinations, + selectedDestinationId = destinations.first().id, + ) + + assertEquals( + destinations.last().id, + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = destinations.first().id, + move = NextcloudCollectionNavigationMove.Last, + )?.id, + ) + assertEquals( + destinations.first().id, + resolveNextcloudCollectionKeyboardDestination( + model = model, + focusedDestinationId = destinations.last().id, + move = NextcloudCollectionNavigationMove.Next, + )?.id, + ) + } + + @Test + fun `large destination models retain focus state only for composed lazy items`() { + val destinationIds = List(10_000) { index -> "destination-$index" } + val registry = NextcloudCollectionComposedDestinationRegistry() + val firstVisible = Any() + val secondVisible = Any() + val recycled = Any() + + assertEquals(0, registry.retainedCount) + registry.attach(destinationIds.first(), firstVisible) + registry.attach(destinationIds[1], secondVisible) + assertEquals(2, registry.retainedCount) + + registry.attach(destinationIds.first(), recycled) + registry.detach(destinationIds.first(), firstVisible) + assertEquals(2, registry.retainedCount) + assertEquals(recycled, registry[destinationIds.first()]) + + registry.detach(destinationIds.first(), recycled) + registry.detach(destinationIds[1], secondVisible) + assertEquals(0, registry.retainedCount) + assertNull(registry[destinationIds.last()]) + } + + @Test + fun `destination and policy reject impossible values`() { + val preferences = NextcloudCollectionDestination( + id = "preferences-layout", + label = "Preferences", + accessibilityId = "prefs-get-user-prefs", + ) + assertEquals( + "prefs-get-user-prefs", + preferences.accessibilityId, + ) + assertEquals("Open destination Preferences", preferences.accessibilityDescription()) + assertEquals( + "collection-destination:prefs-get-user-prefs:preferences-layout", + preferences.automationTestTag(), + ) + assertFailsWith { + NextcloudCollectionDestination("", "Today") + } + assertFailsWith { + NextcloudCollectionDestination("today", " ") + } + assertFailsWith { + NextcloudCollectionDestination("today", "Today", -1) + } + assertFailsWith { + NextcloudCollectionDestination( + id = "today", + label = "Today", + accessibilityId = " ", + ) + } + assertFailsWith { + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.Desktop, + -1, + 2, + ) + } + assertFailsWith { + resolveNextcloudCollectionNavigationMode( + NextcloudCollectionNavigationHost.Desktop, + 800, + -1, + ) + } + } + + @Test + fun `automation tags remain unique when destinations share a source action`() { + val first = NextcloudCollectionDestination( + id = "active-items", + label = "Active", + accessibilityId = "items-index", + ) + val second = NextcloudCollectionDestination( + id = "archived-items", + label = "Archived", + accessibilityId = "items-index", + ) + + NextcloudCollectionNavigationModel.create( + destinations = listOf(first, second), + selectedDestinationId = first.id, + ) + + assertEquals("collection-destination:items-index:active-items", first.automationTestTag()) + assertEquals("collection-destination:items-index:archived-items", second.automationTestTag()) + assertTrue(first.automationTestTag() != second.automationTestTag()) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIconsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIconsTest.kt new file mode 100644 index 00000000..0e7fc3f7 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIconsTest.kt @@ -0,0 +1,174 @@ +package dev.obiente.nextcloudnative.app.design + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NextcloudIconsTest { + @Test + fun `all signed Pantry version 0 23 0 category and store icon tokens resolve without fallback`() { + val categoryTokens = listOf( + "tag", + "food", + "fruit", + "vegetable", + "bakery", + "dairy", + "meat", + "fish", + "snacks", + "cookie", + "drinks", + "coffee", + "frozen", + "household", + "pets", + "baby", + "home", + "leaf", + "pizza", + "clipboard-check", + "clipboard-list", + "format-list-checks", + "cart", + "basket", + "star", + "heart", + "calendar", + "bell", + "flag", + "bookmark", + "pin", + "map-marker", + "briefcase", + "wrench", + "silverware", + "gift", + "book", + "school", + "palette", + "camera", + "music", + "gamepad", + "run", + "dumbbell", + "pill", + "paw", + "flower", + "tree", + "broom", + "lightbulb", + "package", + "car", + "bike", + "beach", + ) + val storeTokens = listOf( + "store", + "storefront", + "market", + "supermarket", + "convenience", + "cart", + "basket", + "shopping", + "online", + "warehouse", + "pharmacy", + "health", + "bakery", + "butcher", + "seafood", + "produce", + "garden", + "florist", + "hardware", + "tools", + "wrench", + "electronics", + "phone", + "clothing", + "shoes", + "furniture", + "homegoods", + "home", + "books", + "toys", + "pets", + "liquor", + "coffee", + "gas", + "gift", + "jewelry", + "sports", + "beauty", + "office", + "baby", + "deli", + ) + val declaredTokens = (categoryTokens + storeTokens).toSet() + + assertEquals(54, categoryTokens.size) + assertEquals(41, storeTokens.size) + assertEquals(86, declaredTokens.size) + declaredTokens.forEach { token -> + assertNotNull(NextcloudIcons.semantic(token), "Missing signed Pantry icon for $token") + } + } + + @Test + fun `declared food icon tokens resolve to faithful distinct vectors`() { + val declaredTokens = listOf( + "food", + "bakery", + "dairy", + "meat", + "fish", + "snacks", + "cookie", + "drinks", + "silverware", + "deli", + "butcher", + "seafood", + ) + + val resolved = declaredTokens.map { token -> + token to assertNotNull(NextcloudIcons.semantic(token), "Missing icon for $token") + } + + assertEquals(declaredTokens, resolved.map { (token, _) -> token }) + assertEquals( + declaredTokens.size, + resolved.map { (_, icon) -> icon.name }.distinct().size, + "Distinct declared concepts must not collapse onto one generic food glyph.", + ) + } + + @Test + fun `semantic icon tokens normalize contract naming variants`() { + assertEquals( + NextcloudIcons.semantic("clipboard-check")?.name, + NextcloudIcons.semantic(" Clipboard_check ")?.name, + ) + assertEquals( + NextcloudIcons.semantic("format-list-checks")?.name, + NextcloudIcons.semantic("format list checks")?.name, + ) + } + + @Test + fun `unknown icon tokens share one neutral bundled fallback`() { + assertNull(NextcloudIcons.semantic("server-specific-icon")) + assertNull(NextcloudIcons.semantic("another-unknown-icon")) + assertEquals( + NextcloudIcons.Apps.name, + NextcloudIcons.semanticOrFallback("server-specific-icon").name, + ) + assertEquals( + NextcloudIcons.semanticOrFallback("server-specific-icon").name, + NextcloudIcons.semanticOrFallback("another-unknown-icon").name, + ) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompilerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompilerTest.kt index a5eaa8ba..089efea5 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompilerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompilerTest.kt @@ -7,11 +7,63 @@ import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class DynamicAppDescriptorCompilerTest { + @Test + fun `read response fields remain separate from write-only resource fields`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Records","version":"1"}, + "servers":[{"url":"/apps/records"}], + "paths":{ + "/api/records":{ + "get":{ + "operationId":"records-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"title":{"type":"string"}}} + }}}}} + }, + "post":{ + "operationId":"records-create", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["id","title"], + "properties":{"id":{"type":"string"},"title":{"type":"string"}} + }}}}, + "responses":{"200":{"description":"OK"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("records", "Records", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/apps/records")), + advertisedOpenApi = AdvertisedOpenApi( + "/apps/records/openapi.json", + Json.parseToJsonElement(document), + OpenApiTrust.sameOriginAdvertisement, + ), + ), + ) + + val read = descriptor.actions.single { it.id == "records-list" } + val create = descriptor.actions.single { it.id == "records-create" } + assertEquals(setOf("id", "title"), descriptor.resources.single().fields.mapTo(mutableSetOf()) { it.id }) + assertEquals(listOf("title"), read.responseFieldIds) + assertTrue(create.responseFieldIds.isEmpty()) + assertTrue(descriptor.validationErrors().isEmpty()) + } + @Test fun `specialized route constants discard stale inherited path parameters`() { val document = """ @@ -106,6 +158,413 @@ class DynamicAppDescriptorCompilerTest { assertTrue(descriptor.validationErrors().isEmpty()) } + @Test + fun `fallback labels humanize camel case and acronym identifiers`() { + val openApi = OPEN_API.replace( + """ + "TableInput": { + "type": "object", + "required": ["title"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" } + } + } + """.trimIndent().prependIndent(" "), + """ + "TableInput": { + "type": "object", + "required": ["title"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "sortOrder": { "type": "integer" }, + "deleteOnDoneDefault": { "type": "boolean" }, + "iconURLValue": { "type": "string" } + } + } + """.trimIndent().prependIndent(" "), + ) + + val descriptor = DynamicAppDescriptorCompiler().compile(input(openApi)) + + val labels = descriptor.forms.single().fields.associate { field -> field.fieldId to field.label } + assertEquals("Sort Order", labels["sortOrder"]) + assertEquals("Delete On Done Default", labels["deleteOnDoneDefault"]) + assertEquals("Icon URL Value", labels["iconURLValue"]) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `collection writes inherit the resource identity of the get on the same route`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Pantry-shaped contract","version":"1"}, + "paths":{ + "/ocs/v2.php/apps/example/api/houses/{houseId}/lists":{ + "get":{ + "operationId":"checklist-index-lists", + "tags":["Lists"], + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{ + "id":{"type":"integer"}, + "name":{"type":"string"} + }} + }}}}} + }, + "post":{ + "operationId":"checklist-create-list", + "tags":["Checklist"], + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["name"], + "properties":{"name":{"type":"string"}} + }}}}, + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"id":{"type":"integer"},"name":{"type":"string"}} + }}}}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy( + "https://cloud.example.test", + listOf("/ocs/v2.php/apps/example"), + ), + advertisedOpenApi = AdvertisedOpenApi( + "/ocs/v2.php/apps/example/openapi.json", + Json.parseToJsonElement(document), + OpenApiTrust.sameOriginAdvertisement, + ), + ), + ) + + val list = descriptor.actions.single { it.id == "checklist-index-lists" } + val create = descriptor.actions.single { it.id == "checklist-create-list" } + assertEquals("lists", list.resourceId) + assertEquals(list.resourceId, create.resourceId) + assertEquals(ActionIntent.create, create.intent) + assertEquals(listOf("houseId"), create.binding.pathParameters.map { it.name }) + assertEquals("lists", descriptor.forms.single { it.actionId == create.id }.resourceId) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `record mutations inherit the resource identity of the get on the same route`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Controller-tagged records","version":"1"}, + "paths":{ + "/ocs/v2.php/apps/example/api/houses/{houseId}/lists/{listId}":{ + "get":{ + "operationId":"checklist-show-list", + "tags":["Lists"], + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"listId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"id":{"type":"integer"},"name":{"type":"string"}} + }}}}} + }, + "patch":{ + "operationId":"checklist-update-list", + "tags":["Checklist"], + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"listId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "properties":{"name":{"type":"string"}} + }}}}, + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"id":{"type":"integer"},"name":{"type":"string"}} + }}}}} + }, + "delete":{ + "operationId":"checklist-delete-list", + "tags":["Checklist"], + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"listId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"204":{"description":"Deleted"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy( + "https://cloud.example.test", + listOf("/ocs/v2.php/apps/example"), + ), + advertisedOpenApi = AdvertisedOpenApi( + "/ocs/v2.php/apps/example/openapi.json", + Json.parseToJsonElement(document), + OpenApiTrust.sameOriginAdvertisement, + ), + ), + ) + + val show = descriptor.actions.single { it.id == "checklist-show-list" } + val update = descriptor.actions.single { it.id == "checklist-update-list" } + val delete = descriptor.actions.single { it.id == "checklist-delete-list" } + assertEquals("lists", show.resourceId) + assertEquals(show.resourceId, update.resourceId) + assertEquals(show.resourceId, delete.resourceId) + assertEquals(ActionIntent.update, update.intent) + assertEquals(ActionIntent.delete, delete.intent) + assertEquals("lists", descriptor.forms.single { it.actionId == update.id }.resourceId) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `state collections and their transitions retain the parent collection resource`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"State collections","version":"1"}, + "paths":{ + "/api/teams/{teamId}/entries":{ + "get":{ + "operationId":"entry-index", + "tags":["Entries"], + "parameters":[ + {"name":"teamId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{ + "id":{"type":"integer"}, + "name":{"type":"string"}, + "deletedAt":{"type":"string","nullable":true} + }} + }}}}} + } + }, + "/api/teams/{teamId}/entries/trash":{ + "get":{ + "operationId":"entry-index-deleted", + "tags":["Trash entries"], + "parameters":[ + {"name":"teamId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{ + "id":{"type":"integer"}, + "name":{"type":"string"}, + "deletedAt":{"type":"string","nullable":true} + }} + }}}}} + } + }, + "/api/teams/{teamId}/entries/{entryId}/restore":{ + "post":{ + "operationId":"entry-restore", + "tags":["Entries"], + "parameters":[ + {"name":"teamId","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"entryId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"200":{"description":"Restored"}} + } + }, + "/api/teams/{teamId}/entries/{entryId}/permanent":{ + "delete":{ + "operationId":"entry-delete-permanently", + "tags":["Entries"], + "parameters":[ + {"name":"teamId","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"entryId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "responses":{"204":{"description":"Deleted"}} + } + } + } + } + """.trimIndent() + val descriptor = DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/api")), + advertisedOpenApi = AdvertisedOpenApi( + "/api/openapi.json", + Json.parseToJsonElement(document), + OpenApiTrust.sameOriginAdvertisement, + ), + ), + ) + + val active = descriptor.actions.single { it.id == "entry-index" } + val trash = descriptor.actions.single { it.id == "entry-index-deleted" } + listOf( + "entry-restore", + "entry-delete-permanently", + ).forEach { actionId -> + assertEquals(active.resourceId, descriptor.actions.single { it.id == actionId }.resourceId) + } + assertEquals(active.resourceId, trash.resourceId) + val activeLayout = descriptor.layouts.single { it.sourceActionId == active.id } + val trashLayout = descriptor.layouts.single { it.sourceActionId == trash.id } + assertNotEquals(activeLayout.id, trashLayout.id) + assertEquals("Entries", activeLayout.title) + assertEquals("Trash", trashLayout.title) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `command routes require proven read resource ownership`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Command ownership","version":"1"}, + "paths":{ + "/apps/example/api/tasks":{ + "get":{ + "operationId":"tasks-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"id":{"type":"integer"},"done":{"type":"boolean"}}} + }}}}} + } + }, + "/apps/example/api/tasks/{taskId}/toggle":{ + "parameters":[{"name":"taskId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"task-toggle", + "summary":"Toggle task done status", + "responses":{"200":{"description":"OK"}} + } + }, + "/apps/example/api/projects":{ + "get":{ + "operationId":"projects-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}} + }}}}} + } + }, + "/apps/example/api/projects/automation/{automationId}/toggle":{ + "parameters":[{"name":"automationId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"automation-toggle", + "summary":"Toggle automation done status", + "responses":{"200":{"description":"OK"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val actions = descriptor.actions.associateBy(DynamicAction::id) + val tasks = assertNotNull(actions["tasks-list"]) + val taskToggle = assertNotNull(actions["task-toggle"]) + val projects = assertNotNull(actions["projects-list"]) + val unrelatedToggle = assertNotNull(actions["automation-toggle"]) + + assertEquals(ActionEffect.toggle, taskToggle.effect) + assertEquals(tasks.resourceId, taskToggle.resourceId) + assertEquals(ActionEffect.toggle, unrelatedToggle.effect) + assertNotEquals(projects.resourceId, unrelatedToggle.resourceId) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `bare toggle vocabulary does not imply completion semantics`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Generic toggle","version":"1"}, + "paths":{ + "/apps/example/api/switches":{ + "get":{ + "operationId":"switches-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"id":{"type":"integer"},"enabled":{"type":"boolean"}}} + }}}}} + } + }, + "/apps/example/api/switches/{switchId}/toggle":{ + "parameters":[{"name":"switchId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"switch-toggle", + "summary":"Toggle switch", + "responses":{"200":{"description":"OK"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val toggle = descriptor.actions.single { action -> action.id == "switch-toggle" } + + assertEquals(ActionEffect.execute, toggle.effect) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `destructive post vocabulary always requires confirmation`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Destructive commands","version":"1"}, + "paths":{ + "/apps/example/api/records/{recordId}/remove":{ + "parameters":[{"name":"recordId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"record-remove", + "summary":"Remove record", + "responses":{"200":{"description":"Removed"}} + } + }, + "/apps/example/api/artifacts/{artifactId}/destroy":{ + "parameters":[{"name":"artifactId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"artifact-destroy", + "summary":"Destroy artifact", + "responses":{"200":{"description":"Destroyed"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + listOf("record-remove", "artifact-destroy").forEach { actionId -> + val action = descriptor.actions.single { candidate -> candidate.id == actionId } + assertEquals(ActionRisk.destructive, action.risk, actionId) + assertTrue(action.requiresConfirmation, actionId) + } + assertTrue(descriptor.validationErrors().isEmpty()) + } + @Test fun refusesCrossOriginServerAndUnapprovedPaths() { val crossOrigin = OPEN_API.replace( @@ -227,7 +686,7 @@ class DynamicAppDescriptorCompilerTest { } @Test - fun `recipe actions recover useful forms when an external schema is unavailable`() { + fun `empty recipe request schemas do not invent mutation fields`() { val document = """ { "openapi":"3.0.3", @@ -267,50 +726,65 @@ class DynamicAppDescriptorCompilerTest { val create = descriptor.actions.single { it.id == "newrecipe" } val import = descriptor.actions.single { it.id == "import" } - assertEquals( - listOf( - "cookTime", - "description", - "keywords", - "name", - "prepTime", - "recipeCategory", - "recipeIngredient", - "recipeInstructions", - "recipeYield", - "tool", - ), - descriptor.forms.single { it.actionId == create.id }.fields.map(FormField::fieldId), - ) - assertEquals( - listOf("url"), - descriptor.forms.single { it.actionId == import.id }.fields.map(FormField::fieldId), - ) - assertTrue((assertNotNull(create.binding.body).schema as JsonObject).containsKey("properties")) - assertTrue((assertNotNull(import.binding.body).schema as JsonObject).containsKey("properties")) + assertTrue(descriptor.forms.single { it.actionId == create.id }.fields.isEmpty()) + assertTrue(descriptor.forms.single { it.actionId == import.id }.fields.isEmpty()) + assertTrue((assertNotNull(create.binding.body).schema as JsonObject).isEmpty()) + assertTrue((assertNotNull(import.binding.body).schema as JsonObject).isEmpty()) assertEquals("recipes", import.resourceId) - assertEquals( - setOf("import", "newrecipe"), - descriptor.planDynamicNavigation().rootFormActions.map { it.actionId }.toSet(), - ) assertTrue(descriptor.validationErrors().isEmpty()) } @Test - fun `recipe action recovery preserves undeclared and non object bodies`() { + fun `explicit recipe request schema remains authoritative`() { val document = """ { "openapi":"3.0.3", "info":{"title":"Recipes","version":"1"}, "paths":{ - "/apps/example/api/v1/recipes/draft":{ + "/apps/example/api/v1/recipes":{ "post":{ - "operationId":"createRecipeDraft", - "summary":"Create a recipe draft", + "operationId":"newRecipe", + "summary":"Create a new recipe", "tags":["Recipes"], - "responses":{"201":{"description":"Created"}} - } - }, + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["customTitle"], + "properties":{ + "customTitle":{"type":"string","title":"Exact contract title"} + } + }}}}, + "responses":{"201":{"description":"Created"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val create = descriptor.actions.single { it.id == "newrecipe" } + val schema = assertNotNull(create.binding.body).schema as JsonObject + + assertEquals(setOf("customTitle"), (schema["properties"] as? JsonObject)?.keys) + assertEquals(listOf("customTitle"), descriptor.forms.single().fields.map(FormField::fieldId)) + assertEquals("Exact contract title", descriptor.forms.single().fields.single().label) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `recipe actions preserve undeclared and non object bodies`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Recipes","version":"1"}, + "paths":{ + "/apps/example/api/v1/recipes/draft":{ + "post":{ + "operationId":"createRecipeDraft", + "summary":"Create a recipe draft", + "tags":["Recipes"], + "responses":{"201":{"description":"Created"}} + } + }, "/apps/example/api/v1/recipes/batch":{ "post":{ "operationId":"createRecipeBatch", @@ -449,6 +923,81 @@ class DynamicAppDescriptorCompilerTest { assertTrue(descriptor.validationErrors().isEmpty()) } + @Test + fun `nested plural routes and verified request fields enrich generic collection semantics`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Example","version":"1"}, + "paths":{ + "/apps/example/api/houses":{ + "get":{ + "operationId":"house-index", + "tags":["house"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array","items":{"type":"object","properties":{"id":{"type":"integer"}}} + }}}}} + } + }, + "/apps/example/api/houses/{houseId}":{ + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "get":{ + "operationId":"house-show", + "tags":["house"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object","properties":{"id":{"type":"integer"}} + }}}}} + } + }, + "/apps/example/api/houses/{houseId}/lists":{ + "parameters":[ + {"name":"houseId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "get":{ + "operationId":"list-index", + "tags":["lists"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array","items":{"type":"object","properties":{ + "id":{"type":"integer"}, + "name":{"type":"string"}, + "color":{} + }} + }}}}} + }, + "post":{ + "operationId":"list-create", + "tags":["lists"], + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["name"], + "properties":{"name":{"type":"string"},"color":{"type":"string"}} + }}}}, + "responses":{"201":{"description":"Created"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val house = descriptor.actions.single { action -> action.id == "house-index" } + val lists = descriptor.actions.single { action -> action.id == "list-index" } + val listResource = descriptor.resources.single { resource -> resource.id == lists.resourceId } + + assertEquals(house.resourceId, descriptor.actions.single { it.id == "house-show" }.resourceId) + assertEquals(FieldKind.string, listResource.fields.single { field -> field.id == "color" }.kind) + assertTrue( + descriptor.links.any { link -> + link.resourceId == house.resourceId && + (link.target as? DynamicLinkTarget.Action)?.actionId == lists.id + }, + "links=${descriptor.links}", + ) + assertTrue(descriptor.validationErrors().isEmpty()) + } + @Test fun `binary artwork reads are not exposed as JSON detail views`() { val document = """ @@ -592,6 +1141,431 @@ class DynamicAppDescriptorCompilerTest { assertTrue(descriptor.warnings.any { it.code == "ignored-unnamed-write" }) } + @Test + fun `generic discovery withholds credential generation mutations`() { + val credentialWrite = OPEN_API.replace( + "\"operationId\": \"tables.create\"", + "\"operationId\": \"settings.createUserKey\"", + ) + + val descriptor = DynamicAppDescriptorCompiler().compile(input(credentialWrite)) + + assertTrue(descriptor.actions.all { action -> action.binding.method == HttpMethod.GET }) + assertTrue(descriptor.forms.isEmpty()) + assertTrue( + descriptor.warnings.any { warning -> + warning.code == "ignored-sensitive-credential-write" + }, + ) + } + + @Test + fun `generic discovery withholds ambiguous result mutations from every semantic source`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Ambiguous results","version":"1"}, + "paths":{ + "/apps/example/api/records":{ + "get":{ + "operationId":"records-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}} + }}}}} + } + }, + "/apps/example/api/messages":{ + "post":{ + "operationId":"messages.sendNow", + "summary":"Dispatch notification", + "responses":{"202":{"description":"Accepted"}} + } + }, + "/apps/example/api/records/{recordId}/collaboration":{ + "parameters":[{"name":"recordId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"collaboration-create", + "summary":"Share record", + "responses":{"201":{"description":"Created"}} + } + }, + "/apps/example/api/records/{recordId}/merge":{ + "parameters":[{"name":"recordId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{ + "operationId":"records-combine", + "summary":"Combine records", + "responses":{"200":{"description":"Combined"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + + assertEquals(listOf("records-list"), descriptor.actions.map(DynamicAction::id)) + assertEquals( + 3, + descriptor.warnings.count { warning -> + warning.code == "ignored-ambiguous-result-write" + }, + ) + assertTrue(descriptor.forms.isEmpty()) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `ambiguous result vocabulary uses exact words and does not hide safe neighbors or reads`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Safe semantic boundaries","version":"1"}, + "paths":{ + "/apps/example/api/share":{ + "get":{ + "operationId":"share-read", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"enabled":{"type":"boolean"}} + }}}}} + } + }, + "/apps/example/api/senders":{ + "post":{ + "operationId":"sender-create", + "summary":"Create sender", + "responses":{"201":{"description":"Created"}} + } + }, + "/apps/example/api/preferences/shared":{ + "patch":{ + "operationId":"shared-preferences-update", + "summary":"Update shared preferences", + "responses":{"200":{"description":"Updated"}} + } + }, + "/apps/example/api/mergeable/{recordId}":{ + "parameters":[{"name":"recordId","in":"path","required":true,"schema":{"type":"integer"}}], + "put":{ + "operationId":"mergeable-record-update", + "summary":"Update mergeable record", + "responses":{"200":{"description":"Updated"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + + assertEquals( + setOf( + "mergeable-record-update", + "sender-create", + "share-read", + "shared-preferences-update", + ), + descriptor.actions.map(DynamicAction::id).toSet(), + ) + assertTrue( + descriptor.warnings.none { warning -> + warning.code == "ignored-ambiguous-result-write" + }, + ) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `signed idempotent ambiguous replacement uses exact read recovery and typed object rows`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Generic shares","version":"1"}, + "paths":{ + "/apps/example/api/entities/{entityId}/share":{ + "parameters":[ + {"name":"entityId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "get":{ + "operationId":"entity-shares-read", + "summary":"Read entity shares", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{ + "uid":{"type":"string"}, + "permission":{"type":"string"} + }} + }}}}} + }, + "put":{ + "operationId":"entity-share", + "summary":"Share entity", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["shares"], + "properties":{ + "shares":{ + "type":"array", + "minItems":1, + "maxItems":8, + "description":"Recipients with permission ('view' or 'edit').", + "items":{ + "type":"object", + "additionalProperties":false, + "required":["uid","permission"], + "properties":{ + "uid":{"type":"string","title":"Recipient","minLength":1}, + "permission":{"type":"string","title":"Permission"} + } + } + } + } + }}}}, + "responses":{"200":{"description":"Updated"}} + } + } + } + } + """.trimIndent() + val input = exampleInput(document) + val descriptor = DynamicAppDescriptorCompiler().compile( + input.copy( + advertisedOpenApi = input.advertisedOpenApi?.copy( + trust = OpenApiTrust.nextcloudSignedAppPackage, + ), + ), + ) + + val actionEvidence = "actions=${descriptor.actions.map(DynamicAction::id)}; " + + "warnings=${descriptor.warnings}" + val read = assertNotNull( + descriptor.actions.singleOrNull { it.id == "entity-shares-read" }, + actionEvidence, + ) + val replacement = assertNotNull( + descriptor.actions.singleOrNull { it.id == "entity-share" }, + actionEvidence, + ) + assertEquals(read.id, replacement.resultRecoveryActionId) + val form = assertNotNull( + descriptor.forms.singleOrNull { it.actionId == replacement.id }, + "Missing replacement form; $actionEvidence; forms=${descriptor.forms.map(DynamicForm::actionId)}", + ) + val field = assertNotNull( + form.fields.singleOrNull(), + "Expected one replacement field; fields=${form.fields}", + ) + assertEquals(DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT, field.format) + val structured = assertNotNull(field.repeatableObjectInput) + assertEquals(1, structured.minimumItems) + assertEquals(8, structured.maximumItems) + assertEquals(listOf("uid", "permission"), structured.fields.map { it.id }) + assertEquals( + listOf("view", "edit"), + structured.fields.single { it.id == "permission" }.enumValues, + ) + assertEquals( + """[{"uid":"alice","permission":"edit"}]""", + structured.encode( + listOf( + RepeatableObjectInputRow( + mapOf("uid" to "alice", "permission" to "edit"), + ), + ), + ), + ) + assertTrue(descriptor.validationErrors().isEmpty()) + assertTrue(descriptor.warnings.none { it.code == "ignored-ambiguous-result-write" }) + } + + @Test + fun `repeatable mutation objects exclude nested read only fields from fields and required set`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Generic recipients","version":"1"}, + "paths":{ + "/apps/example/api/recipients":{ + "post":{ + "operationId":"recipients-create", + "summary":"Create recipients", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["recipients"], + "properties":{ + "recipients":{ + "type":"array", + "items":{ + "type":"object", + "additionalProperties":false, + "required":["serverId","uid"], + "properties":{ + "serverId":{"type":"integer","readOnly":true}, + "uid":{"type":"string","title":"Recipient"} + } + } + } + } + }}}}, + "responses":{"201":{"description":"Created"}} + } + } + } + } + """.trimIndent() + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + + val action = descriptor.actions.single { it.id == "recipients-create" } + val form = descriptor.forms.single { it.actionId == action.id } + val repeatable = assertNotNull(form.fields.single().repeatableObjectInput) + + assertEquals(listOf("uid"), repeatable.fields.map(RepeatableObjectInputFieldSpec::id)) + assertTrue(repeatable.fields.single().required) + assertEquals( + """[{"uid":"alice"}]""", + repeatable.encode(listOf(RepeatableObjectInputRow(mapOf("uid" to "alice")))), + ) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `mutation is withheld when a repeatable object contains only read only fields`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Server snapshots","version":"1"}, + "paths":{ + "/apps/example/api/snapshots":{ + "post":{ + "operationId":"snapshots-create", + "summary":"Create snapshots", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["snapshots"], + "properties":{ + "snapshots":{ + "type":"array", + "items":{ + "type":"object", + "additionalProperties":false, + "required":["serverId"], + "properties":{ + "serverId":{"type":"integer","readOnly":true} + } + } + } + } + }}}}, + "responses":{"201":{"description":"Created"}} + } + } + } + } + """.trimIndent() + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + + assertTrue(descriptor.actions.none { it.id == "snapshots-create" }) + assertTrue(descriptor.forms.none { it.actionId == "snapshots-create" }) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `constrained string array mutations are withheld while unconstrained arrays remain editable`() { + fun operation(operationId: String, arrayConstraints: String, itemConstraints: String = "") = """ + "post":{ + "operationId":"$operationId", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["values"], + "properties":{ + "values":{ + "type":"array"$arrayConstraints, + "items":{"type":"string"$itemConstraints} + } + } + }}}}, + "responses":{"204":{"description":"Updated"}} + } + """.trimIndent() + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"String arrays","version":"1"}, + "paths":{ + "/apps/example/api/plain":{${operation("plain-values", "")}}, + "/apps/example/api/minimum":{${operation("minimum-values", ",\"minItems\":2")}}, + "/apps/example/api/maximum":{${operation("maximum-values", ",\"maxItems\":2")}}, + "/apps/example/api/unique":{${operation("unique-values", ",\"uniqueItems\":true")}}, + "/apps/example/api/enumerated":{ + ${operation("enumerated-values", "", ",\"enum\":[\"one\",\"two\"]")} + } + } + } + """.trimIndent() + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + + val plain = descriptor.actions.single { it.id == "plain-values" } + assertEquals( + DYNAMIC_STRING_ARRAY_FORMAT, + descriptor.forms.single { it.actionId == plain.id }.fields.single().format, + ) + assertTrue( + descriptor.actions.none { action -> + action.id in setOf( + "minimum-values", + "maximum-values", + "unique-values", + "enumerated-values", + ) + }, + ) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `ambiguous replacement remains withheld without exact trusted get recovery`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Unsafe share","version":"1"}, + "paths":{ + "/apps/example/api/entities/{entityId}/share":{ + "parameters":[ + {"name":"entityId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "put":{ + "operationId":"entity-share", + "summary":"Share entity", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["shares"], + "properties":{"shares":{"type":"array","items":{ + "type":"object", + "properties":{"uid":{"type":"string"}} + }}} + }}}}, + "responses":{"200":{"description":"Updated"}} + } + } + } + } + """.trimIndent() + val input = exampleInput(document) + val descriptor = DynamicAppDescriptorCompiler().compile( + input.copy( + advertisedOpenApi = input.advertisedOpenApi?.copy( + trust = OpenApiTrust.nextcloudSignedAppPackage, + ), + ), + ) + + assertTrue(descriptor.actions.isEmpty()) + assertTrue(descriptor.forms.isEmpty()) + assertTrue(descriptor.warnings.any { it.code == "ignored-ambiguous-result-write" }) + assertTrue(descriptor.validationErrors().isEmpty()) + } + @Test fun exposesBodylessAndQueryDrivenMutationsAsConfirmedNativeActions() { val withDeleteAndQuery = OPEN_API @@ -644,6 +1618,165 @@ class DynamicAppDescriptorCompilerTest { assertEquals("application/json", mappedAction.binding.bodyContentType) } + @Test + fun `single member allOf preserves referenced enum metadata for editable fields`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Composed enum contract","version":"1"}, + "components":{"schemas":{ + "Palette":{"type":"string","enum":["red","blue"]}, + "OtherPalette":{"type":"string","enum":["green","yellow"]} + }}, + "paths":{ + "/apps/example/api/items":{ + "get":{ + "operationId":"items-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{ + "id":{"type":"integer"}, + "color":{ + "nullable":true, + "allOf":[{"${'$'}ref":"#/components/schemas/Palette"}] + }, + "ambiguous":{ + "allOf":[ + {"${'$'}ref":"#/components/schemas/Palette"}, + {"${'$'}ref":"#/components/schemas/OtherPalette"} + ] + } + }} + }}}}} + }, + "post":{ + "operationId":"items-create", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "properties":{ + "color":{"type":"string"}, + "ambiguous":{"type":"string"} + } + }}}}, + "responses":{"200":{"description":"Created"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val resource = descriptor.resources.single() + val fields = resource.fields.associateBy(DynamicField::id) + val color = fields.getValue("color") + val ambiguous = fields.getValue("ambiguous") + + assertEquals(FieldKind.enumeration, color.kind) + assertEquals(listOf("red", "blue"), color.enumValues) + assertTrue(color.nullable) + assertEquals(FieldKind.string, ambiguous.kind) + assertNull(ambiguous.enumValues) + + val nativeColor = descriptor.toNativeAppSchema() + .resources + .single() + .fields + .single { field -> field.id == "color" } + assertFalse(nativeColor.readOnly) + assertEquals(FieldKind.enumeration, nativeColor.kind) + assertEquals(listOf("red", "blue"), nativeColor.enumValues) + } + + @Test + fun `only exact declared integer arrays receive the generic integer array format`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Scalar array contract","version":"1"}, + "paths":{ + "/apps/example/api/assignments":{ + "post":{ + "operationId":"assignments-update", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "properties":{ + "integerIds":{"type":"array","items":{"type":"integer","format":"int64"}}, + "textIds":{"type":"array","items":{"type":"string"}}, + "decimalIds":{"type":"array","items":{"type":"number"}}, + "objectIds":{"type":"array","items":{"type":"object"}}, + "mixedIds":{"type":"array","items":{"oneOf":[ + {"type":"integer"},{"type":"string"} + ]}}, + "untypedIds":{"type":"array"} + } + }}}}, + "responses":{"200":{"description":"Updated"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val action = descriptor.actions.single { it.id == "assignments-update" } + val fields = descriptor.forms.single { it.actionId == action.id }.fields.associateBy(FormField::fieldId) + val bodyProperties = ((requireNotNull(action.binding.body).schema as JsonObject)["properties"] as JsonObject) + + assertEquals(DYNAMIC_INTEGER_ARRAY_FORMAT, fields.getValue("integerIds").format) + assertEquals(DYNAMIC_STRING_ARRAY_FORMAT, fields.getValue("textIds").format) + assertNull(fields.getValue("decimalIds").format) + assertNull(fields.getValue("objectIds").format) + assertNull(fields.getValue("mixedIds").format) + assertNull(fields.getValue("untypedIds").format) + assertTrue(bodyProperties["integerIds"].isExactDynamicIntegerArraySchema()) + assertFalse(bodyProperties["textIds"].isExactDynamicIntegerArraySchema()) + + val nativeField = descriptor.toNativeAppSchema() + .resources + .single { resource -> resource.id == action.resourceId } + .fields + .single { field -> field.id == "integerIds" } + assertEquals(FieldKind.integer, nativeField.kind) + assertEquals(DYNAMIC_INTEGER_ARRAY_FORMAT, nativeField.format) + } + + @Test + fun `form encoded arrays do not receive JSON typed editor formats`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Form array contract","version":"1"}, + "paths":{ + "/apps/example/api/assignments":{ + "post":{ + "operationId":"assignments-form-update", + "requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{ + "type":"object", + "properties":{ + "integerIds":{"type":"array","items":{"type":"integer"}}, + "textIds":{"type":"array","items":{"type":"string"}}, + "label":{"type":"string"} + } + }}}}, + "responses":{"200":{"description":"Updated"}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val action = descriptor.actions.single { it.id == "assignments-form-update" } + val fields = descriptor.forms.single().fields.associateBy(FormField::fieldId) + val bodyProperties = ((requireNotNull(action.binding.body).schema as JsonObject)["properties"] as JsonObject) + + assertEquals("application/x-www-form-urlencoded", action.binding.body.contentType) + assertEquals(setOf("label"), fields.keys) + assertNull((bodyProperties.getValue("integerIds") as JsonObject)["format"]) + assertNull((bodyProperties.getValue("textIds") as JsonObject)["format"]) + assertTrue(descriptor.validationErrors().isEmpty()) + } + @Test fun rejectsLegacyOpenApiAndRemoteReferences() { assertFailsWith { @@ -793,6 +1926,226 @@ class DynamicAppDescriptorCompilerTest { assertTrue(descriptor.forms.isEmpty()) } + @Test + fun `root and parent scoped singleton reads retain distinct layouts`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Scoped settings","version":"1"}, + "paths":{ + "/apps/example/api/preferences":{ + "get":{ + "operationId":"preferences-user", + "tags":["Preferences"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"theme":{"type":"string"}} + }}}}} + } + }, + "/apps/example/api/workspaces/{workspaceId}/preferences":{ + "parameters":[ + {"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer"}} + ], + "get":{ + "operationId":"preferences-workspace", + "tags":["Preferences"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"notifications":{"type":"boolean"}} + }}}}} + } + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val preferenceLayouts = descriptor.layouts.filter { layout -> + layout.resourceId == descriptor.actions.single { action -> + action.id == "preferences-user" + }.resourceId + } + + assertEquals(2, preferenceLayouts.size) + assertEquals( + setOf("preferences-user", "preferences-workspace"), + preferenceLayouts.mapNotNull(DynamicLayout::sourceActionId).toSet(), + ) + assertEquals(2, preferenceLayouts.map(DynamicLayout::id).distinct().size) + assertTrue(descriptor.validationErrors().isEmpty()) + } + + @Test + fun `write effects come from contract semantics instead of the HTTP method`() { + val document = """ + { + "openapi":"3.0.3", + "info":{"title":"Semantic actions","version":"1"}, + "paths":{ + "/apps/example/api/widgets":{ + "get":{ + "operationId":"widgets-list", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"array", + "items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}} + }}}}} + }, + "post":{ + "operationId":"widgets-create", + "summary":"Create widget", + "requestBody":{"required":true,"content":{"application/json":{"schema":{ + "type":"object", + "required":["name"], + "properties":{"name":{"type":"string"}} + }}}}, + "responses":{"201":{"description":"Created"}} + }, + "patch":{ + "operationId":"widgets-update", + "summary":"Update widgets", + "responses":{"200":{"description":"Updated"}} + } + }, + "/apps/example/api/widgets/reorder":{ + "post":{"operationId":"widgets-reorder","summary":"Reorder widgets","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/trash":{ + "delete":{"operationId":"widgets-empty-trash","summary":"Empty trash","responses":{"204":{"description":"Empty"}}} + }, + "/apps/example/api/widgets/{widgetId}":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "get":{ + "operationId":"widget-show", + "tags":["WidgetDetails"], + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"id":{"type":"integer"},"name":{"type":"string"}} + }}}}} + } + }, + "/apps/example/api/widgets/{widgetId}/toggle":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"widget-toggle","summary":"Toggle widget done status","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/{widgetId}/restore":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"widget-restore","summary":"Restore widget","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/{widgetId}/archive":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"widget-archive","summary":"Archive widget","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/{widgetId}/unarchive":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"widget-unarchive","summary":"Unarchive widget","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/{widgetId}/copy":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"widget-copy","summary":"Copy widget","responses":{"201":{"description":"Copied"}}} + }, + "/apps/example/api/widgets/{widgetId}/permanent":{ + "parameters":[{"name":"widgetId","in":"path","required":true,"schema":{"type":"integer"}}], + "delete":{"operationId":"widget-delete-permanently","summary":"Permanently delete widget","responses":{"204":{"description":"Deleted"}}} + }, + "/apps/example/api/widgets/batch/delete":{ + "post":{"operationId":"widgets-batch-delete","summary":"Delete selected widgets","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/widgets/upload":{ + "post":{"operationId":"widgets-upload","summary":"Upload widgets","responses":{"200":{"description":"OK"}}} + }, + "/apps/example/api/workspaces/{workspaceId}":{ + "parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer"}}], + "get":{ + "operationId":"workspace-show", + "responses":{"200":{"description":"OK","content":{"application/json":{"schema":{ + "type":"object", + "properties":{"id":{"type":"integer"},"name":{"type":"string"}} + }}}}} + } + }, + "/apps/example/api/workspaces/{workspaceId}/leave":{ + "parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer"}}], + "post":{"operationId":"workspace-leave","summary":"Leave workspace","responses":{"200":{"description":"OK"}}} + } + } + } + """.trimIndent() + + val descriptor = DynamicAppDescriptorCompiler().compile(exampleInput(document)) + val actions = descriptor.actions.associateBy(DynamicAction::id) + + fun assertAction( + id: String, + effect: ActionEffect, + intent: ActionIntent, + risk: ActionRisk, + requiresConfirmation: Boolean, + ) { + val action = assertNotNull(actions[id]) + assertEquals(effect, action.effect, id) + assertEquals(intent, action.intent, id) + assertEquals(risk, action.risk, id) + assertEquals(requiresConfirmation, action.requiresConfirmation, id) + } + + assertAction("widgets-create", ActionEffect.create, ActionIntent.create, ActionRisk.mutating, false) + assertAction("widgets-update", ActionEffect.update, ActionIntent.update, ActionRisk.mutating, false) + assertAction("widgets-reorder", ActionEffect.reorder, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("widget-toggle", ActionEffect.toggle, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("widget-restore", ActionEffect.restore, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("widget-archive", ActionEffect.archive, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("widget-unarchive", ActionEffect.unarchive, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("widget-copy", ActionEffect.copy, ActionIntent.execute, ActionRisk.mutating, false) + assertAction( + "widget-delete-permanently", + ActionEffect.permanentDelete, + ActionIntent.delete, + ActionRisk.destructive, + true, + ) + assertAction( + "widgets-empty-trash", + ActionEffect.empty, + ActionIntent.delete, + ActionRisk.destructive, + true, + ) + assertAction( + "widgets-batch-delete", + ActionEffect.batch, + ActionIntent.execute, + ActionRisk.destructive, + true, + ) + assertAction("widgets-upload", ActionEffect.upload, ActionIntent.execute, ActionRisk.mutating, false) + assertAction("workspace-leave", ActionEffect.leave, ActionIntent.execute, ActionRisk.destructive, true) + assertEquals("widgets", actions.getValue("widget-show").resourceId) + assertTrue( + actions.values + .filter { action -> action.id.startsWith("widget") } + .all { action -> action.resourceId == "widgets" }, + ) + assertEquals(actions.getValue("workspace-show").resourceId, actions.getValue("workspace-leave").resourceId) + assertTrue( + descriptor.resources.none { resource -> + resource.id in setOf( + "archive", + "batch", + "copy", + "leave", + "permanent", + "reorder", + "restore", + "toggle", + "unarchive", + "upload", + ) + }, + ) + assertTrue(descriptor.validationErrors().isEmpty()) + } + private fun input( openApi: String, documentUrl: String = "/apps/tables/openapi.json", diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapperTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapperTest.kt index f7c465df..bc4f3a5a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapperTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapperTest.kt @@ -133,6 +133,93 @@ class DynamicDescriptorMapperTest { ) } + @Test + fun infersUnambiguousSiblingForeignKeysWithoutAppSpecificRelationshipAdapters() { + val categories = resource("categories", fields("id", "name")) + val stores = resource("stores", fields("id", "name")) + val entries = resource( + "entries", + fields("id", "name", "categoryId") + + field("storeIds", FieldKind.integer, format = DYNAMIC_INTEGER_ARRAY_FORMAT), + ) + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/api")), + resources = listOf(categories, stores, entries), + ) + + assertEquals( + setOf( + ResourceRelationshipSpec("categories", "entries", "id", "categoryId", Confidence.high), + ResourceRelationshipSpec("stores", "entries", "id", "storeIds", Confidence.high), + ), + descriptor.toNativeAppSchema().relationships.toSet(), + ) + } + + @Test + fun inferredForeignKeysNeverBecomeVerifiedWriteEvidence() { + val parents = resource("workspaces", fields("id", "name")).copy( + confidence = Confidence.verified, + fields = fields("id", "name").map { field -> field.copy(confidence = Confidence.verified) }, + ) + val children = resource("entries", fields("id", "workspaceId", "title")).copy( + confidence = Confidence.verified, + fields = fields("id", "workspaceId", "title").map { field -> + field.copy(confidence = Confidence.verified) + }, + ) + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/api")), + resources = listOf(parents, children), + ) + + assertEquals( + ResourceRelationshipSpec("workspaces", "entries", "id", "workspaceId", Confidence.high), + descriptor.toNativeAppSchema().relationships.single(), + ) + } + + @Test + fun infersSelfReferentialPluralIdPickersWithoutTreatingIdentityAsARelation() { + val roles = resource( + "roles", + fields("id", "name") + + field("roleIds", FieldKind.integer, format = DYNAMIC_INTEGER_ARRAY_FORMAT), + ) + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/api")), + resources = listOf(roles), + ) + + assertEquals( + listOf( + ResourceRelationshipSpec("roles", "roles", "id", "roleIds", Confidence.high), + ), + descriptor.toNativeAppSchema().relationships, + ) + } + + @Test + fun ambiguousForeignKeyResourceNamesDoNotCreateRelationshipEvidence() { + val category = resource("category", fields("id", "name")) + val categories = resource("categories", fields("id", "name")) + val entries = resource("entries", fields("id", "name", "categoryId")) + val descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("example", "Example", "1"), + endpointPolicy = EndpointPolicy("https://cloud.example.test", listOf("/api")), + resources = listOf(category, categories, entries), + ) + + assertEquals(emptyList(), descriptor.toNativeAppSchema().relationships) + } + @Test fun selectsSpecializedNativeComponentsFromSemanticAndFieldEvidence() { assertEquals(NativeComponent.dataTable, component("rows", fields("id", "columnId", "value"))) @@ -141,6 +228,15 @@ class DynamicDescriptorMapperTest { NativeComponent.board, component("work-items", fields("id", "title", "stage", "position")), ) + assertEquals( + NativeComponent.taskList, + component( + "items", + fields("id", "name", "listId") + + field("sortOrder", FieldKind.integer) + + field("done", FieldKind.boolean), + ), + ) assertEquals( NativeComponent.dashboard, component( @@ -277,10 +373,15 @@ class DynamicDescriptorMapperTest { confidence = Confidence.high, ) - private fun field(id: String, kind: FieldKind = FieldKind.string) = DynamicField( + private fun field( + id: String, + kind: FieldKind = FieldKind.string, + format: String? = null, + ) = DynamicField( id = id, label = id, kind = kind, + format = format, required = false, readOnly = true, nullable = true, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt index 9570357b..41dd259a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt @@ -37,6 +37,57 @@ class DynamicNavigationPlannerTest { assertTrue(descriptor.planDynamicNavigation().rootDestinations.isEmpty()) } + @Test + fun `interactive autocomplete collection is a picker source rather than an app root`() { + val autocomplete = action( + id = "user-autocomplete", + resourceId = "users", + intent = ActionIntent.list, + ).copy( + label = "Autocomplete users", + binding = DynamicHttpBinding( + method = HttpMethod.GET, + path = "/api/users/autocomplete", + queryParameters = listOf( + HttpParameter( + name = "search", + required = false, + schema = buildJsonObject {}, + source = ParameterSource.userInput, + ), + HttpParameter( + name = "limit", + required = false, + schema = buildJsonObject {}, + source = ParameterSource.userInput, + ), + ), + ), + ) + val ordinaryUsers = autocomplete.copy( + id = "list-users", + label = "Users", + binding = autocomplete.binding.copy(path = "/api/users"), + ) + val helperDescriptor = hierarchyDescriptor().copy( + resources = listOf(resource("users")), + layouts = listOf(layout("users", autocomplete.id)), + actions = listOf(autocomplete), + links = emptyList(), + forms = emptyList(), + ) + val ordinaryDescriptor = helperDescriptor.copy( + layouts = listOf(layout("users", ordinaryUsers.id)), + actions = listOf(ordinaryUsers), + ) + + assertTrue(helperDescriptor.planDynamicNavigation().rootDestinations.isEmpty()) + assertEquals( + listOf("users"), + ordinaryDescriptor.planDynamicNavigation().rootDestinations.map(DynamicNavigationDestination::resourceId), + ) + } + @Test fun `Cospend projects lead to bills and members with strict record context`() { val descriptor = hierarchyDescriptor() @@ -87,6 +138,212 @@ class DynamicNavigationPlannerTest { assertFalse(plan.contextualFormActions.any { it.actionId == "edit-bill" }) } + @Test + fun `coincidental context field cannot authorize an unrelated mutation form`() { + val updateProfile = action( + "update-profile", + "profiles", + ActionIntent.update, + "userId", + method = HttpMethod.PATCH, + ) + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("projects"), resource("profiles")), + layouts = emptyList(), + links = emptyList(), + forms = listOf( + form("update-profile.form", "Update profile", "profiles", updateProfile.id), + ), + actions = listOf(updateProfile), + ) + + val plan = descriptor.planDynamicNavigation( + DynamicResourceRecordContext( + resourceId = "projects", + recordId = "project-7", + fieldValues = mapOf("userId" to "user-4"), + ), + ) + + assertTrue(plan.contextualFormActions.isEmpty()) + } + + @Test + fun `verified file upload form is contextual only with complete trusted route bindings`() { + val listPhotos = action( + "list-photos", + "photos", + ActionIntent.list, + "houseId", + ).copy(confidence = Confidence.verified) + val uploadPhoto = action( + "upload-photo", + "photos", + ActionIntent.execute, + "houseId", + method = HttpMethod.POST, + body = HttpBody( + contentType = "multipart/form-data", + required = true, + schema = buildJsonObject { + put("type", JsonPrimitive("object")) + put( + "properties", + buildJsonObject { + put( + "image", + buildJsonObject { + put("type", JsonPrimitive("string")) + put("format", JsonPrimitive("binary")) + }, + ) + }, + ) + }, + ), + ).copy( + effect = ActionEffect.upload, + confidence = Confidence.verified, + ) + val uploadForm = form( + id = "upload-photo.form", + title = "Upload", + resourceId = "photos", + actionId = uploadPhoto.id, + ).copy( + fields = listOf( + FormField( + fieldId = "image", + label = "Image", + kind = FieldKind.file, + required = true, + ), + ), + confidence = Confidence.verified, + ) + val photoLayout = layout("photos", listPhotos.id).copy( + confidence = Confidence.verified, + ) + val descriptor = hierarchyDescriptor().copy( + resources = listOf( + resource("houses").copy(confidence = Confidence.verified), + resource("photos").copy(confidence = Confidence.verified), + ), + layouts = listOf(photoLayout), + links = emptyList(), + forms = listOf(uploadForm), + actions = listOf(listPhotos, uploadPhoto), + ) + val trustedContext = DynamicResourceRecordContext( + resourceId = "houses", + recordId = "house-7", + parameterValues = mapOf("houseId" to "house-7"), + currentLayoutId = photoLayout.id, + ) + + assertEquals( + mapOf("houseId" to "house-7"), + descriptor.planDynamicNavigation(trustedContext) + .contextualFormActions + .single { action -> action.actionId == uploadPhoto.id } + .pathParameterValues, + ) + assertTrue( + descriptor.planDynamicNavigation( + trustedContext.copy( + parameterValues = emptyMap(), + actionSafeIdentity = false, + ), + ).contextualFormActions.isEmpty(), + ) + assertTrue( + descriptor.copy(forms = listOf(uploadForm.copy(confidence = Confidence.high))) + .planDynamicNavigation(trustedContext) + .contextualFormActions + .isEmpty(), + ) + } + + @Test + fun `cross-resource singleton update requires one verified active detail read surface`() { + val readPreferences = action( + "read-preferences", + "preferences", + ActionIntent.read, + "houseId", + ).copy(confidence = Confidence.verified) + val updatePreferences = action( + "update-preferences", + "preferences", + ActionIntent.update, + "houseId", + method = HttpMethod.PATCH, + ).copy(confidence = Confidence.verified) + val preferencesLayout = layout("preferences", readPreferences.id).copy( + id = "preferences.detail", + kind = LayoutKind.detail, + confidence = Confidence.verified, + ) + val preferencesForm = form( + id = "update-preferences.form", + title = "Preferences", + resourceId = "preferences", + actionId = updatePreferences.id, + ).copy( + fields = listOf( + FormField( + fieldId = "enabled", + label = "Enabled", + kind = FieldKind.boolean, + required = true, + ), + ), + confidence = Confidence.verified, + ) + val descriptor = hierarchyDescriptor().copy( + resources = listOf( + resource("houses").copy(confidence = Confidence.verified), + resource("preferences").copy( + collection = false, + confidence = Confidence.verified, + ), + ), + layouts = listOf(preferencesLayout), + links = emptyList(), + forms = listOf(preferencesForm), + actions = listOf(readPreferences, updatePreferences), + ) + val activePreferences = DynamicResourceRecordContext( + resourceId = "houses", + recordId = "house-7", + parameterValues = mapOf("houseId" to "house-7"), + currentLayoutId = preferencesLayout.id, + ) + + assertEquals( + listOf(updatePreferences.id), + descriptor.planDynamicNavigation(activePreferences) + .contextualFormActions + .map(DynamicNavigationFormAction::actionId), + ) + assertTrue( + descriptor.copy( + layouts = listOf(preferencesLayout.copy(kind = LayoutKind.list)), + ).planDynamicNavigation(activePreferences) + .contextualFormActions + .isEmpty(), + ) + assertTrue( + descriptor.planDynamicNavigation( + activePreferences.copy( + parameterValues = emptyMap(), + actionSafeIdentity = false, + ), + ).contextualFormActions.isEmpty(), + ) + } + @Test fun `ephemeral map identity can open read children but cannot authorize forms`() { val plan = hierarchyDescriptor().planDynamicNavigation( @@ -104,6 +361,299 @@ class DynamicNavigationPlannerTest { assertTrue(plan.contextualFormActions.isEmpty()) } + @Test + fun `conflicting record provenance keeps read navigation but withholds every contextual write`() { + val plan = hierarchyDescriptor().planDynamicNavigation( + DynamicResourceRecordContext( + resourceId = "projects", + recordId = "project-7", + parameterValues = mapOf("projectId" to "project-7"), + actionSafeIdentity = true, + actionBindingProvenanceValid = false, + ), + ) + + assertEquals( + setOf("bills", "members"), + plan.contextualChildDestinations.mapTo(mutableSetOf()) { it.resourceId }, + ) + assertTrue(plan.contextualFormActions.isEmpty()) + } + + @Test + fun `record capabilities gate same-record forms without blocking a verified child create`() { + val deleteProject = action( + "delete-project", + "projects", + ActionIntent.delete, + "projectId", + method = HttpMethod.DELETE, + ) + val capabilityFields = listOf( + "readOnly", + "writable", + "canWrite", + "canEdit", + "canUpdate", + "canDelete", + ).map { fieldId -> + DynamicField( + id = fieldId, + label = fieldId, + kind = FieldKind.boolean, + required = false, + readOnly = true, + nullable = false, + multiple = false, + confidence = Confidence.high, + ) + } + val baseline = hierarchyDescriptor() + val descriptor = baseline.copy( + resources = baseline.resources.map { resource -> + resource.takeUnless { it.id == "projects" } + ?: resource.copy(fields = capabilityFields) + }, + forms = baseline.forms + form( + "delete-project.form", + "Delete project", + "projects", + deleteProject.id, + ), + actions = baseline.actions + deleteProject, + ) + val baseContext = DynamicResourceRecordContext( + resourceId = "projects", + recordId = "project-7", + parameterValues = mapOf("projectId" to "project-7"), + ) + + fun contextualFormIds(fieldValues: Map): Set = + descriptor.planDynamicNavigation(baseContext.copy(fieldValues = fieldValues)) + .contextualFormActions + .mapTo(mutableSetOf(), DynamicNavigationFormAction::formId) + + val allowed = mapOf( + "readOnly" to "false", + "writable" to "true", + "canWrite" to "true", + "canEdit" to "true", + "canUpdate" to "true", + "canDelete" to "true", + ) + assertEquals( + setOf("create-bill.form", "delete-project.form", "edit-project.form"), + contextualFormIds(allowed), + ) + assertEquals( + setOf("create-bill.form"), + contextualFormIds(allowed + ("readOnly" to "true")), + ) + assertEquals( + setOf("create-bill.form"), + contextualFormIds( + allowed + mapOf( + "canEdit" to "false", + "canUpdate" to "false", + "canDelete" to "false", + ), + ), + ) + assertEquals( + setOf("create-bill.form"), + contextualFormIds(emptyMap()), + ) + + fun descriptorWithScopedCapabilities(vararg fieldIds: String): DynamicAppDescriptor = + descriptor.copy( + resources = descriptor.resources.map { resource -> + resource.takeUnless { it.id == "projects" } + ?: resource.copy( + fields = capabilityFields.filter { field -> field.id in fieldIds }, + ) + }, + ) + + fun scopedContextualFormIds( + scopedDescriptor: DynamicAppDescriptor, + fieldValues: Map, + ): Set = scopedDescriptor + .planDynamicNavigation(baseContext.copy(fieldValues = fieldValues)) + .contextualFormActions + .mapTo(mutableSetOf(), DynamicNavigationFormAction::formId) + + val deleteOnly = descriptorWithScopedCapabilities("canDelete") + assertEquals( + setOf("create-bill.form", "delete-project.form"), + scopedContextualFormIds(deleteOnly, mapOf("canDelete" to "true")), + ) + + val editOnly = descriptorWithScopedCapabilities("canEdit") + assertEquals( + setOf("create-bill.form", "edit-project.form"), + scopedContextualFormIds(editOnly, mapOf("canEdit" to "true")), + ) + + val noScopedCapabilities = descriptorWithScopedCapabilities() + assertEquals( + setOf("create-bill.form", "delete-project.form", "edit-project.form"), + scopedContextualFormIds(noScopedCapabilities, emptyMap()), + ) + } + + @Test + fun `declared child create can bind one required parent body field from record context`() { + val listCollections = action("list-collections", "collections", ActionIntent.list) + val listEntries = action("list-entries", "entries", ActionIntent.list, "collectionId") + val createEntry = action( + id = "create-entry", + resourceId = "entries", + intent = ActionIntent.create, + method = HttpMethod.POST, + body = HttpBody( + contentType = "application/json", + required = true, + schema = buildJsonObject { + put("type", "object") + put( + "properties", + buildJsonObject { + put("collectionId", buildJsonObject { put("type", "integer") }) + put("title", buildJsonObject { put("type", "string") }) + }, + ) + put( + "required", + buildJsonArray { + add(JsonPrimitive("collectionId")) + add(JsonPrimitive("title")) + }, + ) + }, + ), + ) + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("shared-lists", "Shared lists", "test"), + resources = listOf(resource("collections"), resource("entries")), + layouts = listOf( + layout("collections", listCollections.id), + layout("entries", listEntries.id), + ), + links = listOf( + actionLink("collections.entries", "Entries", "collections", listEntries.id), + ), + forms = listOf( + DynamicForm( + id = "create-entry.form", + title = "Create entry", + resourceId = "entries", + actionId = createEntry.id, + fields = listOf( + FormField("collectionId", "Collection", FieldKind.integer, required = true), + FormField("title", "Title", FieldKind.string, required = true), + ), + confidence = Confidence.high, + ), + ), + actions = listOf(listCollections, listEntries, createEntry), + ) + + val action = descriptor.planDynamicNavigation( + DynamicResourceRecordContext( + resourceId = "collections", + recordId = "collection-7", + ), + ).contextualFormActions.single() + + assertEquals("create-entry.form", action.formId) + assertEquals(mapOf("collectionId" to "collection-7"), action.pathParameterValues) + } + + @Test + fun `body scoped child create is withheld without a declared relationship or safe parent identity`() { + val listCollections = action("list-collections", "collections", ActionIntent.list) + val listEntries = action("list-entries", "entries", ActionIntent.list, "collectionId") + val createEntry = action( + id = "create-entry", + resourceId = "entries", + intent = ActionIntent.create, + method = HttpMethod.POST, + body = HttpBody( + contentType = "application/json", + required = true, + schema = buildJsonObject { + put("type", "object") + put( + "properties", + buildJsonObject { + put("collectionId", buildJsonObject { put("type", "integer") }) + }, + ) + put( + "required", + buildJsonArray { + add(JsonPrimitive("collectionId")) + }, + ) + }, + ), + ) + val createForm = DynamicForm( + id = "create-entry.form", + title = "Create entry", + resourceId = "entries", + actionId = createEntry.id, + fields = listOf( + FormField("collectionId", "Collection", FieldKind.integer, required = true), + ), + confidence = Confidence.high, + ) + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("shared-lists", "Shared lists", "test"), + resources = listOf(resource("collections"), resource("entries")), + layouts = listOf( + layout("collections", listCollections.id), + layout("entries", listEntries.id), + ), + links = listOf( + actionLink("collections.entries", "Entries", "collections", listEntries.id), + ), + forms = listOf(createForm), + actions = listOf(listCollections, listEntries, createEntry), + ) + val selectedCollection = DynamicResourceRecordContext( + resourceId = "collections", + recordId = "collection-7", + ) + + assertTrue( + descriptor.copy(links = emptyList()) + .planDynamicNavigation(selectedCollection) + .contextualFormActions + .isEmpty(), + ) + assertTrue( + descriptor.planDynamicNavigation( + selectedCollection.copy( + parameterValues = mapOf("collectionId" to "collection-7"), + actionSafeIdentity = false, + ), + ).contextualFormActions.isEmpty(), + ) + assertTrue( + descriptor.copy( + links = descriptor.links + actionLink( + "collections.entries.alternate", + "Alternate entries", + "collections", + listEntries.id, + ), + ).planDynamicNavigation(selectedCollection) + .contextualFormActions + .isEmpty(), + ) + } + @Test fun `observed record identity binds a same-resource detail read but never a write`() { val detail = action("read-recipe", "recipes", ActionIntent.read, "id") @@ -463,7 +1013,7 @@ class DynamicNavigationPlannerTest { } @Test - fun `semantic container routing is app neutral and refuses ambiguous mailbox children`() { + fun `semantic container routing keeps archives secondary and refuses ambiguous active mailboxes`() { val descriptor = hierarchyDescriptor().copy( app = AppIdentity("communications", "Communications", "test"), resources = listOf(resource("accounts"), resource("mailboxes"), resource("messages")), @@ -494,7 +1044,7 @@ class DynamicNavigationPlannerTest { assertEquals("mailboxes", mailbox.resourceId) assertEquals("messages", descriptor.preferredSemanticContextualChild(mailboxContext)?.resourceId) - val ambiguous = descriptor.copy( + val withArchive = descriptor.copy( resources = descriptor.resources + resource("archivedMailboxes"), layouts = descriptor.layouts + layout("archivedMailboxes", "list-archived-mailboxes"), links = descriptor.links + actionLink( @@ -511,7 +1061,240 @@ class DynamicNavigationPlannerTest { ), ) - assertNull(ambiguous.preferredSemanticContextualChild(accountContext)) + assertEquals( + "mailboxes", + withArchive.preferredSemanticContextualChild(accountContext)?.resourceId, + ) + + val ambiguousActive = descriptor.copy( + resources = descriptor.resources + resource("sharedMailboxes"), + layouts = descriptor.layouts + layout("sharedMailboxes", "list-shared-mailboxes"), + links = descriptor.links + actionLink( + "accounts.shared-mailboxes", + "Shared mailboxes", + "accounts", + "list-shared-mailboxes", + ), + actions = descriptor.actions + action( + "list-shared-mailboxes", + "sharedMailboxes", + ActionIntent.list, + "accountId", + ), + ) + + assertNull(ambiguousActive.preferredSemanticContextualChild(accountContext)) + } + + @Test + fun `semantic content containers prefer their unique declared child collection`() { + listOf( + "lists" to "items", + "checklists" to "entries", + "projects" to "tasks", + "containers" to "entries", + ).forEach { (parentResourceId, childResourceId) -> + val parentAction = action("read-$parentResourceId", parentResourceId, ActionIntent.list) + val childAction = action("read-$childResourceId", childResourceId, ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource(parentResourceId), resource(childResourceId)), + layouts = listOf( + layout(parentResourceId, parentAction.id), + layout(childResourceId, childAction.id), + ), + links = listOf( + actionLink( + "$parentResourceId.$childResourceId", + childResourceId, + parentResourceId, + childAction.id, + ), + ), + forms = emptyList(), + actions = listOf(parentAction, childAction), + ) + val context = DynamicResourceRecordContext( + resourceId = parentResourceId, + recordId = "selected-record", + ) + + val preferred = assertNotNull(descriptor.preferredSemanticContextualChild(context)) + + assertEquals(childResourceId, preferred.resourceId) + assertEquals(mapOf("id" to "selected-record"), preferred.pathParameterValues) + } + } + + @Test + fun `relationship containers prefer their unique declared list collection`() { + val parent = action("read-spaces", "spaces", ActionIntent.list) + val lists = action("read-lists", "lists", ActionIntent.list, "id") + val categories = action("read-categories", "categories", ActionIntent.list, "id") + val items = action("read-items", "items", ActionIntent.list, "id") + val notes = action("read-notes", "notes", ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("organizer", "Organizer", "test"), + resources = listOf( + resource("spaces"), + resource("lists"), + resource("categories"), + resource("items"), + resource("notes"), + ), + layouts = listOf( + layout("spaces", parent.id), + layout("lists", lists.id), + layout("categories", categories.id), + layout("items", items.id), + layout("notes", notes.id), + ), + links = listOf( + actionLink("spaces.lists", "Lists", "spaces", lists.id), + actionLink("spaces.categories", "Categories", "spaces", categories.id), + actionLink("spaces.items", "Items", "spaces", items.id), + actionLink("spaces.notes", "Notes", "spaces", notes.id), + ), + forms = emptyList(), + actions = listOf(parent, lists, categories, items, notes), + ) + val context = DynamicResourceRecordContext("spaces", "selected-space") + + val preferred = assertNotNull(descriptor.preferredSemanticContextualChild(context)) + + assertEquals("lists", preferred.resourceId) + assertEquals(mapOf("id" to "selected-space"), preferred.pathParameterValues) + } + + @Test + fun `equally meaningful declared content children remain explicit`() { + val parent = action("read-checklists", "checklists", ActionIntent.list) + val items = action("read-items", "items", ActionIntent.list, "id") + val tasks = action("read-tasks", "tasks", ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("checklists"), resource("items"), resource("tasks")), + layouts = listOf( + layout("checklists", parent.id), + layout("items", items.id), + layout("tasks", tasks.id), + ), + links = listOf( + actionLink("checklists.items", "Items", "checklists", items.id), + actionLink("checklists.tasks", "Tasks", "checklists", tasks.id), + ), + forms = emptyList(), + actions = listOf(parent, items, tasks), + ) + val context = DynamicResourceRecordContext("checklists", "selected-record") + + assertNull(descriptor.preferredSemanticContextualChild(context)) + assertEquals( + setOf("items", "tasks"), + descriptor.planDynamicNavigation(context).contextualChildDestinations + .mapTo(mutableSetOf(), DynamicNavigationDestination::resourceId), + ) + } + + @Test + fun `declared content child with missing context is not preferred`() { + val parent = action("read-projects", "projects", ActionIntent.list) + val child = action("read-tasks", "tasks", ActionIntent.list, "id", "scope") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("projects"), resource("tasks")), + layouts = listOf(layout("projects", parent.id), layout("tasks", child.id)), + links = listOf(actionLink("projects.tasks", "Tasks", "projects", child.id)), + forms = emptyList(), + actions = listOf(parent, child), + ) + val context = DynamicResourceRecordContext("projects", "selected-record") + + assertNull(descriptor.preferredSemanticContextualChild(context)) + assertTrue(descriptor.planDynamicNavigation(context).contextualChildDestinations.isEmpty()) + assertEquals( + listOf("scope"), + descriptor.explainDynamicChildNavigation(context) + .single { it.actionId == child.id } + .missingContextParameters, + ) + } + + @Test + fun `inherited ancestor identity does not turn sibling collections into record sections`() { + val lists = action("read-lists", "lists", ActionIntent.list) + val items = action( + "read-items", + "items", + ActionIntent.list, + "houseId", + "listId", + ) + val categories = action("read-categories", "categories", ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("lists"), resource("items"), resource("categories")), + layouts = listOf( + layout("lists", lists.id), + layout("items", items.id), + layout("categories", categories.id), + ), + links = listOf( + actionLink("lists.items", "Items", "lists", items.id), + actionLink("lists.categories", "Categories", "lists", categories.id), + ), + forms = emptyList(), + actions = listOf(lists, items, categories), + ) + val context = DynamicResourceRecordContext( + resourceId = "lists", + recordId = "list-9", + fieldValues = mapOf( + "id" to "list-9", + "listId" to "list-9", + "houseId" to "house-7", + ), + parameterValues = mapOf("id" to "house-7"), + ) + + assertEquals( + listOf("items"), + descriptor.planDynamicNavigation(context).contextualChildDestinations + .map(DynamicNavigationDestination::resourceId), + ) + assertEquals( + DynamicChildCandidateStatus.ancestorOnlyContext, + descriptor.explainDynamicChildNavigation(context) + .single { diagnostic -> diagnostic.actionId == categories.id } + .status, + ) + } + + @Test + fun `declared content relationship cycle is never preferred`() { + val parents = action("read-lists", "lists", ActionIntent.list, "id") + val children = action("read-items", "items", ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("lists"), resource("items")), + layouts = listOf(layout("lists", parents.id), layout("items", children.id)), + links = listOf( + actionLink("a.items.lists", "Lists", "items", parents.id), + actionLink("z.lists.items", "Items", "lists", children.id), + ), + forms = emptyList(), + actions = listOf(parents, children), + ) + val context = DynamicResourceRecordContext("lists", "selected-record") + + assertNull(descriptor.preferredSemanticContextualChild(context)) + assertTrue(descriptor.planDynamicNavigation(context).contextualChildDestinations.isEmpty()) + assertEquals( + DynamicChildCandidateStatus.cycle, + descriptor.explainDynamicChildNavigation(context) + .single { it.actionId == children.id } + .status, + ) } @Test @@ -549,6 +1332,70 @@ class DynamicNavigationPlannerTest { assertEquals(mapOf("category" to "sweet"), preferred.pathParameterValues) } + @Test + fun `taxonomy records do not auto open unrelated helper or technical collections`() { + listOf( + Triple("categories", "metadata", true), + Triple("tags", "summaries", false), + ).forEach { (parentResourceId, childResourceId, technical) -> + val parent = action("read-$parentResourceId", parentResourceId, ActionIntent.list) + val child = action("read-$childResourceId", childResourceId, ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("organizer", "Organizer", "test"), + resources = listOf(resource(parentResourceId), resource(childResourceId)), + layouts = listOf( + layout(parentResourceId, parent.id), + layout(childResourceId, child.id), + ), + links = listOf( + actionLink( + "$parentResourceId.$childResourceId", + childResourceId, + parentResourceId, + child.id, + ), + ), + forms = emptyList(), + actions = listOf(parent, child), + ) + val context = DynamicResourceRecordContext(parentResourceId, "selected-record") + val explicitChild = descriptor.planDynamicNavigation(context) + .contextualChildDestinations + .single() + + assertEquals(technical, descriptor.isSecondaryTechnicalDestination(context, explicitChild)) + assertNull(descriptor.preferredSemanticContextualChild(context)) + } + } + + @Test + fun `archived and deleted collections remain explicit secondary sections`() { + listOf("archive", "archivedItems", "deletedEntries", "trash").forEach { childResourceId -> + val parent = action("read-lists", "lists", ActionIntent.list) + val child = action("read-$childResourceId", childResourceId, ActionIntent.list, "id") + val descriptor = hierarchyDescriptor().copy( + app = AppIdentity("workspace", "Workspace", "test"), + resources = listOf(resource("lists"), resource(childResourceId)), + layouts = listOf( + layout("lists", parent.id), + layout(childResourceId, child.id), + ), + links = listOf( + actionLink("lists.$childResourceId", childResourceId, "lists", child.id), + ), + forms = emptyList(), + actions = listOf(parent, child), + ) + val context = DynamicResourceRecordContext("lists", "selected-record") + val explicitChild = descriptor.planDynamicNavigation(context) + .contextualChildDestinations + .single() + + assertTrue(descriptor.isSecondaryTechnicalDestination(context, explicitChild)) + assertNull(descriptor.preferredSemanticContextualChild(context)) + } + } + @Test fun `message protocol helpers are secondary while useful content stays primary`() { val descriptor = hierarchyDescriptor().copy( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredFormTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredFormTest.kt new file mode 100644 index 00000000..a3c85545 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredFormTest.kt @@ -0,0 +1,144 @@ +package dev.obiente.nextcloudnative.nativeui.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlinx.serialization.json.Json + +class DynamicStructuredFormTest { + @Test + fun `repeatable nullable fields preserve explicit null distinctly from absence`() { + val spec = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 1, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "note", + label = "Note", + kind = RepeatableObjectInputScalarKind.String, + required = false, + nullable = true, + ), + ), + ) + + assertEquals( + """[{"note":null}]""", + spec.encode( + listOf(RepeatableObjectInputRow(nullFieldIds = setOf("note"))), + ), + ) + assertEquals( + """[{}]""", + spec.encode(listOf(RepeatableObjectInputRow())), + ) + assertEquals( + """[{"note":null}]""", + spec.canonicalJson("""[{"note":null}]""").toString(), + ) + } + + @Test + fun `repeatable decimal values preserve exact JSON precision and exact bounds`() { + val unbounded = decimalSpec() + + assertEquals( + """[{"amount":9007199254740993}]""", + unbounded.encode( + listOf(RepeatableObjectInputRow(mapOf("amount" to "9007199254740993"))), + ), + ) + assertEquals( + """[{"amount":1.234567890123456789e-40}]""", + unbounded.encode( + listOf(RepeatableObjectInputRow(mapOf("amount" to "1.234567890123456789e-40"))), + ), + ) + assertEquals( + """[{"amount":9007199254740993}]""", + unbounded.canonicalJson("""[{"amount":9007199254740993}]""").toString(), + ) + + val bounded = decimalSpec( + minimum = "9007199254740992.999999999999999999", + maximum = "9007199254740993.000000000000000001", + ) + assertEquals( + """[{"amount":9007199254740993}]""", + bounded.encode( + listOf(RepeatableObjectInputRow(mapOf("amount" to "9007199254740993"))), + ), + ) + assertFailsWith { + bounded.encode( + listOf( + RepeatableObjectInputRow( + mapOf("amount" to "9007199254740992.999999999999999998"), + ), + ), + ) + } + assertFailsWith { + bounded.encode( + listOf( + RepeatableObjectInputRow( + mapOf("amount" to "9007199254740993.000000000000000002"), + ), + ), + ) + } + } + + @Test + fun `repeatable decimal schema retains exact contract bounds`() { + val spec = assertNotNull( + Json.parseToJsonElement( + """ + { + "type":"array", + "format":"$DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT", + "minItems":1, + "items":{ + "type":"object", + "additionalProperties":false, + "required":["amount"], + "properties":{ + "amount":{ + "type":"number", + "minimum":9007199254740992.999999999999999999, + "maximum":9007199254740993.000000000000000001 + } + } + } + } + """.trimIndent(), + ).repeatableObjectInputSpec(), + ) + + assertEquals( + """[{"amount":9007199254740993}]""", + spec.encode( + listOf(RepeatableObjectInputRow(mapOf("amount" to "9007199254740993"))), + ), + ) + } + + private fun decimalSpec( + minimum: String? = null, + maximum: String? = null, + ): RepeatableObjectInputSpec = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 2, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "amount", + label = "Amount", + kind = RepeatableObjectInputScalarKind.Decimal, + required = true, + minimum = minimum, + maximum = maximum, + ), + ), + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererActionsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererActionsTest.kt new file mode 100644 index 00000000..a9f50785 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererActionsTest.kt @@ -0,0 +1,313 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputFieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputScalarKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GenericNativeRendererActionsTest { + @Test + fun `repeatable object fields retain typed controls and schema stable automation identity`() { + val enumeration = RepeatableObjectInputFieldSpec( + id = "role", + label = "Role", + kind = RepeatableObjectInputScalarKind.Enumeration, + required = true, + enumValues = listOf("viewer", "editor"), + ).toNativeRepeatableObjectFieldSpec() + val enabled = RepeatableObjectInputFieldSpec( + id = "enabled", + label = "Enabled", + kind = RepeatableObjectInputScalarKind.Boolean, + required = false, + ).toNativeRepeatableObjectFieldSpec() + val quantity = RepeatableObjectInputFieldSpec( + id = "quantity", + label = "Quantity", + kind = RepeatableObjectInputScalarKind.Integer, + required = true, + ).toNativeRepeatableObjectFieldSpec() + val amount = RepeatableObjectInputFieldSpec( + id = "amount", + label = "Amount", + kind = RepeatableObjectInputScalarKind.Decimal, + required = true, + ).toNativeRepeatableObjectFieldSpec() + val note = RepeatableObjectInputFieldSpec( + id = "note", + label = "Note", + kind = RepeatableObjectInputScalarKind.String, + required = false, + ).toNativeRepeatableObjectFieldSpec() + + assertEquals(FieldKind.enumeration, enumeration.kind) + assertEquals(listOf("viewer", "editor"), enumeration.enumValues) + assertEquals(FieldKind.boolean, enabled.kind) + assertEquals(FieldKind.integer, quantity.kind) + assertEquals(FieldKind.decimal, amount.kind) + assertEquals(FieldKind.string, note.kind) + assertEquals( + "assignments row 2 role", + nativeRepeatableObjectAutomationFieldId( + fieldId = "assignments", + rowIndex = 1, + itemFieldId = "role", + ), + ) + } + + @Test + fun `pending form identity and bounded draft round trip through saveable primitives`() { + val identity = RestorableNativeRecordFormAction( + actionId = "items.update", + resourceId = "items", + kind = NativeRecordFormActionKind.Edit, + recordId = "item-7", + ) + assertEquals(identity, decodeRestorableNativeRecordFormAction(assertNotNull(identity.encode()))) + + val draft = linkedMapOf( + "title" to "Restored title", + "notes" to "A bounded draft survives process recreation.", + ) + assertEquals(draft, decodeNativeRecordFormDraft(assertNotNull(encodeNativeRecordFormDraft(draft)))) + + val tooManyFields = (0..64).associate { index -> "field$index" to "value" } + assertNull(encodeNativeRecordFormDraft(tooManyFields)) + assertNull(encodeNativeRecordFormDraft(mapOf("notes" to "x".repeat(64 * 1024 + 1)))) + assertNull(decodeRestorableNativeRecordFormAction("not-json")) + } + + @Test + fun `large relation selectors expose only a bounded searchable option window`() { + val options = (0 until 5_000).map { index -> + NativeRelationOption( + value = index.toString(), + label = "Choice ${index.toString().padStart(4, '0')}", + supportingText = "Group ${index % 100}", + ) + } + + val initial = nativeRelationOptionWindow(options, query = "") + assertEquals(NATIVE_RELATION_OPTION_WINDOW_SIZE, initial.options.size) + assertTrue(initial.hasMore) + + val exact = nativeRelationOptionWindow(options, query = "Choice 4999") + assertEquals(listOf(options.last()), exact.options) + assertFalse(exact.hasMore) + + val broadSearch = nativeRelationOptionWindow(options, query = "Group 17") + assertTrue(broadSearch.options.size <= NATIVE_RELATION_OPTION_WINDOW_SIZE) + assertTrue(broadSearch.hasMore) + assertTrue(broadSearch.options.all { option -> option.supportingText == "Group 17" }) + } + + @Test + fun `paged relation window retains selected labels within a separate strict bound`() { + val firstWindow = (1..500).map { index -> + NativeRelationOption( + value = "choice-$index", + label = "Choice $index", + supportingText = null, + ) + } + val retained = retainSelectedNativeRelationOptions( + retained = emptyList(), + available = firstWindow, + selectedValues = listOf("choice-10", "choice-490"), + ) + val laterWindow = (501..1_000).map { index -> + NativeRelationOption( + value = "choice-$index", + label = "Choice $index", + supportingText = null, + ) + } + + assertEquals( + listOf("choice-10", "choice-490"), + retainSelectedNativeRelationOptions( + retained = retained, + available = laterWindow, + selectedValues = listOf("choice-10", "choice-490"), + ).map(NativeRelationOption::value), + ) + assertTrue( + retainSelectedNativeRelationOptions( + retained = firstWindow, + available = laterWindow, + selectedValues = (1..100).map { index -> "choice-$index" }, + ).size <= NATIVE_RELATION_RETAINED_SELECTION_LIMIT, + ) + assertTrue( + retainSelectedNativeRelationOptions( + retained = retained, + available = laterWindow, + selectedValues = emptyList(), + ).isEmpty(), + ) + } + + @Test + fun `unknown command outcomes require reconciliation while explicit rejections may retry`() { + assertTrue(NativeActionFailureOutcome.Unknown.requiresCommandReconciliation()) + assertFalse(NativeActionFailureOutcome.Rejected.requiresCommandReconciliation()) + } + + @Test + fun `unknown form outcomes reconcile without retry while explicit rejections remain retryable`() { + assertTrue(NativeActionFailureOutcome.Unknown.requiresMutationReconciliation()) + assertFalse(NativeActionFailureOutcome.Unknown.allowsGenericFormRetry()) + + assertFalse(NativeActionFailureOutcome.Rejected.requiresMutationReconciliation()) + assertTrue(NativeActionFailureOutcome.Rejected.allowsGenericFormRetry()) + } + + @Test + fun `unknown delete outcomes reconcile without retry while explicit rejections remain retryable`() { + assertTrue(NativeActionFailureOutcome.Unknown.requiresMutationReconciliation()) + assertFalse(NativeActionFailureOutcome.Unknown.allowsGenericDeleteRetry()) + + assertFalse(NativeActionFailureOutcome.Rejected.requiresMutationReconciliation()) + assertTrue(NativeActionFailureOutcome.Rejected.allowsGenericDeleteRetry()) + } + + @Test + fun `unknown completion outcomes suppress retry until refresh while rejection stays retryable`() { + val originalRecords = listOf(NativeRecord("task-1", mapOf("completed" to "false"))) + val originalKey = NativeAuthoritativeRecordsKey(originalRecords) + val reconciliation = mutableMapOf() + + val refreshRequired = reconciliation.recordNativeCompletionFailure( + recordId = "task-1", + authoritativeRecordsKey = originalKey, + outcome = NativeActionFailureOutcome.Unknown, + ) + + assertTrue(refreshRequired) + assertTrue(reconciliation.isNativeCompletionReconciling("task-1", originalKey)) + + reconciliation["task-2"] = originalKey + val rejectedRefreshRequired = reconciliation.recordNativeCompletionFailure( + recordId = "task-2", + authoritativeRecordsKey = originalKey, + outcome = NativeActionFailureOutcome.Rejected, + ) + assertFalse(rejectedRefreshRequired) + assertFalse(reconciliation.isNativeCompletionReconciling("task-2", originalKey)) + + val refreshedKey = NativeAuthoritativeRecordsKey( + listOf(NativeRecord("task-1", mapOf("completed" to "false"))), + ) + assertEquals(setOf("task-1"), reconciliation.reconcileNativeCompletionFailures(refreshedKey)) + assertFalse(reconciliation.isNativeCompletionReconciling("task-1", refreshedKey)) + } + + @Test + fun `authoritative refresh wins over completion override even when records are unchanged`() { + val originalRecords = listOf(NativeRecord("task-1", mapOf("completed" to "false"))) + val unchangedRefreshedRecords = listOf(NativeRecord("task-1", mapOf("completed" to "false"))) + assertEquals(originalRecords, unchangedRefreshedRecords) + assertFalse(originalRecords === unchangedRefreshedRecords) + + val originalKey = NativeAuthoritativeRecordsKey(originalRecords) + val sameSnapshotKey = NativeAuthoritativeRecordsKey(originalRecords) + val refreshedKey = NativeAuthoritativeRecordsKey(unchangedRefreshedRecords) + val override = NativeCompletionOverride(completed = true, sourceRecordsKey = originalKey) + + assertEquals(originalKey, sameSnapshotKey) + assertTrue( + effectiveNativeCompletion( + override = override, + authoritativeRecordsKey = sameSnapshotKey, + authoritativeCompleted = false, + ), + ) + assertFalse( + effectiveNativeCompletion( + override = override, + authoritativeRecordsKey = refreshedKey, + authoritativeCompleted = false, + ), + ) + + val overrides = mutableMapOf("task-1" to override) + overrides.reconcileNativeCompletionOverrides(refreshedKey) + assertTrue(overrides.isEmpty()) + } + + @Test + fun `only optional scalar relations expose an explicit clear choice`() { + val optionalScalar = FieldSpec( + id = "collectionId", + label = "Collection", + kind = FieldKind.integer, + required = false, + readOnly = false, + ) + + assertEquals( + NativeRelationOption(value = "", label = "None", supportingText = "Clear selection"), + nativeScalarRelationClearChoice(optionalScalar), + ) + assertNull(nativeScalarRelationClearChoice(optionalScalar.copy(required = true))) + assertNull( + nativeScalarRelationClearChoice( + optionalScalar.copy(format = DYNAMIC_INTEGER_ARRAY_FORMAT), + ), + ) + } + + @Test + fun `reversible record commands use concise non destructive labels`() { + val expectations = mapOf( + ActionEffect.archive to "Archive", + ActionEffect.unarchive to "Unarchive", + ActionEffect.restore to "Restore", + ActionEffect.copy to "Copy", + ) + + expectations.forEach { (effect, label) -> + val ui = nativeRecordCommandUi(effect, "Example record") + + assertEquals(label, ui.label) + assertFalse(ui.destructive) + assertNull(ui.confirmationTitle) + assertNull(ui.confirmationMessage) + } + } + + @Test + fun `destructive record commands describe the exact effect and item`() { + val permanentDelete = nativeRecordCommandUi( + ActionEffect.permanentDelete, + "Example record", + ) + val clear = nativeRecordCommandUi(ActionEffect.clear, "Example record") + val leave = nativeRecordCommandUi(ActionEffect.leave, "Example record") + + assertEquals("Delete permanently", permanentDelete.label) + assertEquals("Delete Example record permanently?", permanentDelete.confirmationTitle) + assertTrue(requireNotNull(permanentDelete.confirmationMessage).contains("cannot be undone")) + + assertEquals("Clear", clear.label) + assertEquals("Clear Example record?", clear.confirmationTitle) + assertTrue(requireNotNull(clear.confirmationMessage).contains("cannot be undone")) + + assertEquals("Leave", leave.label) + assertEquals("Leave Example record?", leave.confirmationTitle) + assertTrue(requireNotNull(leave.confirmationMessage).contains("lose access")) + + assertTrue(permanentDelete.destructive) + assertTrue(clear.destructive) + assertTrue(leave.destructive) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererStateTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererStateTest.kt index 0050c14e..06285562 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererStateTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererStateTest.kt @@ -7,6 +7,7 @@ import dev.obiente.nextcloudnative.nativeui.model.ApiBinding import dev.obiente.nextcloudnative.nativeui.model.AppIdentity import dev.obiente.nextcloudnative.nativeui.model.Confidence import dev.obiente.nextcloudnative.nativeui.model.CompositeDataGridSpec +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT import dev.obiente.nextcloudnative.nativeui.model.FieldKind import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import dev.obiente.nextcloudnative.nativeui.model.HttpMethod @@ -31,6 +32,25 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class GenericNativeRendererStateTest { + @Test + fun formColorOptionsProduceOrdinaryOpaqueArgbValues() { + val colorField = FieldSpec( + id = "color", + label = "Color", + kind = FieldKind.enumeration, + required = false, + readOnly = false, + enumValues = listOf("f97316"), + ) + + assertEquals(0xFFF97316.toInt(), "f97316".nativeFormColorArgbOrNull(colorField)) + assertEquals(0xFFF97316.toInt(), "#F97316".nativeFormColorArgbOrNull(colorField)) + assertNull("not-a-color".nativeFormColorArgbOrNull(colorField)) + assertNull( + "f97316".nativeFormColorArgbOrNull(colorField.copy(id = "status")), + ) + } + @Test fun datasetInsightsDefaultToTransactionsOnPhoneSizedViewports() { assertFalse(datasetInsightsDefaultExpanded(widthDp = 412f, heightDp = 915f)) @@ -721,6 +741,56 @@ class GenericNativeRendererStateTest { assertNull(nativeDatasetInsights(resource, listOf(NativeRecord("one", mapOf("projectId" to "42", "name" to "A"))))) } + @Test + fun `dataset insights reject unrecognized numeric metadata and timestamps`() { + val resource = ResourceSpec( + id = "houses", + name = "Houses", + confidence = Confidence.high, + fields = listOf( + field("id", FieldKind.integer), + field("houseId", FieldKind.integer), + field("createdAt", FieldKind.integer), + field("updatedAt", FieldKind.integer), + field("revision", FieldKind.integer), + ), + ) + val record = NativeRecord( + "one", + mapOf( + "id" to "1", + "houseId" to "1", + "createdAt" to "1785263751", + "updatedAt" to "1785263751", + "revision" to "4", + ), + ) + + assertNull(nativeDatasetInsights(resource, listOf(record))) + } + + @Test + fun `dataset insights retain explicitly supported integer measures`() { + val resource = ResourceSpec( + id = "stock", + name = "Stock", + confidence = Confidence.high, + fields = listOf( + field("quantity", FieldKind.integer), + field("count", FieldKind.integer), + ), + ) + val records = listOf( + NativeRecord("one", mapOf("quantity" to "2", "count" to "1")), + NativeRecord("two", mapOf("quantity" to "3", "count" to "1")), + ) + + val insights = requireNotNull(nativeDatasetInsights(resource, records)) + + assertEquals("quantity", insights.measure.id) + assertEquals(5.0, insights.total) + } + @Test fun `budget category amounts become chart measures without promoting technical counters`() { val resource = ResourceSpec( @@ -822,6 +892,271 @@ class GenericNativeRendererStateTest { assertEquals(listOf("In review", "To do"), lanes.map { it.title }) } + @Test + fun schemaRelationshipOffersHumanReadableParentChoicesForWritableFields() { + val collections = ResourceSpec( + id = "collections", + name = "Collections", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("id", "ID", FieldKind.string, required = true, readOnly = true), + FieldSpec("title", "Title", FieldKind.string, required = true, readOnly = false), + ), + ) + val entries = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("collectionId", "Collection", FieldKind.string, required = true, readOnly = false), + FieldSpec("title", "Title", FieldKind.string, required = true, readOnly = false), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = listOf(collections, entries), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = collections.id, + childResourceId = entries.id, + parentFieldId = "id", + childFieldId = "collectionId", + confidence = Confidence.verified, + ), + ), + ) + val options = nativeRelationOptions( + field = entries.fields.first(), + formResource = entries, + schema = schema, + context = NativeDatasetContext( + relatedRecords = mapOf( + collections.id to listOf( + NativeRecord("collection-9", mapOf("id" to "collection-9", "title" to "Later")), + NativeRecord("collection-4", mapOf("id" to "collection-4", "title" to "Earlier")), + NativeRecord( + id = "collection-unsafe", + values = mapOf("id" to "collection-unsafe", "title" to "Unsafe"), + actionBindingProvenanceValid = false, + ), + ), + ), + ), + ) + + assertEquals( + listOf( + NativeRelationOption("collection-4", "Earlier", "Collections"), + NativeRelationOption("collection-9", "Later", "Collections"), + ), + options, + ) + } + + @Test + fun relationshipChoiceFallsBackToRecordIdentityWithoutADeclaredLabelField() { + val collections = ResourceSpec( + id = "collections", + name = "Collections", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("id", "ID", FieldKind.string, required = true, readOnly = true), + FieldSpec("description", "Description", FieldKind.string, required = false, readOnly = false), + ), + ) + val entries = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("collectionId", "Collection", FieldKind.string, required = true, readOnly = false), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = listOf(collections, entries), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = collections.id, + childResourceId = entries.id, + parentFieldId = "id", + childFieldId = "collectionId", + confidence = Confidence.verified, + ), + ), + ) + + assertEquals( + listOf(NativeRelationOption("collection-4", "collection-4", "Collections")), + nativeRelationOptions( + field = entries.fields.single(), + formResource = entries, + schema = schema, + context = NativeDatasetContext( + relatedRecords = mapOf( + collections.id to listOf( + NativeRecord( + "collection-4", + mapOf("id" to "collection-4", "description" to "Must not become the label"), + ), + ), + ), + ), + ), + ) + } + + @Test + fun destinationFieldReusesDeclaredRelationshipAndExactSharedScope() { + val containers = ResourceSpec( + id = "containers", + name = "Containers", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("id", "ID", FieldKind.string, required = true, readOnly = true), + FieldSpec("accountId", "Account", FieldKind.string, required = true, readOnly = true), + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + ), + ) + val documents = ResourceSpec( + id = "documents", + name = "Documents", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("containerId", "Container", FieldKind.string, required = true, readOnly = true), + FieldSpec( + "targetContainerId", + "Target container", + FieldKind.string, + required = true, + readOnly = false, + ), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = listOf(containers, documents), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = containers.id, + childResourceId = documents.id, + parentFieldId = "id", + childFieldId = "containerId", + confidence = Confidence.verified, + ), + ), + ) + val options = nativeRelationOptions( + field = documents.fields.single { field -> field.id == "targetContainerId" }, + formResource = documents, + schema = schema, + context = NativeDatasetContext( + parentRecord = NativeRecord( + id = "document-2", + values = mapOf("id" to "document-2", "accountId" to "account-a"), + ), + relatedRecords = mapOf( + containers.id to listOf( + NativeRecord( + "container-1", + mapOf("id" to "container-1", "accountId" to "account-a", "name" to "Primary"), + ), + NativeRecord( + "container-2", + mapOf("id" to "container-2", "accountId" to "account-b", "name" to "Other scope"), + ), + ), + ), + ), + ) + + assertEquals(listOf(NativeRelationOption("container-1", "Primary", "Containers")), options) + } + + @Test + fun ambiguousRelationshipEvidenceOffersNoMutationChoice() { + val parents = listOf( + ResourceSpec("collections", "Collections", Confidence.verified), + ResourceSpec("archives", "Archives", Confidence.verified), + ) + val entries = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("parentId", "Parent", FieldKind.string, required = true, readOnly = false), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = parents + entries, + relationships = parents.map { parent -> + ResourceRelationshipSpec( + parentResourceId = parent.id, + childResourceId = entries.id, + parentFieldId = "id", + childFieldId = "parentId", + confidence = Confidence.verified, + ) + }, + ) + + assertTrue( + nativeRelationOptions( + field = entries.fields.single(), + formResource = entries, + schema = schema, + context = NativeDatasetContext( + relatedRecords = parents.associate { parent -> + parent.id to listOf(NativeRecord("${parent.id}-1", mapOf("id" to "${parent.id}-1"))) + }, + ), + ).isEmpty(), + ) + assertTrue( + nativeRelationFieldRequiresChoice( + field = entries.fields.single(), + formResource = entries, + schema = schema, + ), + ) + } + + @Test + fun `opaque writable identifiers require choices instead of raw manual input`() { + val entries = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec( + "categoryId", + "Category", + FieldKind.integer, + required = false, + readOnly = false, + ), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = listOf(entries), + ) + + assertTrue(nativeRelationFieldRequiresChoice(entries.fields.single(), entries, schema)) + assertTrue(nativeRelationOptions(entries.fields.single(), entries, schema, NativeDatasetContext()).isEmpty()) + } + @Test fun parentCurrencyContextFormatsChildFinancialMeasuresConsistently() { val projects = ResourceSpec( @@ -1054,7 +1389,7 @@ class GenericNativeRendererStateTest { assertEquals("42", request.values["columnId"]) assertEquals("revision-4", request.values["etag"]) assertEquals("15.75", request.values["value"]) - assertEquals("7", request.values["id"]) + assertEquals("row-7", request.values["id"]) assertEquals(record.values["dataByAlias"], request.values["dataByAlias"]) assertTrue(request.confirmed) } @@ -1360,6 +1695,133 @@ class GenericNativeRendererStateTest { assertEquals("https://cloud.example.test/project/1", details[2].formatted.safeLink) } + @Test + fun `declared record icons render visually while descriptions remain text`() { + val resource = ResourceSpec( + id = "collections", + name = "Collections", + confidence = Confidence.verified, + fields = listOf( + field("name", FieldKind.string), + field("icon", FieldKind.enumeration), + field("color", FieldKind.enumeration), + field("description", FieldKind.longText), + ), + ) + val record = NativeRecord( + id = "collection-1", + values = mapOf( + "name" to "Weekly groceries", + "icon" to "clipboard-check", + "color" to "#f97316", + "description" to "Shared household list", + ), + ) + + assertEquals( + NativeRecordPresentation( + title = "Weekly groceries", + subtitle = "Shared household list", + iconKey = "clipboard-check", + colorArgb = 0xFFF97316.toInt(), + ), + nativeRecordPresentation(resource, record), + ) + assertEquals( + listOf("name", "description"), + nativeTableFields(resource, listOf(record)).map(FieldSpec::id), + ) + assertEquals( + listOf("name", "description"), + nativeDetailFields(resource, record).map(NativeDetailFieldPresentation::fieldId), + ) + } + + @Test + fun `record icon tokens reject unsafe or ambiguous values and retain safe unknowns`() { + val resource = ResourceSpec( + id = "collections", + name = "Collections", + confidence = Confidence.verified, + fields = listOf( + field("icon", FieldKind.enumeration), + field("symbol", FieldKind.string), + ), + ) + + assertEquals( + NativeRecordPresentation("collection-1", null, null), + nativeRecordPresentation( + resource, + NativeRecord( + "collection-1", + mapOf("icon" to "https://invalid.example/icon", "symbol" to "clipboard-check"), + ), + ), + ) + assertEquals( + NativeRecordPresentation("collection-2", null, null), + nativeRecordPresentation( + resource, + NativeRecord( + "collection-2", + mapOf("icon" to "clipboard-check", "symbol" to "heart"), + ), + ), + ) + assertEquals( + NativeRecordPresentation("collection-3", null, "heart"), + nativeRecordPresentation( + resource, + NativeRecord( + "collection-3", + mapOf("icon" to "heart", "symbol" to "heart"), + ), + ), + ) + assertEquals( + NativeRecordPresentation("collection-4", null, "server-specific"), + nativeRecordPresentation( + resource, + NativeRecord( + "collection-4", + mapOf("icon" to "server_specific"), + ), + ), + ) + } + + @Test + fun `technical integer metadata does not replace an empty description`() { + val resource = ResourceSpec( + id = "collections", + name = "Collections", + confidence = Confidence.verified, + fields = listOf( + field("name", FieldKind.string), + field("description", FieldKind.longText), + field("createdAt", FieldKind.integer), + field("sortOrder", FieldKind.integer), + ), + ) + + assertEquals( + NativeRecordPresentation("Groceries", null), + nativeRecordPresentation( + resource, + NativeRecord( + "collection-1", + mapOf( + "name" to "Groceries", + "description" to "", + "createdAt" to "1785424226", + "sortOrder" to "1", + ), + ), + ), + ) + } + @Test fun collectionPresentationPrefersSemanticContextOverTechnicalFlags() { val resource = ResourceSpec( @@ -1543,6 +2005,460 @@ class GenericNativeRendererStateTest { assertEquals("false", initialNativeFormDraft(resource, action).values["enabled"]) } + @Test + fun `generic create submit binds one structurally proven normalized parent id`() { + val resource = ResourceSpec( + id = "lists", + name = "Lists", + confidence = Confidence.high, + fields = listOf(field(id = "name", kind = FieldKind.string, required = true)), + ) + val create = action( + id = "lists.create", + intent = ActionIntent.create, + risk = ActionRisk.mutating, + method = HttpMethod.POST, + ).copy( + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/api/houses/{houseId}/lists", + operationId = "lists.create", + pathParameterNames = listOf("houseId"), + requiredPathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("name"), + requiredBodyFieldNames = listOf("name"), + bodyContentType = "application/json", + ), + ) + val schema = NativeAppSchema( + schemaVersion = "0.1", + app = AppIdentity("dynamic-test", "Dynamic Test", "1.0"), + confidence = Confidence.high, + resources = listOf(resource), + views = listOf( + ViewSpec( + id = "lists.create.form", + title = "Create list", + resourceId = resource.id, + component = NativeComponent.form, + sourceActionId = create.id, + confidence = Confidence.high, + ), + ), + actions = listOf(create), + ) + + val ready = assertIs( + buildNativeSubmitRequest( + schema = schema, + view = schema.views.single(), + values = mapOf("id" to "house-7", "name" to "Shopping"), + confirmed = false, + ), + ) + assertEquals( + mapOf("houseId" to "house-7", "name" to "Shopping"), + assertIs(ready.request).values, + ) + + val unrelated = schema.copy( + actions = listOf( + create.copy(binding = create.binding.copy(path = "/api/accounts/{houseId}/lists")), + ), + ) + assertIs( + buildNativeSubmitRequest( + schema = unrelated, + view = unrelated.views.single(), + values = mapOf("id" to "house-7", "name" to "Shopping"), + confirmed = false, + ), + ) + } + + @Test + fun `exact integer arrays are editable validated and safely prefilled as bounded JSON`() { + val bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + } + }, + "required":["ids"] + }""", + ) + val action = action( + id = "assignments.submit", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + method = HttpMethod.PUT, + ).copy( + binding = ApiBinding( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/assignments", + operationId = "assignments.submit", + bodyFieldNames = listOf("ids"), + requiredBodyFieldNames = listOf("ids"), + bodyContentType = "application/json", + bodySchema = bodySchema, + ), + inputSchema = buildJsonObject { + put("properties", buildJsonObject { put("ids", buildJsonObject {}) }) + put("required", buildJsonArray { add(JsonPrimitive("ids")) }) + }, + ) + val resource = ResourceSpec( + id = "items", + name = "Items", + confidence = Confidence.high, + fields = listOf( + field( + id = "ids", + kind = FieldKind.integer, + required = true, + format = DYNAMIC_INTEGER_ARRAY_FORMAT, + ), + ), + ) + val schema = NativeAppSchema( + schemaVersion = "0.1", + app = AppIdentity("dynamic-test", "Dynamic Test", "1.0"), + confidence = Confidence.high, + resources = listOf(resource), + views = listOf(view(NativeComponent.form, sourceActionId = action.id)), + actions = listOf(action), + ) + + assertEquals(listOf("ids"), editableNativeFields(resource, action).map(FieldSpec::id)) + assertTrue(validateNativeForm(resource, action, mapOf("ids" to "[1,-2,1]")).isValid) + assertIs( + buildNativeSubmitRequest( + schema = schema, + view = schema.views.single(), + values = mapOf("ids" to "[1,-2,1]"), + confirmed = false, + ), + ) + + val tooMany = (0..256).joinToString(prefix = "[", postfix = "]") + listOf( + "1,2", + """[1,"2"]""", + "[1.5]", + "[null]", + """[{"id":1}]""", + "[9223372036854775808]", + tooMany, + ).forEach { value -> + assertEquals( + setOf("ids"), + validateNativeForm(resource, action, mapOf("ids" to value)).errors.keys, + value, + ) + } + + val record = NativeRecord( + id = "assignment", + values = emptyMap(), + structuredValues = mapOf( + "ids" to NativeStructuredValue.ListValue( + listOf( + NativeStructuredValue.Scalar("4", NativeStructuredScalarKind.number), + NativeStructuredValue.Scalar("-2", NativeStructuredScalarKind.number), + ), + ), + ), + ) + assertEquals("[4,-2]", initialNativeFormDraft(resource, action, record).values["ids"]) + val truncated = record.copy( + structuredValues = mapOf( + "ids" to NativeStructuredValue.ListValue( + items = listOf( + NativeStructuredValue.Scalar("4", NativeStructuredScalarKind.number), + ), + omittedItems = 1, + ), + ), + ) + assertEquals("", initialNativeFormDraft(resource, action, truncated).values["ids"]) + } + + @Test + fun `integer array forms enforce supported constraints and reject unsupported constraint schemas`() { + val constrainedSchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{ + "type":"integer", + "format":"int64", + "minimum":2, + "maximum":10, + "multipleOf":2 + }, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT", + "minItems":2, + "maxItems":3, + "uniqueItems":true + } + }, + "required":["ids"] + }""", + ) + val base = action( + id = "assignments.submit", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + method = HttpMethod.PUT, + ) + val action = base.copy( + binding = ApiBinding( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/assignments", + operationId = base.id, + bodyFieldNames = listOf("ids"), + requiredBodyFieldNames = listOf("ids"), + bodyContentType = "application/json", + bodySchema = constrainedSchema, + ), + inputSchema = buildJsonObject { + put("properties", buildJsonObject { put("ids", buildJsonObject {}) }) + put("required", buildJsonArray { add(JsonPrimitive("ids")) }) + }, + ) + val resource = ResourceSpec( + id = "items", + name = "Items", + confidence = Confidence.high, + fields = listOf(field("ids", FieldKind.integer, format = DYNAMIC_INTEGER_ARRAY_FORMAT)), + ) + + assertEquals(listOf("ids"), editableNativeFields(resource, action).map(FieldSpec::id)) + assertTrue(validateNativeForm(resource, action, mapOf("ids" to "[2,4]")).isValid) + listOf( + "[]", + "[2]", + "[2,4,6,8]", + "[2,2]", + "[0,2]", + "[2,12]", + "[2,3]", + ).forEach { value -> + assertEquals( + setOf("ids"), + validateNativeForm(resource, action, mapOf("ids" to value)).errors.keys, + value, + ) + } + + val unsupported = action.copy( + binding = action.binding.copy( + bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"integer"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT", + "contains":{"const":1} + } + } + }""", + ), + ), + ) + assertTrue(editableNativeFields(resource, unsupported).isEmpty()) + assertEquals( + setOf("ids"), + uneditableNativeBodyFieldIds(unsupported, editableNativeFields(resource, unsupported), emptyMap()), + ) + + val unsupportedItemFormat = action.copy( + binding = action.binding.copy( + bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"integer","format":"uint64"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + } + } + }""", + ), + ), + ) + assertTrue(editableNativeFields(resource, unsupportedItemFormat).isEmpty()) + assertEquals( + setOf("ids"), + uneditableNativeBodyFieldIds( + unsupportedItemFormat, + editableNativeFields(resource, unsupportedItemFormat), + emptyMap(), + ), + ) + } + + @Test + fun `unsupported or mismatched array schemas remain uneditable and block submission`() { + val base = action( + id = "assignments.submit", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + method = HttpMethod.PUT, + ) + val resource = ResourceSpec( + id = "items", + name = "Items", + confidence = Confidence.high, + fields = listOf( + field("ids", FieldKind.integer, format = DYNAMIC_INTEGER_ARRAY_FORMAT), + field("weights", FieldKind.decimal), + ), + ) + val action = base.copy( + binding = ApiBinding( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/assignments", + operationId = base.id, + bodyFieldNames = listOf("ids", "weights"), + bodyContentType = "application/json", + bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "ids":{ + "type":"array", + "items":{"type":"string"}, + "format":"$DYNAMIC_INTEGER_ARRAY_FORMAT" + }, + "weights":{"type":"array","items":{"type":"number"}} + } + }""", + ), + ), + inputSchema = buildJsonObject { + put("properties", buildJsonObject { + put("ids", buildJsonObject {}) + put("weights", buildJsonObject {}) + }) + }, + ) + val schema = NativeAppSchema( + schemaVersion = "0.1", + app = AppIdentity("dynamic-test", "Dynamic Test", "1.0"), + confidence = Confidence.high, + resources = listOf(resource), + views = listOf(view(NativeComponent.form, sourceActionId = action.id)), + actions = listOf(action), + ) + + assertTrue(editableNativeFields(resource, action).isEmpty()) + assertEquals( + setOf("ids", "weights"), + uneditableNativeBodyFieldIds(action, editableNativeFields(resource, action), emptyMap()), + ) + assertIs( + buildNativeSubmitRequest( + schema = schema, + view = schema.views.single(), + values = mapOf("ids" to "[1,2]", "weights" to "[1.5]"), + confirmed = false, + ), + ) + } + + @Test + fun `ordering fields are omitted only with exact request schema evidence`() { + val bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "position":{"type":"integer"}, + "rank":{"type":"integer","readOnly":true}, + "sortOrder":{"type":"integer","x-nextcloud-native-server-managed":true} + } + }""", + ) + val base = action( + id = "items.update", + intent = ActionIntent.update, + risk = ActionRisk.mutating, + method = HttpMethod.PUT, + ) + val action = base.copy( + binding = ApiBinding( + method = HttpMethod.PUT, + path = "/ocs/v2.php/apps/example/api/items", + operationId = base.id, + bodyFieldNames = listOf("position", "rank", "sortOrder"), + bodyContentType = "application/json", + bodySchema = bodySchema, + ), + inputSchema = buildJsonObject { + put("properties", buildJsonObject { put("position", buildJsonObject {}) }) + }, + ) + val resource = ResourceSpec( + id = "items", + name = "Items", + confidence = Confidence.high, + fields = listOf( + field("position", FieldKind.integer, readOnly = true), + field("rank", FieldKind.integer, readOnly = true), + field("sortOrder", FieldKind.integer, readOnly = true), + ), + ) + + val editable = editableNativeFields(resource, action) + assertEquals(listOf("position"), editable.map(FieldSpec::id)) + assertTrue(uneditableNativeBodyFieldIds(action, editable, emptyMap()).isEmpty()) + + val unsupportedAssumption = action.copy( + binding = action.binding.copy( + bodySchema = Json.parseToJsonElement( + """{ + "type":"object", + "properties":{ + "position":{"type":"integer"}, + "rank":{"type":"integer"}, + "sortOrder":{"type":"integer"} + } + }""", + ), + ), + ) + assertEquals( + setOf("rank", "sortOrder"), + uneditableNativeBodyFieldIds( + unsupportedAssumption, + editableNativeFields(resource, unsupportedAssumption), + emptyMap(), + ), + ) + + val requiredServerManaged = action.copy( + binding = action.binding.copy(requiredBodyFieldNames = listOf("rank")), + ) + assertEquals( + setOf("rank"), + uneditableNativeBodyFieldIds( + requiredServerManaged, + editableNativeFields(resource, requiredServerManaged), + emptyMap(), + ), + ) + } + @Test fun formDraftChangeTrackingIgnoresTouchOnlyAndDetectsValueChanges() { val initial = NativeFormDraft(values = mapOf("enabled" to "true")) @@ -1682,6 +2598,44 @@ class GenericNativeRendererStateTest { assertTrue("password" in editableNativeFields(resource, action).map(FieldSpec::id)) } + @Test + fun `declared body fields without a safe editor block partial mutation forms`() { + val schema = formSchema(ActionRisk.mutating) + val base = schema.actions.single() + val action = base.copy( + binding = base.binding.copy( + bodyFieldNames = listOf("title", "roleIds", "shares"), + ), + ) + val editable = editableNativeFields(schema.resources.single(), action) + + assertEquals( + setOf("roleIds", "shares"), + uneditableNativeBodyFieldIds( + action = action, + editableFields = editable, + autoBoundValues = emptyMap(), + ), + ) + assertEquals( + setOf("shares"), + uneditableNativeBodyFieldIds( + action = action, + editableFields = editable, + autoBoundValues = mapOf("roleIds" to "[1,2]"), + ), + ) + val unsafeSchema = schema.copy(actions = listOf(action)) + assertIs( + buildNativeSubmitRequest( + schema = unsafeSchema, + view = unsafeSchema.views.single(), + values = validValues(), + confirmed = false, + ), + ) + } + @Test fun formNeverExecutesAnActionDeclaredReadOnly() { val schema = formSchema(ActionRisk.mutating) @@ -1719,7 +2673,10 @@ class GenericNativeRendererStateTest { var calls = 0 val coordinator = NativeActionCoordinator(schema, schema.views.single()) { calls += 1 - NativeActionExecutionResult.Failure("Server rejected the request") + NativeActionExecutionResult.Failure( + message = "Server rejected the request", + outcome = NativeActionFailureOutcome.Rejected, + ) } coordinator.submit(emptyMap()) @@ -1731,6 +2688,61 @@ class GenericNativeRendererStateTest { assertEquals("Server rejected the request", assertIs(coordinator.state).message) } + @Test + fun unknownFormOutcomeBlocksRetryUntilANewerAuthoritativeRefresh() = runBlocking { + val schema = formSchema(ActionRisk.mutating) + var calls = 0 + val coordinator = NativeActionCoordinator(schema, schema.views.single()) { + calls += 1 + NativeActionExecutionResult.Failure( + message = "Response ended before the result arrived", + outcome = NativeActionFailureOutcome.Unknown, + ) + } + + coordinator.submit(validValues(), reconciliationGeneration = 7) + + val awaiting = assertIs(coordinator.state) + assertEquals("Response ended before the result arrived", awaiting.message) + assertEquals(7, awaiting.reconciliationGeneration) + + coordinator.clearStatus() + coordinator.submit(validValues(), reconciliationGeneration = 7) + assertEquals(1, calls) + assertIs(coordinator.state) + + coordinator.reconcileAuthoritativeRefresh(reconciliationGeneration = 7) + assertIs(coordinator.state) + + coordinator.reconcileAuthoritativeRefresh(reconciliationGeneration = 8) + assertIs(coordinator.state) + + coordinator.submit(validValues(), reconciliationGeneration = 8) + assertEquals(2, calls) + Unit + } + + @Test + fun rejectedFormOutcomeRemainsImmediatelyRetryable() = runBlocking { + val schema = formSchema(ActionRisk.mutating) + var calls = 0 + val coordinator = NativeActionCoordinator(schema, schema.views.single()) { + calls += 1 + NativeActionExecutionResult.Failure( + message = "Validation rejected", + outcome = NativeActionFailureOutcome.Rejected, + ) + } + + coordinator.submit(validValues(), reconciliationGeneration = 3) + assertIs(coordinator.state) + + coordinator.submit(validValues(), reconciliationGeneration = 3) + assertEquals(2, calls) + assertIs(coordinator.state) + Unit + } + @Test fun loadRequestsMustResolveToDeclaredReadOnlyActions() { val loadAction = action( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNestedCollectionWorkflowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNestedCollectionWorkflowTest.kt new file mode 100644 index 00000000..047f5dbf --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNestedCollectionWorkflowTest.kt @@ -0,0 +1,406 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.app.DynamicSelectedRecordReconciliation +import dev.obiente.nextcloudnative.app.planDynamicMutationRefresh +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_APP_DESCRIPTOR_VERSION +import dev.obiente.nextcloudnative.nativeui.model.DynamicAction +import dev.obiente.nextcloudnative.nativeui.model.DynamicAppDescriptor +import dev.obiente.nextcloudnative.nativeui.model.DynamicField +import dev.obiente.nextcloudnative.nativeui.model.DynamicForm +import dev.obiente.nextcloudnative.nativeui.model.DynamicHttpBinding +import dev.obiente.nextcloudnative.nativeui.model.DynamicLayout +import dev.obiente.nextcloudnative.nativeui.model.DynamicLink +import dev.obiente.nextcloudnative.nativeui.model.DynamicLinkTarget +import dev.obiente.nextcloudnative.nativeui.model.DynamicResource +import dev.obiente.nextcloudnative.nativeui.model.DynamicResourceRecordContext +import dev.obiente.nextcloudnative.nativeui.model.EndpointPolicy +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.FormField +import dev.obiente.nextcloudnative.nativeui.model.HttpBody +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.HttpParameter +import dev.obiente.nextcloudnative.nativeui.model.LayoutKind +import dev.obiente.nextcloudnative.nativeui.model.ParameterSource +import dev.obiente.nextcloudnative.nativeui.model.Provenance +import dev.obiente.nextcloudnative.nativeui.model.ProvenanceKind +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import dev.obiente.nextcloudnative.nativeui.model.planDynamicNavigation +import dev.obiente.nextcloudnative.nativeui.model.toNativeAppSchema +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class GenericNestedCollectionWorkflowTest { + @Test + fun `verified nested workflow preserves exact bindings and invalidates connected resources`() { + val descriptor = nestedCollectionDescriptor() + val navigation = descriptor.planDynamicNavigation( + DynamicResourceRecordContext( + resourceId = COLLECTION_RESOURCE_ID, + recordId = COLLECTION_ID, + fieldValues = mapOf("id" to COLLECTION_ID, "title" to "Selected collection"), + parameterValues = mapOf(COLLECTION_PARAMETER to COLLECTION_ID), + ), + ) + val childDestination = navigation.contextualChildDestinations.single() + + assertEquals(ENTRY_RESOURCE_ID, childDestination.resourceId) + assertEquals(ENTRY_LIST_ACTION_ID, childDestination.actionId) + assertEquals(mapOf(COLLECTION_PARAMETER to COLLECTION_ID), childDestination.pathParameterValues) + + val schema = descriptor.toNativeAppSchema() + val collectionResource = requireNotNull(schema.resource(COLLECTION_RESOURCE_ID)) + val entryResource = requireNotNull(schema.resource(ENTRY_RESOURCE_ID)) + val createAction = requireNotNull(schema.action(ENTRY_CREATE_ACTION_ID)) + val createView = schema.views.single { view -> view.sourceActionId == ENTRY_CREATE_ACTION_ID } + val selectedCollection = NativeRecord( + id = COLLECTION_ID, + values = mapOf("id" to COLLECTION_ID, "title" to "Selected collection"), + bindingContext = childDestination.pathParameterValues, + ) + + assertEquals( + ResourceRelationshipSpec( + parentResourceId = collectionResource.id, + childResourceId = entryResource.id, + parentFieldId = "id", + childFieldId = COLLECTION_PARAMETER, + confidence = Confidence.verified, + ), + schema.relationships.single(), + ) + + val parentBinding = nativeFormAutoBindingResolution( + schema = schema, + action = createAction, + resource = entryResource, + record = selectedCollection, + parentResourceId = collectionResource.id, + navigationValues = childDestination.pathParameterValues, + ) + val visibleCreateFields = editableNativeFields(entryResource, createAction) + .filterNot { field -> field.id in parentBinding.values } + val createRequest = assertIs( + assertIs( + buildNativeSubmitRequest( + schema = schema, + view = createView, + values = parentBinding.values + ("title" to "First entry"), + confirmed = false, + ), + ).request, + ) + + assertNull(parentBinding.error) + assertEquals(mapOf(COLLECTION_PARAMETER to COLLECTION_ID), parentBinding.values) + assertEquals(listOf("title"), visibleCreateFields.map(FieldSpec::id)) + assertFalse(COLLECTION_PARAMETER in visibleCreateFields.map(FieldSpec::id)) + assertEquals( + mapOf(COLLECTION_PARAMETER to COLLECTION_ID, "title" to "First entry"), + createRequest.values, + ) + + val selectedEntry = NativeRecord( + id = ENTRY_ID, + values = mapOf( + "id" to ENTRY_ID, + COLLECTION_PARAMETER to COLLECTION_ID, + "title" to "First entry", + "complete" to "false", + "canEdit" to "true", + ), + bindingContext = childDestination.pathParameterValues, + ) + val entryActions = nativeRecordActions( + schema = schema, + resource = entryResource, + record = selectedEntry, + navigationContext = childDestination.pathParameterValues, + ) + val completion = requireNotNull(entryActions.completion) + val completionRequest = completion.request(completed = true) + + assertFalse(completion.currentlyCompleted) + assertEquals(ENTRY_COMPLETE_ACTION_ID, completion.action.id) + assertEquals( + mapOf(ENTRY_PARAMETER to ENTRY_ID, "complete" to "true"), + completionRequest.values, + ) + + val refresh = requireNotNull( + schema.planDynamicMutationRefresh( + action = completion.action, + selectedRecordResourceId = entryResource.id, + ), + ) + + assertEquals( + setOf(COLLECTION_RESOURCE_ID, ENTRY_RESOURCE_ID), + refresh.affectedResourceIds, + ) + assertEquals( + setOf(COLLECTION_VIEW_ID, ENTRY_VIEW_ID, ENTRY_CREATE_VIEW_ID), + refresh.affectedViewIds, + ) + assertEquals( + DynamicSelectedRecordReconciliation.KeepRouteAndReloadWhenVisible, + refresh.selectedRecordReconciliation, + ) + assertTrue(refresh.invalidateAllAppScreenSnapshots) + assertTrue(refresh.reloadVisibleView) + assertEquals( + emptyMap(), + refresh.discardAffectedRelatedRecords( + mapOf( + COLLECTION_RESOURCE_ID to listOf(selectedCollection), + ENTRY_RESOURCE_ID to listOf(selectedEntry), + ), + ), + ) + } + + private fun nestedCollectionDescriptor(): DynamicAppDescriptor { + val collectionResource = dynamicResource( + id = COLLECTION_RESOURCE_ID, + label = "Collections", + fields = listOf( + dynamicField("id", "ID", FieldKind.string, readOnly = true), + dynamicField("title", "Title", FieldKind.string), + ), + ) + val entryResource = dynamicResource( + id = ENTRY_RESOURCE_ID, + label = "Entries", + fields = listOf( + dynamicField("id", "ID", FieldKind.string, readOnly = true), + dynamicField(COLLECTION_PARAMETER, "Collection", FieldKind.string, readOnly = true), + dynamicField("title", "Title", FieldKind.string), + dynamicField("complete", "Complete", FieldKind.boolean), + dynamicField("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ), + ) + val actions = listOf( + readAction( + id = COLLECTION_LIST_ACTION_ID, + resourceId = COLLECTION_RESOURCE_ID, + path = "/api/collections", + ), + readAction( + id = ENTRY_LIST_ACTION_ID, + resourceId = ENTRY_RESOURCE_ID, + path = "/api/collections/{$COLLECTION_PARAMETER}/entries", + pathParameters = listOf(COLLECTION_PARAMETER), + ), + mutationAction( + id = ENTRY_CREATE_ACTION_ID, + label = "Create entry", + resourceId = ENTRY_RESOURCE_ID, + intent = ActionIntent.create, + method = HttpMethod.POST, + path = "/api/collections/{$COLLECTION_PARAMETER}/entries", + pathParameters = listOf(COLLECTION_PARAMETER), + bodyFields = listOf("title" to "string"), + ), + mutationAction( + id = ENTRY_COMPLETE_ACTION_ID, + label = "Set completion", + resourceId = ENTRY_RESOURCE_ID, + intent = ActionIntent.update, + method = HttpMethod.PATCH, + path = "/api/entries/{$ENTRY_PARAMETER}", + pathParameters = listOf(ENTRY_PARAMETER), + bodyFields = listOf("complete" to "boolean"), + ), + ) + return DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity("nested-collection-fixture", "Nested collection fixture", "test"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/api"), + ), + resources = listOf(collectionResource, entryResource), + layouts = listOf( + DynamicLayout( + id = COLLECTION_VIEW_ID, + title = "Collections", + resourceId = COLLECTION_RESOURCE_ID, + kind = LayoutKind.list, + sourceActionId = COLLECTION_LIST_ACTION_ID, + confidence = Confidence.verified, + ), + DynamicLayout( + id = ENTRY_VIEW_ID, + title = "Entries", + resourceId = ENTRY_RESOURCE_ID, + kind = LayoutKind.list, + sourceActionId = ENTRY_LIST_ACTION_ID, + confidence = Confidence.verified, + ), + ), + links = listOf( + DynamicLink( + id = "collections.entries", + label = "Entries", + resourceId = COLLECTION_RESOURCE_ID, + sourceFieldId = "id", + target = DynamicLinkTarget.Action(ENTRY_LIST_ACTION_ID), + confidence = Confidence.verified, + ), + ), + forms = listOf( + DynamicForm( + id = ENTRY_CREATE_VIEW_ID, + title = "Create entry", + resourceId = ENTRY_RESOURCE_ID, + actionId = ENTRY_CREATE_ACTION_ID, + fields = listOf( + FormField( + fieldId = "title", + label = "Title", + kind = FieldKind.string, + required = true, + ), + ), + confidence = Confidence.verified, + ), + ), + actions = actions, + ) + } + + private fun dynamicResource( + id: String, + label: String, + fields: List, + ): DynamicResource = DynamicResource( + id = id, + label = label, + collection = true, + fields = fields, + confidence = Confidence.verified, + ) + + private fun dynamicField( + id: String, + label: String, + kind: FieldKind, + readOnly: Boolean = false, + ): DynamicField = DynamicField( + id = id, + label = label, + kind = kind, + required = true, + readOnly = readOnly, + nullable = false, + multiple = false, + confidence = Confidence.verified, + ) + + private fun readAction( + id: String, + resourceId: String, + path: String, + pathParameters: List = emptyList(), + ): DynamicAction = DynamicAction( + id = id, + label = id, + resourceId = resourceId, + intent = ActionIntent.list, + risk = ActionRisk.readOnly, + requiresConfirmation = false, + binding = DynamicHttpBinding( + method = HttpMethod.GET, + path = path, + pathParameters = pathParameters.map(::pathParameter), + ), + confidence = Confidence.verified, + ) + + private fun mutationAction( + id: String, + label: String, + resourceId: String, + intent: ActionIntent, + method: HttpMethod, + path: String, + pathParameters: List, + bodyFields: List>, + ): DynamicAction = DynamicAction( + id = id, + label = label, + resourceId = resourceId, + intent = intent, + risk = ActionRisk.mutating, + requiresConfirmation = false, + binding = DynamicHttpBinding( + method = method, + path = path, + pathParameters = pathParameters.map(::pathParameter), + body = HttpBody( + contentType = "application/json", + required = true, + schema = buildJsonObject { + put("type", "object") + put( + "properties", + buildJsonObject { + bodyFields.forEach { (field, type) -> + put(field, buildJsonObject { put("type", type) }) + } + }, + ) + put( + "required", + buildJsonArray { + bodyFields.forEach { (field, _) -> add(JsonPrimitive(field)) } + }, + ) + }, + ), + ), + confidence = Confidence.verified, + provenance = listOf( + Provenance( + kind = ProvenanceKind.advertisedOpenApi, + source = "https://cloud.example.test/api/openapi.json", + detail = "Synthetic verified mutation contract", + ), + ), + ) + + private fun pathParameter(name: String): HttpParameter = HttpParameter( + name = name, + required = true, + schema = buildJsonObject { put("type", "string") }, + source = ParameterSource.resourceField, + ) + + private companion object { + const val COLLECTION_RESOURCE_ID = "collections" + const val ENTRY_RESOURCE_ID = "entries" + const val COLLECTION_PARAMETER = "collectionId" + const val ENTRY_PARAMETER = "entryId" + const val COLLECTION_ID = "collection-4" + const val ENTRY_ID = "entry-9" + const val COLLECTION_LIST_ACTION_ID = "list-collections" + const val ENTRY_LIST_ACTION_ID = "list-entries" + const val ENTRY_CREATE_ACTION_ID = "create-entry" + const val ENTRY_COMPLETE_ACTION_ID = "complete-entry" + const val COLLECTION_VIEW_ID = "collections.list" + const val ENTRY_VIEW_ID = "entries.list" + const val ENTRY_CREATE_VIEW_ID = "entries.create" + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeActionBindingProvenanceTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeActionBindingProvenanceTest.kt new file mode 100644 index 00000000..b8384d18 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeActionBindingProvenanceTest.kt @@ -0,0 +1,386 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeActionBindingProvenanceTest { + @Test + fun `safe action values reject request response and semantic alias conflicts`() { + val requestConflict = NativeRecord( + id = "item-7", + values = mapOf("containerId" to "response-parent"), + bindingContext = mapOf("containerId" to "request-parent"), + ) + val semanticAliasConflict = NativeRecord( + id = "item-7", + values = mapOf("container_id" to "response-parent"), + bindingContext = mapOf("containerId" to "request-parent"), + ) + + assertNull(requestConflict.safeActionBindingValues()) + assertNull(semanticAliasConflict.safeActionBindingValues()) + } + + @Test + fun `safe action values retain confirming aliases and exact request names`() { + val record = NativeRecord( + id = "item-7", + values = mapOf("container_id" to "parent-4", "title" to "Example"), + bindingContext = mapOf("containerId" to "parent-4"), + ) + + assertEquals( + mapOf( + "containerId" to "parent-4", + "container_id" to "parent-4", + "title" to "Example", + "id" to "item-7", + ), + record.safeActionBindingValues(), + ) + } + + @Test + fun `canonical identity may differ from a protocol id without disabling writes`() { + val record = NativeRecord( + id = "73", + values = mapOf("databaseId" to "73", "id" to "protocol-1"), + ) + + assertEquals("73", record.actionBindingValues()["id"]) + assertEquals( + mapOf("databaseId" to "73", "id" to "73"), + record.safeActionBindingValues(), + ) + } + + @Test + fun `canonical identity rejects a conflicting request identity`() { + val record = NativeRecord( + id = "73", + values = mapOf("id" to "protocol-1"), + bindingContext = mapOf("id" to "72"), + ) + + assertNull(record.safeActionBindingValues()) + assertTrue(record.actionBindingValues().isEmpty()) + } + + @Test + fun `board actions reject conflicting lane provenance`() { + val resource = resource( + "cards", + listOf( + field("title", FieldKind.string), + field("stackId", FieldKind.integer), + ), + ) + val move = mutation( + id = "move-card", + resourceId = resource.id, + method = HttpMethod.PUT, + path = "/cards/{cardId}/move", + pathFields = listOf("cardId"), + bodyFields = listOf("stackId"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "42", + values = mapOf("title" to "Example", "stackId" to "10"), + bindingContext = mapOf("boardId" to "7"), + ) + val lanes = listOf( + NativeBoardLane( + key = "10", + title = "To do", + records = listOf(record), + contextValues = mapOf("boardId" to "8", "stackId" to "10"), + ), + NativeBoardLane("11", "Doing", emptyList()), + ) + + val plan = nativeBoardCardActionPlan(schema(resource, move), resource, record, lanes) + + assertNull(plan.edit) + assertNull(plan.move) + assertTrue(plan.directActions.isEmpty()) + } + + @Test + fun `lane creation rejects a context that disagrees with the selected lane`() { + val resource = resource("cards") + val create = mutation( + id = "create-card", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/cards", + bodyFields = listOf("stackId", "title"), + intent = ActionIntent.create, + ) + val lane = NativeBoardLane( + key = "11", + title = "Doing", + records = emptyList(), + contextValues = mapOf("stackId" to "10"), + ) + + assertNull(nativeBoardLaneCreatePlan(schema(resource, create), resource, lane)) + } + + @Test + fun `flat board move cannot reuse a source lane path value as destination body value`() { + val resource = resource( + "cards", + listOf( + field("title", FieldKind.string), + field("stackId", FieldKind.integer), + ), + ) + val move = mutation( + id = "move-card", + resourceId = resource.id, + method = HttpMethod.PUT, + path = "/stacks/{stackId}/cards/{cardId}", + pathFields = listOf("stackId", "cardId"), + bodyFields = listOf("stackId"), + intent = ActionIntent.update, + ) + val record = NativeRecord("42", mapOf("title" to "Example", "stackId" to "10")) + val lanes = listOf( + NativeBoardLane("10", "To do", listOf(record)), + NativeBoardLane("11", "Doing", emptyList()), + ) + + assertNull(nativeBoardCardActionPlan(schema(resource, move), resource, record, lanes).move) + } + + @Test + fun `board actions do not promote a response only generic identity`() { + val resource = resource( + "cards", + listOf( + field("title", FieldKind.string), + field("stackId", FieldKind.integer), + ), + ) + val move = mutation( + id = "move-card", + resourceId = resource.id, + method = HttpMethod.PUT, + path = "/cards/{cardId}/move", + pathFields = listOf("cardId"), + bodyFields = listOf("stackId"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "42", + values = mapOf("id" to "protocol-card", "title" to "Example", "stackId" to "10"), + actionSafeIdentity = false, + ) + val lanes = listOf( + NativeBoardLane("10", "To do", listOf(record)), + NativeBoardLane("11", "Doing", emptyList()), + ) + + assertNull(nativeBoardCardActionPlan(schema(resource, move), resource, record, lanes).move) + } + + @Test + fun `inline cell mutation rejects conflicting projected row context`() { + val valueField = field("value", FieldKind.string) + val resource = resource("rows", listOf(valueField)) + val update = mutation( + id = "update-cell", + resourceId = resource.id, + method = HttpMethod.PATCH, + path = "/rows/{rowId}", + pathFields = listOf("rowId"), + bodyFields = listOf("value"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "row-7", + values = mapOf("rowId" to "7", "value" to "Before"), + ) + val projection = NativeTableProjection( + resource = resource, + records = listOf(record), + cellsByRecord = mapOf( + record.id to mapOf( + valueField.id to NativeProjectedCell( + sourceFieldId = "cells", + cellKey = "value", + value = "Before", + contextValues = mapOf("rowId" to "8"), + valueShape = NativeCellValueShape.scalar, + declaredKind = FieldKind.string, + ), + ), + ), + ) + + assertNull(nativeCellEditPlan(schema(resource, update), resource, projection, record, valueField)) + } + + @Test + fun `inline cell mutation does not treat row identity as missing container identity`() { + val valueField = field("value", FieldKind.string) + val resource = resource("rows", listOf(valueField)) + val update = mutation( + id = "update-cell", + resourceId = resource.id, + method = HttpMethod.PATCH, + path = "/containers/{containerId}/rows/{rowId}", + pathFields = listOf("containerId", "rowId"), + bodyFields = listOf("value"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "row-7", + values = mapOf("value" to "Before"), + ) + val projection = NativeTableProjection( + resource = resource, + records = listOf(record), + cellsByRecord = mapOf( + record.id to mapOf( + valueField.id to NativeProjectedCell( + sourceFieldId = "cells", + cellKey = "value", + value = "Before", + contextValues = emptyMap(), + valueShape = NativeCellValueShape.scalar, + declaredKind = FieldKind.string, + ), + ), + ), + ) + + assertNull(nativeCellEditPlan(schema(resource, update), resource, projection, record, valueField)) + } + + @Test + fun `mail mutations use canonical identity instead of a protocol id`() { + val messages = resource("messages") + val markRead = mutation( + id = "mark-seen", + resourceId = messages.id, + method = HttpMethod.PUT, + path = "/messages/{id}/seen", + pathFields = listOf("id"), + bodyFields = listOf("seen"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "8", + values = mapOf( + "id" to "9", + "subject" to "Hello", + "from" to "A", + "body" to "B", + "seen" to "false", + ), + bindingContext = mapOf("id" to "8"), + ) + + val request = requireNotNull( + nativeMailMessageActionPlan(schema(messages, markRead), messages, record) + .stateActions + .singleOrNull(), + ).request() + + assertEquals("8", request.values["id"]) + assertEquals("true", request.values["seen"]) + } + + @Test + fun `mail mutations reject conflicting canonical request identity`() { + val messages = resource("messages") + val markRead = mutation( + id = "mark-seen", + resourceId = messages.id, + method = HttpMethod.PUT, + path = "/messages/{id}/seen", + pathFields = listOf("id"), + bodyFields = listOf("seen"), + intent = ActionIntent.update, + ) + val record = NativeRecord( + id = "8", + values = mapOf( + "id" to "protocol-message", + "subject" to "Hello", + "from" to "A", + "body" to "B", + "seen" to "false", + ), + bindingContext = mapOf("id" to "7"), + ) + + assertTrue(nativeMailMessageActionPlan(schema(messages, markRead), messages, record).all.isEmpty()) + } + + private fun schema(resource: ResourceSpec, vararg actions: ActionSpec) = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = actions.toList(), + ) + + private fun resource(id: String, fields: List = emptyList()) = ResourceSpec( + id = id, + name = id, + confidence = Confidence.verified, + fields = fields, + ) + + private fun field(id: String, kind: FieldKind) = FieldSpec( + id = id, + label = id, + kind = kind, + required = false, + readOnly = false, + ) + + private fun mutation( + id: String, + resourceId: String, + method: HttpMethod, + path: String, + pathFields: List = emptyList(), + bodyFields: List, + intent: ActionIntent, + ) = ActionSpec( + id = id, + label = id, + resourceId = resourceId, + binding = ApiBinding( + method = method, + path = path, + operationId = id, + pathParameterNames = pathFields, + requiredPathParameterNames = pathFields, + bodyFieldNames = bodyFields, + requiredBodyFieldNames = bodyFields, + bodyContentType = "application/json", + ), + intent = intent, + risk = ActionRisk.mutating, + requiresConfirmation = true, + confidence = Confidence.verified, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiStateTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiStateTest.kt new file mode 100644 index 00000000..4c1b238b --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiStateTest.kt @@ -0,0 +1,306 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_STRING_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import kotlin.test.Test +import kotlin.test.assertEquals + +class NativeCollectionActionUiStateTest { + @Test + fun `selection stays bounded safe and ordered like the active collection`() { + val available = listOf("third", "first", "second") + + val selected = toggleNativeCollectionSelection( + selectedRecordIds = listOf("missing", "first", "first"), + recordId = "third", + availableRecordIds = available, + maximumSelectionSize = 2, + ) + val fullSelection = toggleNativeCollectionSelection( + selectedRecordIds = selected, + recordId = "second", + availableRecordIds = available, + maximumSelectionSize = 2, + ) + + assertEquals(listOf("third", "first"), selected) + assertEquals(selected, fullSelection) + } + + @Test + fun `selection can remove a selected record while at the bound`() { + assertEquals( + listOf("second"), + toggleNativeCollectionSelection( + selectedRecordIds = listOf("first", "second"), + recordId = "first", + availableRecordIds = listOf("first", "second", "third"), + maximumSelectionSize = 2, + ), + ) + } + + @Test + fun `unknown records and invalid bounds cannot enter selection`() { + assertEquals( + listOf("first"), + toggleNativeCollectionSelection( + selectedRecordIds = listOf("first"), + recordId = "missing", + availableRecordIds = listOf("first"), + maximumSelectionSize = 2, + ), + ) + assertEquals( + listOf("first"), + toggleNativeCollectionSelection( + selectedRecordIds = listOf("first"), + recordId = "first", + availableRecordIds = listOf("first"), + maximumSelectionSize = 0, + ), + ) + } + + @Test + fun `reorder movement is explicit bounded and identity preserving`() { + assertEquals( + listOf("second", "first", "third"), + moveNativeCollectionRecord( + orderedRecordIds = listOf("first", "second", "third"), + recordId = "second", + offset = -1, + ), + ) + assertEquals( + listOf("first", "third", "second"), + moveNativeCollectionRecord( + orderedRecordIds = listOf("first", "second", "third"), + recordId = "second", + offset = 1, + ), + ) + assertEquals( + listOf("first", "second"), + moveNativeCollectionRecord( + orderedRecordIds = listOf("first", "second"), + recordId = "first", + offset = -1, + ), + ) + } + + @Test + fun `reorder draft restores a complete permutation of the current planned identities`() { + val currentPlan = listOf("first", "second", "third") + val savedDraft = mutableListOf("third", "first", "second") + + val encoded = requireNotNull(encodeNativeCollectionReorderDraft(savedDraft)) + savedDraft.clear() + + assertEquals(listOf("third", "first", "second"), encoded) + assertEquals( + listOf("third", "first", "second"), + restoreNativeCollectionReorderDraft(encoded, currentPlan), + ) + } + + @Test + fun `reorder draft restoration rejects stale duplicate and oversized identities`() { + val currentPlan = listOf("first", "second", "third") + + listOf( + listOf("first", "second"), + listOf("first", "second", "removed"), + listOf("first", "first", "third"), + listOf("first", "second", " "), + ).forEach { invalidDraft -> + assertEquals( + currentPlan, + restoreNativeCollectionReorderDraft(invalidDraft, currentPlan), + ) + } + assertEquals( + null, + encodeNativeCollectionReorderDraft( + List(501) { index -> "record-$index" }, + ), + ) + } + + @Test + fun `batch fields map only planner metadata to generic field controls`() { + assertEquals( + dev.obiente.nextcloudnative.nativeui.model.FieldSpec( + id = "targetListIds", + label = "Target List Ids", + kind = FieldKind.integer, + required = true, + readOnly = false, + format = DYNAMIC_INTEGER_ARRAY_FORMAT, + enumValues = null, + ), + NativeCollectionBatchInputField( + id = "targetListIds", + kind = NativeCollectionBatchInputKind.IntegerArray, + required = true, + nullable = false, + enumValues = null, + ).toNativeCollectionFieldSpec(), + ) + assertEquals( + DYNAMIC_STRING_ARRAY_FORMAT, + NativeCollectionBatchInputField( + id = "roleIds", + kind = NativeCollectionBatchInputKind.StringArray, + required = false, + nullable = true, + enumValues = null, + ).toNativeCollectionFieldSpec().format, + ) + assertEquals( + dev.obiente.nextcloudnative.nativeui.model.FieldSpec( + id = "enabled", + label = "Enabled", + kind = FieldKind.enumeration, + required = false, + readOnly = false, + format = null, + enumValues = listOf("unchanged", "true", "false"), + ), + NativeCollectionBatchInputField( + id = "enabled", + kind = NativeCollectionBatchInputKind.Boolean, + required = false, + nullable = false, + enumValues = null, + ).toNativeCollectionFieldSpec(), + ) + } + + @Test + fun `batch request values encode line based arrays and omit blank optional values`() { + val fields = listOf( + NativeCollectionBatchInputField( + id = "ids", + kind = NativeCollectionBatchInputKind.IntegerArray, + required = true, + nullable = false, + enumValues = null, + ), + NativeCollectionBatchInputField( + id = "roles", + kind = NativeCollectionBatchInputKind.StringArray, + required = true, + nullable = false, + enumValues = null, + ), + NativeCollectionBatchInputField( + id = "note", + kind = NativeCollectionBatchInputKind.String, + required = false, + nullable = true, + enumValues = null, + ), + ) + + assertEquals( + mapOf( + "ids" to "[2,7]", + "roles" to "[\"editor\",\"viewer\"]", + ), + nativeCollectionBatchRequestValues( + fields = fields, + draft = mapOf( + "ids" to "2\n7", + "roles" to " editor \nviewer", + "note" to " ", + ), + ), + ) + } + + @Test + fun `verified relationship arrays remain picker encoded`() { + assertEquals( + mapOf("roleIds" to "[\"admin\",\"viewer\"]"), + nativeCollectionBatchRequestValues( + fields = listOf( + NativeCollectionBatchInputField( + id = "roleIds", + kind = NativeCollectionBatchInputKind.StringArray, + required = true, + nullable = false, + enumValues = null, + relatedResourceId = "roles", + ), + ), + draft = mapOf("roleIds" to "[\"admin\",\"viewer\"]"), + ), + ) + } + + @Test + fun `only required boolean batch fields receive an explicit false draft`() { + assertEquals( + mapOf("enabled" to "false"), + initialNativeCollectionBatchDraft( + listOf( + NativeCollectionBatchInputField( + id = "enabled", + kind = NativeCollectionBatchInputKind.Boolean, + required = true, + nullable = false, + enumValues = null, + ), + NativeCollectionBatchInputField( + id = "archived", + kind = NativeCollectionBatchInputKind.Boolean, + required = false, + nullable = false, + enumValues = null, + ), + NativeCollectionBatchInputField( + id = "title", + kind = NativeCollectionBatchInputKind.String, + required = true, + nullable = false, + enumValues = null, + ), + ), + ), + ) + } + + @Test + fun `optional boolean batch draft preserves an explicit unchanged state`() { + val field = NativeCollectionBatchInputField( + id = "archived", + kind = NativeCollectionBatchInputKind.Boolean, + required = false, + nullable = false, + enumValues = null, + ) + val initial = initialNativeCollectionBatchDraft(listOf(field)) + + assertEquals(emptyMap(), initial) + assertEquals(emptyMap(), nativeCollectionBatchRequestValues(listOf(field), initial)) + + val enabled = initial + (field.id to "true") + assertEquals(mapOf("archived" to "true"), enabled) + assertEquals(mapOf("archived" to "true"), nativeCollectionBatchRequestValues(listOf(field), enabled)) + + val disabled = enabled + (field.id to "false") + assertEquals(mapOf("archived" to "false"), disabled) + assertEquals(mapOf("archived" to "false"), nativeCollectionBatchRequestValues(listOf(field), disabled)) + + assertEquals( + emptyMap(), + nativeCollectionBatchRequestValues( + fields = listOf(field), + draft = mapOf("archived" to "unchanged"), + ), + ) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionsTest.kt new file mode 100644 index 00000000..328773bd --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionsTest.kt @@ -0,0 +1,1165 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject + +class NativeCollectionActionsTest { + @Test + fun `bodyless collection empty command requires exact active read and confirmation`() { + val resource = resource("items") + val read = readAction( + resourceId = resource.id, + path = "/groups/{groupId}/items/trash", + pathFields = listOf("groupId"), + ) + val empty = action( + id = "empty-items", + resourceId = resource.id, + method = HttpMethod.DELETE, + path = read.binding.path, + pathFields = listOf("groupId"), + intent = ActionIntent.delete, + effect = ActionEffect.empty, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + + val plan = assertNotNull( + nativeCollectionActions( + schema = schema(resource, read, empty), + activeReadAction = read, + resource = resource, + records = emptyList(), + navigationContext = mapOf("groupId" to "7"), + collectionComplete = true, + ).commands.singleOrNull(), + ) + + assertEquals(ActionEffect.empty, plan.effect) + assertFailsWith { plan.request(confirmed = false) } + assertEquals( + mapOf("groupId" to "7"), + plan.request(confirmed = true).values, + ) + } + + @Test + fun `presentation-only observed fields cannot suppress canonical collection actions`() { + val canonicalResource = resource("items") + val presentationResource = canonicalResource.copy( + fields = listOf( + FieldSpec( + id = "observedLabel", + label = "Observed label", + kind = FieldKind.string, + readOnly = true, + required = false, + ), + ), + ) + val read = readAction( + resourceId = canonicalResource.id, + path = "/groups/{groupId}/items", + pathFields = listOf("groupId"), + ) + val batch = action( + id = "batch-items", + resourceId = canonicalResource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + + val plan = assertNotNull( + nativeCollectionActions( + schema = schema(canonicalResource, read, batch), + activeReadAction = read, + resource = presentationResource, + records = records("11", "12"), + navigationContext = mapOf("groupId" to "7"), + collectionComplete = true, + ).batches.singleOrNull(), + ) + + assertEquals( + mapOf("groupId" to "7", "itemIds" to "[11,12]"), + plan.request(listOf("11", "12")).values, + ) + } + + @Test + fun `generic parent id from a read route binds its proven resource specific write alias`() { + val resource = resource("categories") + val read = readAction( + resourceId = resource.id, + path = "/houses/{id}/categories/trash", + pathFields = listOf("id"), + ) + val empty = action( + id = "empty-categories", + resourceId = resource.id, + method = HttpMethod.DELETE, + path = "/houses/{houseId}/categories/trash", + pathFields = listOf("houseId"), + intent = ActionIntent.delete, + effect = ActionEffect.empty, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + + val plan = assertNotNull( + nativeCollectionActions( + schema = schema(resource, read, empty), + activeReadAction = read, + resource = resource, + records = emptyList(), + navigationContext = mapOf("id" to "house-7"), + collectionComplete = true, + ).commands.singleOrNull(), + ) + + assertEquals( + mapOf("houseId" to "house-7"), + plan.request(confirmed = true).values, + ) + } + + @Test + fun `generic parent id does not replace child identities in batch selection`() { + val resource = resource("items") + val read = readAction( + resourceId = resource.id, + path = "/houses/{id}/items", + pathFields = listOf("id"), + ) + val batch = action( + id = "batch-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/houses/{houseId}/items/batch", + pathFields = listOf("houseId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val records = listOf("11", "12").map { recordId -> + NativeRecord( + id = recordId, + values = mapOf( + "id" to recordId, + "canEdit" to "true", + "canDelete" to "true", + ), + bindingContext = mapOf("id" to "house-7"), + ) + } + + val plan = assertNotNull( + nativeCollectionActions( + schema = schema(resource, read, batch), + activeReadAction = read, + resource = resource, + records = records, + navigationContext = mapOf("id" to "house-7"), + collectionComplete = false, + ).batches.singleOrNull(), + ) + + assertEquals( + mapOf("houseId" to "house-7", "itemIds" to "[11,12]"), + plan.request(listOf("11", "12")).values, + ) + } + + @Test + fun `collection command is withheld from a sibling read and ambiguous context`() { + val resource = resource("items") + val active = readAction( + resourceId = resource.id, + path = "/groups/{groupId}/items", + pathFields = listOf("groupId"), + ) + val empty = action( + id = "empty-items", + resourceId = resource.id, + method = HttpMethod.DELETE, + path = "/groups/{groupId}/items/trash", + pathFields = listOf("groupId"), + intent = ActionIntent.delete, + effect = ActionEffect.clear, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + val schema = schema(resource, active, empty) + + assertTrue( + nativeCollectionActions( + schema, + active, + resource, + emptyList(), + mapOf("groupId" to "7"), + collectionComplete = true, + ).commands.isEmpty(), + ) + assertEquals( + NativeCollectionActionCapabilities(emptyList(), null, emptyList()), + nativeCollectionActions( + schema, + active, + resource, + emptyList(), + mapOf("groupId" to "7", "group_id" to "8"), + collectionComplete = true, + ), + ) + } + + @Test + fun `collection actions reject malformed templates and record context conflicts`() { + val resource = resource("items") + val malformedRead = readAction( + resourceId = resource.id, + path = "/groups/{groupId/items", + pathFields = listOf("groupId"), + ) + val malformedCommand = action( + id = "empty-items", + resourceId = resource.id, + method = HttpMethod.DELETE, + path = malformedRead.binding.path, + pathFields = listOf("groupId"), + intent = ActionIntent.delete, + effect = ActionEffect.empty, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + assertEquals( + NativeCollectionActionCapabilities(emptyList(), null, emptyList()), + nativeCollectionActions( + schema(resource, malformedRead, malformedCommand), + malformedRead, + resource, + emptyList(), + mapOf("groupId" to "7"), + collectionComplete = true, + ), + ) + + val read = readAction( + resourceId = resource.id, + path = "/groups/{groupId}/items", + pathFields = listOf("groupId"), + ) + val batch = action( + id = "batch-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "${read.binding.path}/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val conflictingRecord = NativeRecord( + id = "1", + values = mapOf("id" to "1"), + bindingContext = mapOf("groupId" to "8"), + ) + assertEquals( + NativeCollectionActionCapabilities(emptyList(), null, emptyList()), + nativeCollectionActions( + schema(resource, read, batch), + read, + resource, + listOf(conflictingRecord), + mapOf("groupId" to "7"), + collectionComplete = true, + ), + ) + } + + @Test + fun `exact reorder schema produces a complete ordered identity request`() { + val resource = resource("items") + val read = readAction( + resourceId = resource.id, + path = "/groups/{groupId}/items", + pathFields = listOf("groupId"), + ) + val reorder = action( + id = "reorder-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "${read.binding.path}/reorder", + pathFields = listOf("groupId"), + bodyFields = listOf("entries"), + bodySchema = reorderBody("entries", "id", "sortOrder"), + intent = ActionIntent.execute, + effect = ActionEffect.reorder, + risk = ActionRisk.mutating, + ) + val records = records("11", "12", "13") + + val plan = assertNotNull( + nativeCollectionActions( + schema(resource, read, reorder), + read, + resource, + records, + mapOf("groupId" to "7"), + collectionComplete = true, + ).reorder, + ) + assertEquals("id", plan.identityFieldId) + assertEquals("sortOrder", plan.orderFieldId) + + val request = plan.request( + listOf( + NativeCollectionOrderedIdentity("13", 10), + NativeCollectionOrderedIdentity("11", 20), + NativeCollectionOrderedIdentity("12", 30), + ), + ) + assertEquals("7", request.values["groupId"]) + val entries = Json.parseToJsonElement(assertNotNull(request.values["entries"])).jsonArray + assertEquals(listOf(13L, 11L, 12L), entries.map { it.jsonObject["id"]!!.jsonPrimitive.content.toLong() }) + assertEquals(listOf(10L, 20L, 30L), entries.map { + it.jsonObject["sortOrder"]!!.jsonPrimitive.content.toLong() + }) + val derivedEntries = Json.parseToJsonElement( + assertNotNull( + plan.requestInOrder(listOf("13", "11", "12")).values["entries"], + ), + ).jsonArray + assertEquals(listOf(13L, 11L, 12L), derivedEntries.map { + it.jsonObject["id"]!!.jsonPrimitive.content.toLong() + }) + assertEquals(listOf(0L, 1L, 2L), derivedEntries.map { + it.jsonObject["sortOrder"]!!.jsonPrimitive.content.toLong() + }) + assertFailsWith { + plan.request( + listOf( + NativeCollectionOrderedIdentity("11", 10), + NativeCollectionOrderedIdentity("12", 20), + ), + ) + } + assertFailsWith { + plan.request( + listOf( + NativeCollectionOrderedIdentity("11", 20), + NativeCollectionOrderedIdentity("12", 20), + NativeCollectionOrderedIdentity("13", 10), + ), + ) + } + } + + @Test + fun `reorder requires complete bounded collection and one exact schema`() { + val resource = resource("records") + val read = readAction(resource.id, "/spaces/{spaceId}/records", listOf("spaceId")) + val first = action( + id = "reorder-a", + resourceId = resource.id, + method = HttpMethod.POST, + path = "${read.binding.path}/order", + pathFields = listOf("spaceId"), + bodyFields = listOf("entries"), + bodySchema = reorderBody("entries", "recordId", "position"), + intent = ActionIntent.execute, + effect = ActionEffect.reorder, + risk = ActionRisk.mutating, + ) + val second = first.copy(id = "reorder-b", label = "Second reorder") + val records = records("1", "2") + + assertNull( + nativeCollectionActions( + schema(resource, read, first), + read, + resource, + records, + mapOf("spaceId" to "4"), + collectionComplete = false, + ).reorder, + ) + assertNull( + nativeCollectionActions( + schema(resource, read, first, second), + read, + resource, + records, + mapOf("spaceId" to "4"), + collectionComplete = true, + ).reorder, + ) + + val ambiguousShape = first.copy( + id = "ambiguous-shape", + binding = first.binding.copy( + bodySchema = reorderBody( + bodyField = "entries", + identityField = "recordId", + orderField = "position", + extraItemField = "label", + ), + ), + ) + assertNull( + nativeCollectionActions( + schema(resource, read, ambiguousShape), + read, + resource, + records, + mapOf("spaceId" to "4"), + collectionComplete = true, + ).reorder, + ) + } + + @Test + fun `derived reorder positions obey exact integer bounds or withhold the plan`() { + val resource = resource("records") + val read = readAction(resource.id, "/spaces/{spaceId}/records", listOf("spaceId")) + val bounded = action( + id = "reorder-records", + resourceId = resource.id, + method = HttpMethod.POST, + path = "${read.binding.path}/order", + pathFields = listOf("spaceId"), + bodyFields = listOf("entries"), + bodySchema = reorderBody( + bodyField = "entries", + identityField = "recordId", + orderField = "position", + orderMinimum = 10, + orderMaximum = 11, + ), + intent = ActionIntent.execute, + effect = ActionEffect.reorder, + risk = ActionRisk.mutating, + ) + val records = records("1", "2") + val plan = assertNotNull( + nativeCollectionActions( + schema(resource, read, bounded), + read, + resource, + records, + mapOf("spaceId" to "4"), + collectionComplete = true, + ).reorder, + ) + val entries = Json.parseToJsonElement( + assertNotNull(plan.requestInOrder(listOf("2", "1")).values["entries"]), + ).jsonArray + assertEquals(listOf(10L, 11L), entries.map { + it.jsonObject["position"]!!.jsonPrimitive.content.toLong() + }) + + val impossible = bounded.copy( + id = "reorder-impossible", + binding = bounded.binding.copy( + bodySchema = reorderBody( + bodyField = "entries", + identityField = "recordId", + orderField = "position", + orderMinimum = 10, + orderMaximum = 10, + ), + ), + ) + assertNull( + nativeCollectionActions( + schema(resource, read, impossible), + read, + resource, + records, + mapOf("spaceId" to "4"), + collectionComplete = true, + ).reorder, + ) + } + + @Test + fun `batch planner selects only the self resource identity array`() { + val resource = resource("items") + val relatedResource = resource("lists") + val read = readAction(resource.id, "/houses/{houseId}/items", listOf("houseId")) + val batch = action( + id = "move-selected", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/houses/{houseId}/items/batch/move", + pathFields = listOf("houseId"), + bodyFields = listOf("itemIds", "targetListIds"), + bodySchema = batchBody( + "itemIds" to integerArraySchema(), + "targetListIds" to integerArraySchema(), + ), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val relatedSchema = schema(resource, read, batch).copy( + resources = listOf(resource, relatedResource), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = relatedResource.id, + childResourceId = resource.id, + parentFieldId = "id", + childFieldId = "targetListIds", + confidence = Confidence.verified, + ), + ), + ) + val plan = assertNotNull( + nativeCollectionActions( + relatedSchema, + read, + resource, + records("31", "32", "33"), + mapOf("houseId" to "9"), + collectionComplete = false, + ).batches.singleOrNull(), + ) + + assertEquals("itemIds", plan.selectionFieldId) + assertEquals( + listOf( + NativeCollectionBatchInputField( + id = "targetListIds", + kind = NativeCollectionBatchInputKind.IntegerArray, + required = false, + nullable = false, + enumValues = null, + relatedResourceId = relatedResource.id, + ), + ), + plan.fields, + ) + val request = plan.request( + selectedRecordIds = listOf("32", "31"), + values = mapOf("targetListIds" to "[44]"), + ) + assertEquals("[32,31]", request.values["itemIds"]) + assertEquals("[44]", request.values["targetListIds"]) + assertEquals("9", request.values["houseId"]) + } + + @Test + fun `batch planner withholds nullable inputs until explicit null is representable`() { + val resource = resource("items") + val read = readAction(resource.id, "/groups/{groupId}/items", listOf("groupId")) + val optionalNullable = action( + id = "update-selected", + resourceId = resource.id, + method = HttpMethod.PATCH, + path = "/groups/{groupId}/items/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds", "note"), + bodySchema = batchBody( + "itemIds" to integerArraySchema(), + "note" to buildJsonObject { + put("type", "string") + put("nullable", true) + }, + ), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val optionalPlan = assertNotNull( + nativeCollectionActions( + schema(resource, read, optionalNullable), + read, + resource, + records("1", "2"), + mapOf("groupId" to "7"), + collectionComplete = false, + ).batches.singleOrNull(), + ) + + assertTrue(optionalPlan.fields.isEmpty()) + assertEquals( + mapOf("groupId" to "7", "itemIds" to "[1]"), + optionalPlan.request(listOf("1")).values, + ) + assertFailsWith { + optionalPlan.request(listOf("1"), values = mapOf("note" to "null")) + } + + val requiredNullableBody = buildJsonObject { + put("type", "object") + putJsonArray("required") { add(JsonPrimitive("note")) } + putJsonObject("properties") { + put("itemIds", integerArraySchema()) + putJsonObject("note") { + put("type", "string") + put("nullable", true) + } + } + } + val requiredNullable = optionalNullable.copy( + id = "require-nullable-note", + binding = optionalNullable.binding.copy( + operationId = "require-nullable-note", + requiredBodyFieldNames = listOf("note"), + bodySchema = requiredNullableBody, + ), + ) + + assertTrue( + nativeCollectionActions( + schema(resource, read, requiredNullable), + read, + resource, + records("1", "2"), + mapOf("groupId" to "7"), + collectionComplete = false, + ).batches.isEmpty(), + ) + } + + @Test + fun `batch planner rejects foreign or ambiguous selection arrays`() { + val resource = resource("items") + val read = readAction(resource.id, "/houses/{houseId}/items", listOf("houseId")) + val foreign = action( + id = "foreign-selection", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/houses/{houseId}/items/batch", + pathFields = listOf("houseId"), + bodyFields = listOf("listIds"), + bodySchema = batchBody("listIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val ambiguous = foreign.copy( + id = "ambiguous-selection", + binding = foreign.binding.copy( + bodyFieldNames = listOf("ids", "itemIds"), + bodySchema = batchBody( + "ids" to integerArraySchema(), + "itemIds" to integerArraySchema(), + ), + ), + ) + + listOf(foreign, ambiguous).forEach { action -> + assertTrue( + nativeCollectionActions( + schema(resource, read, action), + read, + resource, + records("1", "2"), + mapOf("houseId" to "7"), + collectionComplete = false, + ).batches.isEmpty(), + ) + } + } + + @Test + fun `destructive batch requires confirmation and active safe records`() { + val resource = resource("items") + val read = readAction(resource.id, "/spaces/{spaceId}/items", listOf("spaceId")) + val batch = action( + id = "delete-selected", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/spaces/{spaceId}/items/batch/delete", + pathFields = listOf("spaceId"), + bodyFields = listOf("itemIds", "permanent"), + bodySchema = batchBody( + "itemIds" to integerArraySchema(), + "permanent" to buildJsonObject { + put("type", "boolean") + put("default", false) + }, + ), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + val records = records("8", "9") + val plan = assertNotNull( + nativeCollectionActions( + schema(resource, read, batch), + read, + resource, + records, + mapOf("spaceId" to "3"), + collectionComplete = false, + ).batches.singleOrNull(), + ) + assertFailsWith { + plan.request(listOf("8"), mapOf("permanent" to "false")) + } + assertTrue( + plan.request( + listOf("8"), + mapOf("permanent" to "false"), + confirmed = true, + ).confirmed, + ) + assertFailsWith { + plan.request(listOf("99"), confirmed = true) + } + + val unsafeRecords = listOf( + NativeRecord( + id = "8", + values = mapOf("id" to "8"), + actionSafeIdentity = false, + ), + ) + assertEquals( + NativeCollectionActionCapabilities(emptyList(), null, emptyList()), + nativeCollectionActions( + schema(resource, read, batch), + read, + resource, + unsafeRecords, + mapOf("spaceId" to "3"), + collectionComplete = false, + ), + ) + } + + @Test + fun `mixed projected resources cannot authorize self resource collection actions`() { + val resource = resource("items") + val read = readAction(resource.id, "/groups/{groupId}/items", listOf("groupId")) + val batch = action( + id = "batch-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val mixed = listOf( + NativeRecord( + id = "1", + values = mapOf( + "id" to "1", + NATIVE_SYNTHETIC_RESOURCE_FIELD to "other-records", + ), + ), + ) + + assertTrue( + nativeCollectionActions( + schema(resource, read, batch), + read, + resource, + mixed, + mapOf("groupId" to "2"), + collectionComplete = false, + ).batches.isEmpty(), + ) + } + + @Test + fun `mixed record authority filters batch selection and withholds reorder`() { + val resource = resource("items") + val read = readAction(resource.id, "/groups/{groupId}/items", listOf("groupId")) + val batch = action( + id = "batch-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val reorder = action( + id = "reorder-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/reorder", + pathFields = listOf("groupId"), + bodyFields = listOf("entries"), + bodySchema = reorderBody("entries", "id", "sortOrder"), + intent = ActionIntent.execute, + effect = ActionEffect.reorder, + risk = ActionRisk.mutating, + ) + val mixed = listOf( + NativeRecord("1", mapOf("id" to "1", "canEdit" to "true", "canDelete" to "true")), + NativeRecord("2", mapOf("id" to "2", "canEdit" to "false", "canDelete" to "true")), + NativeRecord("3", mapOf("id" to "3", "canEdit" to "true", "canDelete" to "true")), + ) + + val capabilities = nativeCollectionActions( + schema(resource, read, batch, reorder), + read, + resource, + mixed, + mapOf("groupId" to "7"), + collectionComplete = true, + ) + + assertNull(capabilities.reorder) + val batchPlan = assertNotNull(capabilities.batches.singleOrNull()) + assertEquals(setOf("1", "3"), batchPlan.selectableRecordIds) + assertEquals("[1,3]", batchPlan.request(listOf("1", "3")).values["itemIds"]) + assertFailsWith { + batchPlan.request(listOf("2")) + } + } + + @Test + fun `destructive batch requires delete authority for every selectable record`() { + val resource = resource("items") + val read = readAction(resource.id, "/groups/{groupId}/items", listOf("groupId")) + val batch = action( + id = "delete-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch/delete", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + val records = listOf( + NativeRecord("1", mapOf("id" to "1", "canEdit" to "true", "canDelete" to "false")), + NativeRecord("2", mapOf("id" to "2", "canEdit" to "false", "canDelete" to "true")), + ) + + val plan = assertNotNull( + nativeCollectionActions( + schema(resource, read, batch), + read, + resource, + records, + mapOf("groupId" to "7"), + collectionComplete = false, + ).batches.singleOrNull(), + ) + assertEquals(setOf("2"), plan.selectableRecordIds) + assertEquals("[2]", plan.request(listOf("2"), confirmed = true).values["itemIds"]) + assertFailsWith { + plan.request(listOf("1"), confirmed = true) + } + } + + @Test + fun `parent authority uses edit semantics for batch and reorder and delete semantics for destructive batch`() { + val resource = ResourceSpec( + id = "items", + name = "Items", + confidence = Confidence.verified, + ) + val parent = ResourceSpec( + id = "groups", + name = "Groups", + confidence = Confidence.verified, + fields = listOf( + FieldSpec( + id = "permissions", + label = "Permissions", + kind = FieldKind.objectValue, + readOnly = true, + required = false, + ), + ), + ) + val read = readAction(resource.id, "/groups/{groupId}/items", listOf("groupId")) + val batch = action( + id = "batch-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.mutating, + ) + val destructiveBatch = action( + id = "delete-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/batch/delete", + pathFields = listOf("groupId"), + bodyFields = listOf("itemIds"), + bodySchema = batchBody("itemIds" to integerArraySchema()), + intent = ActionIntent.execute, + effect = ActionEffect.batch, + risk = ActionRisk.destructive, + requiresConfirmation = true, + ) + val reorder = action( + id = "reorder-items", + resourceId = resource.id, + method = HttpMethod.POST, + path = "/groups/{groupId}/items/reorder", + pathFields = listOf("groupId"), + bodyFields = listOf("entries"), + bodySchema = reorderBody("entries", "id", "sortOrder"), + intent = ActionIntent.execute, + effect = ActionEffect.reorder, + risk = ActionRisk.mutating, + ) + val nativeSchema = schema(resource, read, batch, destructiveBatch, reorder).copy( + resources = listOf(resource, parent), + ) + val records = listOf("1", "2").map { id -> + NativeRecord(id = id, values = mapOf("id" to id)) + } + + fun capabilities(canEdit: Boolean, canDelete: Boolean): NativeCollectionActionCapabilities { + fun permission(id: String, allowed: Boolean) = NativeStructuredEntry( + key = id, + label = id, + value = NativeStructuredValue.Scalar( + value = allowed.toString(), + kind = NativeStructuredScalarKind.boolean, + ), + ) + return nativeCollectionActions( + schema = nativeSchema, + activeReadAction = read, + resource = resource, + records = records, + navigationContext = mapOf("groupId" to "7"), + collectionComplete = true, + authorityContext = NativeRecordAuthorityContext( + parentResource = parent, + parentRecord = NativeRecord( + id = "7", + values = emptyMap(), + structuredValues = mapOf( + "permissions" to NativeStructuredValue.ObjectValue( + entries = listOf( + permission("canEditItems", canEdit), + permission("canDeleteItems", canDelete), + ), + ), + ), + ), + ), + ) + } + + val editAllowed = capabilities(canEdit = true, canDelete = false) + assertNotNull(editAllowed.reorder) + assertNotNull(editAllowed.batches.singleOrNull { plan -> plan.action.id == batch.id }) + assertNull(editAllowed.batches.singleOrNull { plan -> plan.action.id == destructiveBatch.id }) + + val editDenied = capabilities(canEdit = false, canDelete = true) + assertNull(editDenied.reorder) + assertNull(editDenied.batches.singleOrNull { plan -> plan.action.id == batch.id }) + assertNotNull(editDenied.batches.singleOrNull { plan -> plan.action.id == destructiveBatch.id }) + } + + private fun schema( + resource: ResourceSpec, + vararg actions: ActionSpec, + ) = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("fixture", "Fixture", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = actions.toList(), + ) + + private fun resource(id: String) = ResourceSpec( + id = id, + name = id.replaceFirstChar(Char::uppercaseChar), + confidence = Confidence.verified, + fields = listOf( + FieldSpec( + id = "canEdit", + label = "Can edit", + kind = FieldKind.boolean, + readOnly = true, + required = false, + ), + FieldSpec( + id = "canDelete", + label = "Can delete", + kind = FieldKind.boolean, + readOnly = true, + required = false, + ), + ), + ) + + private fun readAction( + resourceId: String, + path: String, + pathFields: List = emptyList(), + ) = action( + id = "read-$resourceId-${path.hashCode()}", + resourceId = resourceId, + method = HttpMethod.GET, + path = path, + pathFields = pathFields, + intent = ActionIntent.list, + effect = ActionEffect.list, + risk = ActionRisk.readOnly, + ) + + private fun action( + id: String, + resourceId: String, + method: HttpMethod, + path: String, + pathFields: List = emptyList(), + bodyFields: List = emptyList(), + requiredBodyFields: List = emptyList(), + bodySchema: JsonElement? = null, + intent: ActionIntent, + effect: ActionEffect, + risk: ActionRisk, + requiresConfirmation: Boolean = false, + ) = ActionSpec( + id = id, + label = id.replace('-', ' '), + resourceId = resourceId, + binding = ApiBinding( + method = method, + path = path, + operationId = id, + pathParameterNames = pathFields, + requiredPathParameterNames = pathFields, + bodyFieldNames = bodyFields, + requiredBodyFieldNames = requiredBodyFields, + bodyContentType = bodySchema?.let { "application/json" }, + bodySchema = bodySchema, + ), + intent = intent, + risk = risk, + requiresConfirmation = requiresConfirmation, + confidence = Confidence.verified, + effect = effect, + ) + + private fun records(vararg ids: String): List = ids.map { id -> + NativeRecord( + id = id, + values = mapOf( + "id" to id, + "canEdit" to "true", + "canDelete" to "true", + ), + bindingContext = mapOf("groupId" to "7"), + ) + } + + private fun reorderBody( + bodyField: String, + identityField: String, + orderField: String, + extraItemField: String? = null, + orderMinimum: Long? = null, + orderMaximum: Long? = null, + ): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject(bodyField) { + put("type", "array") + putJsonObject("items") { + put("type", "object") + putJsonArray("required") { + add(JsonPrimitive(identityField)) + add(JsonPrimitive(orderField)) + extraItemField?.let { add(JsonPrimitive(it)) } + } + putJsonObject("properties") { + putJsonObject(identityField) { + put("type", "integer") + put("format", "int64") + } + putJsonObject(orderField) { + put("type", "integer") + put("format", "int64") + orderMinimum?.let { put("minimum", it) } + orderMaximum?.let { put("maximum", it) } + } + extraItemField?.let { field -> + putJsonObject(field) { put("type", "string") } + } + } + } + } + } + } + + private fun batchBody( + vararg fields: Pair, + ): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + fields.forEach { (fieldId, schema) -> put(fieldId, schema) } + } + } + + private fun integerArraySchema(): JsonObject = buildJsonObject { + put("type", "array") + put("default", buildJsonArray {}) + putJsonObject("items") { + put("type", "integer") + put("format", "int64") + } + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecoveryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecoveryTest.kt new file mode 100644 index 00000000..42757866 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecoveryTest.kt @@ -0,0 +1,212 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeFormMutationRecoveryTest { + @Test + fun `restored in-flight submission remains blocked until a newer authoritative read`() { + val owner = createOwner() + val inFlight = owner.begin(reconciliationGeneration = 4) + val encodedInFlight = assertNotNull(inFlight.encode()) + + val stillExecuting = assertNotNull( + resolveNativeFormMutationRecoveryState( + encoded = encodedInFlight, + currentReconciliationGeneration = 4, + ownerStillExecuting = { true }, + ), + ) + assertEquals(NativeFormMutationRecoveryPhase.InFlight, stillExecuting.phase) + + val restored = assertNotNull( + resolveNativeFormMutationRecoveryState( + encoded = encodedInFlight, + currentReconciliationGeneration = 4, + ownerStillExecuting = { false }, + ), + ) + assertEquals(NativeFormMutationRecoveryPhase.AwaitingReconciliation, restored.phase) + assertTrue(restored.blocksSubmission) + assertEquals(owner.actionId, restored.authoritativeReconciliationActionId) + assertNotNull(restored.afterAuthoritativeReconciliation(currentGeneration = 4)) + assertNull(restored.afterAuthoritativeReconciliation(currentGeneration = 5)) + val restoredAfterRacingRead = assertNotNull( + resolveNativeFormMutationRecoveryState( + encoded = encodedInFlight, + currentReconciliationGeneration = 5, + ownerStillExecuting = { false }, + ), + ) + assertEquals(5, restoredAfterRacingRead.reconciliationGeneration) + assertNull(restoredAfterRacingRead.afterAuthoritativeReconciliation(currentGeneration = 6)) + } + + @Test + fun `a read racing the original request does not count as post-ambiguity reconciliation`() { + val inFlight = createOwner().begin(reconciliationGeneration = 2) + assertNotNull(inFlight.afterAuthoritativeReconciliation(currentGeneration = 3)) + + val restored = inFlight.afterLifecycleRestore( + ownerStillExecuting = false, + currentReconciliationGeneration = 3, + ) + assertEquals(3, restored.reconciliationGeneration) + assertNotNull(restored.afterAuthoritativeReconciliation(currentGeneration = 3)) + assertNull(restored.afterAuthoritativeReconciliation(currentGeneration = 4)) + + val unknown = assertNotNull( + inFlight.afterExecutionResult( + result = NativeActionExecutionResult.Failure( + message = "Response unavailable", + outcome = NativeActionFailureOutcome.Unknown, + ), + currentReconciliationGeneration = 3, + ), + ) + assertEquals(3, unknown.reconciliationGeneration) + assertNotNull(unknown.afterAuthoritativeReconciliation(currentGeneration = 3)) + } + + @Test + fun `only an ambiguous execution result waits for reconciliation`() { + val inFlight = createOwner().begin(reconciliationGeneration = 9) + + assertNull(inFlight.afterExecutionResult(NativeActionExecutionResult.Success())) + assertNull( + inFlight.afterExecutionResult( + NativeActionExecutionResult.Failure( + message = "Validation failed", + outcome = NativeActionFailureOutcome.Rejected, + ), + ), + ) + + val ambiguous = inFlight.afterExecutionResult( + NativeActionExecutionResult.Failure( + message = "Connection ended before a response arrived", + outcome = NativeActionFailureOutcome.Unknown, + ), + ) + assertEquals( + NativeFormMutationRecoveryPhase.AwaitingReconciliation, + assertNotNull(ambiguous).phase, + ) + } + + @Test + fun `saved update recovery state round trips its generic owner`() { + val owner = assertNotNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-detail", + actionId = "items.update", + resourceId = "items", + intent = ActionIntent.update, + recordId = "item-42", + ), + ) + val state = owner.begin(reconciliationGeneration = 12) + .afterLifecycleRestore(ownerStillExecuting = false) + val encoded = assertNotNull(state.encode()) + + assertEquals(state, decodeNativeFormMutationRecoveryState(encoded)) + assertNull(decodeNativeFormMutationRecoveryState("[\"incomplete\"]")) + assertNull(decodeNativeFormMutationRecoveryState("not-json")) + } + + @Test + fun `collection batch recovery survives recreation without enabling a duplicate submission`() { + val owner = assertNotNull( + nativeCollectionBatchMutationRecoveryOwner( + appId = "example-app", + viewId = "item-list", + actionId = "items.batch-move", + resourceId = "items", + ), + ) + assertEquals(NativeFormMutationKind.CollectionBatch, owner.kind) + assertNull(owner.recordId) + val encoded = assertNotNull(owner.begin(reconciliationGeneration = 6).encode()) + + val restored = assertNotNull( + resolveNativeFormMutationRecoveryState( + encoded = encoded, + currentReconciliationGeneration = 6, + ownerStillExecuting = { false }, + ), + ) + assertEquals(owner, restored.owner) + assertEquals(NativeFormMutationRecoveryPhase.AwaitingReconciliation, restored.phase) + assertTrue(restored.blocksSubmission) + assertEquals(owner.actionId, restored.authoritativeReconciliationActionId) + assertNotNull(restored.afterAuthoritativeReconciliation(currentGeneration = 6)) + assertNull(restored.afterAuthoritativeReconciliation(currentGeneration = 7)) + } + + @Test + fun `recovery owners exist only for identifiable create update and command mutations`() { + assertNotNull(createOwner()) + assertNotNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-detail", + actionId = "items.copy", + resourceId = "items", + intent = ActionIntent.execute, + recordId = "item-42", + ), + ) + assertNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-detail", + actionId = "items.copy", + resourceId = "items", + intent = ActionIntent.execute, + recordId = null, + ), + ) + assertNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-detail", + actionId = "items.update", + resourceId = "items", + intent = ActionIntent.update, + recordId = null, + ), + ) + assertNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-list", + actionId = "items.delete", + resourceId = "items", + intent = ActionIntent.delete, + recordId = "item-42", + ), + ) + assertNotNull( + createOwner() + .begin(reconciliationGeneration = 0) + .afterLifecycleRestore(ownerStillExecuting = false) + .afterAuthoritativeReconciliation(currentGeneration = 0), + ) + } + + private fun createOwner(): NativeFormMutationRecoveryOwner = assertNotNull( + nativeFormMutationRecoveryOwner( + appId = "example-app", + viewId = "item-list", + actionId = "items.create", + resourceId = "items", + intent = ActionIntent.create, + recordId = null, + ), + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeParentFormBindingTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeParentFormBindingTest.kt new file mode 100644 index 00000000..2a510a9c --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeParentFormBindingTest.kt @@ -0,0 +1,560 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.put + +class NativeParentFormBindingTest { + @Test + fun `nested form binds relationships from selected parent instead of observed child row`() { + val selectedHouse = NativeRecord( + id = "7", + values = mapOf("id" to "7", "name" to "Shared home"), + ) + val observedChecklist = NativeRecord( + id = "draft", + values = mapOf("name" to "Observed checklist"), + ) + + assertSame( + selectedHouse, + nativeFormBindingRecord( + initialRecord = observedChecklist, + parentResourceId = "houses", + parentRecord = selectedHouse, + ), + ) + } + + @Test + fun `pantry checklist create keeps selected house in its hidden path binding`() { + val resource = ResourceSpec( + id = "lists", + name = "Lists", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("id", "ID", FieldKind.integer, required = true, readOnly = true), + FieldSpec("houseId", "House", FieldKind.integer, required = true, readOnly = false), + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + FieldSpec("description", "Description", FieldKind.string, required = true, readOnly = false), + FieldSpec("icon", "Icon", FieldKind.string, required = true, readOnly = false), + FieldSpec("color", "Color", FieldKind.string, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "checklist-create-list", + label = "Create a checklist in a house", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists", + operationId = "checklist-create-list", + pathParameterNames = listOf("houseId"), + requiredPathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("name", "description", "icon", "color"), + requiredBodyFieldNames = listOf("name"), + bodyContentType = "application/json", + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + inputSchema = buildJsonObject { + put("type", "object") + put( + "properties", + buildJsonObject { + put("name", buildJsonObject { put("type", "string") }) + put("description", buildJsonObject { put("type", "string") }) + put("icon", buildJsonObject { put("type", "string") }) + put("color", buildJsonObject { put("type", "string") }) + }, + ) + put("required", buildJsonArray { + add(JsonPrimitive("name")) + }) + }, + ) + val selectedHouse = NativeRecord( + id = "7", + values = mapOf("id" to "7", "name" to "Shared home"), + ) + + val schema = dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema( + schemaVersion = "0.1", + app = dev.obiente.nextcloudnative.nativeui.model.AppIdentity( + id = "pantry", + name = "Pantry", + version = "test", + ), + confidence = Confidence.verified, + resources = listOf(resource), + views = listOf( + dev.obiente.nextcloudnative.nativeui.model.ViewSpec( + id = "checklist-create-list.form", + title = "Create a checklist in a house", + resourceId = resource.id, + component = dev.obiente.nextcloudnative.nativeui.model.NativeComponent.form, + sourceActionId = action.id, + confidence = Confidence.verified, + ), + ), + actions = listOf(action), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = "houses", + childResourceId = resource.id, + parentFieldId = "id", + childFieldId = "houseId", + confidence = Confidence.verified, + ), + ), + ) + val autoBound = nativeFormAutoBoundValues( + schema = schema, + action = action, + resource = resource, + record = selectedHouse, + parentResourceId = "houses", + ) + val visibleFields = editableNativeFields(resource, action) + .filterNot { field -> field.id in autoBound } + val values = initialNativeFormDraft(resource, action) + .values + autoBound + ("name" to "Groceries") + val request = assertIs( + assertIs( + buildNativeSubmitRequest( + schema = schema, + view = schema.views.single(), + values = values + ("undeclared" to "must not reach the request"), + confirmed = false, + ), + ).request, + ) + + assertEquals(mapOf("houseId" to "7"), autoBound) + assertEquals(listOf("name", "description", "icon", "color"), visibleFields.map(FieldSpec::id)) + assertEquals( + mapOf("name" to true, "description" to false, "icon" to false, "color" to false), + visibleFields.associate { field -> field.id to field.required }, + ) + assertFalse("houseId" in visibleFields.map(FieldSpec::id)) + assertEquals(emptyMap(), validateNativeForm(resource, action, values).errors) + assertEquals("7", request.values["houseId"]) + assertEquals("Groceries", request.values["name"]) + assertFalse("undeclared" in request.values) + } + + @Test + fun `response id cannot replace an unsafe selected parent identity`() { + val resource = ResourceSpec( + id = "folders", + name = "Folders", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("houseId", "House", FieldKind.integer, required = true, readOnly = false), + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-photo-folder", + label = "Create a photo folder", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/houses/{houseId}/photos/folders", + operationId = "create-photo-folder", + pathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("name"), + requiredBodyFieldNames = listOf("name"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + inputSchema = buildJsonObject { + put("type", "object") + put( + "properties", + buildJsonObject { + put("houseId", buildJsonObject { put("type", "integer") }) + put("name", buildJsonObject { put("type", "string") }) + }, + ) + put("required", buildJsonArray { + add(JsonPrimitive("houseId")) + add(JsonPrimitive("name")) + }) + }, + ) + val selectedParent = NativeRecord( + id = "synthetic collection row", + values = mapOf("id" to "7", "name" to "Shared collection"), + actionSafeIdentity = false, + ) + + val resolution = nativeFormAutoBindingResolution( + schema = bindingSchema(resource, action, "houses", "houseId"), + action = action, + resource = resource, + record = selectedParent, + parentResourceId = "houses", + ) + + assertEquals(emptyMap(), resolution.values) + assertEquals( + "This action cannot be linked because the selected parent identity could not be verified.", + resolution.error, + ) + } + + @Test + fun `verified navigation context can bind an empty child collection`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("collectionId", "Collection", FieldKind.integer, required = true, readOnly = false), + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-entry", + label = "Create entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/collections/{collectionId}/entries", + operationId = "create-entry", + pathParameterNames = listOf("collectionId"), + requiredPathParameterNames = listOf("collectionId"), + bodyFieldNames = listOf("name"), + requiredBodyFieldNames = listOf("name"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + + val resolution = nativeFormAutoBindingResolution( + schema = bindingSchema(resource, action, "collections", "collectionId"), + action = action, + resource = resource, + record = null, + parentResourceId = "collections", + navigationValues = mapOf("collectionId" to "7"), + ) + + assertNull(resolution.error) + assertEquals(mapOf("collectionId" to "7"), resolution.values) + + val withoutDeclaredRelationship = nativeFormAutoBindingResolution( + schema = bindingSchema(resource, action), + action = action, + resource = resource, + record = NativeRecord( + id = "selected-child-9", + values = mapOf("id" to "selected-child-9"), + bindingContext = mapOf("id" to "unrelated-parent-7"), + ), + parentResourceId = "collections", + navigationValues = mapOf( + "id" to "unrelated-parent-7", + "collectionId" to "7", + ), + ) + + assertNull(withoutDeclaredRelationship.error) + assertEquals(mapOf("collectionId" to "7"), withoutDeclaredRelationship.values) + } + + @Test + fun `conflicting parent provenance disables automatic form binding`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("collectionId", "Collection", FieldKind.integer, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-entry", + label = "Create entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/collections/{collectionId}/entries", + operationId = "create-entry", + pathParameterNames = listOf("collectionId"), + requiredPathParameterNames = listOf("collectionId"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + + val resolution = nativeFormAutoBindingResolution( + schema = bindingSchema(resource, action, "collections", "collectionId"), + action = action, + resource = resource, + record = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + bindingContext = mapOf("collectionId" to "7"), + ), + parentResourceId = "collections", + navigationValues = mapOf("collectionId" to "11"), + ) + + assertEquals(emptyMap(), resolution.values) + assertEquals( + "This action cannot be linked because the selected item no longer matches the navigation context.", + resolution.error, + ) + } + + @Test + fun `unrelated selected records cannot satisfy a parent relation`() { + val resource = ResourceSpec( + id = "checklists", + name = "Checklists", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("houseId", "House", FieldKind.integer, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-checklist", + label = "Create checklist", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/checklists", + operationId = "create-checklist", + bodyFieldNames = listOf("houseId"), + requiredBodyFieldNames = listOf("houseId"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("houseId", buildJsonObject { put("type", "integer") }) + }) + }, + ) + + assertEquals( + emptyMap(), + nativeFormAutoBoundValues( + schema = bindingSchema(resource, action), + action = action, + resource = resource, + record = NativeRecord("message-1", mapOf("id" to "message-1")), + parentResourceId = "messages", + ), + ) + } + + @Test + fun `inferred parent relationship stays an explicit form choice`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("collectionId", "Collection", FieldKind.integer, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-entry", + label = "Create entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/entries", + operationId = "create-entry", + bodyFieldNames = listOf("collectionId"), + requiredBodyFieldNames = listOf("collectionId"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val selectedParent = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + ) + + val resolution = nativeFormAutoBindingResolution( + schema = bindingSchema( + resource = resource, + action = action, + parentResourceId = "collections", + childFieldId = "collectionId", + relationshipConfidence = Confidence.high, + ), + action = action, + resource = resource, + record = selectedParent, + parentResourceId = "collections", + ) + + assertNull(resolution.error) + assertEquals(emptyMap(), resolution.values) + } + + @Test + fun `selected parent fields do not prefill a child create form`() { + val resource = ResourceSpec( + id = "folders", + name = "Folders", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("houseId", "House", FieldKind.integer, required = true, readOnly = false), + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + ), + ) + val action = ActionSpec( + id = "create-photo-folder", + label = "Create a photo folder", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/houses/{houseId}/photos/folders", + operationId = "create-photo-folder", + pathParameterNames = listOf("houseId"), + bodyFieldNames = listOf("name"), + requiredBodyFieldNames = listOf("name"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val selectedHouse = NativeRecord( + id = "7", + values = mapOf("id" to "7", "name" to "Shared home"), + ) + + val prefillRecord = nativeFormPrefillRecord( + action = action, + resource = resource, + record = selectedHouse, + parentResourceId = "houses", + ) + + assertNull(prefillRecord) + assertEquals("", initialNativeFormDraft(resource, action, prefillRecord).values["name"]) + } + + @Test + fun `same resource and update forms retain their record prefill`() { + val resource = ResourceSpec( + id = "settings", + name = "Settings", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("name", "Name", FieldKind.string, required = true, readOnly = false), + ), + ) + val record = NativeRecord("settings", mapOf("name" to "Existing value")) + val createAction = ActionSpec( + id = "create-settings", + label = "Create settings", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/settings", + operationId = "create-settings", + bodyFieldNames = listOf("name"), + requiredBodyFieldNames = listOf("name"), + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val updateAction = createAction.copy( + id = "update-settings", + label = "Update settings", + intent = ActionIntent.update, + binding = createAction.binding.copy( + method = HttpMethod.PUT, + operationId = "update-settings", + ), + ) + + assertSame( + record, + nativeFormPrefillRecord( + action = createAction, + resource = resource, + record = record, + parentResourceId = "settings", + ), + ) + assertSame( + record, + nativeFormPrefillRecord( + action = updateAction, + resource = resource, + record = record, + parentResourceId = "houses", + ), + ) + } + + private fun bindingSchema( + resource: ResourceSpec, + action: ActionSpec, + parentResourceId: String? = null, + childFieldId: String? = null, + relationshipConfidence: Confidence = Confidence.verified, + ): NativeAppSchema = NativeAppSchema( + schemaVersion = "test", + app = AppIdentity("synthetic", "Synthetic", "test"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = listOf(action), + relationships = if (parentResourceId != null && childFieldId != null) { + listOf( + ResourceRelationshipSpec( + parentResourceId = parentResourceId, + childResourceId = resource.id, + parentFieldId = "id", + childFieldId = childFieldId, + confidence = relationshipConfidence, + ), + ) + } else { + emptyList() + }, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActionsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActionsTest.kt new file mode 100644 index 00000000..d84c1787 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActionsTest.kt @@ -0,0 +1,2659 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.ActionEffect +import dev.obiente.nextcloudnative.nativeui.model.ActionIntent +import dev.obiente.nextcloudnative.nativeui.model.ActionRisk +import dev.obiente.nextcloudnative.nativeui.model.ActionSpec +import dev.obiente.nextcloudnative.nativeui.model.ApiBinding +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_INTEGER_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.HttpMethod +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputFieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputRow +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputScalarKind +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeRecordActionsTest { + @Test + fun `structural record actions bind exact parent and item identities`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field("notes", "Notes", FieldKind.longText), + field("done", "Done", FieldKind.boolean), + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + field("canDelete", "Can delete", FieldKind.boolean, readOnly = true), + ), + ) + val actions = listOf( + action( + id = "add", + intent = ActionIntent.create, + method = HttpMethod.POST, + pathNames = listOf("containerId"), + bodyNames = listOf("title", "notes"), + requiredBodyNames = listOf("title"), + ), + action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title", "notes"), + requiredBodyNames = listOf("title"), + ), + action( + id = "remove", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath(""), + action( + id = "set-state", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + requiredBodyNames = listOf("done"), + ), + action( + id = "archive", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive"), + ) + val schema = schema(resource, actions) + val record = NativeRecord( + id = "item-9", + values = mapOf( + "id" to "item-9", + "title" to "Prepare room", + "notes" to "Before noon", + "done" to "false", + "canEdit" to "true", + "canDelete" to "true", + ), + displayValues = mapOf("notes" to "Formatted notes must not become a write value"), + bindingContext = mapOf("containerId" to "collection-4"), + ) + + val plans = nativeRecordActions(schema, resource, record) + + assertEquals(listOf("title", "notes"), plans.create?.fields?.map(FieldSpec::id)) + assertEquals( + mapOf("containerId" to "collection-4", "title" to "New item"), + requireNotNull(plans.create).request(mapOf("title" to "New item")).values, + ) + assertEquals( + mapOf("title" to "Prepare room", "notes" to "Before noon"), + plans.edit?.initialValues, + ) + assertEquals( + mapOf("recordId" to "item-9", "title" to "Updated", "notes" to ""), + requireNotNull(plans.edit).request(mapOf("title" to "Updated", "notes" to "")).values, + ) + assertEquals( + mapOf("recordId" to "item-9"), + requireNotNull(plans.delete).request(confirmed = true).values, + ) + val completion = requireNotNull(plans.completion) + assertFalse(completion.currentlyCompleted) + assertEquals( + mapOf("recordId" to "item-9", "done" to "true"), + completion.request(completed = true).values, + ) + assertEquals( + mapOf("recordId" to "item-9", "done" to "false"), + completion.request(completed = false).values, + ) + + val emptyCollectionCreate = nativeRecordActions( + schema = schema, + resource = resource, + navigationContext = mapOf("containerId" to "collection-4"), + ).create + assertEquals( + mapOf("containerId" to "collection-4", "title" to "First item"), + requireNotNull(emptyCollectionCreate).request(mapOf("title" to "First item")).values, + ) + } + + @Test + fun `explicit record capability denials withhold writes while preserving collection create`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field("done", "Done", FieldKind.boolean), + field("readOnly", "Read only", FieldKind.boolean, readOnly = true), + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + field("canDelete", "Can delete", FieldKind.boolean, readOnly = true), + ), + ) + val actions = listOf( + action( + id = "add", + intent = ActionIntent.create, + method = HttpMethod.POST, + pathNames = listOf("containerId"), + bodyNames = listOf("title"), + requiredBodyNames = listOf("title"), + ), + action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + requiredBodyNames = listOf("title"), + ), + action( + id = "remove", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath(""), + action( + id = "set-state", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + requiredBodyNames = listOf("done"), + ), + action( + id = "archive", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive"), + ) + val schema = schema(resource, actions) + val readOnlyRecord = NativeRecord( + id = "item-9", + values = mapOf( + "id" to "item-9", + "title" to "Prepare room", + "done" to "false", + "readOnly" to "true", + ), + bindingContext = mapOf("containerId" to "collection-4"), + ) + + val readOnlyPlans = nativeRecordActions(schema, resource, readOnlyRecord) + + assertTrue(readOnlyPlans.create != null) + assertNull(readOnlyPlans.edit) + assertNull(readOnlyPlans.delete) + assertNull(readOnlyPlans.completion) + assertTrue(readOnlyPlans.commands.isEmpty()) + + val unknownPlans = nativeRecordActions( + schema, + resource, + readOnlyRecord.copy( + values = readOnlyRecord.values + ("readOnly" to "false"), + ), + ) + + assertTrue(unknownPlans.create != null) + assertNull(unknownPlans.edit) + assertNull(unknownPlans.delete) + assertNull(unknownPlans.completion) + assertTrue(unknownPlans.commands.isEmpty()) + + val deleteOnlyRecord = readOnlyRecord.copy( + values = readOnlyRecord.values + mapOf( + "readOnly" to "false", + "canEdit" to "false", + "canDelete" to "true", + ), + ) + val deleteOnlyPlans = nativeRecordActions(schema, resource, deleteOnlyRecord) + + assertNull(deleteOnlyPlans.edit) + assertNull(deleteOnlyPlans.completion) + assertTrue(deleteOnlyPlans.delete != null) + assertTrue(deleteOnlyPlans.commands.isEmpty()) + + val writablePlans = nativeRecordActions( + schema, + resource, + deleteOnlyRecord.copy( + values = deleteOnlyRecord.values + ("canEdit" to "true"), + ), + ) + assertEquals(listOf(ActionEffect.archive), writablePlans.commands.map { command -> command.effect }) + } + + @Test + fun `presentation-only observed fields cannot become mutation authority`() { + val canonicalResource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + ), + ) + val presentationResource = canonicalResource.copy( + fields = canonicalResource.fields + + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ) + val edit = action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val record = NativeRecord( + id = "item-9", + values = mapOf("id" to "item-9", "title" to "Prepare room"), + ) + + val observedOnly = nativeRecordActions( + schema = schema(canonicalResource, listOf(edit)), + resource = presentationResource, + record = record, + ) + + assertNull( + observedOnly.edit, + "Neither endpoint existence nor a renderer-local field may authorize mutation.", + ) + + val declaredResource = canonicalResource.copy(fields = presentationResource.fields) + val declaredMissing = nativeRecordActions( + schema = schema(declaredResource, listOf(edit)), + resource = presentationResource, + record = record, + ) + val declaredAllowed = nativeRecordActions( + schema = schema(declaredResource, listOf(edit)), + resource = presentationResource, + record = record.copy(values = record.values + ("canEdit" to "true")), + ) + + assertNull( + declaredMissing.edit, + "A contract-declared capability with missing evidence must continue to fail closed.", + ) + assertTrue(declaredAllowed.edit != null) + } + + @Test + fun `partial record capability surfaces authorize only their declared mutation category`() { + val fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field("done", "Done", FieldKind.boolean), + ) + val edit = action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val delete = action( + id = "remove", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("") + val complete = action( + id = "set-state", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + requiredBodyNames = listOf("done"), + ) + val archive = action( + id = "archive", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive") + val actions = listOf(edit, delete, complete, archive) + val baseRecord = NativeRecord( + id = "item-9", + values = mapOf("id" to "item-9", "title" to "Prepare room", "done" to "false"), + ) + val deleteOnlyResource = resource( + fields = fields + field("canDelete", "Can delete", FieldKind.boolean, readOnly = true), + ) + val deleteOnly = nativeRecordActions( + schema(deleteOnlyResource, actions), + deleteOnlyResource, + baseRecord.copy(values = baseRecord.values + ("canDelete" to "true")), + ) + + assertNull(deleteOnly.edit) + assertNull(deleteOnly.completion) + assertTrue(deleteOnly.commands.isEmpty()) + assertTrue(deleteOnly.delete != null) + + val editOnlyResource = resource( + fields = fields + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ) + val editOnly = nativeRecordActions( + schema(editOnlyResource, actions), + editOnlyResource, + baseRecord.copy(values = baseRecord.values + ("canEdit" to "true")), + ) + + assertTrue(editOnly.edit != null) + assertTrue(editOnly.completion != null) + assertEquals(listOf(ActionEffect.archive), editOnly.commands.map { command -> command.effect }) + assertNull(editOnly.delete) + } + + @Test + fun `parent authority uniquely authorizes list note and photo deletion`() { + listOf("lists", "notes", "photos").forEach { resourceId -> + val identityName = resourceId.dropLast(1) + "Id" + val child = resource( + id = resourceId, + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field("title", "Title", FieldKind.string), + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ), + ) + val parent = resource( + id = "houses", + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field("isAdmin", "Is admin", FieldKind.boolean, readOnly = true), + field("permissions", "Permissions", FieldKind.objectValue, readOnly = true), + ), + ) + val delete = action( + id = "$resourceId-delete", + intent = ActionIntent.delete, + effect = ActionEffect.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("houseId", identityName), + confirmation = true, + ).let { action -> + action.copy( + resourceId = child.id, + binding = action.binding.copy( + path = "/api/houses/{houseId}/$resourceId/{$identityName}", + ), + ) + } + val selected = NativeRecord( + id = "23", + values = mapOf("id" to "23", "title" to "Selected", "canEdit" to "true"), + ) + val permissionId = "canDelete" + resourceId.replaceFirstChar(Char::uppercase) + val parentRecord = NativeRecord( + id = "4", + values = mapOf("id" to "4", "isAdmin" to "false"), + structuredValues = mapOf( + "permissions" to NativeStructuredValue.ObjectValue( + entries = listOf( + NativeStructuredEntry( + key = permissionId, + label = permissionId, + value = NativeStructuredValue.Scalar( + value = "true", + kind = NativeStructuredScalarKind.boolean, + ), + ), + ), + ), + ), + ) + val nativeSchema = schema(child, listOf(delete)).copy( + resources = listOf(child, parent), + ) + val authority = NativeRecordAuthorityContext(parent, parentRecord) + + val plan = nativeRecordActions( + schema = nativeSchema, + resource = child, + record = selected, + navigationContext = mapOf("id" to "4"), + authorityContext = authority, + ).delete + + assertEquals( + mapOf("houseId" to "4", identityName to "23"), + requireNotNull(plan) { resourceId }.request(confirmed = true).values, + ) + assertNull( + nativeRecordActions( + schema = nativeSchema, + resource = child, + record = selected, + navigationContext = mapOf("id" to "4"), + ).delete, + "$resourceId canEdit must not authorize deletion without parent evidence.", + ) + } + } + + @Test + fun `parent authority fails closed for false absent malformed and ambiguous permissions`() { + val child = resource( + id = "notes", + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ), + ) + val parent = resource( + id = "houses", + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field("isAdmin", "Is admin", FieldKind.boolean, readOnly = true), + field("permissions", "Permissions", FieldKind.objectValue, readOnly = true), + ), + ) + val delete = action( + id = "notes-delete", + intent = ActionIntent.delete, + effect = ActionEffect.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("houseId", "noteId"), + confirmation = true, + ).let { action -> + action.copy( + resourceId = child.id, + binding = action.binding.copy( + path = "/api/houses/{houseId}/notes/{noteId}", + ), + ) + } + val selected = NativeRecord( + id = "23", + values = mapOf("id" to "23", "canEdit" to "true"), + ) + val nativeSchema = schema(child, listOf(delete)).copy(resources = listOf(child, parent)) + + fun deleteWith( + entries: List?, + isAdmin: String = "false", + omittedEntries: Int = 0, + ) = + nativeRecordActions( + schema = nativeSchema, + resource = child, + record = selected, + navigationContext = mapOf("id" to "4"), + authorityContext = NativeRecordAuthorityContext( + parentResource = parent, + parentRecord = NativeRecord( + id = "4", + values = mapOf("id" to "4", "isAdmin" to isAdmin), + structuredValues = entries?.let { values -> + mapOf( + "permissions" to NativeStructuredValue.ObjectValue( + entries = values, + omittedEntries = omittedEntries, + ), + ) + }.orEmpty(), + ), + ), + ).delete + + fun permission( + id: String, + value: String?, + kind: NativeStructuredScalarKind = NativeStructuredScalarKind.boolean, + ) = NativeStructuredEntry( + key = id, + label = id, + value = NativeStructuredValue.Scalar(value, kind), + ) + + assertNull(deleteWith(listOf(permission("canDeleteNotes", "false")))) + assertNull(deleteWith(listOf(permission("canEditNotes", "true")))) + assertNull( + deleteWith( + listOf( + permission("canDeleteNotes", "true"), + permission("canRemoveNotes", "true"), + ), + ), + ) + assertNull( + deleteWith( + listOf(permission("canDeleteNotes", "true", NativeStructuredScalarKind.string)), + ), + ) + assertNull( + deleteWith( + entries = listOf(permission("canDeleteNotes", "true")), + omittedEntries = 1, + ), + ) + assertNull(deleteWith(entries = null)) + assertTrue(deleteWith(entries = emptyList(), isAdmin = "true") != null) + } + + @Test + fun `selected note command binds a proven parent alias from generic navigation id`() { + val notes = resource( + id = "notes", + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field("deletedAt", "Deleted at", FieldKind.dateTime, readOnly = true), + ), + ) + val restore = action( + id = "note-restore", + intent = ActionIntent.execute, + effect = ActionEffect.restore, + method = HttpMethod.POST, + pathNames = listOf("houseId", "noteId"), + ).let { action -> + action.copy( + resourceId = notes.id, + binding = action.binding.copy( + path = "/api/houses/{houseId}/notes/{noteId}/restore", + ), + ) + } + val note = NativeRecord( + id = "23", + values = mapOf("id" to "23", "deletedAt" to "2026-07-30T12:00:00Z"), + ) + + val plan = nativeRecordActions( + schema = schema(notes, listOf(restore)), + resource = notes, + record = note, + navigationContext = mapOf("id" to "4"), + authorityContext = affirmativeParentAuthority(), + ).commands.single() + + assertEquals( + mapOf("houseId" to "4", "noteId" to "23"), + plan.request().values, + ) + } + + @Test + fun `sparse records cannot authorize replacement put edit forms`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field("description", "Description", FieldKind.longText), + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ), + ) + val replace = action( + id = "replace", + intent = ActionIntent.update, + method = HttpMethod.PUT, + pathNames = listOf("recordId"), + bodyNames = listOf("title", "description"), + requiredBodyNames = listOf("title"), + ) + val sparseRecord = NativeRecord( + id = "item-9", + values = mapOf( + "id" to "item-9", + "title" to "Visible title", + "canEdit" to "true", + ), + ) + + assertNull( + nativeRecordActions(schema(resource, listOf(replace)), resource, sparseRecord).edit, + ) + + val patch = replace.copy( + id = "patch", + binding = replace.binding.copy(method = HttpMethod.PATCH, operationId = "patch"), + ) + assertTrue( + nativeRecordActions(schema(resource, listOf(patch)), resource, sparseRecord).edit != null, + ) + + val nullableRecord = sparseRecord.copy( + values = sparseRecord.values + ("description" to null), + ) + val completeRecord = sparseRecord.copy( + values = sparseRecord.values + ("description" to "Authoritative description"), + ) + assertNull(nativeRecordActions(schema(resource, listOf(replace)), resource, nullableRecord).edit) + assertNull(nativeRecordActions(schema(resource, listOf(replace)), resource, completeRecord).edit) + + val unsafePlan = NativeRecordFormActionPlan( + kind = NativeRecordFormActionKind.Edit, + action = replace, + fields = resource.fields.filter { field -> field.id in replace.binding.bodyFieldNames }, + initialValues = mapOf( + "title" to "Visible title", + "description" to "Authoritative description", + ), + bindingValues = mapOf("recordId" to "item-9"), + ) + assertFailsWith { + unsafePlan.request( + mapOf( + "title" to "Updated title", + "description" to "Updated description", + ), + ) + } + } + + @Test + fun `selected record mutations require affirmative capability or parent authority`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + ), + ) + val parent = resource( + id = "spaces", + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("isAdmin", "Is admin", FieldKind.boolean, readOnly = true), + ), + ) + val edit = action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val archive = action( + id = "archive", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive") + val delete = action( + id = "remove", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("") + val actions = listOf(edit, archive, delete) + val record = NativeRecord( + id = "item-9", + values = mapOf("id" to "item-9", "title" to "Selected"), + ) + val nativeSchema = schema(resource, actions).copy(resources = listOf(resource, parent)) + + val absent = nativeRecordActions(nativeSchema, resource, record) + val unscoped = nativeRecordActions( + schema = nativeSchema, + resource = resource, + record = record, + authorityContext = NativeRecordAuthorityContext( + parentResource = parent.copy(fields = parent.fields.take(1)), + parentRecord = NativeRecord("space-4", mapOf("id" to "space-4")), + ), + ) + listOf(absent, unscoped).forEach { capabilities -> + assertNull(capabilities.edit) + assertNull(capabilities.delete) + assertTrue(capabilities.commands.isEmpty()) + } + + val parentAllowed = nativeRecordActions( + schema = nativeSchema, + resource = resource, + record = record, + authorityContext = NativeRecordAuthorityContext( + parentResource = parent, + parentRecord = NativeRecord( + id = "space-4", + values = mapOf("id" to "space-4", "isAdmin" to "true"), + ), + ), + ) + assertTrue(parentAllowed.edit != null) + assertTrue(parentAllowed.delete != null) + assertEquals(listOf(ActionEffect.archive), parentAllowed.commands.map { plan -> plan.effect }) + + val capableResource = resource.copy( + fields = resource.fields + + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true) + + field("canDelete", "Can delete", FieldKind.boolean, readOnly = true), + ) + val canonicalAllowed = nativeRecordActions( + schema = schema(capableResource, actions), + resource = capableResource, + record = record.copy( + values = record.values + mapOf("canEdit" to "true", "canDelete" to "true"), + ), + ) + assertTrue(canonicalAllowed.edit != null) + assertTrue(canonicalAllowed.delete != null) + assertEquals(listOf(ActionEffect.archive), canonicalAllowed.commands.map { plan -> plan.effect }) + + val scopedEditResource = resource.copy( + fields = resource.fields + + field("writable", "Writable", FieldKind.boolean, readOnly = true) + + field("canEdit", "Can edit", FieldKind.boolean, readOnly = true), + ) + val scopedEditOnly = nativeRecordActions( + schema = schema(scopedEditResource, actions), + resource = scopedEditResource, + record = record.copy( + values = record.values + mapOf("writable" to "true", "canEdit" to "true"), + ), + ) + assertTrue(scopedEditOnly.edit != null) + assertEquals(listOf(ActionEffect.archive), scopedEditOnly.commands.map { plan -> plan.effect }) + assertNull( + scopedEditOnly.delete, + "General writable evidence cannot fill an absent canDelete capability category.", + ) + } + + @Test + fun `record delete must consume the selected item identity`() { + val resource = resource( + id = "tasks", + fields = listOf(field("id", "ID", FieldKind.string, readOnly = true)), + ) + val collectionDelete = action( + id = "delete-task-collection", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("projectId"), + confirmation = true, + ).let { action -> + action.copy( + resourceId = resource.id, + binding = action.binding.copy(path = "/api/projects/{projectId}/tasks"), + ) + } + val itemDelete = collectionDelete.copy( + id = "delete-task", + binding = collectionDelete.binding.copy( + path = "/api/projects/{projectId}/tasks/{taskId}", + operationId = "delete-task", + pathParameterNames = listOf("projectId", "taskId"), + requiredPathParameterNames = listOf("projectId", "taskId"), + ), + ) + val record = NativeRecord( + id = "task-9", + values = mapOf("id" to "task-9"), + bindingContext = mapOf("projectId" to "project-4"), + ) + + assertNull( + nativeRecordActions( + schema(resource, listOf(collectionDelete)), + resource, + record, + ).delete, + ) + assertEquals( + mapOf("projectId" to "project-4", "taskId" to "task-9"), + requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(itemDelete)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).delete, + ).request(confirmed = true).values, + ) + } + + @Test + fun `bodyless semantic toggle becomes a reversible completion action`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("done", "Done", FieldKind.boolean, readOnly = true), + ), + ) + val toggle = action( + id = "toggle-state", + intent = ActionIntent.execute, + effect = ActionEffect.toggle, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).let { action -> + action.copy( + binding = action.binding.copy(path = "/api/records/{recordId}/toggle"), + ) + } + val incomplete = NativeRecord( + id = "record-14", + values = mapOf("id" to "record-14", "done" to "false"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(toggle)), + resource = resource, + record = incomplete, + authorityContext = affirmativeParentAuthority(), + ).completion, + ) + + assertEquals(NativeRecordCompletionActionKind.Toggle, plan.kind) + assertFalse(plan.currentlyCompleted) + assertEquals( + mapOf("recordId" to "record-14"), + plan.request(completed = true).values, + ) + assertFailsWith { + plan.request(completed = false) + } + + val completed = incomplete.copy(values = incomplete.values + ("done" to "true")) + val reversePlan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(toggle)), + resource = resource, + record = completed, + authorityContext = affirmativeParentAuthority(), + ).completion, + ) + assertEquals( + mapOf("recordId" to "record-14"), + reversePlan.request(completed = false).values, + ) + + val dedicatedPutToggle = toggle.copy( + binding = toggle.binding.copy(method = HttpMethod.PUT), + ) + assertEquals( + NativeRecordCompletionActionKind.Toggle, + requireNotNull( + nativeRecordActions( + schema(resource, listOf(dedicatedPutToggle)), + resource, + incomplete, + authorityContext = affirmativeParentAuthority(), + ).completion, + ).kind, + ) + } + + @Test + fun `non-task boolean state aliases expose no completion mutation`() { + listOf( + "enabled" to "Status", + "published" to "State", + "available" to "Status", + ).forEach { (stateFieldId, stateFieldLabel) -> + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field(stateFieldId, stateFieldLabel, FieldKind.boolean), + ), + ) + val setState = action( + id = "set-$stateFieldId", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf(stateFieldId), + requiredBodyNames = listOf(stateFieldId), + ) + val toggle = action( + id = "toggle-$stateFieldId", + intent = ActionIntent.execute, + effect = ActionEffect.toggle, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("toggle-$stateFieldId") + val record = NativeRecord( + id = "record-14", + values = mapOf( + "id" to "record-14", + "title" to "Ordinary record", + stateFieldId to "true", + ), + ) + + assertNull( + nativeRecordActions( + schema(resource, listOf(setState, toggle)), + resource, + record, + ).completion, + "$stateFieldId must not acquire task completion semantics from a boolean state alias.", + ) + } + } + + @Test + fun `record transition commands bind exact identities and declared values`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("destinationId", "Destination", FieldKind.string), + ), + ) + val archive = action( + id = "archive-record", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive") + val copy = action( + id = "copy-record", + intent = ActionIntent.execute, + effect = ActionEffect.copy, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + bodyNames = listOf("destinationId"), + requiredBodyNames = listOf("destinationId"), + ).withRecordPath("copy") + .withScalarBodySchema("destinationId", "string") + val record = NativeRecord( + id = "record-20", + values = mapOf( + "id" to "record-20", + "destinationId" to "destination-4", + ), + ) + + val capabilities = nativeRecordActions( + schema = schema(resource, listOf(copy, archive)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ) + + assertEquals( + listOf(ActionEffect.archive), + capabilities.commands.map(NativeRecordCommandActionPlan::effect), + ) + assertEquals( + mapOf("recordId" to "record-20"), + capabilities.commands.single().request().values, + ) + assertEquals( + listOf(ActionEffect.copy), + capabilities.commandForms.map(NativeRecordCommandFormActionPlan::effect), + ) + assertFalse(capabilities.commands.any(NativeRecordCommandActionPlan::requiresConfirmation)) + } + + @Test + fun `destructive record commands require explicit confirmation`() { + val resource = resource(fields = listOf(field("id", "ID", FieldKind.string, readOnly = true))) + val permanentDelete = action( + id = "delete-record-permanently", + intent = ActionIntent.delete, + effect = ActionEffect.permanentDelete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("permanent") + val record = NativeRecord( + "record-21", + mapOf("id" to "record-21", "deletedAt" to "2026-07-30T12:00:00Z"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(permanentDelete)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).commands.singleOrNull(), + ) + + assertTrue(plan.requiresConfirmation) + assertFailsWith { + plan.request() + } + assertEquals( + mapOf("recordId" to "record-21"), + plan.request(confirmed = true).values, + ) + assertTrue(plan.request(confirmed = true).confirmed) + } + + @Test + fun `record state gates reversible and permanent transition commands`() { + val resource = resource(fields = listOf(field("id", "ID", FieldKind.string, readOnly = true))) + val archive = action( + id = "archive-record", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive") + val unarchive = archive.copy( + id = "unarchive-record", + effect = ActionEffect.unarchive, + binding = archive.binding.copy(path = "/api/records/{recordId}/unarchive"), + ) + val restore = archive.copy( + id = "restore-record", + effect = ActionEffect.restore, + binding = archive.binding.copy(path = "/api/records/{recordId}/restore"), + ) + val permanentDelete = action( + id = "delete-record-permanently", + intent = ActionIntent.delete, + effect = ActionEffect.permanentDelete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("permanent") + val schema = schema(resource, listOf(archive, unarchive, restore, permanentDelete)) + + assertEquals( + listOf(ActionEffect.archive), + nativeRecordActions( + schema = schema, + resource = resource, + record = NativeRecord("record-active", mapOf("id" to "record-active")), + authorityContext = affirmativeParentAuthority(), + ).commands.map(NativeRecordCommandActionPlan::effect), + ) + assertEquals( + listOf(ActionEffect.unarchive), + nativeRecordActions( + schema = schema, + resource = resource, + record = NativeRecord( + "record-archived", + mapOf("id" to "record-archived", "archivedAt" to "2026-07-30T12:00:00Z"), + ), + authorityContext = affirmativeParentAuthority(), + ).commands.map(NativeRecordCommandActionPlan::effect), + ) + assertEquals( + listOf(ActionEffect.restore, ActionEffect.permanentDelete), + nativeRecordActions( + schema = schema, + resource = resource, + record = NativeRecord( + "record-deleted", + mapOf("id" to "record-deleted", "deletedAt" to "2026-07-30T12:00:00Z"), + ), + authorityContext = affirmativeParentAuthority(), + ).commands.map(NativeRecordCommandActionPlan::effect), + ) + } + + @Test + fun `record commands reject ambiguous unsupported and unresolved inputs`() { + val resource = resource( + fields = listOf(field("id", "ID", FieldKind.string, readOnly = true)), + ) + val archive = action( + id = "archive-record", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive") + val unresolvedQuery = action( + id = "restore-record", + intent = ActionIntent.execute, + effect = ActionEffect.restore, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + queryNames = listOf("revision"), + ).withRecordPath("restore") + val unsupportedBatch = action( + id = "batch-record", + intent = ActionIntent.execute, + effect = ActionEffect.batch, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("batch") + val opaqueBody = action( + id = "unarchive-record", + intent = ActionIntent.execute, + effect = ActionEffect.unarchive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("unarchive").let { candidate -> + candidate.copy(binding = candidate.binding.copy(bodyContentType = "application/json")) + } + val unsafeLeave = action( + id = "leave-record", + intent = ActionIntent.execute, + effect = ActionEffect.leave, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = false, + ).withRecordPath("leave") + val duplicateArchive = archive.copy(id = "archive-record-alias") + val record = NativeRecord("record-22", mapOf("id" to "record-22")) + + val commands = nativeRecordActions( + schema( + resource, + listOf( + archive, + duplicateArchive, + unresolvedQuery, + unsupportedBatch, + opaqueBody, + unsafeLeave, + ), + ), + resource, + record, + ).commands + + assertTrue(commands.isEmpty()) + } + + @Test + fun `normalized parent id binds one proven documented parent placeholder`() { + val resource = resource( + id = "lists", + fields = listOf(field("name", "Name", FieldKind.string)), + ) + val create = action( + id = "create-list", + intent = ActionIntent.create, + method = HttpMethod.POST, + pathNames = listOf("houseId"), + bodyNames = listOf("name"), + requiredBodyNames = listOf("name"), + ).let { action -> + action.copy( + resourceId = "lists", + binding = action.binding.copy( + path = "/api/houses/{houseId}/lists", + ), + ) + } + + val plan = nativeRecordActions( + schema = schema(resource, listOf(create)), + resource = resource, + navigationContext = mapOf("id" to "house-7"), + ).create + + assertEquals( + mapOf("houseId" to "house-7", "name" to "Shopping"), + requireNotNull(plan).request(mapOf("name" to "Shopping")).values, + ) + assertNull( + nativeRecordActions( + schema = schema( + resource, + listOf( + create.copy( + binding = create.binding.copy( + path = "/api/accounts/{houseId}/lists", + ), + ), + ), + ), + resource = resource, + navigationContext = mapOf("id" to "house-7"), + ).create, + ) + } + + @Test + fun `safe record plans become reachable card actions without changing their targets`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + ), + ) + val schema = schema( + resource, + listOf( + action( + id = "change", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + requiredBodyNames = listOf("title"), + ), + action( + id = "remove", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath(""), + ), + ) + val record = NativeRecord( + id = "item-12", + values = mapOf("id" to "item-12", "title" to "Reachable item"), + ) + val capabilities = nativeRecordActions( + schema = schema, + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ) + var editTarget: Pair? = null + var deleteTarget: Pair? = null + var commandTarget: Pair? = null + + val actions = nativeRecordCardActions( + capabilities = capabilities, + record = record, + onEditRecord = { target, plan -> editTarget = target to plan }, + onDeleteRecord = { target, plan -> deleteTarget = target to plan }, + onCommandRecord = { target, plan -> commandTarget = target to plan }, + ) + + assertEquals(listOf("Edit", "Delete"), actions.map { action -> action.label }) + assertFalse(actions.first().destructive) + assertTrue(actions.last().destructive) + + actions.first().onClick() + actions.last().onClick() + + assertEquals(record, editTarget?.first) + assertEquals(capabilities.edit, editTarget?.second) + assertEquals(record, deleteTarget?.first) + assertEquals(capabilities.delete, deleteTarget?.second) + assertNull(commandTarget) + } + + @Test + fun `body bearing record commands remain distinct from edit and bind exact context`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("targetListId", "Destination", FieldKind.integer), + field( + "roleIds", + "Roles", + FieldKind.integer, + format = DYNAMIC_INTEGER_ARRAY_FORMAT, + ), + field( + "visibility", + "Visibility", + FieldKind.enumeration, + enumValues = listOf("private", "shared"), + ), + ), + ) + val copy = action( + id = "copy-record", + intent = ActionIntent.execute, + effect = ActionEffect.copy, + method = HttpMethod.POST, + pathNames = listOf("containerId", "recordId"), + bodyNames = listOf("targetListId"), + requiredBodyNames = listOf("targetListId"), + ).let { action -> + action.copy( + binding = action.binding.copy( + path = "/api/containers/{containerId}/records/{recordId}/copy", + ), + ) + }.withScalarBodySchema("targetListId", "integer") + val assign = action( + id = "set-record-roles", + intent = ActionIntent.update, + effect = ActionEffect.assign, + method = HttpMethod.PUT, + pathNames = listOf("containerId", "recordId"), + bodyNames = listOf("roleIds"), + requiredBodyNames = listOf("roleIds"), + ).let { action -> + action.copy( + binding = action.binding.copy( + path = "/api/containers/{containerId}/records/{recordId}/roles", + bodySchema = Json.parseToJsonElement( + """ + { + "type": "object", + "properties": { + "roleIds": { + "type": "array", + "format": "$DYNAMIC_INTEGER_ARRAY_FORMAT", + "items": { "type": "integer" } + } + }, + "required": ["roleIds"] + } + """.trimIndent(), + ), + ), + ) + } + val execute = action( + id = "set-record-visibility", + intent = ActionIntent.execute, + effect = ActionEffect.execute, + method = HttpMethod.POST, + pathNames = listOf("containerId", "recordId"), + bodyNames = listOf("visibility"), + requiredBodyNames = listOf("visibility"), + ).let { action -> + action.copy( + binding = action.binding.copy( + path = "/api/containers/{containerId}/records/{recordId}/visibility", + ), + ) + }.withScalarBodySchema( + fieldId = "visibility", + type = "string", + enumValues = listOf("private", "shared"), + ) + val record = NativeRecord( + id = "record-9", + values = mapOf( + "id" to "record-9", + "roleIds" to "[2,7]", + ), + bindingContext = mapOf("containerId" to "container-4"), + ) + + val plans = nativeRecordActions( + schema = schema(resource, listOf(copy, assign, execute)), + resource = resource, + record = record, + navigationContext = mapOf("containerId" to "container-4"), + authorityContext = affirmativeParentAuthority(), + ) + + assertNull(plans.edit) + assertEquals( + listOf("copy-record", "set-record-roles", "set-record-visibility"), + plans.commandForms.map { it.action.id }, + ) + val copyPlan = plans.commandForms.single { it.action.id == "copy-record" } + assertEquals(listOf("targetListId"), copyPlan.fields.map(FieldSpec::id)) + assertTrue(copyPlan.initialValues.isEmpty()) + assertEquals( + mapOf( + "containerId" to "container-4", + "recordId" to "record-9", + "targetListId" to "12", + ), + copyPlan.request(mapOf("targetListId" to "12")).values, + ) + val assignPlan = plans.commandForms.single { it.action.id == "set-record-roles" } + assertEquals(mapOf("roleIds" to "[2,7]"), assignPlan.initialValues) + assertEquals( + mapOf( + "containerId" to "container-4", + "recordId" to "record-9", + "roleIds" to "[3,8]", + ), + assignPlan.request(mapOf("roleIds" to "[3,8]")).values, + ) + listOf("""["3"]""", "[3.5]", """{"roleIds":[3]}""").forEach { invalid -> + assertFailsWith(invalid) { + assignPlan.request(mapOf("roleIds" to invalid)) + } + } + val executePlan = plans.commandForms.single { it.action.id == "set-record-visibility" } + assertEquals( + "shared", + executePlan.request(mapOf("visibility" to "shared")).values["visibility"], + ) + assertFailsWith { + executePlan.request(mapOf("visibility" to "public")) + } + } + + @Test + fun `record command form encodes exact typed repeatable object rows`() { + val structured = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 4, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "uid", + label = "Recipient", + kind = RepeatableObjectInputScalarKind.String, + required = true, + minimumLength = 1, + ), + RepeatableObjectInputFieldSpec( + id = "permission", + label = "Permission", + kind = RepeatableObjectInputScalarKind.Enumeration, + required = true, + enumValues = listOf("view", "edit"), + ), + ), + ) + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.integer, readOnly = true), + field( + id = "shares", + label = "Shares", + kind = FieldKind.objectValue, + format = DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT, + repeatableObjectInput = structured, + ), + ), + ) + val assign = action( + id = "replace-shares", + intent = ActionIntent.update, + effect = ActionEffect.update, + method = HttpMethod.PUT, + pathNames = listOf("recordId"), + bodyNames = listOf("shares"), + requiredBodyNames = listOf("shares"), + ).withRecordPath("shares").let { action -> + action.copy( + resultRecoveryActionId = "read-shares", + binding = action.binding.copy( + bodySchema = Json.parseToJsonElement( + """ + { + "type":"object", + "required":["shares"], + "properties":{ + "shares":{ + "type":"array", + "format":"$DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT", + "minItems":1, + "maxItems":4, + "items":{ + "type":"object", + "additionalProperties":false, + "required":["uid","permission"], + "properties":{ + "uid":{"type":"string","title":"Recipient","minLength":1}, + "permission":{ + "type":"string", + "title":"Permission", + "enum":["view","edit"] + } + } + } + } + } + } + """.trimIndent(), + ), + ), + ) + } + val record = NativeRecord( + id = "7", + values = mapOf("id" to "7"), + ) + + val plan = nativeRecordActions( + schema = schema(resource, listOf(assign)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).commandForms.single() + val request = plan.requestWithStructuredInput( + scalarInputValues = emptyMap(), + repeatableObjectValues = mapOf( + "shares" to listOf( + RepeatableObjectInputRow( + mapOf("uid" to "alice", "permission" to "edit"), + ), + ), + ), + ) + + assertEquals( + """[{"uid":"alice","permission":"edit"}]""", + request.values["shares"], + ) + assertFailsWith { + plan.requestWithStructuredInput( + scalarInputValues = mapOf("shares" to "[]"), + repeatableObjectValues = emptyMap(), + ) + } + assertFailsWith { + plan.requestWithStructuredInput( + scalarInputValues = emptyMap(), + repeatableObjectValues = mapOf( + "shares" to listOf( + RepeatableObjectInputRow( + mapOf("uid" to "alice", "permission" to "owner"), + ), + ), + ), + ) + } + } + + @Test + fun `record command forms fail closed for unsafe ambiguous or unconfirmed actions`() { + val resource = resource( + fields = listOf( + field("targetId", "Destination", FieldKind.string), + field("payload", "Payload", FieldKind.objectValue), + ), + ) + val safe = action( + id = "copy-record", + intent = ActionIntent.execute, + effect = ActionEffect.copy, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + bodyNames = listOf("targetId"), + requiredBodyNames = listOf("targetId"), + ).withRecordPath("copy") + .withScalarBodySchema("targetId", "string") + val record = NativeRecord("record-4", emptyMap()) + + fun commandForms(action: ActionSpec, target: ResourceSpec = resource) = + nativeRecordActions( + schema = schema(target, listOf(action)), + resource = target, + record = record, + authorityContext = affirmativeParentAuthority(), + ).commandForms + + assertEquals(listOf("copy-record"), commandForms(safe).map { it.action.id }) + assertTrue( + commandForms( + safe.withScalarBodySchema("targetId", "integer"), + ).isEmpty(), + ) + val bounded = safe.withScalarBodySchema( + fieldId = "targetId", + type = "string", + minimumLength = 3, + maximumLength = 12, + ) + val boundedPlan = commandForms(bounded).single() + assertFailsWith { + boundedPlan.request(mapOf("targetId" to "x")) + } + assertEquals( + "record-8", + boundedPlan.request(mapOf("targetId" to "record-8")).values["targetId"], + ) + assertTrue( + nativeRecordActions( + schema(resource, listOf(safe, safe.copy(id = "copy-record-again"))), + resource, + record, + ).commandForms.isEmpty(), + ) + assertTrue( + commandForms( + safe.copy( + binding = safe.binding.copy( + path = "/api/records/copy", + ), + ), + ).isEmpty(), + ) + assertTrue( + commandForms( + safe.copy( + binding = safe.binding.copy( + path = "/api/records/{recordId}/{undeclaredScope}/copy", + ), + ), + ).isEmpty(), + ) + assertTrue( + commandForms( + safe.copy( + binding = safe.binding.copy( + bodyFieldNames = listOf("recordId", "targetId"), + ), + ), + ).isEmpty(), + ) + assertTrue(commandForms(safe.copy(confidence = Confidence.medium)).isEmpty()) + assertTrue( + commandForms( + safe.copy( + binding = safe.binding.copy(allowsObservedBodyFields = true), + ), + ).isEmpty(), + ) + val unsupported = safe.copy( + binding = safe.binding.copy( + bodyFieldNames = listOf("payload"), + requiredBodyFieldNames = listOf("payload"), + ), + ) + assertTrue(commandForms(unsupported).isEmpty()) + + val unconfirmedDestructive = safe.copy( + risk = ActionRisk.destructive, + requiresConfirmation = false, + ) + assertTrue(commandForms(unconfirmedDestructive).isEmpty()) + val confirmedDestructive = unconfirmedDestructive.copy(requiresConfirmation = true) + val destructivePlan = commandForms(confirmedDestructive).single() + assertFailsWith { + destructivePlan.request(mapOf("targetId" to "record-8")) + } + assertTrue( + destructivePlan.request( + inputValues = mapOf("targetId" to "record-8"), + confirmed = true, + ).confirmed, + ) + } + + @Test + fun `related action resource can form a command only from exact selected record identity`() { + val members = resource( + id = "members", + fields = listOf(field("id", "ID", FieldKind.integer, readOnly = true)), + ) + val roles = resource( + id = "roles", + fields = listOf( + field( + "roleIds", + "Roles", + FieldKind.integer, + format = DYNAMIC_INTEGER_ARRAY_FORMAT, + ), + ), + ) + val setRoles = action( + id = "set-member-roles", + intent = ActionIntent.update, + effect = ActionEffect.assign, + method = HttpMethod.PUT, + pathNames = listOf("houseId", "memberId"), + bodyNames = listOf("roleIds"), + requiredBodyNames = listOf("roleIds"), + ).let { action -> + action.copy( + resourceId = roles.id, + binding = action.binding.copy( + path = "/api/houses/{houseId}/members/{memberId}/roles", + bodySchema = Json.parseToJsonElement( + """ + { + "type": "object", + "properties": { + "roleIds": { + "type": "array", + "format": "$DYNAMIC_INTEGER_ARRAY_FORMAT", + "items": { "type": "integer" } + } + }, + "required": ["roleIds"] + } + """.trimIndent(), + ), + ), + ) + } + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(members, roles), + actions = listOf(setRoles), + ) + val member = NativeRecord( + id = "23", + values = mapOf("id" to "23"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema, + resource = members, + record = member, + navigationContext = mapOf("id" to "4"), + authorityContext = affirmativeParentAuthority(), + ).commandForms.singleOrNull(), + ) + + assertEquals(setRoles.id, plan.action.id) + assertEquals(listOf("roleIds"), plan.fields.map(FieldSpec::id)) + assertEquals( + mapOf("houseId" to "4", "memberId" to "23", "roleIds" to "[2,5]"), + plan.request(mapOf("roleIds" to "[2,5]")).values, + ) + assertTrue( + nativeRecordActions( + schema = schema.copy( + actions = listOf( + setRoles.copy( + binding = setRoles.binding.copy( + path = "/api/houses/{houseId}/members/current/roles", + ), + ), + ), + ), + resource = members, + record = member, + navigationContext = mapOf("id" to "4"), + authorityContext = affirmativeParentAuthority(), + ).commandForms.isEmpty(), + ) + } + + @Test + fun `enumerated status is reversible only when both states are declared`() { + val resource = resource( + fields = listOf( + field("uuid", "UUID", FieldKind.string, readOnly = true), + field("summary", "Summary", FieldKind.string), + field( + "state", + "State", + FieldKind.enumeration, + enumValues = listOf("active", "finished", "paused"), + ), + ), + ) + val update = action( + id = "update-state", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("state"), + requiredBodyNames = listOf("state"), + ) + val record = NativeRecord( + id = "entry-a", + values = mapOf("uuid" to "entry-a", "summary" to "Lock up", "state" to "active"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(update)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).completion, + ) + + assertEquals("finished", plan.completedWireValue) + assertEquals("active", plan.incompleteWireValue) + assertEquals("finished", plan.request(completed = true).values["state"]) + + val oneWayResource = resource.copy( + fields = resource.fields.map { field -> + if (field.id == "state") field.copy(enumValues = listOf("finished", "paused")) else field + }, + ) + assertNull( + nativeRecordActions( + schema = schema(oneWayResource, listOf(update)), + resource = oneWayResource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).completion, + ) + } + + @Test + fun `partial completion update is withheld from replacement style put`() { + val resource = resource( + fields = listOf( + field("id", "ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + field("done", "Done", FieldKind.boolean), + ), + ) + val putUpdate = action( + id = "replace-record", + intent = ActionIntent.update, + method = HttpMethod.PUT, + pathNames = listOf("recordId"), + bodyNames = listOf("title", "done"), + requiredBodyNames = listOf("done"), + ) + val record = NativeRecord( + id = "record-22", + values = mapOf( + "id" to "record-22", + "title" to "Authoritative title", + "done" to "false", + ), + ) + + assertNull( + nativeRecordActions( + schema = schema(resource, listOf(putUpdate)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).completion, + ) + + val patchUpdate = putUpdate.copy( + id = "patch-record", + binding = putUpdate.binding.copy( + method = HttpMethod.PATCH, + operationId = "patch-record", + ), + ) + assertEquals( + mapOf("recordId" to "record-22", "done" to "true"), + requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(patchUpdate)), + resource = resource, + record = record, + authorityContext = affirmativeParentAuthority(), + ).completion, + ).request(completed = true).values, + ) + } + + @Test + fun `ambiguous actions and completion fields fail closed`() { + val resource = resource( + fields = listOf( + field("title", "Title", FieldKind.string), + field("done", "Done", FieldKind.boolean), + ), + ) + val edit = action( + id = "edit-one", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val duplicateEdit = edit.copy(id = "edit-two") + val complete = action( + id = "complete-one", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + ) + val duplicateComplete = complete.copy(id = "complete-two") + val record = NativeRecord( + id = "record-8", + values = mapOf("title" to "Example", "done" to "false"), + ) + + val plans = nativeRecordActions( + schema(resource, listOf(edit, duplicateEdit, complete, duplicateComplete)), + resource, + record, + ) + + assertNull(plans.edit) + assertNull(plans.completion) + + val ambiguousResource = resource.copy( + fields = resource.fields + field("completed", "Completed", FieldKind.boolean), + ) + assertNull( + nativeRecordActions( + schema(ambiguousResource, listOf(complete)), + ambiguousResource, + record.copy(values = record.values + ("completed" to "false")), + ).completion, + ) + } + + @Test + fun `unsafe identity unresolved context and low confidence expose no writes`() { + val resource = resource( + fields = listOf(field("title", "Title", FieldKind.string)), + ) + val create = action( + id = "create", + intent = ActionIntent.create, + method = HttpMethod.POST, + pathNames = listOf("parentToken"), + bodyNames = listOf("title"), + requiredBodyNames = listOf("title"), + ) + val edit = action( + id = "edit", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val delete = action( + id = "delete", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("") + val unsafeRecord = NativeRecord( + id = "response-row", + values = mapOf("title" to "Observed only"), + actionSafeIdentity = false, + ) + + val plans = nativeRecordActions( + schema(resource, listOf(create, edit, delete)), + resource, + unsafeRecord, + ) + + assertNull(plans.create) + assertNull(plans.edit) + assertNull(plans.delete) + + val lowConfidence = create.copy(confidence = Confidence.medium) + assertNull( + nativeRecordActions( + schema(resource, listOf(lowConfidence)), + resource, + navigationContext = mapOf("parentToken" to "parent-1"), + ).create, + ) + } + + @Test + fun `unknown required body and unconfirmed destructive action fail closed`() { + val resource = resource( + fields = listOf(field("title", "Title", FieldKind.string)), + ) + val incompleteCreate = action( + id = "create", + intent = ActionIntent.create, + method = HttpMethod.POST, + bodyNames = listOf("title", "policyToken"), + requiredBodyNames = listOf("title", "policyToken"), + ) + val unconfirmedDelete = action( + id = "delete", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = false, + ) + val record = NativeRecord("row-2", mapOf("title" to "Example")) + val plans = nativeRecordActions( + schema(resource, listOf(incompleteCreate, unconfirmedDelete)), + resource, + record, + ) + + assertNull(plans.create) + assertNull(plans.delete) + } + + @Test + fun `unsupported optional structured bodies never become empty record forms`() { + val resource = resource( + fields = listOf(field("configuration", "Configuration", FieldKind.objectValue)), + ) + val create = action( + id = "create", + intent = ActionIntent.create, + method = HttpMethod.POST, + bodyNames = listOf("configuration"), + ) + + assertNull( + nativeRecordActions(schema(resource, listOf(create)), resource).create, + ) + } + + @Test + fun `form plans reject undeclared invalid and missing inputs`() { + val resource = resource( + fields = listOf( + field("title", "Title", FieldKind.string), + field("priority", "Priority", FieldKind.integer), + ), + ) + val create = action( + id = "create", + intent = ActionIntent.create, + method = HttpMethod.POST, + bodyNames = listOf("title", "priority"), + requiredBodyNames = listOf("title"), + ) + val plan = requireNotNull(nativeRecordActions(schema(resource, listOf(create)), resource).create) + + assertFailsWith { + plan.request(mapOf("unknown" to "value")) + } + assertFailsWith { + plan.request(mapOf("title" to "")) + } + assertFailsWith { + plan.request(mapOf("title" to "Example", "priority" to "high")) + } + } + + @Test + fun `delete plan requires a fresh explicit confirmation`() { + val resource = resource(fields = emptyList()) + val delete = action( + id = "delete", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("") + val plan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(delete)), + resource = resource, + record = NativeRecord("row-3", emptyMap()), + authorityContext = affirmativeParentAuthority(), + ).delete, + ) + + assertFailsWith { + plan.request(confirmed = false) + } + assertTrue(plan.request(confirmed = true).confirmed) + } + + @Test + fun `inline completion with confirmation requirement fails closed`() { + val resource = resource( + fields = listOf(field("done", "Done", FieldKind.boolean)), + ) + val completion = action( + id = "complete", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + confirmation = true, + ) + + assertNull( + nativeRecordActions( + schema(resource, listOf(completion)), + resource, + NativeRecord("row-4", mapOf("done" to "false")), + ).completion, + ) + } + + @Test + fun `conflicting binding provenance and overlapping channels fail closed`() { + val resource = resource( + fields = listOf( + field("containerId", "Container", FieldKind.string), + field("title", "Title", FieldKind.string), + ), + ) + val create = action( + id = "create", + intent = ActionIntent.create, + method = HttpMethod.POST, + pathNames = listOf("containerId"), + bodyNames = listOf("title"), + ) + val record = NativeRecord( + id = "row-5", + values = mapOf("containerId" to "response-parent", "title" to "Example"), + bindingContext = mapOf("containerId" to "request-parent"), + ) + + assertEquals( + null, + nativeRecordActions( + schema(resource, listOf(create)), + resource, + record, + navigationContext = mapOf("containerId" to "different-parent"), + ).create, + ) + + val overlapping = create.copy( + binding = create.binding.copy( + bodyFieldNames = listOf("containerId", "title"), + ), + ) + assertNull( + nativeRecordActions( + schema(resource, listOf(overlapping)), + resource, + navigationContext = mapOf("containerId" to "request-parent"), + ).create, + ) + + val aliasConflict = record.copy( + bindingContext = mapOf("container_id" to "request-parent"), + ) + assertNull( + nativeRecordActions( + schema(resource, listOf(create)), + resource, + aliasConflict, + navigationContext = mapOf("containerId" to "different-parent"), + ).create, + ) + + val invalidProvenance = nativeRecordActions( + schema(resource, listOf(create)), + resource, + record.copy(actionBindingProvenanceValid = false), + ) + assertNull(invalidProvenance.create) + assertNull(invalidProvenance.edit) + assertNull(invalidProvenance.delete) + assertNull(invalidProvenance.completion) + } + + @Test + fun `irregular plural resource identity binds canonical record id`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf(field("title", "Title", FieldKind.string)), + ) + val edit = ActionSpec( + id = "edit-entry", + label = "Edit entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.PATCH, + path = "/records/{entryId}", + operationId = "edit-entry", + pathParameterNames = listOf("entryId"), + requiredPathParameterNames = listOf("entryId"), + bodyFieldNames = listOf("title"), + bodyContentType = "application/json", + ), + intent = ActionIntent.update, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema(resource, listOf(edit)), + resource = resource, + record = NativeRecord("entry-7", mapOf("title" to "Before")), + authorityContext = affirmativeParentAuthority(), + ).edit, + ) + + assertEquals( + mapOf("entryId" to "entry-7", "title" to "After"), + plan.request(mapOf("title" to "After")).values, + ) + } + + @Test + fun `unsafe canonical record ids withhold every path mutation`() { + val resource = resource( + fields = listOf( + field("title", "Title", FieldKind.string), + field("done", "Done", FieldKind.boolean), + ), + ) + val actions = listOf( + action( + id = "edit", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ).withRecordPath("edit"), + action( + id = "delete", + intent = ActionIntent.delete, + risk = ActionRisk.destructive, + method = HttpMethod.DELETE, + pathNames = listOf("recordId"), + confirmation = true, + ).withRecordPath("delete"), + action( + id = "complete", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("done"), + requiredBodyNames = listOf("done"), + ).withRecordPath("complete"), + action( + id = "archive", + intent = ActionIntent.execute, + effect = ActionEffect.archive, + method = HttpMethod.POST, + pathNames = listOf("recordId"), + ).withRecordPath("archive"), + ) + val schema = schema(resource, actions) + + listOf( + "", + "item/9", + "item\\9", + "item\n9", + "x".repeat(257), + ).forEach { unsafeId -> + val plans = nativeRecordActions( + schema = schema, + resource = resource, + record = NativeRecord( + id = unsafeId, + values = mapOf("title" to "Example", "done" to "false"), + ), + ) + + assertNull(plans.edit, "Edit must reject unsafe canonical ID '$unsafeId'.") + assertNull(plans.delete, "Delete must reject unsafe canonical ID '$unsafeId'.") + assertNull(plans.completion, "Completion must reject unsafe canonical ID '$unsafeId'.") + assertTrue(plans.commands.isEmpty(), "Commands must reject unsafe canonical ID '$unsafeId'.") + } + } + + @Test + fun `record identity wins only for the selected child resource`() { + val resource = resource( + fields = listOf( + field("id", "Protocol ID", FieldKind.string, readOnly = true), + field("title", "Title", FieldKind.string), + ), + ) + val edit = action( + id = "edit", + intent = ActionIntent.update, + method = HttpMethod.PATCH, + pathNames = listOf("recordId"), + bodyNames = listOf("title"), + ) + val record = NativeRecord( + id = "child-9", + values = mapOf("id" to "display-id", "title" to "Before"), + bindingContext = mapOf("id" to "parent-4"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema(resource, listOf(edit)), + resource, + record, + navigationContext = mapOf("id" to "parent-4"), + authorityContext = affirmativeParentAuthority(), + ).edit, + ) + + assertEquals( + mapOf("recordId" to "child-9", "title" to "After"), + plan.request(mapOf("title" to "After")).values, + ) + } + + @Test + fun `declared parent relationship is bound from navigation and never shown as user input`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + field("workspaceId", "Workspace", FieldKind.string), + field("title", "Title", FieldKind.string), + ), + ) + val create = ActionSpec( + id = "create-entry", + label = "Create entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/entries", + operationId = "create-entry", + bodyFieldNames = listOf("workspaceId", "title"), + requiredBodyFieldNames = listOf("workspaceId", "title"), + bodyContentType = "application/json", + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = listOf(create), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = "workspaces", + childResourceId = "entries", + parentFieldId = "id", + childFieldId = "workspaceId", + confidence = Confidence.verified, + ), + ), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema, + resource = resource, + navigationContext = mapOf("workspaceId" to "workspace-4"), + ).create, + ) + + assertEquals(listOf("title"), plan.fields.map(FieldSpec::id)) + assertEquals( + mapOf("workspaceId" to "workspace-4", "title" to "First entry"), + plan.request(mapOf("title" to "First entry")).values, + ) + assertFailsWith { + plan.request(mapOf("workspaceId" to "other", "title" to "First entry")) + } + } + + @Test + fun `high confidence inferred relationship stays editable and cannot silently bind a write`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + field("workspaceId", "Workspace", FieldKind.string), + field("title", "Title", FieldKind.string), + ), + ) + val create = ActionSpec( + id = "create-entry", + label = "Create entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.POST, + path = "/entries", + operationId = "create-entry", + bodyFieldNames = listOf("workspaceId", "title"), + requiredBodyFieldNames = listOf("workspaceId", "title"), + bodyContentType = "application/json", + ), + intent = ActionIntent.create, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = listOf(create), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = "workspaces", + childResourceId = "entries", + parentFieldId = "id", + childFieldId = "workspaceId", + confidence = Confidence.high, + ), + ), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema, + resource = resource, + navigationContext = mapOf("workspaceId" to "workspace-4"), + ).create, + ) + + assertEquals(listOf("workspaceId", "title"), plan.fields.map(FieldSpec::id)) + assertEquals( + mapOf("workspaceId" to "workspace-9", "title" to "First entry"), + plan.request(mapOf("workspaceId" to "workspace-9", "title" to "First entry")).values, + ) + assertFailsWith { + plan.request(mapOf("title" to "First entry")) + } + } + + @Test + fun `editable relationship remains visible so a record can move to another parent`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.verified, + fields = listOf( + field("workspaceId", "Workspace", FieldKind.string), + field("title", "Title", FieldKind.string), + ), + ) + val edit = ActionSpec( + id = "edit-entry", + label = "Edit entry", + resourceId = resource.id, + binding = ApiBinding( + method = HttpMethod.PATCH, + path = "/entries/{entryId}", + operationId = "edit-entry", + pathParameterNames = listOf("entryId"), + requiredPathParameterNames = listOf("entryId"), + bodyFieldNames = listOf("workspaceId", "title"), + requiredBodyFieldNames = listOf("workspaceId", "title"), + bodyContentType = "application/json", + ), + intent = ActionIntent.update, + risk = ActionRisk.mutating, + requiresConfirmation = false, + confidence = Confidence.verified, + ) + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = listOf(edit), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = "workspaces", + childResourceId = "entries", + parentFieldId = "id", + childFieldId = "workspaceId", + confidence = Confidence.verified, + ), + ), + ) + val record = NativeRecord( + id = "entry-8", + values = mapOf("entryId" to "entry-8", "workspaceId" to "workspace-4", "title" to "First entry"), + bindingContext = mapOf("entryId" to "entry-8"), + ) + + val plan = requireNotNull( + nativeRecordActions( + schema = schema, + resource = resource, + record = record, + navigationContext = mapOf("workspaceId" to "workspace-4"), + authorityContext = affirmativeParentAuthority(), + ).edit, + ) + + assertEquals(listOf("workspaceId", "title"), plan.fields.map(FieldSpec::id)) + assertEquals( + mapOf("entryId" to "entry-8", "workspaceId" to "workspace-9", "title" to "Moved entry"), + plan.request(mapOf("workspaceId" to "workspace-9", "title" to "Moved entry")).values, + ) + } + + private fun schema(resource: ResourceSpec, actions: List) = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("synthetic", "Synthetic", "1"), + confidence = Confidence.verified, + resources = listOf(resource), + actions = actions, + ) + + private fun resource( + fields: List, + id: String = "records", + ) = ResourceSpec( + id = id, + name = id.replaceFirstChar(Char::uppercase), + confidence = Confidence.verified, + fields = fields, + ) + + private fun affirmativeParentAuthority() = NativeRecordAuthorityContext( + parentResource = resource( + id = "parent-records", + fields = listOf( + field("isAdmin", "Is admin", FieldKind.boolean, readOnly = true), + ), + ), + parentRecord = NativeRecord( + id = "parent-1", + values = mapOf("isAdmin" to "true"), + ), + ) + + private fun field( + id: String, + label: String, + kind: FieldKind, + readOnly: Boolean = false, + enumValues: List? = null, + format: String? = null, + repeatableObjectInput: RepeatableObjectInputSpec? = null, + ) = FieldSpec( + id = id, + label = label, + kind = kind, + required = false, + readOnly = readOnly, + format = format, + enumValues = enumValues, + repeatableObjectInput = repeatableObjectInput, + ) + + private fun action( + id: String, + intent: ActionIntent, + effect: ActionEffect = ActionEffect.unspecified, + risk: ActionRisk = ActionRisk.mutating, + method: HttpMethod, + pathNames: List = emptyList(), + queryNames: List = emptyList(), + bodyNames: List = emptyList(), + requiredBodyNames: List = emptyList(), + confirmation: Boolean = false, + ) = ActionSpec( + id = id, + label = id, + resourceId = "records", + binding = ApiBinding( + method = method, + path = buildString { + append("/api/records") + pathNames.forEach { name -> + append("/{") + append(name) + append('}') + } + }, + operationId = id, + pathParameterNames = pathNames, + requiredPathParameterNames = pathNames, + queryParameterNames = queryNames, + bodyFieldNames = bodyNames, + requiredBodyFieldNames = requiredBodyNames, + bodyContentType = if (bodyNames.isEmpty()) null else "application/json", + ), + intent = intent, + risk = risk, + requiresConfirmation = confirmation, + confidence = Confidence.verified, + effect = effect, + ) + + private fun ActionSpec.withRecordPath(operation: String): ActionSpec = copy( + binding = binding.copy( + path = "/api/records/{recordId}" + + operation.takeIf(String::isNotBlank)?.let { suffix -> "/$suffix" }.orEmpty(), + ), + ) + + private fun ActionSpec.withScalarBodySchema( + fieldId: String, + type: String, + enumValues: List? = null, + minimumLength: Int? = null, + maximumLength: Int? = null, + ): ActionSpec { + val property = JsonObject( + buildMap { + put("type", JsonPrimitive(type)) + enumValues?.let { values -> + put("enum", JsonArray(values.map(::JsonPrimitive))) + } + minimumLength?.let { value -> put("minLength", JsonPrimitive(value)) } + maximumLength?.let { value -> put("maxLength", JsonPrimitive(value)) } + }, + ) + return copy( + binding = binding.copy( + bodySchema = JsonObject( + buildMap { + put("type", JsonPrimitive("object")) + put( + "properties", + JsonObject(mapOf(fieldId to property)), + ) + if (fieldId in binding.requiredBodyFieldNames) { + put("required", JsonArray(listOf(JsonPrimitive(fieldId)))) + } + }, + ), + ), + ) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiStateTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiStateTest.kt new file mode 100644 index 00000000..6747416c --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiStateTest.kt @@ -0,0 +1,250 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputFieldSpec +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputRow +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputScalarKind +import dev.obiente.nextcloudnative.nativeui.model.RepeatableObjectInputSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NativeRepeatableObjectUiStateTest { + private val spec = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 2, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "label", + label = "Label", + kind = RepeatableObjectInputScalarKind.String, + required = true, + ), + RepeatableObjectInputFieldSpec( + id = "enabled", + label = "Enabled", + kind = RepeatableObjectInputScalarKind.Boolean, + required = true, + ), + ), + ) + private val field = FieldSpec( + id = "entries", + label = "Entries", + kind = FieldKind.objectValue, + required = true, + readOnly = false, + repeatableObjectInput = spec, + ) + + @Test + fun `initial rows satisfy the minimum and initialize required booleans`() { + assertEquals( + mapOf( + "entries" to listOf( + RepeatableObjectInputRow(mapOf("enabled" to "false")), + ), + ), + initialNativeRepeatableObjectDraft(listOf(field), emptyMap()), + ) + } + + @Test + fun `add and remove remain inside schema bounds`() { + val initial = listOf(RepeatableObjectInputRow()) + val two = addNativeRepeatableObjectRow(initial, spec) + + assertEquals(2, two.size) + assertEquals(two, addNativeRepeatableObjectRow(two, spec)) + assertEquals(initial, removeNativeRepeatableObjectRow(two, 1, spec)) + assertEquals(initial, removeNativeRepeatableObjectRow(initial, 0, spec)) + } + + @Test + fun `row updates preserve identities and remove blank scalar values`() { + val initial = listOf(RepeatableObjectInputRow(mapOf("enabled" to "false"))) + val withLabel = updateNativeRepeatableObjectValue( + rows = initial, + rowIndex = 0, + field = spec.fields.first(), + value = "Milk", + ) + + assertEquals( + mapOf("enabled" to "false", "label" to "Milk"), + withLabel.single().values, + ) + assertEquals( + mapOf("enabled" to "false"), + updateNativeRepeatableObjectValue( + rows = withLabel, + rowIndex = 0, + field = spec.fields.first(), + value = "", + ).single().values, + ) + } + + @Test + fun `structured drafts round trip without exposing raw JSON state`() { + val values = mapOf( + "entries" to listOf( + RepeatableObjectInputRow( + mapOf("label" to "Milk", "enabled" to "true"), + ), + ), + ) + val specs = mapOf("entries" to spec) + val saved = encodeNativeRepeatableObjectDraft(values, specs) + + assertEquals(values, saved?.let { decodeNativeRepeatableObjectDraft(it, specs) }) + assertNull(decodeNativeRepeatableObjectDraft(listOf("entries", "not-json"), specs)) + } + + @Test + fun `nullable structured value preserves and edits explicit null state`() { + val nullableSpec = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 1, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "note", + label = "Note", + kind = RepeatableObjectInputScalarKind.String, + required = false, + nullable = true, + ), + ), + ) + val nullableField = field.copy(repeatableObjectInput = nullableSpec) + val initial = initialNativeRepeatableObjectDraft( + fields = listOf(nullableField), + initialValues = mapOf("entries" to """[{"note":null}]"""), + ) + val rows = initial?.get("entries").orEmpty() + + assertEquals(setOf("note"), rows.single().nullFieldIds) + assertTrue(rows.single().values.isEmpty()) + + val specs = mapOf("entries" to nullableSpec) + assertEquals( + initial, + encodeNativeRepeatableObjectDraft(initial.orEmpty(), specs) + ?.let { decodeNativeRepeatableObjectDraft(it, specs) }, + ) + + val withoutNull = updateNativeRepeatableObjectNull( + rows = rows, + rowIndex = 0, + field = nullableSpec.fields.single(), + explicitNull = false, + ) + assertTrue(withoutNull.single().nullFieldIds.isEmpty()) + + val typed = updateNativeRepeatableObjectValue( + rows = rows, + rowIndex = 0, + field = nullableSpec.fields.single(), + value = "Keep this", + ) + assertEquals(mapOf("note" to "Keep this"), typed.single().values) + assertTrue(typed.single().nullFieldIds.isEmpty()) + } + + @Test + fun `structured draft saver preserves incomplete required rows`() { + val values = mapOf( + "entries" to listOf( + RepeatableObjectInputRow(mapOf("enabled" to "false")), + ), + ) + val specs = mapOf("entries" to spec) + + val saved = encodeNativeRepeatableObjectDraft(values, specs) + + assertEquals(values, saved?.let { decodeNativeRepeatableObjectDraft(it, specs) }) + } + + @Test + fun `structured draft saver does not apply scalar submission validation`() { + val numberSpec = RepeatableObjectInputSpec( + minimumItems = 1, + maximumItems = 1, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "quantity", + label = "Quantity", + kind = RepeatableObjectInputScalarKind.Integer, + required = true, + minimum = "1", + ), + ), + ) + val values = mapOf( + "entries" to listOf( + RepeatableObjectInputRow(mapOf("quantity" to "-")), + ), + ) + val specs = mapOf("entries" to numberSpec) + + val saved = encodeNativeRepeatableObjectDraft(values, specs) + + assertEquals(values, saved?.let { decodeNativeRepeatableObjectDraft(it, specs) }) + } + + @Test + fun `structured draft restore rejects undeclared fields and non-string values`() { + val specs = mapOf("entries" to spec) + + assertNull( + decodeNativeRepeatableObjectDraft( + listOf("entries", """[{"unknown":"value"}]"""), + specs, + ), + ) + assertNull( + decodeNativeRepeatableObjectDraft( + listOf("entries", """[{"enabled":false}]"""), + specs, + ), + ) + } + + @Test + fun `structured draft saver preserves valid drafts larger than the scalar saver limit`() { + val largeSpec = RepeatableObjectInputSpec( + minimumItems = 0, + maximumItems = 32, + fields = listOf( + RepeatableObjectInputFieldSpec( + id = "content", + label = "Content", + kind = RepeatableObjectInputScalarKind.String, + required = false, + ), + ), + ) + val values = mapOf( + "entries" to List(17) { + RepeatableObjectInputRow(mapOf("content" to "x".repeat(4_096))) + }, + ) + val specs = mapOf("entries" to largeSpec) + + val saved = encodeNativeRepeatableObjectDraft(values, specs) + + assertEquals(values, saved?.let { decodeNativeRepeatableObjectDraft(it, specs) }) + } + + @Test + fun `invalid initial structured value fails closed`() { + assertNull( + initialNativeRepeatableObjectDraft( + fields = listOf(field), + initialValues = mapOf("entries" to """[{"unknown":"value"}]"""), + ), + ) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt index ec5e9524..78ac8204 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentationsTest.kt @@ -720,6 +720,113 @@ class NativeSemanticPresentationsTest { ) } + @Test + fun `typed completion fields promote generic item collections without app identifiers`() { + val booleanResource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.high, + fields = listOf( + FieldSpec("label", "Label", FieldKind.string, required = true, readOnly = false), + FieldSpec("completed", "Completed", FieldKind.boolean, required = true, readOnly = false), + ), + ) + val booleanRows = requireNotNull( + nativeTaskCollectionPresentations( + booleanResource, + listOf( + NativeRecord("entry-a", mapOf("label" to "First entry", "completed" to "false")), + NativeRecord("entry-b", mapOf("label" to "Second entry", "completed" to "true")), + ), + ), + ) + + assertEquals(listOf("First entry", "Second entry"), booleanRows.map { (_, item) -> item.title }) + assertEquals(listOf(false, true), booleanRows.map { (_, item) -> item.completed }) + + val enumeratedResource = ResourceSpec( + id = "records", + name = "Records", + confidence = Confidence.high, + fields = listOf( + FieldSpec("label", "Label", FieldKind.string, required = true, readOnly = false), + FieldSpec( + "state", + "State", + FieldKind.enumeration, + required = true, + readOnly = false, + enumValues = listOf("active", "finished"), + ), + ), + ) + val enumeratedRows = requireNotNull( + nativeTaskCollectionPresentations( + enumeratedResource, + listOf( + NativeRecord("record-a", mapOf("label" to "Open record", "state" to "active")), + NativeRecord("record-b", mapOf("label" to "Closed record", "state" to "finished")), + ), + ), + ) + + assertEquals(listOf("Open record", "Closed record"), enumeratedRows.map { (_, item) -> item.title }) + assertEquals(listOf(false, true), enumeratedRows.map { (_, item) -> item.completed }) + } + + @Test + fun `non-task boolean state aliases do not render as task completion`() { + listOf( + "enabled" to "Status", + "published" to "State", + "available" to "Status", + ).forEach { (stateFieldId, stateFieldLabel) -> + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.high, + fields = listOf( + FieldSpec("label", "Label", FieldKind.string, required = true, readOnly = false), + FieldSpec( + stateFieldId, + stateFieldLabel, + FieldKind.boolean, + required = true, + readOnly = false, + ), + ), + ) + val record = NativeRecord( + "entry-a", + mapOf("label" to "Ordinary record", stateFieldId to "true"), + ) + + assertEquals(null, nativeGroupwarePresentation(resource, record)) + assertEquals(null, nativeTaskCollectionPresentations(resource, listOf(record))) + } + } + + @Test + fun `unrelated boolean fields do not promote ordinary records to tasks`() { + val resource = ResourceSpec( + id = "entries", + name = "Entries", + confidence = Confidence.high, + fields = listOf( + FieldSpec("label", "Label", FieldKind.string, required = true, readOnly = false), + FieldSpec("favorite", "Favorite", FieldKind.boolean, required = false, readOnly = false), + ), + ) + + assertEquals( + null, + nativeTaskCollectionPresentations( + resource, + listOf(NativeRecord("entry-a", mapOf("label" to "Ordinary record", "favorite" to "true"))), + ), + ) + } + @Test fun `ordinary cards do not become contacts from their resource name alone`() { val presentation = nativeGroupwarePresentation( diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 76dd806c..3e9d8d99 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -29,6 +29,8 @@ import java.util.prefs.Preferences import javax.xml.parsers.DocumentBuilderFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -73,6 +75,40 @@ internal const val DIRECT_EDITING_INFO_RELATIVE_PATH = internal const val NEXTCLOUD_CAPABILITIES_RELATIVE_PATH = "/ocs/v1.php/cloud/capabilities?format=json" +internal fun resolveDesktopNextcloudRedirectLocation( + requestUrl: HttpUrl, + serverUrl: String, + location: String?, +): String? { + val target = location?.let(requestUrl::resolve) ?: return null + if (target.fragment != null) return null + val account = serverUrl.toHttpUrlOrNull() ?: return null + if ( + target.scheme != account.scheme || + target.host != account.host || + target.port != account.port + ) { + return null + } + val accountPath = account.encodedPath.trimEnd('/').takeUnless { it == "/" }.orEmpty() + if ( + accountPath.isNotEmpty() && + target.encodedPath != accountPath && + !target.encodedPath.startsWith("$accountPath/") + ) { + return null + } + val relativePath = target.encodedPath.removePrefix(accountPath) + if (!relativePath.startsWith('/') || relativePath.startsWith("//")) return null + return buildString { + append(relativePath) + target.encodedQuery?.let { query -> + append('?') + append(query) + } + } +} + internal const val DIRECT_EDITING_OPEN_RELATIVE_PATH = "/ocs/v2.php/apps/files/api/v1/directEditing/open?format=json" @@ -1160,6 +1196,12 @@ class DesktopNextcloudServices( request: NextcloudApiRequest, ): NextcloudApiResponse = withContext(Dispatchers.IO) { val safeRequest = request.requireSafe() + safeRequest.multipartBody?.let { multipart -> + return@withContext executeNextcloudMultipartUpload( + session, + multipart.toUploadRequest(safeRequest), + ) + } val accountId = desktopFileCacheAccountId(session) val cacheIdentity = safeRequest.dynamicReadCacheIdentity() if (safeRequest.method != NextcloudApiMethod.GET) { @@ -1882,7 +1924,15 @@ class DesktopNextcloudServices( responseBody.byteStream().readBounded(readLimit), responseBody.contentType()?.toString(), response.header("ETag") ?: response.header("OC-Etag"), - response.header("Location"), + if (session == null) { + response.header("Location") + } else { + resolveDesktopNextcloudRedirectLocation( + requestUrl = response.request.url, + serverUrl = session.serverUrl, + location = response.header("Location"), + ) + }, response.header("X-Chat-Last-Given"), response.header("Content-Range"), ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt index 8150fd71..2d8c20c2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt @@ -81,7 +81,7 @@ fun main() = application { view = dynamicBoardView(), state = NativeScreenState.Ready(records), actionExecutor = executor, - onActionSucceeded = {}, + onActionSucceeded = { _ -> }, modifier = Modifier.fillMaxSize(), ) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudRedirectTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudRedirectTest.kt new file mode 100644 index 00000000..6a76a665 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudRedirectTest.kt @@ -0,0 +1,45 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import okhttp3.HttpUrl.Companion.toHttpUrl + +class DesktopNextcloudRedirectTest { + @Test + fun `authenticated redirects resolve inside the account origin and base path`() { + val serverUrl = "https://cloud.example.test/nextcloud" + val requestUrl = "$serverUrl/apps/music/api/artwork/current".toHttpUrl() + + assertEquals( + "/apps/music/api/covers/7?size=large", + resolveDesktopNextcloudRedirectLocation( + requestUrl, + serverUrl, + "../covers/7?size=large", + ), + ) + assertEquals( + "/apps/music/api/covers/8", + resolveDesktopNextcloudRedirectLocation( + requestUrl, + serverUrl, + "$serverUrl/apps/music/api/covers/8", + ), + ) + assertNull( + resolveDesktopNextcloudRedirectLocation( + requestUrl, + serverUrl, + "https://other.example.test/nextcloud/apps/music/api/covers/9", + ), + ) + assertNull( + resolveDesktopNextcloudRedirectLocation( + requestUrl, + serverUrl, + "https://cloud.example.test/apps/music/api/covers/9", + ), + ) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/CookbookOpenApiCompatibilityTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/CookbookOpenApiCompatibilityTest.kt index 068879d1..d059d2f0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/CookbookOpenApiCompatibilityTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/CookbookOpenApiCompatibilityTest.kt @@ -90,14 +90,18 @@ class CookbookOpenApiCompatibilityTest { ), ), ) - val resource = descriptor.resources.single { it.id == "recipes" } - val detailAction = descriptor.actions.single { action -> + val resource = assertNotNull( + descriptor.resources.singleOrNull { it.id == "recipes" }, + "resources=${descriptor.resources.map { it.id }}", + ) + val detailAction = assertNotNull(descriptor.actions.singleOrNull { action -> action.binding.method == HttpMethod.GET && action.binding.path == "/apps/cookbook/api/v1/recipes/{id}" - } - val detailView = descriptor.toNativeAppSchema().views.single { view -> + }, "actions=${descriptor.actions.map { "${it.id}:${it.binding.method}:${it.binding.path}:${it.resourceId}" }}") + val nativeViews = descriptor.toNativeAppSchema().views + val detailView = assertNotNull(nativeViews.singleOrNull { view -> view.resourceId == resource.id && view.component == NativeComponent.detail - } + }, "views=$nativeViews") assertEquals(detailAction.id, detailView.sourceActionId) val request = buildDynamicApiRequest( @@ -108,11 +112,15 @@ class CookbookOpenApiCompatibilityTest { assertEquals(NextcloudApiMethod.GET, request.method) assertEquals("/apps/cookbook/api/v1/recipes/123", request.relativePath) - val record = parseDynamicRecords( + val records = parseDynamicRecords( detailAction, fixtureResponse(), resource.fields.mapTo(linkedSetOf(), DynamicField::id), - ).single() + ) + val record = assertNotNull( + records.singleOrNull(), + "records=$records fields=${resource.fields.map { "${it.id}:${it.kind}" }}", + ) val nativeResource = descriptor.toNativeAppSchema().resources.single { it.id == resource.id } val detail = nativeStructuredDetail(nativeResource, record) assertEquals( @@ -194,9 +202,10 @@ class CookbookOpenApiCompatibilityTest { action.binding.path == "/apps/cookbook/api/v1/config" } val configWrite = descriptor.actions.single { action -> - action.intent == ActionIntent.create && + action.binding.method == HttpMethod.POST && action.binding.path == "/apps/cookbook/api/v1/config" } + assertEquals(ActionEffect.execute, configWrite.effect) assertTrue(rootPlan.rootDestinations.any { destination -> destination.actionId == configRead.id }) assertTrue(rootPlan.rootFormActions.any { action -> action.actionId == configWrite.id }) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/MusicLiveContractCompatibilityTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/MusicLiveContractCompatibilityTest.kt index 40a7a571..6320ad9d 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/MusicLiveContractCompatibilityTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/MusicLiveContractCompatibilityTest.kt @@ -82,11 +82,17 @@ class MusicLiveContractCompatibilityTest { action.binding.method == HttpMethod.GET && action.binding.path == "/apps/music/api/settings/user/keys" }) - assertTrue(descriptor.actions.none { action -> + val unsafeKeyMutations = descriptor.actions.filter { action -> action.binding.method != HttpMethod.GET && (action.binding.path == "/apps/music/api/settings/user/keys" || action.binding.path == "/apps/music/api/settings/userkey/generate") - }) + } + assertTrue( + unsafeKeyMutations.isEmpty(), + unsafeKeyMutations.joinToString { action -> + "${action.id}:${action.binding.method}:${action.binding.path}:${action.binding.body}" + }, + ) assertTrue(descriptor.validationErrors().isEmpty()) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt new file mode 100644 index 00000000..0ffa18e5 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt @@ -0,0 +1,1203 @@ +package dev.obiente.nextcloudnative.nativeui.model + +import dev.obiente.nextcloudnative.contracts.ContractAcquisitionRequest +import dev.obiente.nextcloudnative.contracts.FileAppStoreCatalogCache +import dev.obiente.nextcloudnative.contracts.FileVerifiedContractCache +import dev.obiente.nextcloudnative.contracts.OpenApiContractSourceKind +import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer +import dev.obiente.nextcloudnative.contracts.VerifiedContractKind +import dev.obiente.nextcloudnative.app.dynamicCollectionState +import dev.obiente.nextcloudnative.app.dynamicContextualFormTargetsActiveSurface +import dev.obiente.nextcloudnative.app.dynamicFormRelationLoadRequests +import dev.obiente.nextcloudnative.app.dynamicReadBindingContext +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecordAuthorityContext +import dev.obiente.nextcloudnative.nativeui.runtime.editableNativeFields +import dev.obiente.nextcloudnative.nativeui.runtime.nativeCollectionActions +import dev.obiente.nextcloudnative.nativeui.runtime.nativeRecordActions +import dev.obiente.nextcloudnative.nativeui.runtime.uneditableNativeBodyFieldIds +import java.io.File +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PantryLiveContractCompatibilityTest { + @Test + fun `signed Pantry item category relationship resolves within its selected house`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val schema = descriptor.toNativeAppSchema() + val itemCreate = descriptor.action("checklist-add-item") + val categoryRead = descriptor.action("category-index") + assertTrue( + setOf("id", "houseId", "name").all(categoryRead.responseFieldIds::contains), + "Category read identities or labels are missing from the signed response fields: " + + categoryRead.responseFieldIds, + ) + val form = assertNotNull( + schema.views.singleOrNull { view -> + view.component == NativeComponent.form && + view.sourceActionId == itemCreate.id + }, + "Missing item create form.", + ) + val relationship = assertNotNull( + schema.relationships.singleOrNull { candidate -> + candidate.parentResourceId == categoryRead.resourceId && + candidate.childResourceId == itemCreate.resourceId && + candidate.childFieldId == "categoryId" + }, + "Missing accepted categories to items.categoryId relationship; " + + "relationships=${schema.relationships}", + ) + assertTrue( + relationship.confidence in setOf(Confidence.high, Confidence.verified), + relationship.toString(), + ) + assertEquals("id", relationship.parentFieldId, relationship.toString()) + + val request = assertNotNull( + dynamicFormRelationLoadRequests( + schema = schema, + formView = form, + availableValues = mapOf( + "id" to "list-9", + "listId" to "list-9", + "houseId" to "house-7", + ), + ).singleOrNull { candidate -> + candidate.plan.resourceId == categoryRead.resourceId + }, + "Missing category relation load request.", + ) + assertEquals(categoryRead.id, request.plan.actionId) + assertEquals( + mapOf(categoryRead.binding.pathParameters.single().name to "house-7"), + request.cacheKey.bindingValues, + ) + } + + @Test + fun `signed Pantry item create body has a safe native editor or contextual binding`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val schema = descriptor.toNativeAppSchema() + val action = assertNotNull(schema.action("checklist-add-item")) + val resource = assertNotNull(schema.resource(action.resourceId)) + val editable = editableNativeFields(resource, action) + val uneditable = uneditableNativeBodyFieldIds( + action = action, + editableFields = editable, + autoBoundValues = mapOf( + "houseId" to "house-7", + "listId" to "list-9", + ), + ) + + assertTrue( + uneditable.isEmpty(), + "Signed Pantry item creation contains body fields without safe native editors: " + + uneditable.joinToString() + + "; schemas=" + + uneditable.associateWith { fieldId -> + val property = ( + (action.binding.bodySchema as? kotlinx.serialization.json.JsonObject) + ?.get("properties") as? kotlinx.serialization.json.JsonObject + )?.get(fieldId) as? kotlinx.serialization.json.JsonObject + mapOf( + "keys" to property?.keys.orEmpty(), + "itemKeys" to + (property?.get("items") as? kotlinx.serialization.json.JsonObject) + ?.keys + .orEmpty(), + "itemFormat" to + ( + (property?.get("items") as? kotlinx.serialization.json.JsonObject) + ?.get("format") as? kotlinx.serialization.json.JsonPrimitive + )?.contentOrNull, + ) + }, + ) + } + + @Test + fun `signed Pantry nullable enum fields remain native choices`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val schema = descriptor.toNativeAppSchema() + + val checklistCreate = assertNotNull(schema.action("checklist-create-list")) + val checklistResource = assertNotNull(schema.resource(checklistCreate.resourceId)) + val checklistFields = editableNativeFields(checklistResource, checklistCreate) + .associateBy(FieldSpec::id) + val checklistColor = assertNotNull(checklistFields["color"]) + val checklistIcon = assertNotNull(checklistFields["icon"]) + assertEquals(FieldKind.enumeration, checklistColor.kind) + assertEquals(16, assertNotNull(checklistColor.enumValues).size) + assertTrue(checklistColor.enumValues.orEmpty().all { value -> + value.matches(Regex("^#[0-9a-fA-F]{6}$")) + }) + assertEquals(FieldKind.enumeration, checklistIcon.kind) + assertTrue(assertNotNull(checklistIcon.enumValues).isNotEmpty()) + + val noteCreate = assertNotNull(schema.action("note-create-note")) + val noteResource = assertNotNull(schema.resource(noteCreate.resourceId)) + val noteColor = assertNotNull( + editableNativeFields(noteResource, noteCreate).singleOrNull { field -> + field.id == "color" + }, + ) + assertEquals(FieldKind.enumeration, noteColor.kind) + assertTrue(assertNotNull(noteColor.enumValues).isNotEmpty()) + } + + @Test + fun `every signed Pantry mutation is reachable or explicitly blocked by missing contract evidence`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val unreachable = descriptor.unreachablePantryMutations() + + assertEquals( + mapOf( + "contextual relationship form" to listOf("share-set-shares"), + ), + unreachable, + unreachable.entries.joinToString( + prefix = "Signed Pantry mutations have an unexpected generic reachability result:\n", + separator = "\n", + ) { (capability, operationIds) -> + "- $capability: ${operationIds.joinToString(", ")}" + }, + ) + } + + @Test + fun `signed Pantry nested Items surface exposes exact record and collection actions`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val schema = descriptor.toNativeAppSchema() + val houses = descriptor.action("house-index") + val lists = descriptor.action("checklist-index-lists") + val items = descriptor.action("checklist-index-items") + val houseResource = assertNotNull(schema.resource(houses.resourceId)) + val listResource = assertNotNull(schema.resource(lists.resourceId)) + val itemResource = assertNotNull(schema.resource(items.resourceId)) + + val selectedHouse = pantryRuntimeRecord( + readAction = houses, + resource = houseResource, + recordId = "7", + overrides = mapOf("isAdmin" to "true"), + ) + assertTrue( + selectedHouse.actionSafeIdentity, + "House response has no contract-declared runtime identity: ${houses.responseFieldIds}", + ) + val houseContext = DynamicResourceRecordContext( + resourceId = houseResource.id, + recordId = selectedHouse.id, + fieldValues = selectedHouse.values, + actionSafeIdentity = selectedHouse.actionSafeIdentity, + ) + val listDestination = assertNotNull( + descriptor.planDynamicNavigation(houseContext) + .contextualChildDestinations + .singleOrNull { destination -> destination.actionId == lists.id }, + "Selected house did not expose the signed checklist collection.", + ) + assertEquals(mapOf("id" to "7"), listDestination.pathParameterValues) + + val selectedList = pantryRuntimeRecord( + readAction = lists, + resource = listResource, + recordId = "9", + bindingContext = listDestination.pathParameterValues, + overrides = mapOf( + "name" to "Contract checklist", + "canEdit" to "true", + ), + ) + assertTrue( + selectedList.actionSafeIdentity, + "Checklist response has no contract-declared runtime identity: ${lists.responseFieldIds}", + ) + val listContext = DynamicResourceRecordContext( + resourceId = listResource.id, + recordId = selectedList.id, + fieldValues = selectedList.values, + parameterValues = listDestination.pathParameterValues, + actionSafeIdentity = selectedList.actionSafeIdentity, + ) + val itemDestination = assertNotNull( + descriptor.planDynamicNavigation(listContext) + .contextualChildDestinations + .singleOrNull { destination -> destination.actionId == items.id }, + "Selected checklist did not expose the signed nested Items collection.", + ) + assertEquals( + mapOf( + "id" to "7", + "houseId" to "7", + "listId" to "9", + ), + itemDestination.pathParameterValues, + ) + val itemReadBindingContext = dynamicReadBindingContext( + action = items, + values = itemDestination.pathParameterValues, + runtimeContext = itemDestination.pathParameterValues, + ) + assertEquals( + mapOf( + "houseId" to "7", + "listId" to "9", + ), + itemReadBindingContext, + ) + val itemCollectionCreate = assertNotNull( + nativeRecordActions( + schema = schema, + resource = itemResource, + navigationContext = itemDestination.pathParameterValues, + ).create, + "Signed nested Items collection did not expose checklist-add-item.", + ) + assertEquals("checklist-add-item", itemCollectionCreate.action.id) + + val itemRecords = listOf("11", "12").map { recordId -> + pantryRuntimeRecord( + readAction = items, + resource = itemResource, + recordId = recordId, + bindingContext = itemReadBindingContext, + overrides = mapOf( + "name" to "Contract item $recordId", + "canEdit" to "true", + "done" to "false", + "archived" to null, + "archivedAt" to null, + ).filterKeys(items.responseFieldIds::contains), + ) + } + assertTrue( + itemRecords.all(NativeRecord::actionSafeIdentity), + "Items response has no contract-declared runtime identity: ${items.responseFieldIds}", + ) + assertTrue( + itemRecords.all { record -> + record.values.keys == items.responseFieldIds.toSet() && + record.bindingContext == itemReadBindingContext + }, + "Synthetic records must have exactly the fields and parent bindings produced by " + + "the signed Items read; fields=${items.responseFieldIds}", + ) + + val authority = NativeRecordAuthorityContext( + parentResource = houseResource, + parentRecord = selectedHouse, + ) + val recordActions = nativeRecordActions( + schema = schema, + resource = itemResource, + record = itemRecords.first(), + navigationContext = itemDestination.pathParameterValues, + authorityContext = authority, + ) + val expectedRecordBindings = mapOf( + "houseId" to "7", + "listId" to "9", + "itemId" to "11", + ) + val edit = assertNotNull( + recordActions.edit, + "Signed nested item did not expose checklist-update-item.", + ) + assertEquals("checklist-update-item", edit.action.id) + assertEquals(expectedRecordBindings, edit.request(emptyMap()).values) + + val delete = assertNotNull( + recordActions.delete, + "Signed nested item did not expose checklist-delete-item with house authority.", + ) + assertEquals("checklist-delete-item", delete.action.id) + assertEquals(expectedRecordBindings, delete.request(confirmed = true).values) + + val archive = assertNotNull( + recordActions.commands.singleOrNull { plan -> + plan.action.id == "checklist-archive-item" + }, + "Signed nested item did not expose checklist-archive-item.", + ) + assertEquals(expectedRecordBindings, archive.request().values) + + val copy = assertNotNull( + recordActions.commandForms.singleOrNull { plan -> + plan.action.id == "checklist-copy-item" + }, + "Signed nested item did not expose checklist-copy-item as a command form.", + ) + assertEquals(listOf("targetListId"), copy.fields.map(FieldSpec::id)) + assertEquals( + expectedRecordBindings + ("targetListId" to "10"), + copy.request(mapOf("targetListId" to "10")).values, + ) + + val collectionActions = nativeCollectionActions( + schema = schema, + activeReadAction = assertNotNull(schema.action(items.id)), + resource = itemResource, + records = itemRecords, + navigationContext = itemDestination.pathParameterValues, + collectionComplete = true, + ) + val batchDelete = assertNotNull( + collectionActions.batches.singleOrNull { plan -> + plan.action.id == "checklist-batch-delete-items" + }, + "Signed nested Items collection did not expose checklist-batch-delete-items.", + ) + assertEquals("itemIds", batchDelete.selectionFieldId) + assertEquals(listOf("permanent"), batchDelete.fields.map { field -> field.id }) + assertEquals( + mapOf( + "houseId" to "7", + "permanent" to "false", + "itemIds" to "[11,12]", + ), + batchDelete.request( + selectedRecordIds = listOf("11", "12"), + values = mapOf("permanent" to "false"), + confirmed = true, + ).values, + ) + + val reorder = assertNotNull( + collectionActions.reorder, + "Signed nested Items collection did not expose checklist-reorder-items.", + ) + assertEquals("checklist-reorder-items", reorder.action.id) + val reorderRequest = reorder.requestInOrder(listOf("12", "11")) + assertEquals("7", reorderRequest.values["houseId"]) + assertEquals("9", reorderRequest.values["listId"]) + assertTrue( + reorderRequest.values.keys == + setOf("houseId", "listId", reorder.action.binding.bodyFieldNames.single()), + "Reorder request contains undeclared values: ${reorderRequest.values.keys}", + ) + } + + @Test + fun `signed Pantry house Items surface exposes exact item edit`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val schema = descriptor.toNativeAppSchema() + val houses = descriptor.action("house-index") + val houseItems = descriptor.action("checklist-index-house-items") + val houseResource = assertNotNull(schema.resource(houses.resourceId)) + val itemResource = assertNotNull(schema.resource(houseItems.resourceId)) + val selectedHouse = pantryRuntimeRecord( + readAction = houses, + resource = houseResource, + recordId = "7", + overrides = mapOf("isAdmin" to "true"), + ) + val houseContext = DynamicResourceRecordContext( + resourceId = houseResource.id, + recordId = selectedHouse.id, + fieldValues = selectedHouse.values, + actionSafeIdentity = selectedHouse.actionSafeIdentity, + ) + val itemDestination = assertNotNull( + descriptor.planDynamicNavigation(houseContext) + .contextualChildDestinations + .singleOrNull { destination -> destination.actionId == houseItems.id }, + ) + val itemBindingContext = dynamicReadBindingContext( + action = houseItems, + values = itemDestination.pathParameterValues, + runtimeContext = itemDestination.pathParameterValues, + ) + val itemRecord = pantryRuntimeRecord( + readAction = houseItems, + resource = itemResource, + recordId = "11", + bindingContext = itemBindingContext, + overrides = mapOf( + "name" to "Contract house item", + "done" to "false", + "archived" to null, + "archivedAt" to null, + ).filterKeys(houseItems.responseFieldIds::contains), + ) + val actions = nativeRecordActions( + schema = schema, + resource = itemResource, + record = itemRecord, + navigationContext = itemDestination.pathParameterValues, + authorityContext = NativeRecordAuthorityContext( + parentResource = houseResource, + parentRecord = selectedHouse, + ), + ) + + assertNotNull( + actions.edit, + "House-wide item edit is missing; destination=$itemDestination; " + + "binding=$itemBindingContext; response=${houseItems.responseFieldIds}; " + + "record=${itemRecord.values.keys}; commands=${actions.commands.map { it.action.id }}; " + + "forms=${actions.commandForms.map { it.action.id }}", + ) + } + + @Test + fun `signed Pantry contract opens a selected house into its checklist collection`() { + if (System.getenv("RUN_LIVE_NEXTCLOUD_APPSTORE_TEST") != "1") return + val descriptor = signedPantryDescriptor() + val houses = descriptor.action("house-index") + val lists = descriptor.action("checklist-index-lists") + val items = descriptor.action("checklist-index-items") + val categories = descriptor.action("category-index") + val notes = descriptor.action("note-index-notes") + val photos = descriptor.action("photo-index-photos") + listOf( + "category-reorder" to ActionEffect.reorder, + "checklist-reorder-lists" to ActionEffect.reorder, + "checklist-empty-lists-trash" to ActionEffect.empty, + "checklist-restore-list" to ActionEffect.restore, + "checklist-permanently-delete-list" to ActionEffect.permanentDelete, + "checklist-toggle-item" to ActionEffect.toggle, + "checklist-copy-item" to ActionEffect.copy, + "checklist-archive-item" to ActionEffect.archive, + "checklist-unarchive-item" to ActionEffect.unarchive, + "checklist-reorder-items" to ActionEffect.reorder, + "checklist-batch-move-items" to ActionEffect.batch, + "checklist-batch-archive-items" to ActionEffect.batch, + "checklist-upload-item-image" to ActionEffect.upload, + "checklist-clear-item-image" to ActionEffect.clear, + "house-leave" to ActionEffect.leave, + "note-restore-note" to ActionEffect.restore, + "photo-upload-photo" to ActionEffect.upload, + ).forEach { (operationId, expectedEffect) -> + assertEquals(expectedEffect, descriptor.action(operationId).effect, operationId) + } + listOf( + "checklist-add-item" to "storeIds", + "checklist-batch-move-items" to "itemIds", + "role-set-member-roles" to "roleIds", + ).forEach { (operationId, fieldId) -> + val form = assertNotNull( + descriptor.forms.singleOrNull { candidate -> candidate.actionId == operationId }, + "Missing compiled form for $operationId", + ) + val field = assertNotNull( + form.fields.singleOrNull { candidate -> candidate.fieldId == fieldId }, + "Missing compiled field $operationId:$fieldId", + ) + assertEquals( + DYNAMIC_INTEGER_ARRAY_FORMAT, + field.format, + "$operationId:$fieldId", + ) + } + listOf( + "category-reorder" to "items", + "share-set-shares" to "shares", + ).forEach { (operationId, fieldId) -> + val form = assertNotNull( + descriptor.forms.singleOrNull { candidate -> candidate.actionId == operationId }, + "Missing compiled form for $operationId", + ) + val field = assertNotNull( + form.fields.singleOrNull { candidate -> candidate.fieldId == fieldId }, + "Missing compiled field $operationId:$fieldId", + ) + assertEquals( + DYNAMIC_REPEATABLE_OBJECT_ARRAY_FORMAT, + field.format, + "$operationId:$fieldId", + ) + } + listOf( + "checklist-reorder-lists", + "checklist-empty-lists-trash", + "checklist-restore-list", + "checklist-permanently-delete-list", + ).forEach { operationId -> + assertEquals(lists.resourceId, descriptor.action(operationId).resourceId, operationId) + } + listOf( + "checklist-toggle-item", + "checklist-copy-item", + "checklist-archive-item", + "checklist-unarchive-item", + "checklist-restore-item", + "checklist-permanently-delete-item", + "checklist-reorder-items", + "checklist-upload-item-image", + "checklist-clear-item-image", + ).forEach { operationId -> + assertEquals(items.resourceId, descriptor.action(operationId).resourceId, operationId) + } + assertEquals(categories.resourceId, descriptor.action("category-reorder").resourceId) + assertEquals(notes.resourceId, descriptor.action("note-restore-note").resourceId) + assertEquals(photos.resourceId, descriptor.action("photo-restore-photo").resourceId) + assertEquals(houses.resourceId, descriptor.action("house-leave").resourceId) + val context = DynamicResourceRecordContext( + resourceId = houses.resourceId, + recordId = "house-7", + fieldValues = mapOf("id" to "house-7"), + actionSafeIdentity = true, + ) + val plan = descriptor.planDynamicNavigation(context) + val preferred = descriptor.preferredSemanticContextualChild(context) + val userPreferences = descriptor.action("prefs-get-user-prefs") + val housePreferences = descriptor.action("prefs-get-house-prefs") + assertTrue( + plan.rootDestinations.any { destination -> + destination.actionId == userPreferences.id + }, + "User preferences must remain an app root.", + ) + assertTrue( + plan.contextualChildDestinations.any { destination -> + destination.actionId == housePreferences.id + }, + "House preferences must remain a selected-house destination.", + ) + val housePreferencesDestination = plan.contextualChildDestinations.single { destination -> + destination.actionId == housePreferences.id + } + val housePreferencesPlan = descriptor.planDynamicNavigation( + context.copy( + parameterValues = housePreferencesDestination.pathParameterValues, + currentLayoutId = housePreferencesDestination.layoutId, + ), + ) + assertTrue( + housePreferencesPlan.contextualFormActions.any { formAction -> + formAction.actionId == "prefs-set-house-prefs" + }, + "House preferences update is not available on its verified detail surface; " + + "read=$housePreferences; destination=$housePreferencesDestination; " + + "forms=${descriptor.forms.filter { form -> form.resourceId == housePreferences.resourceId }}; " + + "planned=${housePreferencesPlan.contextualFormActions}", + ) + assertEquals( + 2, + descriptor.layouts + .filter { layout -> layout.sourceActionId in setOf(userPreferences.id, housePreferences.id) } + .map(DynamicLayout::id) + .distinct() + .size, + ) + assertTrue( + plan.rootDestinations.none { destination -> + destination.actionId == "house-autocomplete-users" + }, + "User autocomplete must be consumed as relation data instead of an app root.", + ) + val evidence = buildString { + append("children=") + append( + plan.contextualChildDestinations.map { destination -> + "${destination.resourceId}:${destination.actionId}" + }, + ) + append(" diagnostics=") + append( + descriptor.explainDynamicChildNavigation(context).map { diagnostic -> + "${diagnostic.resourceId}:${diagnostic.actionId}:${diagnostic.status}" + }, + ) + append(" links=") + append( + descriptor.links.map { link -> + "${link.resourceId}:${(link.target as? DynamicLinkTarget.Action)?.actionId}" + }, + ) + append(" listAction=") + append( + "${lists.resourceId}:${lists.intent}:${lists.risk}:${lists.fallbackOnly}:" + + "${lists.binding.path}:${lists.binding.pathParameters.map(HttpParameter::name)}", + ) + } + + assertTrue( + plan.contextualChildDestinations.any { destination -> destination.actionId == lists.id }, + evidence, + ) + val preferredLists = assertNotNull(preferred, evidence) + assertEquals(lists.id, preferredLists.actionId, evidence) + + val schema = descriptor.toNativeAppSchema() + val houseResource = assertNotNull(schema.resource(houses.resourceId), evidence) + val listResource = assertNotNull(schema.resource(lists.resourceId), evidence) + val itemResource = assertNotNull(schema.resource(items.resourceId), evidence) + val itemActions = nativeRecordActions( + schema = schema, + resource = itemResource, + record = NativeRecord( + id = "item-11", + values = mapOf( + "id" to "item-11", + "itemId" to "item-11", + "listId" to "list-9", + "houseId" to "house-7", + "name" to "Test item", + "done" to "false", + ), + bindingContext = mapOf( + "houseId" to "house-7", + "listId" to "list-9", + "itemId" to "item-11", + ), + ), + navigationContext = mapOf( + "houseId" to "house-7", + "listId" to "list-9", + ), + authorityContext = NativeRecordAuthorityContext( + parentResource = houseResource, + parentRecord = NativeRecord( + id = "house-7", + values = mapOf("id" to "house-7", "isAdmin" to "true"), + ), + ), + ) + val completion = assertNotNull( + itemActions.completion, + "The signed Pantry toggle did not become a structural completion action.", + ) + assertEquals( + mapOf( + "houseId" to "house-7", + "listId" to "list-9", + "itemId" to "item-11", + ), + completion.request(completed = true).values, + ) + val createEvidence = schema.actions + .filter { action -> action.resourceId == listResource.id } + .joinToString { action -> + "${action.id}:${action.intent}:${action.risk}:${action.confidence}:" + + "${action.binding.method}:${action.binding.path}:" + + "${action.binding.bodyFieldNames}:${action.binding.requiredBodyFieldNames}" + } + val collectionActions = nativeRecordActions( + schema = schema, + resource = listResource, + navigationContext = preferredLists.pathParameterValues, + ) + assertNotNull( + collectionActions.create, + "No structural create plan for ${listResource.id}; actions=$createEvidence " + + "fields=${listResource.fields.map { field -> "${field.id}:${field.kind}:${field.readOnly}" }}", + ) + val selectedListActions = nativeRecordActions( + schema = schema, + resource = listResource, + record = NativeRecord( + id = "list-9", + values = basePantryAuditRecordValues( + resource = listResource, + responseFieldIds = lists.responseFieldIds.toSet(), + recordId = "list-9", + navigationValues = preferredLists.pathParameterValues, + ) + mapOf( + "name" to "Test list", + "canEdit" to "true", + "houseId" to "house-7", + ), + bindingContext = mapOf( + "houseId" to "house-7", + "listId" to "list-9", + ), + ), + navigationContext = preferredLists.pathParameterValues, + authorityContext = NativeRecordAuthorityContext( + parentResource = houseResource, + parentRecord = NativeRecord( + id = "house-7", + values = mapOf( + "id" to "house-7", + "isAdmin" to "true", + ), + ), + ), + ) + assertNotNull( + selectedListActions.edit, + "No structural edit plan for ${listResource.id}; actions=$createEvidence; fields=" + + listResource.fields.map { field -> + "${field.id}:${field.kind}:${field.readOnly}:${field.format}:${field.enumValues}" + }, + ) + assertNotNull( + selectedListActions.delete, + "No structural delete plan for ${listResource.id}; actions=$createEvidence", + ) + + val selectedListContext = DynamicResourceRecordContext( + resourceId = lists.resourceId, + recordId = "list-9", + fieldValues = mapOf( + "id" to "list-9", + "listId" to "list-9", + "houseId" to "house-7", + ), + parameterValues = preferredLists.pathParameterValues, + actionSafeIdentity = true, + ) + val listChildren = descriptor.planDynamicNavigation(selectedListContext) + .contextualChildDestinations + val listPreferred = descriptor.preferredSemanticContextualChild(selectedListContext) + val listEvidence = buildString { + append("children=") + append( + listChildren.map { destination -> + "${destination.resourceId}:${destination.actionId}:${destination.pathParameterValues}" + }, + ) + append(" preferred=") + append(listPreferred) + append(" diagnostics=") + append( + descriptor.explainDynamicChildNavigation(selectedListContext).map { diagnostic -> + "${diagnostic.resourceId}:${diagnostic.actionId}:${diagnostic.status}:" + + "${diagnostic.missingContextParameters}" + }, + ) + } + assertTrue( + listChildren.none { destination -> + destination.resourceId in setOf( + "categories", + "folders", + "members", + "notes", + "photos", + "stores", + ) + }, + listEvidence, + ) + assertTrue( + listChildren.any { destination -> + destination.actionId == "checklist-index-archived-items" + }, + listEvidence, + ) + assertEquals("items", assertNotNull(listPreferred, listEvidence).resourceId, listEvidence) + } + + private fun DynamicAppDescriptor.action(operationId: String): DynamicAction = + actions.single { action -> action.id == operationId } + + private fun signedPantryDescriptor(): DynamicAppDescriptor = signedPantryDescriptorFixture + + companion object { + private val signedPantryDescriptorFixture: DynamicAppDescriptor by lazy( + LazyThreadSafetyMode.SYNCHRONIZED, + ) { + val cacheRoot = File( + System.getProperty("java.io.tmpdir"), + "nc-native-signed-pantry-contract-test-cache", + ) + val contract = requireNotNull( + SignedAppStoreContractAcquirer( + catalogCache = FileAppStoreCatalogCache(File(cacheRoot, "catalogs")), + verifiedContractCache = FileVerifiedContractCache(File(cacheRoot, "contracts")), + ).acquire( + ContractAcquisitionRequest("pantry", "34.0.1", "0.23.0"), + ), + ) + check(contract.sourceKind == OpenApiContractSourceKind.SignedAppPackage) + check(contract.contractKind == VerifiedContractKind.OpenApi) + DynamicAppDescriptorCompiler().compile( + DynamicDiscoveryInput( + app = AppIdentity("pantry", "Pantry", "0.23.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/ocs/v2.php/apps/pantry/api"), + ), + advertisedOpenApi = AdvertisedOpenApi( + documentUrl = contract.sourceUrl, + document = Json.parseToJsonElement(contract.document), + trust = OpenApiTrust.nextcloudSignedAppPackage, + ), + ), + ) + } + } +} + +private fun pantryRuntimeRecord( + readAction: DynamicAction, + resource: ResourceSpec, + recordId: String, + bindingContext: Map = emptyMap(), + overrides: Map = emptyMap(), +): NativeRecord { + val responseFieldIds = readAction.responseFieldIds + require(responseFieldIds.distinct().size == responseFieldIds.size) + require(overrides.keys.all(responseFieldIds::contains)) { + "Runtime fixture overrides must be declared by ${readAction.id}: " + + overrides.keys.filterNot(responseFieldIds::contains) + } + val identityFieldId = listOf("databaseId", "id", "uuid") + .firstNotNullOfOrNull { candidate -> + responseFieldIds.singleOrNull { fieldId -> + fieldId.equals(candidate, ignoreCase = true) + } + } + val values = responseFieldIds.associateWith { fieldId -> + when { + fieldId == identityFieldId -> recordId + fieldId in overrides -> overrides.getValue(fieldId) + fieldId in bindingContext -> bindingContext.getValue(fieldId) + else -> samplePantryAuditValue( + fieldId = fieldId, + kind = resource.fields.singleOrNull { field -> field.id == fieldId }?.kind, + ) + } + } + return NativeRecord( + id = recordId, + values = values, + bindingContext = bindingContext, + actionSafeIdentity = identityFieldId != null, + ) +} + +/** + * Audits invocation paths that exist in the generic shell and renderer, not merely descriptor + * actions or generated forms. Pantry-specific operation names participate only in the failure + * report; reachability is derived from generic action intent, navigation, binding, and renderer + * capability planning. + */ +private fun DynamicAppDescriptor.unreachablePantryMutations(): Map> { + val schema = toNativeAppSchema() + val reachableActionIds = linkedSetOf() + val rootPlan = planDynamicNavigation() + reachableActionIds += rootPlan.rootFormActions.map( + DynamicNavigationFormAction::actionId, + ) + val collectionSurfaces = ArrayDeque() + rootPlan.rootDestinations.mapNotNullTo(collectionSurfaces) { destination -> + destination.takeIf { candidate -> isPantryAuditCollectionDestination(candidate) } + ?.let(PantryAuditCollectionSurface::from) + } + val auditedCollectionStates = linkedSetOf() + while (collectionSurfaces.isNotEmpty()) { + val surface = collectionSurfaces.removeFirst() + val stateKey = "${surface.actionId}:${surface.parameterValues.keys.sorted().joinToString(",")}" + if (!auditedCollectionStates.add(stateKey)) continue + val resource = resources.singleOrNull { candidate -> candidate.id == surface.resourceId } ?: continue + val readAction = actions.singleOrNull { action -> action.id == surface.actionId } ?: continue + val responseFieldIds = readAction.responseFieldIds.toSet() + val navigationValues = surface.parameterValues + val recordBindingValues = dynamicReadBindingContext( + action = readAction, + values = navigationValues, + runtimeContext = navigationValues, + ) + val fieldValues = buildMap { + responseFieldIds.forEach { fieldId -> + put( + fieldId, + recordBindingValues[fieldId] + ?: samplePantryAuditValue( + fieldId = fieldId, + kind = resource.fields.singleOrNull { field -> field.id == fieldId }?.kind, + ), + ) + } + put("id", "7") + } + val selectedContext = DynamicResourceRecordContext( + resourceId = resource.id, + recordId = "7", + fieldValues = fieldValues, + parameterValues = navigationValues, + actionSafeIdentity = true, + currentLayoutId = surface.layoutId, + ) + val navigationPlan = planDynamicNavigation(selectedContext) + val contextualSurfaceContexts = buildList { + add(selectedContext) + navigationPlan.contextualChildDestinations.forEach { destination -> + val destinationKind = layouts.singleOrNull { layout -> + layout.id == destination.layoutId + }?.kind + if (destinationKind == LayoutKind.detail) { + add( + selectedContext.copy( + parameterValues = destination.pathParameterValues, + currentLayoutId = destination.layoutId, + ), + ) + } + } + } + contextualSurfaceContexts.forEach { surfaceContext -> + val surfacePlan = if (surfaceContext === selectedContext) { + navigationPlan + } else { + planDynamicNavigation(surfaceContext) + } + surfacePlan.contextualFormActions.forEach { formAction -> + val action = actions.single { candidate -> candidate.id == formAction.actionId } + val actionSpec = schema.actions.singleOrNull { candidate -> + candidate.id == action.id + } ?: return@forEach + val formView = schema.views.singleOrNull { view -> + view.id == formAction.formId + } ?: return@forEach + val candidateViewIds = buildSet { + surfaceContext.currentLayoutId?.let(::add) + addAll( + surfacePlan.contextualChildDestinations.map( + DynamicNavigationDestination::layoutId, + ), + ) + } + val actionResource = schema.resources.singleOrNull { candidate -> + candidate.id.sameDynamicResourceAs(actionSpec.resourceId) + } + val exposedByShell = candidateViewIds.asSequence() + .mapNotNull { viewId -> + schema.views.singleOrNull { view -> view.id == viewId } + } + .any { activeView -> + dynamicContextualFormTargetsActiveSurface( + action = actionSpec, + formView = formView, + activeView = activeView, + activeReadAction = schema.actions.singleOrNull { candidate -> + candidate.id == activeView.sourceActionId + }, + plannedBindingValues = formAction.pathParameterValues, + selectedRecordResourceId = resource.id, + selectedCollectionState = dynamicCollectionState( + schema.actions.singleOrNull { candidate -> + candidate.id == activeView.sourceActionId + }, + ), + hasEditableFileField = actionResource + ?.let { target -> editableNativeFields(target, actionSpec) } + ?.any { field -> field.kind == FieldKind.file } + ?: false, + uniqueTargetResource = actionResource != null, + ) + } + if (exposedByShell) reachableActionIds += action.id + } + } + navigationPlan.contextualChildDestinations.mapNotNullTo(collectionSurfaces) { destination -> + destination.takeIf { candidate -> isPantryAuditCollectionDestination(candidate) } + ?.let(PantryAuditCollectionSurface::from) + } + + val nativeResource = schema.resource(resource.id) ?: continue + val rendersCollectionToolbar = layouts.singleOrNull { layout -> + layout.id == surface.layoutId + }?.kind in setOf(LayoutKind.list, LayoutKind.grid) + if (rendersCollectionToolbar) { + val collectionCapabilities = nativeCollectionActions( + schema = schema, + activeReadAction = schema.actions.singleOrNull { action -> + action.id == readAction.id + } ?: continue, + resource = nativeResource, + records = listOf("7", "8").map { recordId -> + NativeRecord( + id = recordId, + values = basePantryAuditRecordValues( + resource = nativeResource, + responseFieldIds = responseFieldIds, + recordId = recordId, + navigationValues = navigationValues + recordBindingValues, + ), + bindingContext = recordBindingValues, + actionSafeIdentity = true, + ) + }, + navigationContext = navigationValues, + collectionComplete = true, + ) + reachableActionIds += collectionCapabilities.commands.map { plan -> plan.action.id } + collectionCapabilities.reorder?.let { plan -> reachableActionIds += plan.action.id } + reachableActionIds += collectionCapabilities.batches.map { plan -> plan.action.id } + } + + nativeRecordActions( + schema = schema, + resource = nativeResource, + navigationContext = navigationValues, + ).create?.let { plan -> reachableActionIds += plan.action.id } + + val baseRecordValues = basePantryAuditRecordValues( + resource = nativeResource, + responseFieldIds = responseFieldIds, + recordId = "7", + navigationValues = navigationValues + recordBindingValues, + ) + val authorityContext = schema.pantryAuditHouseAuthority( + navigationValues = navigationValues, + recordBindingValues = recordBindingValues, + ) + val stateVariants = listOf( + baseRecordValues, + baseRecordValues.withPantryAuditState("deleted"), + baseRecordValues.withPantryAuditState("archived"), + ) + stateVariants.forEach { values -> + val capabilities = nativeRecordActions( + schema = schema, + resource = nativeResource, + record = NativeRecord( + id = "7", + values = values, + bindingContext = recordBindingValues, + actionSafeIdentity = true, + ), + navigationContext = navigationValues, + authorityContext = authorityContext, + ) + capabilities.edit?.let { plan -> reachableActionIds += plan.action.id } + capabilities.delete?.let { plan -> reachableActionIds += plan.action.id } + capabilities.completion?.let { plan -> reachableActionIds += plan.action.id } + reachableActionIds += capabilities.commands.map { plan -> plan.action.id } + reachableActionIds += capabilities.commandForms.map { plan -> plan.action.id } + } + } + + return actions + .asSequence() + .filter { action -> + if (action.binding.method == HttpMethod.GET) { + false + } else { + action.id !in reachableActionIds + } + } + .groupBy(DynamicAction::missingGenericPantryCapability) + .mapValues { (_, missing) -> missing.map(DynamicAction::id).sorted() } + .toSortedMap() +} + +private fun NativeAppSchema.pantryAuditHouseAuthority( + navigationValues: Map, + recordBindingValues: Map, +): NativeRecordAuthorityContext? { + val houseResource = resources.singleOrNull { resource -> + resource.fields.count { field -> + field.id.equals("isAdmin", ignoreCase = true) && + field.kind == FieldKind.boolean + } == 1 + } ?: return null + val houseId = recordBindingValues.entries + .singleOrNull { (name, _) -> name.equals("houseId", ignoreCase = true) } + ?.value + ?: navigationValues.entries + .singleOrNull { (name, _) -> + name.equals("houseId", ignoreCase = true) || + name.equals("id", ignoreCase = true) + } + ?.value + ?: return null + return NativeRecordAuthorityContext( + parentResource = houseResource, + parentRecord = NativeRecord( + id = houseId, + values = mapOf( + "id" to houseId, + "isAdmin" to "true", + ), + ), + ) +} + +private fun basePantryAuditRecordValues( + resource: ResourceSpec, + responseFieldIds: Set, + recordId: String, + navigationValues: Map, +): Map = resource.fields.associate { field -> + field.id to when { + field.id in navigationValues -> navigationValues[field.id] + field.id in responseFieldIds -> samplePantryAuditValue(field.id, field.kind) + else -> null + } +} + navigationValues + mapOf("id" to recordId) + +private data class PantryAuditCollectionSurface( + val layoutId: String, + val resourceId: String, + val actionId: String, + val parameterValues: Map, +) { + companion object { + fun from(destination: DynamicNavigationDestination): PantryAuditCollectionSurface = + PantryAuditCollectionSurface( + layoutId = destination.layoutId, + resourceId = destination.resourceId, + actionId = destination.actionId, + parameterValues = destination.pathParameterValues, + ) + } +} + +private fun DynamicAppDescriptor.isPantryAuditCollectionDestination( + destination: DynamicNavigationDestination, +): Boolean = layouts.singleOrNull { layout -> layout.id == destination.layoutId } + ?.kind in setOf(LayoutKind.list, LayoutKind.grid) + +private fun samplePantryAuditValue(fieldId: String, kind: FieldKind? = null): String? { + val semanticId = fieldId.lowercase().filter(Char::isLetterOrDigit) + return when { + semanticId in setOf("readonly") -> "false" + semanticId == "isadmin" -> "true" + semanticId in setOf("writable", "canwrite", "canedit", "canupdate", "candelete") -> "true" + semanticId.contains("deleted") || + semanticId.contains("trashed") || + semanticId.contains("removed") || + semanticId.contains("archived") -> null + kind == FieldKind.boolean -> "false" + kind == FieldKind.date -> "2026-07-30" + kind == FieldKind.dateTime -> "2026-07-30T12:00:00Z" + semanticId.startsWith("is") || semanticId.startsWith("has") -> "false" + else -> "7" + } +} + +private fun Map.withPantryAuditState(state: String): Map { + val stateField = keys.firstOrNull { fieldId -> + fieldId.lowercase().filter(Char::isLetterOrDigit).contains(state) + } ?: return this + return this + (stateField to "2026-07-30T12:00:00Z") +} + +private fun DynamicAction.missingGenericPantryCapability(): String { + val semantics = "$id $label $resourceId ${binding.path}" + .lowercase() + .split(Regex("[^a-z0-9]+")) + .filter(String::isNotBlank) + .toSet() + return when { + effect == ActionEffect.batch -> "batch/selection action" + effect == ActionEffect.reorder -> "collection reorder action" + effect == ActionEffect.empty -> "collection-level command" + effect == ActionEffect.upload -> "file/upload action" + semantics.any { word -> + word in setOf("prefs", "preference", "preferences", "setting", "settings") + } -> "contextual settings form" + effect == ActionEffect.assign -> "contextual relationship form" + effect == ActionEffect.copy && binding.body != null -> "record command form with input" + else -> "generic mutation form or command" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/StaticRouteCompatibilityTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/StaticRouteCompatibilityTest.kt index 65998c8a..a49e2e3c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/StaticRouteCompatibilityTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/StaticRouteCompatibilityTest.kt @@ -109,7 +109,8 @@ class StaticRouteCompatibilityTest { val patch = descriptor.actions.single { action -> action.binding.method == HttpMethod.PATCH } val delete = descriptor.actions.single { action -> action.binding.method == HttpMethod.DELETE } - assertTrue(listOf(create, update, patch, delete).all(DynamicAction::requiresConfirmation)) + assertTrue(listOf(create, update, patch).none(DynamicAction::requiresConfirmation)) + assertTrue(delete.requiresConfirmation) assertEquals(ActionRisk.mutating, create.risk) assertEquals(ActionRisk.mutating, update.risk) assertEquals(ActionRisk.mutating, patch.risk) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkflowTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkflowTest.kt index 4dc200d6..3a50b627 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkflowTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkflowTest.kt @@ -10,6 +10,7 @@ import dev.obiente.nextcloudnative.nativeui.model.FieldKind import dev.obiente.nextcloudnative.nativeui.model.FieldSpec import dev.obiente.nextcloudnative.nativeui.model.HttpMethod import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceRelationshipSpec import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec import kotlin.test.Test import kotlin.test.assertEquals @@ -131,7 +132,26 @@ class NativeMailWorkflowTest { readOnly = false, ) val messages = resource("messages", "Messages", listOf(destination, account)) - val mailboxes = resource("mailboxes", "Mailboxes") + val mailboxes = resource( + "mailboxes", + "Mailboxes", + listOf( + FieldSpec( + id = "databaseId", + label = "Database ID", + kind = FieldKind.integer, + required = true, + readOnly = true, + ), + FieldSpec( + id = "name", + label = "Name", + kind = FieldKind.string, + required = true, + readOnly = true, + ), + ), + ) val move = action( id = "messages-move", method = HttpMethod.POST, @@ -144,14 +164,27 @@ class NativeMailWorkflowTest { id = "42", values = mapOf("subject" to "Move me", "from" to "sender@example.test", "accountId" to "7", "mailboxId" to "9"), ) - val appSchema = schema(resources = listOf(messages, mailboxes), actions = listOf(move)) + val appSchema = schema( + resources = listOf(messages, mailboxes), + actions = listOf(move), + relationships = listOf( + ResourceRelationshipSpec( + parentResourceId = mailboxes.id, + childResourceId = messages.id, + parentFieldId = "databaseId", + childFieldId = destination.id, + confidence = Confidence.verified, + ), + ), + ) assertEquals( - mapOf("accountId" to "7"), - nativeFormAutoBoundValues(move, messages, selectedMessage), + mapOf("accountId" to "7", "id" to "42"), + nativeFormAutoBoundValues(appSchema, move, messages, selectedMessage), ) val options = nativeRelationOptions( field = destination, + formResource = messages, schema = appSchema, context = NativeDatasetContext( parentResourceId = messages.id, @@ -205,6 +238,7 @@ class NativeMailWorkflowTest { private fun schema( resources: List, actions: List = emptyList(), + relationships: List = emptyList(), ) = NativeAppSchema( schemaVersion = "1", app = AppIdentity("mail", "Mail", "5.10.9"), @@ -212,5 +246,6 @@ class NativeMailWorkflowTest { resources = resources, views = emptyList(), actions = actions, + relationships = relationships, ) } diff --git a/website/public/screenshots/adaptive-dynamic-collection-mobile.png b/website/public/screenshots/adaptive-dynamic-collection-mobile.png new file mode 100644 index 00000000..1e92e041 Binary files /dev/null and b/website/public/screenshots/adaptive-dynamic-collection-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png b/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png new file mode 100644 index 00000000..e01ac11e Binary files /dev/null and b/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-data-mobile.png b/website/public/screenshots/adaptive-dynamic-data-mobile.png index 8153ac40..05f22ca3 100644 Binary files a/website/public/screenshots/adaptive-dynamic-data-mobile.png and b/website/public/screenshots/adaptive-dynamic-data-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-data.png b/website/public/screenshots/adaptive-dynamic-data.png index 9128b422..d00b5c64 100644 Binary files a/website/public/screenshots/adaptive-dynamic-data.png and b/website/public/screenshots/adaptive-dynamic-data.png differ diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index ff7d9ec8..38c063de 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -30,6 +30,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", @@ -70,6 +72,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarkdownEditing.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDemoFixture.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingLivePhotoCaptureScenarios.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingPhotoFolderCaptureScenarios.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingPhotoTimelineCaptureScenarios.kt", @@ -154,17 +157,20 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudBoardDragAutoScroll.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudBoardDragHandle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCardActions.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudComponents.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudContextMenu.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudTheme.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudTokens.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudWorkspaceBreakpoints.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptor.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompiler.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorMapper.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicDescriptorValidation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicFormFormats.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicStructuredForm.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/NativeSchema.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/SemanticConceptTokens.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeRuntimePreviewApp.kt", @@ -175,12 +181,18 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeAudioLibrary.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardActions.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeBoardDragPlacement.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionUiState.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeFormMutationRecovery.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailActions.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailMarkup.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecipePresentations.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecipeScaling.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActions.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRepeatableObjectUiState.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSemanticPresentations.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeTaskSemantics.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/template/BracedTemplate.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DeckInteractionPreviewMain.kt", @@ -197,7 +209,7 @@ "ui/src/desktopMain/resources/marketing/raw-render-fixture.png", "ui/src/desktopMain/resources/marketing/raw-render-fixture.svg" ], - "captureSourceSha256": "95415ff1e63f81bc2e56f2613d8a431876dfeac57f8bd2be0ba0a98b30343986", + "captureSourceSha256": "63ba7406572b71015c8cd07a89ee35c575fc626b506508fdfc923e68888cc56d", "avatarSha256": "a20433eeda834a418f92d76853633b4fc9115ad3006c5622ce2611432dc1f14d", "captures": [ { @@ -212,7 +224,7 @@ "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "8c51b7a628149cd9c7dad580de118d55e653eb11708651d699346393e0cdb814" + "sha256": "1495005bbb47a669a52ebd4931e821e1cbc7a76608b7df608382aabf7fa41f95" }, { "scenario": "mobile-home", @@ -226,7 +238,7 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "071f9d85242b37a8cac81a06fb23e290d452bd0c48923c910155fc50882336bc" + "sha256": "f92c861a4800b7a52886eb902849c5719ebc6db6eadfa6ea4e8d4ae8600ce714" }, { "scenario": "obsidian-vault-sync", @@ -240,7 +252,7 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-compact", - "sha256": "193b88b5ab1cf40c269a76fe44ac91038465225f17dc7b20fc35f08f9d6bdbd4" + "sha256": "d55a72b9dea2139eb28bf2f361726f91a3726df9f9b7aa6c7742c903f2955e57" }, { "scenario": "media-backup-queue", @@ -254,7 +266,7 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "c0c83a52a1b1912d403a0ddcd61a75f5e5d758ea45e571c9ecf3e1f834e2ee53" + "sha256": "cf3059d8088e16a1171ebdbfc46816c817775ec8173995bf0501597c5abd64fa" }, { "scenario": "adaptive-dynamic-data", @@ -263,12 +275,12 @@ "height": 900, "density": 1.0, "feature": "Dynamic apps", - "surface": "Data table", - "state": "Ready", + "surface": "Nested collection and semantic form", + "state": "Synthetic visual QA", "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "8d67ba3f63f6a47eb314ca6c46bd48a080148afeb9f9182a2a87154b8c6e772c" + "sha256": "ddbe82d0dc42a3902f41897231147402d7e3b6d0c0b51a15ce69ff1d2cf40202" }, { "scenario": "adaptive-dynamic-data-mobile", @@ -277,12 +289,40 @@ "height": 1800, "density": 2.625, "feature": "Dynamic apps", - "surface": "Data table", - "state": "Ready", - "purpose": "showcase", + "surface": "Nested collection and semantic form", + "state": "Synthetic visual QA", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "304a7dea161c51ec61978764db46d69fbf9194476fe7ae05b8c900b645fe59c7" + }, + { + "scenario": "adaptive-dynamic-collection-mobile", + "file": "adaptive-dynamic-collection-mobile.png", + "width": 1080, + "height": 1800, + "density": 2.625, + "feature": "Dynamic apps", + "surface": "Nested collection actions", + "state": "Synthetic visual QA", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "849dbda0b56d984944f77b7d547d03b9bf3aaf0ac6de2d96ce6138445d65f796" + }, + { + "scenario": "adaptive-dynamic-context-menu-mobile", + "file": "adaptive-dynamic-context-menu-mobile.png", + "width": 1080, + "height": 1800, + "density": 2.625, + "feature": "Dynamic apps", + "surface": "Context workspace menu", + "state": "Synthetic visual QA", + "purpose": "state-coverage", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "f1290c89723184800c127401de7fea4256294049828d014beed5ce11c8e35ea5" + "sha256": "e483aa2640fc4df5c905c1a79af01c522cc2fa8c7ffc511433bd730329551080" }, { "scenario": "photo-timeline-revalidation-error-mobile", @@ -298,7 +338,7 @@ "viewport": "phone-portrait", "pullRequest": 246, "issue": 242, - "sha256": "60d76a9530c215f5d5473975e162dda5b192fc56e3672401f53e9c0774c10740" + "sha256": "74b26981a5d7b8abfe3837acc238d69e99a34db6f2af88cda7d20b41d5b32d03" }, { "scenario": "photo-timeline-return-to-newest-error-mobile", @@ -314,7 +354,7 @@ "viewport": "phone-portrait", "pullRequest": 246, "issue": 242, - "sha256": "c6960155703b6bee4216b684adda94c9c4b5325e621d10d7c24be2cd0be5213f" + "sha256": "dbe920a60aed29947f5c81c30d173c67e0013506a84329ebefb57125fe702b2f" }, { "scenario": "photo-timeline-raw-retry-mobile", @@ -330,7 +370,7 @@ "viewport": "phone-portrait", "pullRequest": 249, "issue": 248, - "sha256": "b82e70232c249715c0e18db678f24bc84f1c77db2463770a8a2c5b4d45c62ee9" + "sha256": "6af1b7d716ea58413727e3e5ce1fbd178d772b583e8481ebc71a5cd6fa1ebccd" }, { "scenario": "photo-folder-browser-mobile", @@ -346,7 +386,7 @@ "viewport": "phone-portrait", "pullRequest": 245, "issue": 243, - "sha256": "c24e27abd1a897d556c0dac47448bf00da2deb1ab12065401aae05f730de6a35" + "sha256": "60c32a8efa89d4f1c6d006a44912abd6504cdf7321002b0e886f9ea45204d219" }, { "scenario": "photo-folder-browser-desktop", @@ -362,7 +402,7 @@ "viewport": "wide", "pullRequest": 245, "issue": 243, - "sha256": "0222cf8131beeff16e5ab0448b8de4e3cf7616520a5781bac015d2755335d5b5" + "sha256": "c22be231ab73c80eeb8619932b7830463100fe0aa1293ee0f31660f3b09cabfd" }, { "scenario": "raw-preview-loading-mobile", @@ -378,7 +418,7 @@ "viewport": "phone-compact", "pullRequest": 218, "issue": 85, - "sha256": "05e2c33c9a37cad7a95808419b77e72b7e33c1d4274e658cb04e65edac9c0193" + "sha256": "14744822f7b9e60015675f2515f3b7e9685345ef3d56c080556f2cf9acc935e7" }, { "scenario": "raw-preview-error-mobile", @@ -394,7 +434,7 @@ "viewport": "phone-compact", "pullRequest": 218, "issue": 85, - "sha256": "01d0d42f3dc57b3f0a5da0353ad40f30a9e9e219fbc52b0c65358d72c41758d0" + "sha256": "7aef0508cdbae8d691c9187847de3c0d188cef65465021ce861aee6d3eb0863a" }, { "scenario": "raw-preview-memories-ready-mobile", @@ -410,7 +450,7 @@ "viewport": "phone-portrait", "pullRequest": 218, "issue": 85, - "sha256": "25cd22beb5640c73269b5b786fc6a6525e0913f402c17c29f963c2709c8b2a7a" + "sha256": "0381f21fa25ef09aeca8d7a9606f39eab96a8ad0e5a2178ef1fc14561fa0fc39" }, { "scenario": "raw-preview-high-detail-desktop", @@ -426,7 +466,7 @@ "viewport": "wide", "pullRequest": 218, "issue": 85, - "sha256": "5b0d81b2626247baf3572be949143fee9cdf8392156a5a5eee5d9d70ad160a9e" + "sha256": "265c35d941a8ebce4c0408544dcb78cfc0ac3700dd4b1366e04cf2792b451028" }, { "scenario": "live-photo-motion-failure-mobile", @@ -442,7 +482,7 @@ "viewport": "phone-compact", "pullRequest": 249, "issue": 182, - "sha256": "3fc5ec6e2db43ebbebaca5b764e81d46685543bedfbeb391065d95ef0fc97f52" + "sha256": "2f248b1b9251f0211774cd57c33a9e639c31ca7935147d0a309120f070ae914a" }, { "scenario": "native-tiff-preview-mobile", @@ -458,7 +498,7 @@ "viewport": "phone-compact", "pullRequest": 249, "issue": 84, - "sha256": "42a2fba65982fb18c7462b8a2838ad79507f43ebd5443b2cfcbd2bf147fca30d" + "sha256": "780fbac0bcf219772df7d99b2726deeec27ed9fa2355a16e1576e17ccdbe8ae2" }, { "scenario": "file-share-user-mobile", @@ -474,7 +514,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "ea41901e75f47ff662d9b4d2f0ffb5fdf9822f8fde4e0decbdddef7675da8ae0" + "sha256": "c1084b73d58e6ad6f0dffc2e7e31f6c2388b1fe268205c3f20ff303f14f69aa0" }, { "scenario": "file-share-group-desktop", @@ -490,7 +530,7 @@ "viewport": "wide", "pullRequest": 219, "issue": 124, - "sha256": "5fc62f679169035bfdb67a2dbe8cea7c8c21a418448896294fc7988814f8e5b6" + "sha256": "7ec0df01a0a04ff054afb8a50672e64b3edfcf45f35cc15d90f8d396e3ff78ee" }, { "scenario": "file-share-loading-mobile", @@ -506,7 +546,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "e524289888582bbf6531c9e6467664d187e99c9d23508f020f0aaff638b48c02" + "sha256": "03646d3ee9b1a809531b586df0014095f0cb1a6b9c885ab5b9d562492e2328e3" }, { "scenario": "file-share-error-mobile", @@ -522,7 +562,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "30107de4377bddd5a13fd851bc5283f784ef3fe96f67e8cc81be5e41c35ae83e" + "sha256": "5500df269e219eecbf7d9173b11698fef809e2d1b99a39ece1b4c4ec5a112514" }, { "scenario": "transfer-mobile-pending", @@ -538,7 +578,7 @@ "viewport": "phone-portrait", "pullRequest": 220, "issue": 168, - "sha256": "5a8ec403de582eab401e9c2173df8e5fdddabf7bf5664dc5a592eb037f9109e2" + "sha256": "9cc97dd52807da7d43fa0ae5a2ec1b2d1acf8258d5289a38262ea7c8ffd8b992" }, { "scenario": "transfer-mobile-failed-cached", @@ -554,7 +594,7 @@ "viewport": "phone-portrait", "pullRequest": 220, "issue": 168, - "sha256": "e5c2ecbc65842cd46b12006cc879ac0dfc7787d2cee03e5938d1222a05708da4" + "sha256": "497e4544313c5b4910d6bec4f77547ed1876cb071305879a87b970275271ce59" }, { "scenario": "transfer-desktop-active", @@ -570,7 +610,7 @@ "viewport": "wide", "pullRequest": 220, "issue": 168, - "sha256": "b1c3ba9ed6236345ae8fe8d18024ea2adaed33a861758a1aa52d571d634083a1" + "sha256": "099c1cbb5c61037b5aba13fe2ca59cf00307aebe98d2bce74f5b14b8252ea69f" }, { "scenario": "transfer-desktop-completed-page", @@ -586,7 +626,7 @@ "viewport": "wide", "pullRequest": 220, "issue": 168, - "sha256": "570b0c78d0d88f73407a7ea827fb6c26a8d1c3f4bc19f2bd05e10a8e38df9a23" + "sha256": "2d2d6887f26d60551c25e774b0f3bcd4ea46df54d34d697a408d5661bd61d1df" }, { "scenario": "deck-board-desktop", @@ -602,7 +642,7 @@ "viewport": "wide", "pullRequest": 221, "issue": 52, - "sha256": "b41f9a2d8da5653fb1bc174d77f72bf1eda5c10b63e38209ed8f5fc9a17bc019" + "sha256": "f13fdb332b679bc88f76a2d9393f9da1c529dbb893c6aec490f455bfe44ec9fe" }, { "scenario": "deck-board-mobile", @@ -618,7 +658,7 @@ "viewport": "phone-portrait", "pullRequest": 221, "issue": 52, - "sha256": "ea047a30de48e5366edf30e43d142ab8a6491617dcf0051d6f1f932a213133f5" + "sha256": "a1cfcc4d620103cb146c3f2f5b2de1f9dd3ffdea6c73e8b31444107537fd6e18" } ] } diff --git a/website/public/screenshots/deck-board-desktop.png b/website/public/screenshots/deck-board-desktop.png index dc8e8db5..ccadcd9f 100644 Binary files a/website/public/screenshots/deck-board-desktop.png and b/website/public/screenshots/deck-board-desktop.png differ diff --git a/website/public/screenshots/deck-board-mobile.png b/website/public/screenshots/deck-board-mobile.png index bf21834f..b156049b 100644 Binary files a/website/public/screenshots/deck-board-mobile.png and b/website/public/screenshots/deck-board-mobile.png differ diff --git a/website/public/screenshots/desktop-home.png b/website/public/screenshots/desktop-home.png index ed553b74..0ffd8c94 100644 Binary files a/website/public/screenshots/desktop-home.png and b/website/public/screenshots/desktop-home.png differ diff --git a/website/public/screenshots/file-share-error-mobile.png b/website/public/screenshots/file-share-error-mobile.png index 3b565cf1..6321e83d 100644 Binary files a/website/public/screenshots/file-share-error-mobile.png and b/website/public/screenshots/file-share-error-mobile.png differ diff --git a/website/public/screenshots/file-share-group-desktop.png b/website/public/screenshots/file-share-group-desktop.png index d24d7b2a..c9e30b12 100644 Binary files a/website/public/screenshots/file-share-group-desktop.png and b/website/public/screenshots/file-share-group-desktop.png differ diff --git a/website/public/screenshots/file-share-loading-mobile.png b/website/public/screenshots/file-share-loading-mobile.png index 1475a6fd..4656affb 100644 Binary files a/website/public/screenshots/file-share-loading-mobile.png and b/website/public/screenshots/file-share-loading-mobile.png differ diff --git a/website/public/screenshots/file-share-user-mobile.png b/website/public/screenshots/file-share-user-mobile.png index 20881f23..0f5fde51 100644 Binary files a/website/public/screenshots/file-share-user-mobile.png and b/website/public/screenshots/file-share-user-mobile.png differ diff --git a/website/public/screenshots/live-photo-motion-failure-mobile.png b/website/public/screenshots/live-photo-motion-failure-mobile.png index 79103f8d..5e3c867c 100644 Binary files a/website/public/screenshots/live-photo-motion-failure-mobile.png and b/website/public/screenshots/live-photo-motion-failure-mobile.png differ diff --git a/website/public/screenshots/media-backup-queue.png b/website/public/screenshots/media-backup-queue.png index f5c11054..59c2456f 100644 Binary files a/website/public/screenshots/media-backup-queue.png and b/website/public/screenshots/media-backup-queue.png differ diff --git a/website/public/screenshots/mobile-home.png b/website/public/screenshots/mobile-home.png index 882ee34c..5c8d6cce 100644 Binary files a/website/public/screenshots/mobile-home.png and b/website/public/screenshots/mobile-home.png differ diff --git a/website/public/screenshots/native-tiff-preview-mobile.png b/website/public/screenshots/native-tiff-preview-mobile.png index 4bb5c5ca..6d25e001 100644 Binary files a/website/public/screenshots/native-tiff-preview-mobile.png and b/website/public/screenshots/native-tiff-preview-mobile.png differ diff --git a/website/public/screenshots/obsidian-vault-sync.png b/website/public/screenshots/obsidian-vault-sync.png index 5708a9ac..ad579a32 100644 Binary files a/website/public/screenshots/obsidian-vault-sync.png and b/website/public/screenshots/obsidian-vault-sync.png differ diff --git a/website/public/screenshots/photo-folder-browser-desktop.png b/website/public/screenshots/photo-folder-browser-desktop.png index 697a5f56..cdf672c9 100644 Binary files a/website/public/screenshots/photo-folder-browser-desktop.png and b/website/public/screenshots/photo-folder-browser-desktop.png differ diff --git a/website/public/screenshots/photo-folder-browser-mobile.png b/website/public/screenshots/photo-folder-browser-mobile.png index f00d4234..391c679e 100644 Binary files a/website/public/screenshots/photo-folder-browser-mobile.png and b/website/public/screenshots/photo-folder-browser-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-raw-retry-mobile.png b/website/public/screenshots/photo-timeline-raw-retry-mobile.png index 34c10784..c79375b9 100644 Binary files a/website/public/screenshots/photo-timeline-raw-retry-mobile.png and b/website/public/screenshots/photo-timeline-raw-retry-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png b/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png index 53d06abe..75e35418 100644 Binary files a/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png and b/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-revalidation-error-mobile.png b/website/public/screenshots/photo-timeline-revalidation-error-mobile.png index e7d4ca56..2863a5d5 100644 Binary files a/website/public/screenshots/photo-timeline-revalidation-error-mobile.png and b/website/public/screenshots/photo-timeline-revalidation-error-mobile.png differ diff --git a/website/public/screenshots/raw-preview-error-mobile.png b/website/public/screenshots/raw-preview-error-mobile.png index 0c6ed453..2c589fed 100644 Binary files a/website/public/screenshots/raw-preview-error-mobile.png and b/website/public/screenshots/raw-preview-error-mobile.png differ diff --git a/website/public/screenshots/raw-preview-high-detail-desktop.png b/website/public/screenshots/raw-preview-high-detail-desktop.png index 640cadd2..39e51df2 100644 Binary files a/website/public/screenshots/raw-preview-high-detail-desktop.png and b/website/public/screenshots/raw-preview-high-detail-desktop.png differ diff --git a/website/public/screenshots/raw-preview-loading-mobile.png b/website/public/screenshots/raw-preview-loading-mobile.png index 5ef58e5a..bad35388 100644 Binary files a/website/public/screenshots/raw-preview-loading-mobile.png and b/website/public/screenshots/raw-preview-loading-mobile.png differ diff --git a/website/public/screenshots/raw-preview-memories-ready-mobile.png b/website/public/screenshots/raw-preview-memories-ready-mobile.png index 68c4cec8..742944d8 100644 Binary files a/website/public/screenshots/raw-preview-memories-ready-mobile.png and b/website/public/screenshots/raw-preview-memories-ready-mobile.png differ diff --git a/website/public/screenshots/transfer-desktop-active.png b/website/public/screenshots/transfer-desktop-active.png index 8d158ed8..31a69105 100644 Binary files a/website/public/screenshots/transfer-desktop-active.png and b/website/public/screenshots/transfer-desktop-active.png differ diff --git a/website/public/screenshots/transfer-desktop-completed-page.png b/website/public/screenshots/transfer-desktop-completed-page.png index 1e4d90b7..422d3deb 100644 Binary files a/website/public/screenshots/transfer-desktop-completed-page.png and b/website/public/screenshots/transfer-desktop-completed-page.png differ diff --git a/website/public/screenshots/transfer-mobile-failed-cached.png b/website/public/screenshots/transfer-mobile-failed-cached.png index 0305acc7..553ccb9d 100644 Binary files a/website/public/screenshots/transfer-mobile-failed-cached.png and b/website/public/screenshots/transfer-mobile-failed-cached.png differ diff --git a/website/public/screenshots/transfer-mobile-pending.png b/website/public/screenshots/transfer-mobile-pending.png index da7d063b..15c63f80 100644 Binary files a/website/public/screenshots/transfer-mobile-pending.png and b/website/public/screenshots/transfer-mobile-pending.png differ