| @@ -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) |