craft-native 0.0.113 → 0.0.118

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.
@@ -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
+ }