PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 0.7.0 All 126 releases
extendify / app / Agent / Controllers / UpdateBlocksController.php

UpdateBlocksController.php in Extendify 3.1.5, at app/Agent/Controllers/UpdateBlocksController.php

472 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Agent\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Agent\PostBlockFinder;
8
9 // Ops splice in request order against one stamped parse, so earlier ops never
10 // invalidate later ids; untouched blocks round-trip byte-for-byte.
11 class UpdateBlocksController
12 {
13 // Keyed by the model-facing container word; the placeholder paragraph
14 // marks the wrapped block's slot.
15 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
16 const WRAP_TEMPLATES = [
17 'core/column' => '<!-- wp:columns --><div class="wp-block-columns">'
18 . '<!-- wp:column --><div class="wp-block-column">'
19 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
20 . '</div><!-- /wp:column -->'
21 . '</div><!-- /wp:columns -->',
22 'core/group' => '<!-- wp:group {"layout":{"type":"constrained"}} -->'
23 . '<div class="wp-block-group">'
24 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
25 . '</div><!-- /wp:group -->',
26 ];
27
28 /**
29 * Apply a batch of block operations to a post.
30 *
31 * @param \WP_REST_Request $request The REST API request object.
32 * @return \WP_REST_Response
33 */
34 public static function updateBlocks(\WP_REST_Request $request)
35 {
36 $params = $request->get_json_params();
37 $params = is_array($params) ? $params : [];
38
39 // Template parts have a separate id space; refusing beats a silent no-op.
40 if (!empty($params['partSlug'])) {
41 return new \WP_REST_Response(
42 ['error' => 'Template-part blocks cannot be saved by this endpoint'],
43 400
44 );
45 }
46
47 $postId = (int) ($params['postId'] ?? 0);
48 $post = $postId ? \get_post($postId) : null;
49 if (!$post) {
50 return new \WP_REST_Response(['error' => 'Post not found'], 404);
51 }
52
53 if (!\current_user_can('edit_post', $post->ID)) {
54 return new \WP_REST_Response(['error' => 'Forbidden for this post'], 403);
55 }
56
57 $operations = isset($params['operations']) && is_array($params['operations'])
58 ? $params['operations']
59 : [];
60 if (!$operations) {
61 return new \WP_REST_Response(['error' => 'operations required'], 400);
62 }
63
64 $blocks = PostBlockFinder::stamp(\parse_blocks($post->post_content));
65
66 $applied = [];
67 $refused = [];
68 $sharedWrappers = [];
69 foreach ($operations as $operation) {
70 $operation = is_array($operation) ? $operation : [];
71 $op = (string) ($operation['op'] ?? '');
72 $blockId = (int) ($operation['blockId'] ?? 0);
73
74 if ($op === 'add') {
75 $anchorId = (int) ($operation['anchorId'] ?? 0);
76 $reason = self::applyAdd($blocks, $anchorId, $operation, $sharedWrappers);
77 if ($reason !== null) {
78 $refused[] = ['anchorId' => ($anchorId ?: null), 'reason' => $reason];
79 continue;
80 }
81 $applied[] = ['op' => 'add', 'anchorId' => $anchorId];
82 continue;
83 }
84
85 if ($op === 'wrap') {
86 $reason = self::applyWrap($blocks, $blockId, $operation, $sharedWrappers);
87 if ($reason !== null) {
88 $refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
89 continue;
90 }
91 $applied[] = ['op' => 'wrap', 'blockId' => $blockId];
92 continue;
93 }
94
95 if (!in_array($op, ['edit', 'delete', 'move'], true)) {
96 $refused[] = ['blockId' => ($blockId ?: null), 'reason' => 'unknown op'];
97 continue;
98 }
99
100 $reason = self::applyOperation($blocks, $op, $blockId, $operation);
101 if ($reason !== null) {
102 $refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
103 continue;
104 }
105 $applied[] = ['op' => $op, 'blockId' => $blockId];
106 }
107
108 if ($applied) {
109 $update = \wp_update_post([
110 'ID' => $post->ID,
111 'post_content' => \wp_slash(\serialize_blocks($blocks)),
112 ], true);
113 if (\is_wp_error($update)) {
114 return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
115 }
116 }
117
118 return new \WP_REST_Response(['applied' => $applied, 'refused' => $refused], 200);
119 }
120
121 // Returns null when the op spliced in, or the refusal reason.
122 private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)
123 {
124 if ($op === 'edit') {
125 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
126 if (!$newBlock) {
127 return 'block must parse to exactly one block';
128 }
129 $path = PostBlockFinder::pathByRef($blocks, $blockId);
130 if ($path === null) {
131 return 'block id not found in this post';
132 }
133 $newBlock = self::carryStamps(self::blockAtPath($blocks, $path), $newBlock);
134 $blocks = self::spliceAtPath($blocks, $path, $newBlock);
135 return null;
136 }
137
138 if ($op === 'delete') {
139 $path = PostBlockFinder::pathByRef($blocks, $blockId);
140 if ($path === null) {
141 return 'block id not found in this post';
142 }
143 $blocks = self::spliceAtPath($blocks, $path, null);
144 return null;
145 }
146
147 $position = (string) ($operation['position'] ?? '');
148 if (!in_array($position, ['before', 'after'], true)) {
149 return "position must be 'before' or 'after'";
150 }
151 $targetId = (int) ($operation['targetId'] ?? 0);
152 if ($targetId === $blockId) {
153 return 'cannot move a block relative to itself';
154 }
155 $sourcePath = PostBlockFinder::pathByRef($blocks, $blockId);
156 if ($sourcePath === null) {
157 return 'block id not found in this post';
158 }
159 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
160 if ($targetPath === null) {
161 return 'target block not found in this post';
162 }
163 if (array_slice($targetPath, 0, count($sourcePath)) === $sourcePath) {
164 return 'target is inside the moved block';
165 }
166
167 $moved = self::blockAtPath($blocks, $sourcePath);
168 $blocks = self::spliceAtPath($blocks, $sourcePath, null);
169 // Extraction shifted indexes; the stamp still finds the target.
170 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
171 $blocks = self::insertAtPath($blocks, $targetPath, $moved, $position);
172 return null;
173 }
174
175 // Returns null when the block spliced in next to its anchor, or the refusal reason.
176 private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers)
177 {
178 $position = (string) ($operation['position'] ?? '');
179 if (!in_array($position, ['before', 'after'], true)) {
180 return "position must be 'before' or 'after'";
181 }
182 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
183 if (!$newBlock) {
184 return 'block must parse to exactly one block';
185 }
186 $anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId);
187 if ($anchorPath === null) {
188 return 'anchor block not found in this post';
189 }
190 // A bare column is only valid as a core/columns child, so its anchor
191 // decides the splice here — the code owns the wrapper, never the model.
192 if (($newBlock['blockName'] ?? '') === 'core/column') {
193 return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers);
194 }
195 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
196 return null;
197 }
198
199 // A pre-existing section never absorbs an add — the only wrapper an add
200 // joins is one this same batch created.
201 private static function spliceColumn(
202 array &$blocks,
203 array $anchorPath,
204 array $newBlock,
205 string $position,
206 int $anchorId,
207 array &$sharedWrappers
208 ) {
209 if ((self::blockAtPath($blocks, $anchorPath)['blockName'] ?? '') === 'core/column') {
210 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
211 return null;
212 }
213
214 $key = $anchorId . ':' . $position;
215 if (isset($sharedWrappers[$key])) {
216 $wrapperPath = PostBlockFinder::pathByRef($blocks, $sharedWrappers[$key]);
217 $wrapper = self::blockAtPath($blocks, $wrapperPath);
218 $lastChild = array_merge($wrapperPath, ['innerBlocks', count($wrapper['innerBlocks']) - 1]);
219 $blocks = self::insertAtPath($blocks, $lastChild, $newBlock, 'after');
220 return null;
221 }
222
223 $wrapper = self::parseSingleBlock(
224 '<!-- wp:columns --><div class="wp-block-columns">'
225 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
226 . '</div><!-- /wp:columns -->'
227 );
228 $wrapper['innerBlocks'][0] = $newBlock;
229 // Render stamps are positive, so a negative ref can't collide.
230 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
231 $sharedWrappers[$key] = $wrapper[PostBlockFinder::REF_KEY];
232 $blocks = self::insertAtPath($blocks, $anchorPath, $wrapper, $position);
233 return null;
234 }
235
236 // Returns null when the container spliced in around the block, or the
237 // refusal reason. The wrapped subtree is reused verbatim — bytes and id
238 // stamps survive, so later ops in the batch can still anchor to it.
239 private static function applyWrap(array &$blocks, int $blockId, array $operation, array &$sharedWrappers)
240 {
241 $container = (string) ($operation['container'] ?? '');
242 if (!isset(self::WRAP_TEMPLATES[$container])) {
243 return 'unknown container';
244 }
245 $path = PostBlockFinder::pathByRef($blocks, $blockId);
246 if ($path === null) {
247 return 'block id not found in this post';
248 }
249 $block = self::blockAtPath($blocks, $path);
250 // Pulling a column out of core/columns leaves the parent unserializable.
251 if (($block['blockName'] ?? '') === 'core/column') {
252 return 'a column cannot be wrapped';
253 }
254 $isColumn = $container === 'core/column';
255 if ($isColumn && self::joinWrapShell($blocks, $path, $block, $blockId, $sharedWrappers)) {
256 return null;
257 }
258 $wrapper = self::parseSingleBlock(self::WRAP_TEMPLATES[$container]);
259 if ($container === 'core/column') {
260 $wrapper['innerBlocks'][0]['innerBlocks'][0] = $block;
261 // "Wrap this in a column and add another beside it": a later
262 // column add anchored to the wrapped block joins this wrapper.
263 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
264 $sharedWrappers[$blockId . ':after'] = $wrapper[PostBlockFinder::REF_KEY];
265 $sharedWrappers[$blockId . ':before'] = $wrapper[PostBlockFinder::REF_KEY];
266 $sharedWrappers['wrap-shell'] = $wrapper[PostBlockFinder::REF_KEY];
267 } else {
268 $wrapper['innerBlocks'][0] = $block;
269 }
270 $blocks = self::spliceAtPath($blocks, $path, $wrapper);
271 return null;
272 }
273
274 // Two column wraps in one batch mean side by side — join the first shell.
275 private static function joinWrapShell(
276 array &$blocks,
277 array $path,
278 array $block,
279 int $blockId,
280 array &$sharedWrappers
281 ) {
282 if (!isset($sharedWrappers['wrap-shell'])) {
283 return false;
284 }
285 $shellRef = $sharedWrappers['wrap-shell'];
286 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
287 // A shell inside the wrapped block would leave with it — start fresh.
288 if ($shellPath === null || array_slice($shellPath, 0, count($path)) === $path) {
289 return false;
290 }
291 $blocks = self::spliceAtPath($blocks, $path, null);
292 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
293 $shell = self::blockAtPath($blocks, $shellPath);
294 $column = self::parseSingleBlock(
295 '<!-- wp:column --><div class="wp-block-column">'
296 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
297 . '</div><!-- /wp:column -->'
298 );
299 $column['innerBlocks'][0] = $block;
300 $lastChild = array_merge($shellPath, ['innerBlocks', count($shell['innerBlocks']) - 1]);
301 $blocks = self::insertAtPath($blocks, $lastChild, $column, 'after');
302 $sharedWrappers[$blockId . ':after'] = $shellRef;
303 $sharedWrappers[$blockId . ':before'] = $shellRef;
304 return true;
305 }
306
307 private static function blockAtPath(array $blocks, array $path)
308 {
309 $node = $blocks;
310 foreach ($path as $key) {
311 $node = $node[$key];
312 }
313 return $node;
314 }
315
316 // Position can't tell an untouched child from a swapped one.
317 private static function carryStamps(array $old, array $new): array
318 {
319 if (isset($old[PostBlockFinder::REF_KEY])) {
320 $new[PostBlockFinder::REF_KEY] = $old[PostBlockFinder::REF_KEY];
321 }
322 $oldInner = isset($old['innerBlocks']) && is_array($old['innerBlocks'])
323 ? $old['innerBlocks']
324 : [];
325 $newInner = isset($new['innerBlocks']) && is_array($new['innerBlocks'])
326 ? $new['innerBlocks']
327 : [];
328 foreach ($newInner as $i => $child) {
329 if (!isset($oldInner[$i]) || !is_array($oldInner[$i]) || !is_array($child)) {
330 continue;
331 }
332 if (\serialize_blocks([$oldInner[$i]]) !== \serialize_blocks([$child])) {
333 continue;
334 }
335 $newInner[$i] = self::carryStamps($oldInner[$i], $child);
336 }
337 if ($newInner) {
338 $new['innerBlocks'] = $newInner;
339 }
340 return $new;
341 }
342
343 private static function parseSingleBlock(string $markup)
344 {
345 $parsed = array_values(array_filter(
346 \parse_blocks($markup),
347 static function ($block) {
348 return is_array($block) && !empty($block['blockName']);
349 }
350 ));
351 return count($parsed) === 1 ? $parsed[0] : null;
352 }
353
354 // Path elements alternate index / 'innerBlocks' / …; null deletes.
355 private static function spliceAtPath(array $list, array $path, $newBlock): array
356 {
357 $head = ($path[0] ?? null);
358 if (!is_int($head) || !isset($list[$head])) {
359 return $list;
360 }
361
362 $rest = array_slice($path, 1);
363 if (!$rest) {
364 if ($newBlock === null) {
365 array_splice($list, $head, 1);
366 } else {
367 $list[$head] = $newBlock;
368 }
369 return $list;
370 }
371
372 if ($rest[0] !== 'innerBlocks') {
373 return $list;
374 }
375
376 $childPath = array_slice($rest, 1);
377 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
378 ? $list[$head]['innerBlocks']
379 : [];
380
381 // Drop the child's innerContent placeholder too, or serialize_blocks
382 // misaligns the remaining children.
383 if ($newBlock === null && count($childPath) === 1) {
384 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
385 ? $list[$head]['innerContent']
386 : [];
387 $list[$head]['innerContent'] = self::withoutNthPlaceholder($innerContent, $childPath[0]);
388 }
389
390 $list[$head]['innerBlocks'] = self::spliceAtPath($inner, $childPath, $newBlock);
391 return $list;
392 }
393
394 private static function withoutNthPlaceholder(array $innerContent, int $n): array
395 {
396 $seen = -1;
397 foreach ($innerContent as $i => $chunk) {
398 if (is_string($chunk)) {
399 continue;
400 }
401 $seen++;
402 if ($seen === $n) {
403 array_splice($innerContent, $i, 1);
404 break;
405 }
406 }
407 return $innerContent;
408 }
409
410 private static function insertAtPath(array $list, array $path, array $block, string $position): array
411 {
412 $head = ($path[0] ?? null);
413 if (!is_int($head) || !isset($list[$head])) {
414 return $list;
415 }
416
417 $rest = array_slice($path, 1);
418 if (!$rest) {
419 array_splice($list, $head + ($position === 'after' ? 1 : 0), 0, [$block]);
420 return $list;
421 }
422
423 if ($rest[0] !== 'innerBlocks') {
424 return $list;
425 }
426
427 $childPath = array_slice($rest, 1);
428 // Mirror the delete case: a new child needs its innerContent
429 // placeholder too, or serialize_blocks misaligns the children.
430 if (count($childPath) === 1) {
431 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
432 ? $list[$head]['innerContent']
433 : [];
434 $list[$head]['innerContent'] = self::withPlaceholderAt(
435 $innerContent,
436 $childPath[0] + ($position === 'after' ? 1 : 0)
437 );
438 }
439
440 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
441 ? $list[$head]['innerBlocks']
442 : [];
443 $list[$head]['innerBlocks'] = self::insertAtPath($inner, $childPath, $block, $position);
444 return $list;
445 }
446
447 // Inserts a null so it becomes the nth placeholder; past the last one it
448 // lands right after it, before any trailing closing markup.
449 private static function withPlaceholderAt(array $innerContent, int $n): array
450 {
451 $seen = -1;
452 foreach ($innerContent as $i => $chunk) {
453 if (is_string($chunk)) {
454 continue;
455 }
456 $seen++;
457 if ($seen === $n) {
458 array_splice($innerContent, $i, 0, [null]);
459 return $innerContent;
460 }
461 }
462 for ($i = count($innerContent) - 1; $i >= 0; $i--) {
463 if (!is_string($innerContent[$i])) {
464 array_splice($innerContent, $i + 1, 0, [null]);
465 return $innerContent;
466 }
467 }
468 $innerContent[] = null;
469 return $innerContent;
470 }
471 }
472