PluginProbe
Extendify / trunk
Extendify vtrunk
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.6 → trunk 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)