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
← All changes | app/Agent/Controllers/UpdateBlocksController.php +167 -46 3.1.63.2.1 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,169 @@
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 (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];
82 120 continue;
83 121 }
122 + $trees[$owner]['dirty'] = true;
123 + $applied[] = ['op' => $op, $reportKey => $reportId, 'owner' => $owner];
124 + }
84 125
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];
126 + $failed = [];
127 + foreach ($trees as $tree) {
128 + if (!$tree['dirty']) {
92 129 continue;
93 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 + }
94 139
95 - if (!in_array($op, ['edit', 'delete', 'move'], true)) {
96 - $refused[] = ['blockId' => ($blockId ?: null), 'reason' => 'unknown op'];
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']])) {
97 143 continue;
98 144 }
145 + $refused[] = [
146 + 'blockId' => $entry['blockId'] ?? ($entry['anchorId'] ?? null),
147 + 'reason' => $failed[$entry['owner']],
148 + ];
149 + unset($applied[$index]);
150 + }
99 151
100 - $reason = self::applyOperation($blocks, $op, $blockId, $operation);
101 - if ($reason !== null) {
102 - $refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
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])) {
103 168 continue;
104 169 }
105 - $applied[] = ['op' => $op, 'blockId' => $blockId];
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'];
106 189 }
107 190
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 - }
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');
116 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 + }
117 217
118 - return new \WP_REST_Response(['applied' => $applied, 'refused' => $refused], 200);
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');
119 240 }
120 241
121 242 // Returns null when the op spliced in, or the refusal reason.
122 243 private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)