craft-native 0.0.113 → 0.0.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.js +6 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +181 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +335 -0
- package/dist/android/templates/MainActivityNative.kt.template +493 -42
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -1
- package/dist/ios/src/index.js +2 -0
- package/dist/ios/templates/CraftNativeFlatList.swift +323 -0
- package/dist/ios/templates/CraftNativeMutation.swift +365 -0
- package/dist/ios/templates/CraftNativeScreen.swift +460 -31
- package/dist/types.d.ts +81 -1
- package/package.json +1 -1
|
@@ -415,6 +415,12 @@ async function init(options) {
|
|
|
415
415
|
const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
|
|
416
416
|
const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
|
|
417
417
|
writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
|
|
418
|
+
if (config.renderer === "native") {
|
|
419
|
+
const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
|
|
420
|
+
const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
|
|
421
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
422
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
423
|
+
}
|
|
418
424
|
const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
|
|
419
425
|
const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
|
|
420
426
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.view.View
|
|
5
|
+
import android.view.ViewGroup
|
|
6
|
+
import android.widget.FrameLayout
|
|
7
|
+
import androidx.recyclerview.widget.DiffUtil
|
|
8
|
+
import androidx.recyclerview.widget.GridLayoutManager
|
|
9
|
+
import androidx.recyclerview.widget.LinearLayoutManager
|
|
10
|
+
import androidx.recyclerview.widget.ListAdapter
|
|
11
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
12
|
+
import org.json.JSONObject
|
|
13
|
+
import kotlin.math.ceil
|
|
14
|
+
|
|
15
|
+
internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
|
|
16
|
+
|
|
17
|
+
/** A stable-key RecyclerView host for stx-native FlatList nodes. */
|
|
18
|
+
internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
19
|
+
private data class Item(
|
|
20
|
+
val identity: String,
|
|
21
|
+
val node: JSONObject,
|
|
22
|
+
val signature: String,
|
|
23
|
+
val chrome: Boolean,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
private class Holder(val host: FrameLayout) : ViewHolder(host) {
|
|
27
|
+
var identity: String? = null
|
|
28
|
+
val hostedView: View? get() = host.getChildAt(0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private val listAdapter = object : ListAdapter<Item, Holder>(object : DiffUtil.ItemCallback<Item>() {
|
|
32
|
+
override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.identity == newItem.identity
|
|
33
|
+
override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem.signature == newItem.signature
|
|
34
|
+
}) {
|
|
35
|
+
init { setHasStableIds(true) }
|
|
36
|
+
|
|
37
|
+
override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
|
|
38
|
+
|
|
39
|
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
|
|
40
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
override fun onBindViewHolder(holder: Holder, position: Int) {
|
|
44
|
+
val item = getItem(position)
|
|
45
|
+
if (holder.identity != null && holder.identity != item.identity) release(holder)
|
|
46
|
+
val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
|
|
47
|
+
holder.identity = item.identity
|
|
48
|
+
if (holder.hostedView !== view) {
|
|
49
|
+
(view.parent as? ViewGroup)?.removeView(view)
|
|
50
|
+
holder.host.removeAllViews()
|
|
51
|
+
holder.host.addView(view, FrameLayout.LayoutParams(
|
|
52
|
+
LayoutParams.MATCH_PARENT,
|
|
53
|
+
LayoutParams.WRAP_CONTENT,
|
|
54
|
+
))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override fun onViewRecycled(holder: Holder) {
|
|
59
|
+
release(holder)
|
|
60
|
+
super.onViewRecycled(holder)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private fun release(holder: Holder) {
|
|
64
|
+
holder.identity?.let { recycleItem?.invoke(it) }
|
|
65
|
+
holder.host.removeAllViews()
|
|
66
|
+
holder.identity = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private var renderItem: CraftNativeRenderItem? = null
|
|
71
|
+
private var recycleItem: ((String) -> Unit)? = null
|
|
72
|
+
private var endReached: (() -> Unit)? = null
|
|
73
|
+
private var threshold = 0.1
|
|
74
|
+
private var endReachedSignature: String? = null
|
|
75
|
+
private var horizontal = false
|
|
76
|
+
private var columns = 1
|
|
77
|
+
|
|
78
|
+
init {
|
|
79
|
+
adapter = listAdapter
|
|
80
|
+
itemAnimator = null
|
|
81
|
+
addOnScrollListener(object : OnScrollListener() {
|
|
82
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun apply(
|
|
87
|
+
nodes: List<JSONObject>,
|
|
88
|
+
horizontal: Boolean,
|
|
89
|
+
columns: Int,
|
|
90
|
+
inverted: Boolean,
|
|
91
|
+
endReachedThreshold: Double,
|
|
92
|
+
renderItem: CraftNativeRenderItem,
|
|
93
|
+
recycleItem: (String) -> Unit,
|
|
94
|
+
endReached: (() -> Unit)?,
|
|
95
|
+
) {
|
|
96
|
+
this.renderItem = renderItem
|
|
97
|
+
this.recycleItem = recycleItem
|
|
98
|
+
this.endReached = endReached
|
|
99
|
+
threshold = endReachedThreshold.coerceAtLeast(0.0)
|
|
100
|
+
|
|
101
|
+
val nextColumns = columns.coerceAtLeast(1)
|
|
102
|
+
val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
|
|
103
|
+
val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
|
|
104
|
+
this.horizontal = horizontal
|
|
105
|
+
this.columns = nextColumns
|
|
106
|
+
if (layoutChanged || layoutManager == null) configureLayoutManager()
|
|
107
|
+
|
|
108
|
+
val seen = mutableMapOf<String, Int>()
|
|
109
|
+
val next = nodes.mapIndexed { index, node ->
|
|
110
|
+
val base = identity(node) ?: "index:$index"
|
|
111
|
+
val occurrence = seen.getOrDefault(base, 0)
|
|
112
|
+
seen[base] = occurrence + 1
|
|
113
|
+
val unique = if (occurrence == 0) base else "$base#$occurrence"
|
|
114
|
+
val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
|
|
115
|
+
Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
|
|
116
|
+
}.let { if (inverted) it.reversed() else it }
|
|
117
|
+
|
|
118
|
+
val oldIds = listAdapter.currentList.mapTo(mutableSetOf()) { it.identity }
|
|
119
|
+
val nextIds = next.mapTo(mutableSetOf()) { it.identity }
|
|
120
|
+
(oldIds - nextIds).forEach(recycleItem)
|
|
121
|
+
val dataItems = next.filterNot { it.chrome }
|
|
122
|
+
val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
|
|
123
|
+
if (contentSignature != endReachedSignature) endReachedSignature = null
|
|
124
|
+
listAdapter.submitList(next) {
|
|
125
|
+
retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
|
|
126
|
+
evaluateEndReached()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fun discardAll() {
|
|
131
|
+
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
132
|
+
listAdapter.submitList(emptyList())
|
|
133
|
+
renderItem = null
|
|
134
|
+
recycleItem = null
|
|
135
|
+
endReached = null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fun configureLayoutManager() {
|
|
139
|
+
layoutManager = when {
|
|
140
|
+
horizontal -> LinearLayoutManager(context, HORIZONTAL, false)
|
|
141
|
+
columns > 1 -> GridLayoutManager(context, columns).also { grid ->
|
|
142
|
+
grid.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
|
143
|
+
override fun getSpanSize(position: Int): Int =
|
|
144
|
+
if (listAdapter.currentList.getOrNull(position)?.chrome == true) columns else 1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else -> LinearLayoutManager(context)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private fun evaluateEndReached() {
|
|
152
|
+
val manager = layoutManager as? LinearLayoutManager ?: return
|
|
153
|
+
val lastVisible = manager.findLastVisibleItemPosition()
|
|
154
|
+
val dataPositions = listAdapter.currentList.indices.filter { !listAdapter.currentList[it].chrome }
|
|
155
|
+
val lastDataOrdinal = dataPositions.indexOfLast { it <= lastVisible }
|
|
156
|
+
if (dataPositions.isEmpty() || lastDataOrdinal < 0 || endReached == null) return
|
|
157
|
+
val remaining = dataPositions.size - lastDataOrdinal - 1
|
|
158
|
+
val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
|
|
159
|
+
if (remaining > thresholdItems) return
|
|
160
|
+
val lastIdentity = listAdapter.currentList[dataPositions.last()].identity
|
|
161
|
+
val signature = "${dataPositions.size}:$lastIdentity"
|
|
162
|
+
if (endReachedSignature == signature) return
|
|
163
|
+
endReachedSignature = signature
|
|
164
|
+
endReached?.invoke()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun identity(node: JSONObject): String? {
|
|
168
|
+
val props = node.optJSONObject("props") ?: JSONObject()
|
|
169
|
+
return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
|
|
170
|
+
.firstOrNull { it.isNotBlank() }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun stableId(value: String): Long {
|
|
174
|
+
var hash = -0x340d631b7bdddcdbL
|
|
175
|
+
for (byte in value.encodeToByteArray()) {
|
|
176
|
+
hash = hash xor (byte.toLong() and 0xff)
|
|
177
|
+
hash *= 0x100000001b3L
|
|
178
|
+
}
|
|
179
|
+
return hash
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
const val CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION = 1
|
|
7
|
+
|
|
8
|
+
data class CraftNativeMutationFailure(
|
|
9
|
+
val code: String,
|
|
10
|
+
override val message: String,
|
|
11
|
+
val operationIndex: Int? = null
|
|
12
|
+
) : Exception(message)
|
|
13
|
+
|
|
14
|
+
data class CraftNativeMutationResult(
|
|
15
|
+
val batchId: String,
|
|
16
|
+
val revision: Int,
|
|
17
|
+
val document: JSONObject?,
|
|
18
|
+
val updatedNodeIds: List<String>,
|
|
19
|
+
val affectedNodeIds: List<String>,
|
|
20
|
+
val requiresFullRender: Boolean
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Validates a complete batch against a copy before committing any state. */
|
|
24
|
+
class CraftNativeMutationDocument {
|
|
25
|
+
private data class Node(
|
|
26
|
+
val id: String,
|
|
27
|
+
var type: String,
|
|
28
|
+
var props: JSONObject,
|
|
29
|
+
var style: JSONObject,
|
|
30
|
+
var events: JSONObject,
|
|
31
|
+
var text: MutableList<String>,
|
|
32
|
+
var children: MutableList<String>,
|
|
33
|
+
var parent: String?
|
|
34
|
+
) {
|
|
35
|
+
fun duplicate(): Node = copy(
|
|
36
|
+
props = JSONObject(props.toString()),
|
|
37
|
+
style = JSONObject(style.toString()),
|
|
38
|
+
events = JSONObject(events.toString()),
|
|
39
|
+
text = text.toMutableList(),
|
|
40
|
+
children = children.toMutableList()
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private var nodes = linkedMapOf<String, Node>()
|
|
45
|
+
private var rootId: String? = null
|
|
46
|
+
var revision: Int = 0
|
|
47
|
+
private set
|
|
48
|
+
|
|
49
|
+
fun replace(document: JSONObject) {
|
|
50
|
+
val replacement = linkedMapOf<String, Node>()
|
|
51
|
+
val root = importNode(document, "root", null, replacement)
|
|
52
|
+
nodes = replacement
|
|
53
|
+
rootId = root
|
|
54
|
+
revision = 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fun apply(payload: JSONObject): CraftNativeMutationResult {
|
|
58
|
+
val batchId = payload.optString("batchId")
|
|
59
|
+
if (batchId.isBlank()) fail("INVALID_BATCH", "batchId must be a non-empty string")
|
|
60
|
+
if (strictInteger(payload.opt("version")) != CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION) {
|
|
61
|
+
fail(
|
|
62
|
+
"UNSUPPORTED_VERSION",
|
|
63
|
+
"mutation protocol version must be $CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION"
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (strictInteger(payload.opt("baseRevision")) != revision) {
|
|
67
|
+
fail("REVISION_MISMATCH", "baseRevision must equal the current revision $revision")
|
|
68
|
+
}
|
|
69
|
+
val nextRevision = strictInteger(payload.opt("revision"))
|
|
70
|
+
if (nextRevision != revision + 1) {
|
|
71
|
+
fail("INVALID_REVISION", "revision must advance exactly once from $revision")
|
|
72
|
+
}
|
|
73
|
+
val operations = payload.opt("operations") as? JSONArray
|
|
74
|
+
if (operations == null || operations.length() == 0) {
|
|
75
|
+
fail("INVALID_BATCH", "operations must be a non-empty array")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
val candidateNodes = linkedMapOf<String, Node>().apply {
|
|
79
|
+
nodes.forEach { (id, node) -> put(id, node.duplicate()) }
|
|
80
|
+
}
|
|
81
|
+
var candidateRoot = rootId
|
|
82
|
+
val updatedNodeIds = mutableListOf<String>()
|
|
83
|
+
val affectedNodeIds = mutableListOf<String>()
|
|
84
|
+
var requiresFullRender = false
|
|
85
|
+
for (index in 0 until operations.length()) {
|
|
86
|
+
val operation = operations.opt(index) as? JSONObject
|
|
87
|
+
?: failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
|
|
88
|
+
val name = operation.opt("op") as? String
|
|
89
|
+
if (name.isNullOrBlank()) {
|
|
90
|
+
failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
when (name) {
|
|
94
|
+
"createNode" -> {
|
|
95
|
+
if (operation.opt("root") == true) requiresFullRender = true
|
|
96
|
+
candidateRoot = createNode(operation, candidateNodes, candidateRoot)
|
|
97
|
+
}
|
|
98
|
+
"updateNode" -> {
|
|
99
|
+
updateNode(operation, candidateNodes)
|
|
100
|
+
operation.optString("id").let {
|
|
101
|
+
if (it !in updatedNodeIds) updatedNodeIds.add(it)
|
|
102
|
+
if (it !in affectedNodeIds) affectedNodeIds.add(it)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
"insertChild" -> {
|
|
106
|
+
insertChild(operation, candidateNodes)
|
|
107
|
+
operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
|
|
108
|
+
}
|
|
109
|
+
"moveChild" -> {
|
|
110
|
+
moveChild(operation, candidateNodes)
|
|
111
|
+
operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
|
|
112
|
+
}
|
|
113
|
+
"removeNode" -> {
|
|
114
|
+
val parent = candidateNodes[operation.optString("id")]?.parent
|
|
115
|
+
if (parent == null) requiresFullRender = true
|
|
116
|
+
else if (parent !in affectedNodeIds) affectedNodeIds.add(parent)
|
|
117
|
+
candidateRoot = removeNode(operation, candidateNodes, candidateRoot)
|
|
118
|
+
}
|
|
119
|
+
else -> fail("UNKNOWN_OPERATION", "unsupported operation $name")
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (failure: CraftNativeMutationFailure) {
|
|
123
|
+
throw CraftNativeMutationFailure(failure.code, failure.message, index)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (candidateRoot == null && candidateNodes.isNotEmpty()) {
|
|
127
|
+
fail("MISSING_ROOT", "a non-empty document must have one root node")
|
|
128
|
+
}
|
|
129
|
+
if (candidateRoot != null) {
|
|
130
|
+
val root = candidateNodes[candidateRoot]
|
|
131
|
+
if (root == null || root.parent != null) {
|
|
132
|
+
fail("INVALID_TREE", "root node must not have a parent")
|
|
133
|
+
}
|
|
134
|
+
if (descendants(candidateRoot, candidateNodes).size + 1 != candidateNodes.size) {
|
|
135
|
+
fail("INVALID_TREE", "all nodes must be reachable from the root")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
nodes = candidateNodes
|
|
140
|
+
rootId = candidateRoot
|
|
141
|
+
revision = nextRevision!!
|
|
142
|
+
return CraftNativeMutationResult(
|
|
143
|
+
batchId,
|
|
144
|
+
revision,
|
|
145
|
+
candidateRoot?.let { materialize(it, candidateNodes) },
|
|
146
|
+
updatedNodeIds,
|
|
147
|
+
affectedNodeIds,
|
|
148
|
+
requiresFullRender
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fun node(id: String): JSONObject? = materialize(id, nodes)
|
|
153
|
+
|
|
154
|
+
private fun createNode(
|
|
155
|
+
operation: JSONObject,
|
|
156
|
+
nodes: MutableMap<String, Node>,
|
|
157
|
+
root: String?
|
|
158
|
+
): String? {
|
|
159
|
+
val id = requiredId(operation, "id", "createNode")
|
|
160
|
+
if (nodes.containsKey(id)) fail("DUPLICATE_NODE", "node $id already exists")
|
|
161
|
+
val value = operation.opt("node") as? JSONObject
|
|
162
|
+
?: fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
|
|
163
|
+
val type = value.opt("type") as? String
|
|
164
|
+
if (type.isNullOrBlank()) fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
|
|
165
|
+
val makeRoot = operation.opt("root") == true
|
|
166
|
+
if (makeRoot && root != null) fail("ROOT_EXISTS", "the document already has a root node")
|
|
167
|
+
nodes[id] = Node(
|
|
168
|
+
id,
|
|
169
|
+
type,
|
|
170
|
+
dictionary(value.opt("props")),
|
|
171
|
+
dictionary(value.opt("style")),
|
|
172
|
+
dictionary(value.opt("events")),
|
|
173
|
+
textChildren(if (value.has("children")) value.opt("children") else value.opt("text")),
|
|
174
|
+
mutableListOf(),
|
|
175
|
+
null
|
|
176
|
+
)
|
|
177
|
+
return if (makeRoot) id else root
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun updateNode(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
181
|
+
val id = requiredId(operation, "id", "updateNode")
|
|
182
|
+
val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
|
|
183
|
+
val patch = operation.opt("patch") as? JSONObject
|
|
184
|
+
?: fail("INVALID_PATCH", "updateNode.patch must be an object")
|
|
185
|
+
val allowed = setOf("props", "style", "events", "children", "text")
|
|
186
|
+
val unknown = patch.keys().asSequence().filter { it !in allowed }.sorted().firstOrNull()
|
|
187
|
+
if (unknown != null) fail("INVALID_PATCH", "unsupported patch field $unknown")
|
|
188
|
+
if (patch.has("props")) node.props = dictionary(patch.opt("props"))
|
|
189
|
+
if (patch.has("style")) node.style = dictionary(patch.opt("style"))
|
|
190
|
+
if (patch.has("events")) node.events = dictionary(patch.opt("events"))
|
|
191
|
+
if (patch.has("children") || patch.has("text")) {
|
|
192
|
+
node.text = textChildren(if (patch.has("children")) patch.opt("children") else patch.opt("text"))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private fun insertChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
197
|
+
val parentId = requiredId(operation, "parentId", "insertChild")
|
|
198
|
+
val childId = requiredId(operation, "childId", "insertChild")
|
|
199
|
+
val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
|
|
200
|
+
val child = nodes[childId] ?: fail("UNKNOWN_NODE", "node $childId does not exist")
|
|
201
|
+
if (!acceptsChildren(parent.type)) fail("INVALID_PARENT", "node $parentId cannot contain child nodes")
|
|
202
|
+
if (child.parent != null) fail("NODE_ATTACHED", "node $childId already has a parent")
|
|
203
|
+
if (parentId == childId || parentId in descendants(childId, nodes)) {
|
|
204
|
+
fail("CYCLE", "inserting node $childId would create a cycle")
|
|
205
|
+
}
|
|
206
|
+
val index = insertionIndex(operation, parent.children.size)
|
|
207
|
+
parent.children.add(index, childId)
|
|
208
|
+
child.parent = parentId
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun moveChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
|
|
212
|
+
val parentId = requiredId(operation, "parentId", "moveChild")
|
|
213
|
+
val childId = requiredId(operation, "childId", "moveChild")
|
|
214
|
+
val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
|
|
215
|
+
val oldIndex = parent.children.indexOf(childId)
|
|
216
|
+
if (nodes[childId]?.parent != parentId || oldIndex < 0) {
|
|
217
|
+
fail("NOT_A_CHILD", "node $childId is not a child of $parentId")
|
|
218
|
+
}
|
|
219
|
+
parent.children.removeAt(oldIndex)
|
|
220
|
+
parent.children.add(insertionIndex(operation, parent.children.size), childId)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun removeNode(
|
|
224
|
+
operation: JSONObject,
|
|
225
|
+
nodes: MutableMap<String, Node>,
|
|
226
|
+
root: String?
|
|
227
|
+
): String? {
|
|
228
|
+
val id = requiredId(operation, "id", "removeNode")
|
|
229
|
+
val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
|
|
230
|
+
node.parent?.let { nodes[it]?.children?.remove(id) }
|
|
231
|
+
(descendants(id, nodes) + id).forEach { nodes.remove(it) }
|
|
232
|
+
return if (root == id) null else root
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private fun requiredId(operation: JSONObject, field: String, name: String): String {
|
|
236
|
+
val id = operation.opt(field) as? String
|
|
237
|
+
if (id.isNullOrBlank()) fail("INVALID_NODE", "$name.$field must be a non-empty string")
|
|
238
|
+
return id
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private fun insertionIndex(operation: JSONObject, count: Int): Int {
|
|
242
|
+
val index = strictInteger(operation.opt("index"))
|
|
243
|
+
if (index == null || index < 0 || index > count) {
|
|
244
|
+
fail("INVALID_INDEX", "index must be between 0 and $count")
|
|
245
|
+
}
|
|
246
|
+
return index
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private fun textChildren(value: Any?): MutableList<String> {
|
|
250
|
+
if (value == null || value == JSONObject.NULL) return mutableListOf()
|
|
251
|
+
if (value is String) return mutableListOf(value)
|
|
252
|
+
val values = value as? JSONArray
|
|
253
|
+
?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
|
|
254
|
+
val result = mutableListOf<String>()
|
|
255
|
+
for (index in 0 until values.length()) {
|
|
256
|
+
val text = values.opt(index) as? String
|
|
257
|
+
?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
|
|
258
|
+
result.add(text)
|
|
259
|
+
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private fun acceptsChildren(type: String): Boolean =
|
|
264
|
+
type == "View" || type == "SafeAreaView" || type == "ScrollView"
|
|
265
|
+
|
|
266
|
+
private fun descendants(id: String, nodes: Map<String, Node>): Set<String> {
|
|
267
|
+
val result = linkedSetOf<String>()
|
|
268
|
+
nodes[id]?.children?.forEach { child ->
|
|
269
|
+
result.add(child)
|
|
270
|
+
result.addAll(descendants(child, nodes))
|
|
271
|
+
}
|
|
272
|
+
return result
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun materialize(id: String, nodes: Map<String, Node>): JSONObject? {
|
|
276
|
+
val node = nodes[id] ?: return null
|
|
277
|
+
val children = JSONArray()
|
|
278
|
+
node.text.forEach { children.put(it) }
|
|
279
|
+
node.children.forEach { child -> materialize(child, nodes)?.let { children.put(it) } }
|
|
280
|
+
return JSONObject()
|
|
281
|
+
.put("id", node.id)
|
|
282
|
+
.put("type", node.type)
|
|
283
|
+
.put("props", node.props)
|
|
284
|
+
.put("style", node.style)
|
|
285
|
+
.put("events", node.events)
|
|
286
|
+
.put("children", children)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private fun importNode(
|
|
290
|
+
value: JSONObject,
|
|
291
|
+
preferredId: String,
|
|
292
|
+
parent: String?,
|
|
293
|
+
nodes: MutableMap<String, Node>
|
|
294
|
+
): String {
|
|
295
|
+
val props = dictionary(value.opt("props"))
|
|
296
|
+
val explicit = (value.opt("id") as? String)?.takeIf { it.isNotBlank() }
|
|
297
|
+
val key = listOf(value.opt("key"), props.opt("key"), props.opt("testID"))
|
|
298
|
+
.filterIsInstance<String>().firstOrNull { it.isNotBlank() }
|
|
299
|
+
var id = explicit ?: key?.let { "${parent ?: "root"}/key:$it" } ?: preferredId
|
|
300
|
+
if (nodes.containsKey(id)) id = "$preferredId#${nodes.size}"
|
|
301
|
+
val children = value.optJSONArray("children") ?: JSONArray()
|
|
302
|
+
val node = Node(
|
|
303
|
+
id,
|
|
304
|
+
value.optString("type", "View"),
|
|
305
|
+
props,
|
|
306
|
+
dictionary(value.opt("style")),
|
|
307
|
+
dictionary(value.opt("events")),
|
|
308
|
+
mutableListOf(),
|
|
309
|
+
mutableListOf(),
|
|
310
|
+
parent
|
|
311
|
+
)
|
|
312
|
+
nodes[id] = node
|
|
313
|
+
for (index in 0 until children.length()) {
|
|
314
|
+
when (val child = children.opt(index)) {
|
|
315
|
+
is String -> node.text.add(child)
|
|
316
|
+
is JSONObject -> node.children.add(importNode(child, "$id/index:$index", id, nodes))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return id
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private fun dictionary(value: Any?): JSONObject =
|
|
323
|
+
if (value is JSONObject) JSONObject(value.toString()) else JSONObject()
|
|
324
|
+
|
|
325
|
+
private fun strictInteger(value: Any?): Int? {
|
|
326
|
+
if (value !is Number) return null
|
|
327
|
+
val integer = value.toInt()
|
|
328
|
+
return integer.takeIf { value.toDouble() == integer.toDouble() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private fun fail(code: String, message: String): Nothing = throw CraftNativeMutationFailure(code, message)
|
|
332
|
+
|
|
333
|
+
private fun failAt(code: String, message: String, index: Int): Nothing =
|
|
334
|
+
throw CraftNativeMutationFailure(code, message, index)
|
|
335
|
+
}
|