PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 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 All 127 releases
extendify / app / Agent / Controllers / UpdateBlocksController.php

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

593 lines 23.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 use Extendify\Agent\TemplatePartBlockFinder;
9
10 // Ops splice in request order against one stamped parse, so earlier ops never
11 // invalidate later ids; untouched blocks round-trip byte-for-byte.
12 class UpdateBlocksController
13 {
14 // Labels match the words the apply helpers use for a missing id.
15 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
16 const ID_FIELDS = [
17 'blockId' => 'block id',
18 'anchorId' => 'anchor block',
19 'targetId' => 'target block',
20 ];
21
22 // Keyed by the model-facing container word; the placeholder paragraph
23 // marks the wrapped block's slot.
24 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
25 const WRAP_TEMPLATES = [
26 'core/column' => '<!-- wp:columns --><div class="wp-block-columns">'
27 . '<!-- wp:column --><div class="wp-block-column">'
28 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
29 . '</div><!-- /wp:column -->'
30 . '</div><!-- /wp:columns -->',
31 'core/group' => '<!-- wp:group {"layout":{"type":"constrained"}} -->'
32 . '<div class="wp-block-group">'
33 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
34 . '</div><!-- /wp:group -->',
35 ];
36
37 /**
38 * Apply a batch of block operations to a post.
39 *
40 * @param \WP_REST_Request $request The REST API request object.
41 * @return \WP_REST_Response
42 */
43 public static function updateBlocks(\WP_REST_Request $request)
44 {
45 $params = $request->get_json_params();
46 $params = is_array($params) ? $params : [];
47
48 $partSlug = (string) ($params['partSlug'] ?? '');
49 $inTemplatePart = $partSlug !== '';
50
51 if ($inTemplatePart) {
52 $post = self::resolveTemplatePart($partSlug);
53 if (\is_wp_error($post)) {
54 return new \WP_REST_Response(['error' => $post->get_error_message()], 404);
55 }
56 if (!\current_user_can('edit_theme_options')) {
57 return new \WP_REST_Response(['error' => 'Forbidden for this template part'], 403);
58 }
59 $blocks = TemplatePartBlockFinder::stamp(\parse_blocks($post->post_content));
60 } else {
61 $postId = (int) ($params['postId'] ?? 0);
62 $post = $postId ? \get_post($postId) : null;
63 if (!$post) {
64 return new \WP_REST_Response(['error' => 'Post not found'], 404);
65 }
66 if (!\current_user_can('edit_post', $post->ID)) {
67 return new \WP_REST_Response(['error' => 'Forbidden for this post'], 403);
68 }
69 $blocks = PostBlockFinder::stamp(\parse_blocks($post->post_content));
70 }
71
72 $operations = isset($params['operations']) && is_array($params['operations'])
73 ? $params['operations']
74 : [];
75 if (!$operations) {
76 return new \WP_REST_Response(['error' => 'operations required'], 400);
77 }
78
79 $trees = [$post->ID => self::newTree($post, $blocks)];
80
81 $applied = [];
82 $refused = [];
83 foreach ($operations as $operation) {
84 $operation = is_array($operation) ? $operation : [];
85 $op = (string) ($operation['op'] ?? '');
86 $reportKey = $op === 'add' ? 'anchorId' : 'blockId';
87 $reportId = $operation[$reportKey] ?? null;
88
89 $routed = self::route($operation, $trees, $post);
90 if (\is_wp_error($routed)) {
91 $refused[] = [$reportKey => $reportId, 'reason' => $routed->get_error_message()];
92 continue;
93 }
94 $owner = $routed['owner'];
95 $operation = $routed['operation'];
96 $blockId = (int) ($operation['blockId'] ?? 0);
97
98 if ($op === 'add') {
99 $reason = self::applyAdd(
100 $trees[$owner]['blocks'],
101 (int) ($operation['anchorId'] ?? 0),
102 $operation,
103 $trees[$owner]['wrappers']
104 );
105 } elseif ($op === 'wrap') {
106 $reason = self::applyWrap(
107 $trees[$owner]['blocks'],
108 $blockId,
109 $operation,
110 $trees[$owner]['wrappers']
111 );
112 } elseif (in_array($op, ['edit', 'delete', 'move'], true)) {
113 $reason = self::applyOperation($trees[$owner]['blocks'], $op, $blockId, $operation);
114 } else {
115 $reason = 'unknown op';
116 }
117
118 if ($reason !== null) {
119 $refused[] = [$reportKey => $reportId, 'reason' => $reason];
120 continue;
121 }
122 $trees[$owner]['dirty'] = true;
123 $applied[] = ['op' => $op, $reportKey => $reportId, 'owner' => $owner];
124 }
125
126 $failed = [];
127 foreach ($trees as $tree) {
128 if (!$tree['dirty']) {
129 continue;
130 }
131 $update = \wp_update_post([
132 'ID' => $tree['post']->ID,
133 'post_content' => \wp_slash(\serialize_blocks($tree['blocks'])),
134 ], true);
135 if (\is_wp_error($update)) {
136 $failed[$tree['post']->ID] = $update->get_error_message();
137 }
138 }
139
140 // An earlier post is already written, so a failed save reports itself.
141 foreach ($applied as $index => $entry) {
142 if (!isset($failed[$entry['owner']])) {
143 continue;
144 }
145 $refused[] = [
146 'blockId' => $entry['blockId'] ?? ($entry['anchorId'] ?? null),
147 'reason' => $failed[$entry['owner']],
148 ];
149 unset($applied[$index]);
150 }
151
152 return new \WP_REST_Response([
153 'applied' => array_values(array_map(function ($entry) {
154 unset($entry['owner']);
155 return $entry;
156 }, $applied)),
157 'refused' => $refused,
158 ], 200);
159 }
160
161 // A composite id names another post, and a splice can't reach across two of
162 // them — so an operation whose ids disagree on the owner has nowhere to land.
163 private static function route(array $operation, array &$trees, \WP_Post $container)
164 {
165 $owner = null;
166 foreach (self::ID_FIELDS as $field => $label) {
167 if (!isset($operation[$field])) {
168 continue;
169 }
170 $resolved = TemplatePartBlockFinder::owningPost($operation[$field], $container);
171 if ($resolved === null) {
172 return new \WP_Error('not_found', "{$label} not found in this post");
173 }
174 $postId = $resolved['post']->ID;
175 if ($owner !== null && $owner !== $postId) {
176 return new \WP_Error('spans_posts', 'one operation cannot span two posts');
177 }
178 if (!isset($trees[$postId])) {
179 if (!\current_user_can('edit_post', $postId)) {
180 return new \WP_Error('forbidden', 'Forbidden for the post that owns this block');
181 }
182 $trees[$postId] = self::newTree(
183 $resolved['post'],
184 TemplatePartBlockFinder::stamp(\parse_blocks($resolved['post']->post_content))
185 );
186 }
187 $owner = $postId;
188 $operation[$field] = $resolved['blockId'];
189 }
190
191 return ['owner' => $owner ?? $container->ID, 'operation' => $operation];
192 }
193
194 // `wrappers` is per-post: an add can only join a container this same batch
195 // created in the same post.
196 private static function newTree(\WP_Post $post, array $blocks): array
197 {
198 return ['post' => $post, 'blocks' => $blocks, 'wrappers' => [], 'dirty' => false];
199 }
200
201 // Resolve via WP's own resolver so the save lands on the post WP renders
202 // from; no wp_id means an uncustomized theme-file part with nothing to
203 // save to. Mirrors QuickEdit's SaveController::resolveSourcePost.
204 private static function resolveTemplatePart(string $slug)
205 {
206 $stylesheet = \wp_get_theme()->get_stylesheet();
207 $template = \get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
208 if (!$template) {
209 return new \WP_Error('not_found', 'Template part not found');
210 }
211 $post = empty($template->wp_id) ? null : \get_post($template->wp_id);
212 if ($post) {
213 return $post;
214 }
215 return self::forkThemeTemplatePart($template, $stylesheet, $slug);
216 }
217
218 // An untouched part has no post, so the first edit has to mint one.
219 private static function forkThemeTemplatePart($template, string $stylesheet, string $slug)
220 {
221 $postId = \wp_insert_post([
222 'post_type' => 'wp_template_part',
223 'post_name' => $slug,
224 'post_title' => empty($template->title) ? $slug : $template->title,
225 'post_content' => $template->content,
226 'post_status' => 'publish',
227 ], true);
228 if (\is_wp_error($postId)) {
229 return $postId;
230 }
231
232 // Absent the theme term, get_block_template never resolves the fork again.
233 \wp_set_object_terms($postId, $stylesheet, 'wp_theme');
234 if (!empty($template->area)) {
235 \wp_set_object_terms($postId, $template->area, 'wp_template_part_area');
236 }
237
238 $post = \get_post($postId);
239 return $post ? $post : new \WP_Error('not_found', 'Template part not found');
240 }
241
242 // Returns null when the op spliced in, or the refusal reason.
243 private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)
244 {
245 if ($op === 'edit') {
246 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
247 if (!$newBlock) {
248 return 'block must parse to exactly one block';
249 }
250 $path = PostBlockFinder::pathByRef($blocks, $blockId);
251 if ($path === null) {
252 return 'block id not found in this post';
253 }
254 $newBlock = self::carryStamps(self::blockAtPath($blocks, $path), $newBlock);
255 $blocks = self::spliceAtPath($blocks, $path, $newBlock);
256 return null;
257 }
258
259 if ($op === 'delete') {
260 $path = PostBlockFinder::pathByRef($blocks, $blockId);
261 if ($path === null) {
262 return 'block id not found in this post';
263 }
264 $blocks = self::spliceAtPath($blocks, $path, null);
265 return null;
266 }
267
268 $position = (string) ($operation['position'] ?? '');
269 if (!in_array($position, ['before', 'after'], true)) {
270 return "position must be 'before' or 'after'";
271 }
272 $targetId = (int) ($operation['targetId'] ?? 0);
273 if ($targetId === $blockId) {
274 return 'cannot move a block relative to itself';
275 }
276 $sourcePath = PostBlockFinder::pathByRef($blocks, $blockId);
277 if ($sourcePath === null) {
278 return 'block id not found in this post';
279 }
280 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
281 if ($targetPath === null) {
282 return 'target block not found in this post';
283 }
284 if (array_slice($targetPath, 0, count($sourcePath)) === $sourcePath) {
285 return 'target is inside the moved block';
286 }
287
288 $moved = self::blockAtPath($blocks, $sourcePath);
289 $blocks = self::spliceAtPath($blocks, $sourcePath, null);
290 // Extraction shifted indexes; the stamp still finds the target.
291 $targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
292 $blocks = self::insertAtPath($blocks, $targetPath, $moved, $position);
293 return null;
294 }
295
296 // Returns null when the block spliced in next to its anchor, or the refusal reason.
297 private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers)
298 {
299 $position = (string) ($operation['position'] ?? '');
300 if (!in_array($position, ['before', 'after'], true)) {
301 return "position must be 'before' or 'after'";
302 }
303 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
304 if (!$newBlock) {
305 return 'block must parse to exactly one block';
306 }
307 $anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId);
308 if ($anchorPath === null) {
309 return 'anchor block not found in this post';
310 }
311 // A bare column is only valid as a core/columns child, so its anchor
312 // decides the splice here — the code owns the wrapper, never the model.
313 if (($newBlock['blockName'] ?? '') === 'core/column') {
314 return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers);
315 }
316 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
317 return null;
318 }
319
320 // A pre-existing section never absorbs an add — the only wrapper an add
321 // joins is one this same batch created.
322 private static function spliceColumn(
323 array &$blocks,
324 array $anchorPath,
325 array $newBlock,
326 string $position,
327 int $anchorId,
328 array &$sharedWrappers
329 ) {
330 if ((self::blockAtPath($blocks, $anchorPath)['blockName'] ?? '') === 'core/column') {
331 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
332 return null;
333 }
334
335 $key = $anchorId . ':' . $position;
336 if (isset($sharedWrappers[$key])) {
337 $wrapperPath = PostBlockFinder::pathByRef($blocks, $sharedWrappers[$key]);
338 $wrapper = self::blockAtPath($blocks, $wrapperPath);
339 $lastChild = array_merge($wrapperPath, ['innerBlocks', count($wrapper['innerBlocks']) - 1]);
340 $blocks = self::insertAtPath($blocks, $lastChild, $newBlock, 'after');
341 return null;
342 }
343
344 $wrapper = self::parseSingleBlock(
345 '<!-- wp:columns --><div class="wp-block-columns">'
346 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
347 . '</div><!-- /wp:columns -->'
348 );
349 $wrapper['innerBlocks'][0] = $newBlock;
350 // Render stamps are positive, so a negative ref can't collide.
351 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
352 $sharedWrappers[$key] = $wrapper[PostBlockFinder::REF_KEY];
353 $blocks = self::insertAtPath($blocks, $anchorPath, $wrapper, $position);
354 return null;
355 }
356
357 // Returns null when the container spliced in around the block, or the
358 // refusal reason. The wrapped subtree is reused verbatim — bytes and id
359 // stamps survive, so later ops in the batch can still anchor to it.
360 private static function applyWrap(array &$blocks, int $blockId, array $operation, array &$sharedWrappers)
361 {
362 $container = (string) ($operation['container'] ?? '');
363 if (!isset(self::WRAP_TEMPLATES[$container])) {
364 return 'unknown container';
365 }
366 $path = PostBlockFinder::pathByRef($blocks, $blockId);
367 if ($path === null) {
368 return 'block id not found in this post';
369 }
370 $block = self::blockAtPath($blocks, $path);
371 // Pulling a column out of core/columns leaves the parent unserializable.
372 if (($block['blockName'] ?? '') === 'core/column') {
373 return 'a column cannot be wrapped';
374 }
375 $isColumn = $container === 'core/column';
376 if ($isColumn && self::joinWrapShell($blocks, $path, $block, $blockId, $sharedWrappers)) {
377 return null;
378 }
379 $wrapper = self::parseSingleBlock(self::WRAP_TEMPLATES[$container]);
380 if ($container === 'core/column') {
381 $wrapper['innerBlocks'][0]['innerBlocks'][0] = $block;
382 // "Wrap this in a column and add another beside it": a later
383 // column add anchored to the wrapped block joins this wrapper.
384 $wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
385 $sharedWrappers[$blockId . ':after'] = $wrapper[PostBlockFinder::REF_KEY];
386 $sharedWrappers[$blockId . ':before'] = $wrapper[PostBlockFinder::REF_KEY];
387 $sharedWrappers['wrap-shell'] = $wrapper[PostBlockFinder::REF_KEY];
388 } else {
389 $wrapper['innerBlocks'][0] = $block;
390 }
391 $blocks = self::spliceAtPath($blocks, $path, $wrapper);
392 return null;
393 }
394
395 // Two column wraps in one batch mean side by side — join the first shell.
396 private static function joinWrapShell(
397 array &$blocks,
398 array $path,
399 array $block,
400 int $blockId,
401 array &$sharedWrappers
402 ) {
403 if (!isset($sharedWrappers['wrap-shell'])) {
404 return false;
405 }
406 $shellRef = $sharedWrappers['wrap-shell'];
407 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
408 // A shell inside the wrapped block would leave with it — start fresh.
409 if ($shellPath === null || array_slice($shellPath, 0, count($path)) === $path) {
410 return false;
411 }
412 $blocks = self::spliceAtPath($blocks, $path, null);
413 $shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
414 $shell = self::blockAtPath($blocks, $shellPath);
415 $column = self::parseSingleBlock(
416 '<!-- wp:column --><div class="wp-block-column">'
417 . '<!-- wp:paragraph --><p></p><!-- /wp:paragraph -->'
418 . '</div><!-- /wp:column -->'
419 );
420 $column['innerBlocks'][0] = $block;
421 $lastChild = array_merge($shellPath, ['innerBlocks', count($shell['innerBlocks']) - 1]);
422 $blocks = self::insertAtPath($blocks, $lastChild, $column, 'after');
423 $sharedWrappers[$blockId . ':after'] = $shellRef;
424 $sharedWrappers[$blockId . ':before'] = $shellRef;
425 return true;
426 }
427
428 private static function blockAtPath(array $blocks, array $path)
429 {
430 $node = $blocks;
431 foreach ($path as $key) {
432 $node = $node[$key];
433 }
434 return $node;
435 }
436
437 // Position can't tell an untouched child from a swapped one.
438 private static function carryStamps(array $old, array $new): array
439 {
440 if (isset($old[PostBlockFinder::REF_KEY])) {
441 $new[PostBlockFinder::REF_KEY] = $old[PostBlockFinder::REF_KEY];
442 }
443 $oldInner = isset($old['innerBlocks']) && is_array($old['innerBlocks'])
444 ? $old['innerBlocks']
445 : [];
446 $newInner = isset($new['innerBlocks']) && is_array($new['innerBlocks'])
447 ? $new['innerBlocks']
448 : [];
449 foreach ($newInner as $i => $child) {
450 if (!isset($oldInner[$i]) || !is_array($oldInner[$i]) || !is_array($child)) {
451 continue;
452 }
453 if (\serialize_blocks([$oldInner[$i]]) !== \serialize_blocks([$child])) {
454 continue;
455 }
456 $newInner[$i] = self::carryStamps($oldInner[$i], $child);
457 }
458 if ($newInner) {
459 $new['innerBlocks'] = $newInner;
460 }
461 return $new;
462 }
463
464 private static function parseSingleBlock(string $markup)
465 {
466 $parsed = array_values(array_filter(
467 \parse_blocks($markup),
468 static function ($block) {
469 return is_array($block) && !empty($block['blockName']);
470 }
471 ));
472 return count($parsed) === 1 ? $parsed[0] : null;
473 }
474
475 // Path elements alternate index / 'innerBlocks' / …; null deletes.
476 private static function spliceAtPath(array $list, array $path, $newBlock): array
477 {
478 $head = ($path[0] ?? null);
479 if (!is_int($head) || !isset($list[$head])) {
480 return $list;
481 }
482
483 $rest = array_slice($path, 1);
484 if (!$rest) {
485 if ($newBlock === null) {
486 array_splice($list, $head, 1);
487 } else {
488 $list[$head] = $newBlock;
489 }
490 return $list;
491 }
492
493 if ($rest[0] !== 'innerBlocks') {
494 return $list;
495 }
496
497 $childPath = array_slice($rest, 1);
498 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
499 ? $list[$head]['innerBlocks']
500 : [];
501
502 // Drop the child's innerContent placeholder too, or serialize_blocks
503 // misaligns the remaining children.
504 if ($newBlock === null && count($childPath) === 1) {
505 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
506 ? $list[$head]['innerContent']
507 : [];
508 $list[$head]['innerContent'] = self::withoutNthPlaceholder($innerContent, $childPath[0]);
509 }
510
511 $list[$head]['innerBlocks'] = self::spliceAtPath($inner, $childPath, $newBlock);
512 return $list;
513 }
514
515 private static function withoutNthPlaceholder(array $innerContent, int $n): array
516 {
517 $seen = -1;
518 foreach ($innerContent as $i => $chunk) {
519 if (is_string($chunk)) {
520 continue;
521 }
522 $seen++;
523 if ($seen === $n) {
524 array_splice($innerContent, $i, 1);
525 break;
526 }
527 }
528 return $innerContent;
529 }
530
531 private static function insertAtPath(array $list, array $path, array $block, string $position): array
532 {
533 $head = ($path[0] ?? null);
534 if (!is_int($head) || !isset($list[$head])) {
535 return $list;
536 }
537
538 $rest = array_slice($path, 1);
539 if (!$rest) {
540 array_splice($list, $head + ($position === 'after' ? 1 : 0), 0, [$block]);
541 return $list;
542 }
543
544 if ($rest[0] !== 'innerBlocks') {
545 return $list;
546 }
547
548 $childPath = array_slice($rest, 1);
549 // Mirror the delete case: a new child needs its innerContent
550 // placeholder too, or serialize_blocks misaligns the children.
551 if (count($childPath) === 1) {
552 $innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
553 ? $list[$head]['innerContent']
554 : [];
555 $list[$head]['innerContent'] = self::withPlaceholderAt(
556 $innerContent,
557 $childPath[0] + ($position === 'after' ? 1 : 0)
558 );
559 }
560
561 $inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
562 ? $list[$head]['innerBlocks']
563 : [];
564 $list[$head]['innerBlocks'] = self::insertAtPath($inner, $childPath, $block, $position);
565 return $list;
566 }
567
568 // Inserts a null so it becomes the nth placeholder; past the last one it
569 // lands right after it, before any trailing closing markup.
570 private static function withPlaceholderAt(array $innerContent, int $n): array
571 {
572 $seen = -1;
573 foreach ($innerContent as $i => $chunk) {
574 if (is_string($chunk)) {
575 continue;
576 }
577 $seen++;
578 if ($seen === $n) {
579 array_splice($innerContent, $i, 0, [null]);
580 return $innerContent;
581 }
582 }
583 for ($i = count($innerContent) - 1; $i >= 0; $i--) {
584 if (!is_string($innerContent[$i])) {
585 array_splice($innerContent, $i + 1, 0, [null]);
586 return $innerContent;
587 }
588 }
589 $innerContent[] = null;
590 return $innerContent;
591 }
592 }
593