'
',
'core/group' => ''
. '',
];
/**
* Apply a batch of block operations to a post.
*
* @param \WP_REST_Request $request The REST API request object.
* @return \WP_REST_Response
*/
public static function updateBlocks(\WP_REST_Request $request)
{
$params = $request->get_json_params();
$params = is_array($params) ? $params : [];
// Template parts have a separate id space; refusing beats a silent no-op.
if (!empty($params['partSlug'])) {
return new \WP_REST_Response(
['error' => 'Template-part blocks cannot be saved by this endpoint'],
400
);
}
$postId = (int) ($params['postId'] ?? 0);
$post = $postId ? \get_post($postId) : null;
if (!$post) {
return new \WP_REST_Response(['error' => 'Post not found'], 404);
}
if (!\current_user_can('edit_post', $post->ID)) {
return new \WP_REST_Response(['error' => 'Forbidden for this post'], 403);
}
$operations = isset($params['operations']) && is_array($params['operations'])
? $params['operations']
: [];
if (!$operations) {
return new \WP_REST_Response(['error' => 'operations required'], 400);
}
$blocks = PostBlockFinder::stamp(\parse_blocks($post->post_content));
$applied = [];
$refused = [];
$sharedWrappers = [];
foreach ($operations as $operation) {
$operation = is_array($operation) ? $operation : [];
$op = (string) ($operation['op'] ?? '');
$blockId = (int) ($operation['blockId'] ?? 0);
if ($op === 'add') {
$anchorId = (int) ($operation['anchorId'] ?? 0);
$reason = self::applyAdd($blocks, $anchorId, $operation, $sharedWrappers);
if ($reason !== null) {
$refused[] = ['anchorId' => ($anchorId ?: null), 'reason' => $reason];
continue;
}
$applied[] = ['op' => 'add', 'anchorId' => $anchorId];
continue;
}
if ($op === 'wrap') {
$reason = self::applyWrap($blocks, $blockId, $operation, $sharedWrappers);
if ($reason !== null) {
$refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
continue;
}
$applied[] = ['op' => 'wrap', 'blockId' => $blockId];
continue;
}
if (!in_array($op, ['edit', 'delete', 'move'], true)) {
$refused[] = ['blockId' => ($blockId ?: null), 'reason' => 'unknown op'];
continue;
}
$reason = self::applyOperation($blocks, $op, $blockId, $operation);
if ($reason !== null) {
$refused[] = ['blockId' => ($blockId ?: null), 'reason' => $reason];
continue;
}
$applied[] = ['op' => $op, 'blockId' => $blockId];
}
if ($applied) {
$update = \wp_update_post([
'ID' => $post->ID,
'post_content' => \wp_slash(\serialize_blocks($blocks)),
], true);
if (\is_wp_error($update)) {
return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
}
}
return new \WP_REST_Response(['applied' => $applied, 'refused' => $refused], 200);
}
// Returns null when the op spliced in, or the refusal reason.
private static function applyOperation(array &$blocks, string $op, int $blockId, array $operation)
{
if ($op === 'edit') {
$newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
if (!$newBlock) {
return 'block must parse to exactly one block';
}
$path = PostBlockFinder::pathByRef($blocks, $blockId);
if ($path === null) {
return 'block id not found in this post';
}
$newBlock = self::carryStamps(self::blockAtPath($blocks, $path), $newBlock);
$blocks = self::spliceAtPath($blocks, $path, $newBlock);
return null;
}
if ($op === 'delete') {
$path = PostBlockFinder::pathByRef($blocks, $blockId);
if ($path === null) {
return 'block id not found in this post';
}
$blocks = self::spliceAtPath($blocks, $path, null);
return null;
}
$position = (string) ($operation['position'] ?? '');
if (!in_array($position, ['before', 'after'], true)) {
return "position must be 'before' or 'after'";
}
$targetId = (int) ($operation['targetId'] ?? 0);
if ($targetId === $blockId) {
return 'cannot move a block relative to itself';
}
$sourcePath = PostBlockFinder::pathByRef($blocks, $blockId);
if ($sourcePath === null) {
return 'block id not found in this post';
}
$targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
if ($targetPath === null) {
return 'target block not found in this post';
}
if (array_slice($targetPath, 0, count($sourcePath)) === $sourcePath) {
return 'target is inside the moved block';
}
$moved = self::blockAtPath($blocks, $sourcePath);
$blocks = self::spliceAtPath($blocks, $sourcePath, null);
// Extraction shifted indexes; the stamp still finds the target.
$targetPath = PostBlockFinder::pathByRef($blocks, $targetId);
$blocks = self::insertAtPath($blocks, $targetPath, $moved, $position);
return null;
}
// Returns null when the block spliced in next to its anchor, or the refusal reason.
private static function applyAdd(array &$blocks, int $anchorId, array $operation, array &$sharedWrappers)
{
$position = (string) ($operation['position'] ?? '');
if (!in_array($position, ['before', 'after'], true)) {
return "position must be 'before' or 'after'";
}
$newBlock = self::parseSingleBlock((string) ($operation['block'] ?? ''));
if (!$newBlock) {
return 'block must parse to exactly one block';
}
$anchorPath = PostBlockFinder::pathByRef($blocks, $anchorId);
if ($anchorPath === null) {
return 'anchor block not found in this post';
}
// A bare column is only valid as a core/columns child, so its anchor
// decides the splice here — the code owns the wrapper, never the model.
if (($newBlock['blockName'] ?? '') === 'core/column') {
return self::spliceColumn($blocks, $anchorPath, $newBlock, $position, $anchorId, $sharedWrappers);
}
$blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
return null;
}
// A pre-existing section never absorbs an add — the only wrapper an add
// joins is one this same batch created.
private static function spliceColumn(
array &$blocks,
array $anchorPath,
array $newBlock,
string $position,
int $anchorId,
array &$sharedWrappers
) {
if ((self::blockAtPath($blocks, $anchorPath)['blockName'] ?? '') === 'core/column') {
$blocks = self::insertAtPath($blocks, $anchorPath, $newBlock, $position);
return null;
}
$key = $anchorId . ':' . $position;
if (isset($sharedWrappers[$key])) {
$wrapperPath = PostBlockFinder::pathByRef($blocks, $sharedWrappers[$key]);
$wrapper = self::blockAtPath($blocks, $wrapperPath);
$lastChild = array_merge($wrapperPath, ['innerBlocks', count($wrapper['innerBlocks']) - 1]);
$blocks = self::insertAtPath($blocks, $lastChild, $newBlock, 'after');
return null;
}
$wrapper = self::parseSingleBlock(
''
);
$wrapper['innerBlocks'][0] = $newBlock;
// Render stamps are positive, so a negative ref can't collide.
$wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
$sharedWrappers[$key] = $wrapper[PostBlockFinder::REF_KEY];
$blocks = self::insertAtPath($blocks, $anchorPath, $wrapper, $position);
return null;
}
// Returns null when the container spliced in around the block, or the
// refusal reason. The wrapped subtree is reused verbatim — bytes and id
// stamps survive, so later ops in the batch can still anchor to it.
private static function applyWrap(array &$blocks, int $blockId, array $operation, array &$sharedWrappers)
{
$container = (string) ($operation['container'] ?? '');
if (!isset(self::WRAP_TEMPLATES[$container])) {
return 'unknown container';
}
$path = PostBlockFinder::pathByRef($blocks, $blockId);
if ($path === null) {
return 'block id not found in this post';
}
$block = self::blockAtPath($blocks, $path);
// Pulling a column out of core/columns leaves the parent unserializable.
if (($block['blockName'] ?? '') === 'core/column') {
return 'a column cannot be wrapped';
}
$isColumn = $container === 'core/column';
if ($isColumn && self::joinWrapShell($blocks, $path, $block, $blockId, $sharedWrappers)) {
return null;
}
$wrapper = self::parseSingleBlock(self::WRAP_TEMPLATES[$container]);
if ($container === 'core/column') {
$wrapper['innerBlocks'][0]['innerBlocks'][0] = $block;
// "Wrap this in a column and add another beside it": a later
// column add anchored to the wrapped block joins this wrapper.
$wrapper[PostBlockFinder::REF_KEY] = -(count($sharedWrappers) + 1);
$sharedWrappers[$blockId . ':after'] = $wrapper[PostBlockFinder::REF_KEY];
$sharedWrappers[$blockId . ':before'] = $wrapper[PostBlockFinder::REF_KEY];
$sharedWrappers['wrap-shell'] = $wrapper[PostBlockFinder::REF_KEY];
} else {
$wrapper['innerBlocks'][0] = $block;
}
$blocks = self::spliceAtPath($blocks, $path, $wrapper);
return null;
}
// Two column wraps in one batch mean side by side — join the first shell.
private static function joinWrapShell(
array &$blocks,
array $path,
array $block,
int $blockId,
array &$sharedWrappers
) {
if (!isset($sharedWrappers['wrap-shell'])) {
return false;
}
$shellRef = $sharedWrappers['wrap-shell'];
$shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
// A shell inside the wrapped block would leave with it — start fresh.
if ($shellPath === null || array_slice($shellPath, 0, count($path)) === $path) {
return false;
}
$blocks = self::spliceAtPath($blocks, $path, null);
$shellPath = PostBlockFinder::pathByRef($blocks, $shellRef);
$shell = self::blockAtPath($blocks, $shellPath);
$column = self::parseSingleBlock(
''
);
$column['innerBlocks'][0] = $block;
$lastChild = array_merge($shellPath, ['innerBlocks', count($shell['innerBlocks']) - 1]);
$blocks = self::insertAtPath($blocks, $lastChild, $column, 'after');
$sharedWrappers[$blockId . ':after'] = $shellRef;
$sharedWrappers[$blockId . ':before'] = $shellRef;
return true;
}
private static function blockAtPath(array $blocks, array $path)
{
$node = $blocks;
foreach ($path as $key) {
$node = $node[$key];
}
return $node;
}
// Position can't tell an untouched child from a swapped one.
private static function carryStamps(array $old, array $new): array
{
if (isset($old[PostBlockFinder::REF_KEY])) {
$new[PostBlockFinder::REF_KEY] = $old[PostBlockFinder::REF_KEY];
}
$oldInner = isset($old['innerBlocks']) && is_array($old['innerBlocks'])
? $old['innerBlocks']
: [];
$newInner = isset($new['innerBlocks']) && is_array($new['innerBlocks'])
? $new['innerBlocks']
: [];
foreach ($newInner as $i => $child) {
if (!isset($oldInner[$i]) || !is_array($oldInner[$i]) || !is_array($child)) {
continue;
}
if (\serialize_blocks([$oldInner[$i]]) !== \serialize_blocks([$child])) {
continue;
}
$newInner[$i] = self::carryStamps($oldInner[$i], $child);
}
if ($newInner) {
$new['innerBlocks'] = $newInner;
}
return $new;
}
private static function parseSingleBlock(string $markup)
{
$parsed = array_values(array_filter(
\parse_blocks($markup),
static function ($block) {
return is_array($block) && !empty($block['blockName']);
}
));
return count($parsed) === 1 ? $parsed[0] : null;
}
// Path elements alternate index / 'innerBlocks' / …; null deletes.
private static function spliceAtPath(array $list, array $path, $newBlock): array
{
$head = ($path[0] ?? null);
if (!is_int($head) || !isset($list[$head])) {
return $list;
}
$rest = array_slice($path, 1);
if (!$rest) {
if ($newBlock === null) {
array_splice($list, $head, 1);
} else {
$list[$head] = $newBlock;
}
return $list;
}
if ($rest[0] !== 'innerBlocks') {
return $list;
}
$childPath = array_slice($rest, 1);
$inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
? $list[$head]['innerBlocks']
: [];
// Drop the child's innerContent placeholder too, or serialize_blocks
// misaligns the remaining children.
if ($newBlock === null && count($childPath) === 1) {
$innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
? $list[$head]['innerContent']
: [];
$list[$head]['innerContent'] = self::withoutNthPlaceholder($innerContent, $childPath[0]);
}
$list[$head]['innerBlocks'] = self::spliceAtPath($inner, $childPath, $newBlock);
return $list;
}
private static function withoutNthPlaceholder(array $innerContent, int $n): array
{
$seen = -1;
foreach ($innerContent as $i => $chunk) {
if (is_string($chunk)) {
continue;
}
$seen++;
if ($seen === $n) {
array_splice($innerContent, $i, 1);
break;
}
}
return $innerContent;
}
private static function insertAtPath(array $list, array $path, array $block, string $position): array
{
$head = ($path[0] ?? null);
if (!is_int($head) || !isset($list[$head])) {
return $list;
}
$rest = array_slice($path, 1);
if (!$rest) {
array_splice($list, $head + ($position === 'after' ? 1 : 0), 0, [$block]);
return $list;
}
if ($rest[0] !== 'innerBlocks') {
return $list;
}
$childPath = array_slice($rest, 1);
// Mirror the delete case: a new child needs its innerContent
// placeholder too, or serialize_blocks misaligns the children.
if (count($childPath) === 1) {
$innerContent = isset($list[$head]['innerContent']) && is_array($list[$head]['innerContent'])
? $list[$head]['innerContent']
: [];
$list[$head]['innerContent'] = self::withPlaceholderAt(
$innerContent,
$childPath[0] + ($position === 'after' ? 1 : 0)
);
}
$inner = isset($list[$head]['innerBlocks']) && is_array($list[$head]['innerBlocks'])
? $list[$head]['innerBlocks']
: [];
$list[$head]['innerBlocks'] = self::insertAtPath($inner, $childPath, $block, $position);
return $list;
}
// Inserts a null so it becomes the nth placeholder; past the last one it
// lands right after it, before any trailing closing markup.
private static function withPlaceholderAt(array $innerContent, int $n): array
{
$seen = -1;
foreach ($innerContent as $i => $chunk) {
if (is_string($chunk)) {
continue;
}
$seen++;
if ($seen === $n) {
array_splice($innerContent, $i, 0, [null]);
return $innerContent;
}
}
for ($i = count($innerContent) - 1; $i >= 0; $i--) {
if (!is_string($innerContent[$i])) {
array_splice($innerContent, $i + 1, 0, [null]);
return $innerContent;
}
}
$innerContent[] = null;
return $innerContent;
}
}