PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
← All changes | app/Agent/Controllers/UpdateBlocksController.php +246 -48 3.1.4 → 3.2.2 View file →
@@ -4,13 +4,22 @@
4 4
5 5 defined('ABSPATH') || die('No direct access.');
6 6
7 7 use Extendify\Agent\PostBlockFinder;
8 +use Extendify\Agent\TemplatePartBlockFinder;
8 9
9 10 // Ops splice in request order against one stamped parse, so earlier ops never
10 11 // invalidate later ids; untouched blocks round-trip byte-for-byte.
11 12 class UpdateBlocksController
12 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 +
13 22 // Keyed by the model-facing container word; the placeholder paragraph
14 23 // marks the wrapped block's slot.
15 24 // phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound
16 25 const WRAP_TEMPLATES = [
@@ -35,26 +44,32 @@
35 44 {
36 45 $params = $request->get_json_params();
37 46 $params = is_array($params) ? $params : [];
38 47
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 - }
48 + $partSlug = (string) ($params['partSlug'] ?? '');
49 + $inTemplatePart = $partSlug !== '';
46 50
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 + 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));
51 70 }
52 71
53 - if (!\current_user_can('edit_post', $post->ID)) {
54 - return new \WP_REST_Response(['error' => 'Forbidden for this post'], 403);
55 - }
56 -
57 72 $operations = isset($params['operations']) && is_array($params['operations'])
58 73 ? $params['operations']
59 74 : [];
60 75 if (!$operations) {
@@ -60,63 +75,171 @@
60 75 if (!$operations) {
61 76 return new \WP_REST_Response(['error' => 'operations required'], 400);
62 77 }
63 78
64 - $blocks = PostBlockFinder::stamp(\parse_blocks($post->post_content));
79 + $trees = [$post->ID => self::newTree($post, $blocks)];
65 80
66 81 $applied = [];
67 82 $refused = [];
68 - $sharedWrappers = [];
69 83 foreach ($operations as $operation) {
70 84 $operation = is_array($operation) ? $operation : [];
71 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'];
72 96 $blockId = (int) ($operation['blockId'] ?? 0);
73 97
74 98 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];
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 ($op === 'duplicate') {
113 + $reason = self::applyDuplicate($trees[$owner]['blocks'], $blockId, $operation);
114 + } elseif (in_array($op, ['edit', 'delete', 'move'], true)) {
115 + $reason = self::applyOperation($trees[$owner]['blocks'], $op, $blockId, $operation);
116 + } else {
117 + $reason = 'unknown op';
118 + }
119 +
120 + if ($reason !== null) {
121 + $refused[] = [$reportKey => $reportId, 'reason' => $reason];
82 122 continue;
83 123 }
124 + $trees[$owner]['dirty'] = true;
125 + $applied[] = ['op' => $op, $reportKey => $reportId, 'owner' => $owner];
126 + }
84 127
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];
128 + $failed = [];
129 + foreach ($trees as $tree) {
130 + if (!$tree['dirty']) {
92 131 continue;
93 132 }
133 + $update = \wp_update_post([
134 + 'ID' => $tree['post']->ID,
135 + 'post_content' => \wp_slash(\serialize_blocks($tree['blocks'])),
136 + ], true);
137 + if (\is_wp_error($update)) {
138 + $failed[$tree['post']->ID] = $update->get_error_message();
139 + }
140 + }
94 141
95 - if (!in_array($op, ['edit', 'delete', 'move'], true)) {
96 - $refused[] = ['blockId' => ($blockId ?: null), 'reason' => 'unknown op'];
142 + // An earlier post is already written, so a failed save reports itself.
143 + foreach ($applied as $index => $entry) {
144 + if (!isset($failed[$entry['owner']])) {
97 145 continue;
98 146 }
147 + $refused[] = [
148 + 'blockId' => $entry['blockId'] ?? ($entry['anchorId'] ?? null),
149 + 'reason' => $failed[$entry['owner']],
150 + ];
151 + unset($applied[$index]);
152 + }
99 153
100 - $reason = self::applyOperation($blocks, $op, $blockId, $operation);
101 - if ($reason !== null) {
102 - $refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
154 + return new \WP_REST_Response([
155 + 'applied' => array_values(array_map(function ($entry) {
156 + unset($entry['owner']);
157 + return $entry;
158 + }, $applied)),
159 + 'refused' => $refused,
160 + ], 200);
161 + }
162 +
163 + // A composite id names another post, and a splice can't reach across two of
164 + // them — so an operation whose ids disagree on the owner has nowhere to land.
165 + private static function route(array $operation, array &$trees, \WP_Post $container)
166 + {
167 + $owner = null;
168 + foreach (self::ID_FIELDS as $field => $label) {
169 + if (!isset($operation[$field])) {
103 170 continue;
104 171 }
105 - $applied[] = ['op' => $op, 'blockId' => $blockId];
172 + $resolved = TemplatePartBlockFinder::owningPost($operation[$field], $container);
173 + if ($resolved === null) {
174 + return new \WP_Error('not_found', "{$label} not found in this post");
175 + }
176 + $postId = $resolved['post']->ID;
177 + if ($owner !== null && $owner !== $postId) {
178 + return new \WP_Error('spans_posts', 'one operation cannot span two posts');
179 + }
180 + if (!isset($trees[$postId])) {
181 + if (!\current_user_can('edit_post', $postId)) {
182 + return new \WP_Error('forbidden', 'Forbidden for the post that owns this block');
183 + }
184 + $trees[$postId] = self::newTree(
185 + $resolved['post'],
186 + TemplatePartBlockFinder::stamp(\parse_blocks($resolved['post']->post_content))
187 + );
188 + }
189 + $owner = $postId;
190 + $operation[$field] = $resolved['blockId'];
106 191 }
107 192
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 - }
193 + return ['owner' => $owner ?? $container->ID, 'operation' => $operation];
194 + }
195 +
196 + // `wrappers` is per-post: an add can only join a container this same batch
197 + // created in the same post.
198 + private static function newTree(\WP_Post $post, array $blocks): array
199 + {
200 + return ['post' => $post, 'blocks' => $blocks, 'wrappers' => [], 'dirty' => false];
201 + }
202 +
203 + // Resolve via WP's own resolver so the save lands on the post WP renders
204 + // from; no wp_id means an uncustomized theme-file part with nothing to
205 + // save to. Mirrors QuickEdit's SaveController::resolveSourcePost.
206 + private static function resolveTemplatePart(string $slug)
207 + {
208 + $stylesheet = \wp_get_theme()->get_stylesheet();
209 + $template = \get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
210 + if (!$template) {
211 + return new \WP_Error('not_found', 'Template part not found');
116 212 }
213 + $post = empty($template->wp_id) ? null : \get_post($template->wp_id);
214 + if ($post) {
215 + return $post;
216 + }
217 + return self::forkThemeTemplatePart($template, $stylesheet, $slug);
218 + }
117 219
118 - return new \WP_REST_Response(['applied' => $applied, 'refused' => $refused], 200);
220 + // An untouched part has no post, so the first edit has to mint one.
221 + private static function forkThemeTemplatePart($template, string $stylesheet, string $slug)
222 + {
223 + $postId = \wp_insert_post([
224 + 'post_type' => 'wp_template_part',
225 + 'post_name' => $slug,
226 + 'post_title' => empty($template->title) ? $slug : $template->title,
227 + 'post_content' => $template->content,
228 + 'post_status' => 'publish',
229 + ], true);
230 + if (\is_wp_error($postId)) {
231 + return $postId;
232 + }
233 +
234 + // Absent the theme term, get_block_template never resolves the fork again.
235 + \wp_set_object_terms($postId, $stylesheet, 'wp_theme');
236 + if (!empty($template->area)) {
237 + \wp_set_object_terms($postId, $template->area, 'wp_template_part_area');
238 + }
239 +
240 + $post = \get_post($postId);
241 + return $post ? $post : new \WP_Error('not_found', 'Template part not found');
119 242 }
120 243
121 244 // Returns null when the op spliced in, or the refusal reason.
122 245 private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)
@@ -175,10 +298,10 @@
175 298 // Returns null when the block spliced in next to its anchor, or the refusal reason.
176 299 private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers)
177 300 {
178 301 $position = (string) ($operation['position'] ?? '');
179 - if (!in_array($position, ['before', 'after'], true)) {
180 - return "position must be 'before' or 'after'";
302 + if (!in_array($position, ['before', 'after', 'inside'], true)) {
303 + return "position must be 'before', 'after' or 'inside'";
181 304 }
182 305 $newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
183 306 if (!$newBlock) {
184 307 return 'block must parse to exactly one block';
@@ -186,8 +309,11 @@
186 309 $anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId);
187 310 if ($anchorPath === null) {
188 311 return 'anchor block not found in this post';
189 312 }
313 + if ($position === 'inside') {
314 + return self::nestInMenuItem($blocks, $anchorPath, $newBlock);
315 + }
190 316 // A bare column is only valid as a core/columns child, so its anchor
191 317 // decides the splice here — the code owns the wrapper, never the model.
192 318 if (($newBlock['blockName'] ?? '') === 'core/column') {
193 319 return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers);
@@ -195,8 +321,35 @@
195 321 $blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
196 322 return null;
197 323 }
198 324
325 + // The editor's "Add submenu": the link becomes a submenu keeping its label and URL.
326 + private static function nestInMenuItem(array &$blocks, array $anchorPath, array $newBlock)
327 + {
328 + $anchor = self::blockAtPath($blocks, $anchorPath);
329 + $anchorName = $anchor['blockName'] ?? '';
330 + if (!in_array($anchorName, ['core/navigation-link', 'core/navigation-submenu'], true)) {
331 + return "position 'inside' needs a menu item as the anchor";
332 + }
333 + if (($newBlock['blockName'] ?? '') !== 'core/navigation-link') {
334 + return 'only a menu link can go inside a menu item';
335 + }
336 + // A child claiming top level renders as if it sat beside its parent.
337 + unset($newBlock['attrs']['isTopLevelLink']);
338 + if ($anchorName === 'core/navigation-link') {
339 + $anchor['blockName'] = 'core/navigation-submenu';
340 + $attrs = [];
341 + foreach (($anchor['attrs'] ?? []) as $key => $value) {
342 + $attrs[$key === 'isTopLevelLink' ? 'isTopLevelItem' : $key] = $value;
343 + }
344 + $anchor['attrs'] = $attrs;
345 + }
346 + $anchor['innerBlocks'][] = $newBlock;
347 + $anchor['innerContent'][] = null;
348 + $blocks = self::spliceAtPath($blocks, $anchorPath, $anchor);
349 + return null;
350 + }
351 +
199 352 // A pre-existing section never absorbs an add — the only wrapper an add
200 353 // joins is one this same batch created.
201 354 private static function spliceColumn(
202 355 array &$blocks,
@@ -301,8 +454,53 @@
301 454 $blocks = self::insertAtPath($blocks, $lastChild, $column, 'after');
302 455 $sharedWrappers[$blockId . ':after'] = $shellRef;
303 456 $sharedWrappers[$blockId . ':before'] = $shellRef;
304 457 return true;
458 + }
459 +
460 + // Returns null when the copy spliced in, or the refusal reason.
461 + private static function applyDuplicate(array &$blocks, int $blockId, array $operation)
462 + {
463 + $path = PostBlockFinder::pathByRef($blocks, $blockId);
464 + if ($path === null) {
465 + return 'block id not found in this post';
466 + }
467 + $original = self::blockAtPath($blocks, $path);
468 + $copy = self::withoutStamps($original);
469 + if (isset($operation['block'])) {
470 + $copy = self::parseSingleBlock((string) $operation['block']);
471 + if (!$copy) {
472 + return 'block must parse to exactly one block';
473 + }
474 + if (($copy['blockName'] ?? '') !== ($original['blockName'] ?? '')) {
475 + return 'a copy must keep the original block type';
476 + }
477 + }
478 + $anchorPath = $path;
479 + $position = 'after';
480 + if (isset($operation['targetId'])) {
481 + $anchorPath = PostBlockFinder::pathByRef($blocks, (int) $operation['targetId']);
482 + if ($anchorPath === null) {
483 + return 'target block not found in this post';
484 + }
485 + if (array_slice($anchorPath, 0, count($path)) === $path && $anchorPath !== $path) {
486 + return 'target is inside the copied block';
487 + }
488 + $position = (string) ($operation['position'] ?? 'after');
489 + if (!in_array($position, ['before', 'after'], true)) {
490 + return "position must be 'before' or 'after'";
491 + }
492 + }
493 + $blocks = self::insertAtPath($blocks, $anchorPath, $copy, $position);
494 + return null;
495 + }
496 +
497 + // A stamped copy would catch later ops aimed at the original's ids.
498 + private static function withoutStamps(array $block): array
499 + {
500 + unset($block[PostBlockFinder::REF_KEY]);
501 + $block['innerBlocks'] = array_map([self::class, 'withoutStamps'], $block['innerBlocks'] ?? []);
502 + return $block;
305 503 }
306 504
307 505 private static function blockAtPath(array $blocks, array $path)
308 506 {