File: //proc/self/cwd/new/wp-content/plugins/extendify/app/QuickEdit/Controllers/SaveController.php
<?php
namespace Extendify\QuickEdit\Controllers;
defined('ABSPATH') || die('No direct access.');
use Extendify\Agent\TagBlocks;
use Extendify\Agent\TemplatePartBlockFinder;
use Extendify\Config;
use Extendify\QuickEdit\Schemas\Registry;
use Extendify\QuickEdit\Services\BlockFingerprint;
use Extendify\QuickEdit\Services\TranslatedContext;
class SaveController
{
public static function init()
{
add_action('rest_api_init', [self::class, 'registerRoutes']);
}
public static function registerRoutes()
{
register_rest_route('extendify/v1', '/quick-edit/save', [
'methods' => 'POST',
'permission_callback' => [self::class, 'permissionCallback'],
'callback' => [self::class, 'handleSave'],
]);
register_rest_route('extendify/v1', '/quick-edit/schemas', [
'methods' => 'GET',
'permission_callback' => [self::class, 'permissionCallback'],
'callback' => function () {
return new \WP_REST_Response(Registry::describe());
},
]);
}
public static function permissionCallback(): bool
{
return current_user_can(Config::$requiredCapability);
}
public static function handleSave(\WP_REST_Request $req)
{
$body = $req->get_json_params() ?: [];
$source = $body['source'] ?? null;
$blockId = isset($body['blockId']) ? (int) $body['blockId'] : 0;
$blockType = isset($body['blockType']) ? (string) $body['blockType'] : '';
$patches = is_array($body['patches'] ?? null) ? $body['patches'] : [];
// patches: schema-driven (image/cover). rawBlock: serialized block markup
// from the BlockEditor text editor; bypasses schema apply.
$rawBlock = isset($body['rawBlock']) ? (string) $body['rawBlock'] : '';
if (!is_array($source) || !$blockId || !$blockType || (!$patches && !$rawBlock)) {
return new \WP_REST_Response(
['error' => 'source, blockId, blockType + (patches OR rawBlock) required'],
400
);
}
$schema = Registry::get($blockType);
if (!$rawBlock && !$schema) {
return new \WP_REST_Response(
['error' => 'no schema registered for block type', 'blockType' => $blockType],
400
);
}
// rawBlock bypasses schema apply and is spliced in whole, so restrict it
// to the text blocks BlockTextEditor actually emits — otherwise an
// arbitrary block type could be smuggled through this path.
$rawBlockAllowed = ['core/paragraph', 'core/heading', 'core/button'];
if ($rawBlock !== '' && !in_array($blockType, $rawBlockAllowed, true)) {
return new \WP_REST_Response(
['error' => 'rawBlock not supported for this block type'],
400
);
}
// A text save rewrites the source post_content, but on a non-default-
// language render the user is looking at the translation — writing here
// would overwrite the source with the wrong language. The client
// suppresses the text editor on those pages; refuse here too so the path
// fails closed even when no fingerprint is sent. Image / layout /
// schema-patch saves touch shared (untranslated) source and stay allowed.
if (self::writesText($rawBlock, $patches) && self::isTranslatedRender($body)) {
return new \WP_REST_Response([
'error' => 'translated_content',
'message' => "Quick Edit can't edit translated content from a non-default-language page.",
], 409);
}
$sourcePost = self::resolveSourcePost($source);
if (is_wp_error($sourcePost)) {
return new \WP_REST_Response(['error' => $sourcePost->get_error_message()], 404);
}
// edit_posts is a coarse gate; per-source check enforces edit_theme_options
// for template parts and edit_post on this specific id for posts.
if (!self::userCanEditSource($sourcePost)) {
return new \WP_REST_Response(['error' => 'forbidden for this source'], 403);
}
$blocks = parse_blocks($sourcePost->post_content);
// TagBlocks counts top-level blocks only (post-content), TagTemplateParts
// counts every nested block in preorder — keep the two ID spaces apart
// here or `findBlock`'s top-level walk never reaches inner template-part
// blockIds (e.g. social-link inside core/social-links in a header).
$maxCounter = 0;
$visited = [];
$found = (($source['kind'] ?? '') === 'template-part')
? TemplatePartBlockFinder::find($blocks, $blockId, $maxCounter, $visited)
: self::findBlock($blocks, $blockId);
$fingerprint = is_array($body['fingerprint'] ?? null) ? $body['fingerprint'] : [];
// Block ids are best-effort: TagBlocks numbers at render time while
// findBlock re-derives at parse time, and the two diverge on synced
// patterns, nested navs, and dynamic expansion. Accept the count-resolved
// block only when it's the right type AND carries the clicked block's
// fingerprint — matched against the raw markup, then the rendered block
// so shortcodes / wptexturize line up with what the client read.
$countOk = $found !== null
&& ($found['block']['blockName'] ?? '') === $blockType
&& (
!$fingerprint
|| BlockFingerprint::matches($found['block'], $fingerprint)
|| BlockFingerprint::matches(
$found['block'],
$fingerprint,
self::renderBlockHtml($found['block'], $sourcePost)
)
);
if (!$countOk && $fingerprint) {
// The count missed or landed on the wrong block; recover by identity
// and edit the unique block that carries the fingerprint. Ambiguous
// (or absent) → refuse rather than overwrite an unintended block.
$matches = self::findBlocksByFingerprint($blocks, $blockType, $fingerprint, $sourcePost);
if (count($matches) !== 1) {
$resp = [
'error' => 'block fingerprint mismatch',
'blockId' => $blockId,
'candidates' => count($matches),
];
// Devmode-only: surface what the post actually holds so a
// candidates:0 (text not in storage) vs ambiguous mismatch can be
// diagnosed straight from the response.
if (defined('EXTENDIFY_DEVMODE') && EXTENDIFY_DEVMODE) {
$resp['debug'] = [
'wanted' => $fingerprint,
'countLanded' => $found ? [
'name' => $found['block']['blockName'] ?? null,
'text' => self::debugSnippet($found['block']),
] : null,
'sameTypeInPost' => self::collectTextsByType($blocks, $blockType),
];
}
return new \WP_REST_Response($resp, 409);
}
$found = $matches[0];
} elseif (!$countOk) {
// No fingerprint to recover with — surface the original count failure.
if ($found === null) {
return new \WP_REST_Response([
'error' => 'block not found in source',
'blockId' => $blockId,
'maxCounter' => $maxCounter,
'sourceKind' => $source['kind'] ?? null,
'partSlug' => $source['partSlug'] ?? null,
'visited' => $visited,
], 404);
}
return new \WP_REST_Response([
'error' => 'block type mismatch',
'expected' => $blockType,
'actual' => $found['block']['blockName'] ?? null,
], 409);
}
$targetBlock = $found['block'];
if ($rawBlock !== '') {
$parsed = parse_blocks(wp_unslash($rawBlock));
$parsed = array_values(array_filter(
$parsed,
static fn ($b) => is_array($b) && !empty($b['blockName'])
));
if (count($parsed) !== 1) {
return new \WP_REST_Response([
'error' => 'rawBlock must parse to exactly one block',
'parsed_count' => count($parsed),
], 400);
}
if ($parsed[0]['blockName'] !== $blockType) {
return new \WP_REST_Response([
'error' => 'rawBlock type does not match blockType',
'expected' => $blockType,
'got' => $parsed[0]['blockName'],
], 400);
}
// kses the parsed innerHTML in place so both the persisted content
// and the re-rendered HTML echoed back below are sanitized, not just
// what wp_update_post stores for non-unfiltered_html users.
$inner = wp_kses_post($parsed[0]['innerHTML'] ?? '');
if ($blockType === 'core/paragraph') {
$inner = self::syncTelLink($inner);
}
$parsed[0]['innerHTML'] = $inner;
$parsed[0]['innerContent'] = [$inner];
$targetBlock = $parsed[0];
} else {
// Patch order matters: some schemas cross-refer to innerHTML
// so text-then-align differs from align-then-text.
foreach ($patches as $patch) {
if (!is_array($patch)) {
continue;
}
$fieldKey = (string) ($patch['fieldKey'] ?? '');
if ($fieldKey === '') {
continue;
}
$targetBlock = $schema->apply($targetBlock, $fieldKey, $patch['value'] ?? null);
}
}
$blocks = self::replaceBlockAtPath($blocks, $found['path'], $targetBlock);
$newContent = serialize_blocks($blocks);
$update = wp_update_post([
'ID' => $sourcePost->ID,
'post_content' => wp_slash($newContent),
], true);
if (is_wp_error($update)) {
return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
}
// Re-render via the same filter chain a live page uses. Counter classes
// start at 1 here; client splices via patchVariantClasses to align them.
$rendered = self::renderBlockHtml($targetBlock, $sourcePost);
return new \WP_REST_Response([
'ok' => true,
'blockId' => $blockId,
'blockType' => $blockType,
'rendered' => trim($rendered),
]);
}
// Whether the save edits translatable text: the rawBlock text-editor path is
// always text, and the schema-patch path is text only for the content / text
// fields (align / image / url / service / level are shared, untranslated).
private static function writesText(string $rawBlock, array $patches): bool
{
if ($rawBlock !== '') {
return true;
}
foreach ($patches as $patch) {
if (is_array($patch) && in_array((string) ($patch['fieldKey'] ?? ''), ['content', 'text'], true)) {
return true;
}
}
return false;
}
// Keep a phone CTA dialing the number the user can see. When a saved
// paragraph's content is a single <a href="tel:…"> link, re-point the
// anchor's href + data-id at the normalized digits of its visible text and
// pin data-type="tel". Editing the digits in RichText keeps the link format
// but only swaps the text, leaving href on the number the link was first
// built with — without this, tap-to-call dials the stale number.
//
// Scoped narrowly: only a lone tel: anchor is touched. http/mailto links,
// multi-link paragraphs, no link, or text that yields no usable number are
// returned unchanged, so an ordinary paragraph's link is never rewritten.
// Runs after wp_kses_post; the value written (a tel: URI of digits and an
// optional leading +) needs no further sanitizing.
private static function syncTelLink(string $innerHtml): string
{
if (stripos($innerHtml, '<a') === false || stripos($innerHtml, 'tel:') === false) {
return $innerHtml;
}
$dom = new \DOMDocument();
$previous = libxml_use_internal_errors(true);
// The encoding hint stops DOMDocument mangling UTF-8; the flags keep it
// from wrapping the fragment in <html>/<body>.
$loaded = $dom->loadHTML(
'<?xml encoding="utf-8"?>' . $innerHtml,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) {
return $innerHtml;
}
$anchors = $dom->getElementsByTagName('a');
if ($anchors->length !== 1) {
return $innerHtml;
}
$anchor = $anchors->item(0);
if (stripos((string) $anchor->getAttribute('href'), 'tel:') !== 0) {
return $innerHtml;
}
$normalized = self::normalizePhoneNumber($anchor->textContent);
if ($normalized === null) {
return $innerHtml;
}
$newHref = 'tel:' . $normalized;
// Rewrite the single anchor's opening tag only — the <p> wrapper and the
// link text stay byte-for-byte intact.
return preg_replace_callback(
'/<a\b[^>]*>/i',
static fn ($match) => self::setTagAttributes($match[0], [
'href' => $newHref,
'data-id' => $newHref,
'data-type' => 'tel',
]),
$innerHtml,
1
);
}
// Reduce visible phone text to bare dialable digits: keep a single leading
// + (international prefix) and drop spaces / dashes / parens / other visual
// separators. Returns null when the result isn't a plausible phone number
// (E.164 caps at 15 digits) so the caller leaves the href untouched rather
// than writing a broken tel: link.
private static function normalizePhoneNumber(string $text)
{
$text = trim($text);
$plus = (strncmp($text, '+', 1) === 0) ? '+' : '';
$digits = (string) preg_replace('/\D+/', '', $text);
$length = strlen($digits);
if ($length < 7 || $length > 15) {
return null;
}
return $plus . $digits;
}
// Set attributes within a single opening-tag string: replace an existing
// attribute's value in place, otherwise inject it before the closing '>'.
private static function setTagAttributes(string $tag, array $attributes): string
{
foreach ($attributes as $name => $value) {
$rendered = ' ' . $name . '="' . esc_attr($value) . '"';
$pattern = '/\s' . preg_quote($name, '/') . '\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i';
$tag = preg_match($pattern, $tag)
? preg_replace($pattern, $rendered, $tag, 1)
: preg_replace('/\s*\/?>$/', $rendered . '>', $tag, 1);
}
return $tag;
}
// The REST save request isn't language-scoped the way the page render is, so
// trust the translatedContext the client forwards (detected at enqueue); also
// re-check server-side in case this request is language-scoped on its own.
private static function isTranslatedRender(array $body): bool
{
$clientContext = $body['translatedContext'] ?? null;
if (is_array($clientContext) && !empty($clientContext['isTranslated'])) {
return true;
}
return !empty(TranslatedContext::detect()['isTranslated']);
}
// Render a single block through the same the_content chain a live page uses
// (expanding shortcodes, wptexturize, etc.). In a REST request the main
// query has no post, so wp_reset_postdata() can't restore $GLOBALS['post'] —
// snapshot and restore it so a template-part save can't leave global $post
// dangling for the rest of the request.
private static function renderBlockHtml(array $block, \WP_Post $sourcePost): string
{
$previousPost = $GLOBALS['post'] ?? null;
$GLOBALS['post'] = $sourcePost;
setup_postdata($sourcePost);
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core WP filter
$html = apply_filters('the_content', serialize_blocks([$block]));
wp_reset_postdata();
$GLOBALS['post'] = $previousPost;
return (string) $html;
}
// Every block of $blockType that carries the fingerprint, with its path.
// The caller only proceeds on a *unique* match — two blocks with the same
// content can't be told apart, so that refuses rather than guesses. Skips
// ignored dynamic loops and nested template-part scopes. Tries the cheap
// raw/fold match across candidates first and only renders (expanding
// shortcodes etc.) if nothing matched raw.
private static function findBlocksByFingerprint(
array $blocks,
string $blockType,
array $fingerprint,
\WP_Post $sourcePost
): array {
$ignored = TagBlocks::$ignored;
$candidates = [];
$walk = function (array $list, array $pathSoFar) use (&$walk, &$candidates, $blockType, $ignored) {
foreach ($list as $i => $block) {
$name = $block['blockName'] ?? '';
if ($name === '') {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
}
continue;
}
if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
continue;
}
if ($name === $blockType) {
$candidates[] = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
}
}
};
$walk($blocks, []);
$raw = array_values(array_filter(
$candidates,
static fn ($c) => BlockFingerprint::matches($c['block'], $fingerprint)
));
if ($raw) {
return $raw;
}
$rendered = array_values(array_filter(
$candidates,
static fn ($c) => BlockFingerprint::matches(
$c['block'],
$fingerprint,
self::renderBlockHtml($c['block'], $sourcePost)
)
));
if ($rendered) {
return $rendered;
}
// Last resort: a block-level shortcode render (e.g. [products]) splits
// the paragraph in the browser, so the live element's text — and thus
// the fingerprint — is truncated to a prefix of the stored block.
return array_values(array_filter(
$candidates,
static fn ($c) => BlockFingerprint::matches($c['block'], $fingerprint, '', true)
));
}
private static function debugSnippet(array $block): string
{
return mb_substr(trim((string) wp_strip_all_tags((string) ($block['innerHTML'] ?? ''))), 0, 80);
}
// Devmode diagnostics: the raw text of every block of $blockType in the
// post, so a fingerprint mismatch can be compared against what's stored.
private static function collectTextsByType(array $blocks, string $blockType): array
{
$out = [];
$ignored = TagBlocks::$ignored;
$walk = function (array $list) use (&$walk, &$out, $blockType, $ignored) {
foreach ($list as $block) {
$name = $block['blockName'] ?? '';
if ($name === '') {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks']);
}
continue;
}
if ($name === 'core/template-part' || in_array($name, $ignored, true)) {
continue;
}
if ($name === $blockType) {
$out[] = self::debugSnippet($block);
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks']);
}
}
};
$walk($blocks);
return $out;
}
/**
* @return \WP_Post|\WP_Error
*/
private static function resolveSourcePost(array $source)
{
$kind = (string) ($source['kind'] ?? '');
if ($kind === 'post') {
$id = (int) ($source['id'] ?? 0);
$post = $id ? get_post($id) : null;
if (!$post) {
return new \WP_Error('not_found', 'post not found');
}
$disallowed = ['revision', 'wp_navigation', 'wp_template',
'wp_template_part', 'wp_block', 'attachment'];
if (
in_array($post->post_type, $disallowed, true)
|| $post->post_status === 'auto-draft'
) {
return new \WP_Error(
'post_type_not_supported',
'Edit this content via its dedicated endpoint'
);
}
return $post;
}
if ($kind === 'template-part') {
$slug = (string) ($source['partSlug'] ?? '');
if ($slug === '') {
return new \WP_Error('bad_source', 'template-part requires partSlug');
}
// Use WP's own resolver so the save lands on the post WP renders
// from. Raw get_posts by name returns rows from every wp_theme
// term — when an install has had multiple theme variants active
// at different times (e.g. `extendable` and `extendable-2` both
// owning a "header" post), the wrong row wins on post_date
// ordering and we patch a stale orphan instead of the live part.
$stylesheet = wp_get_theme()->get_stylesheet();
$template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
if (!$template || empty($template->wp_id)) {
return new \WP_Error('not_found', 'template-part not found');
}
$post = get_post($template->wp_id);
if (!$post) {
return new \WP_Error('not_found', 'template-part not found');
}
return $post;
}
return new \WP_Error('bad_source', 'unknown source kind');
}
private static function userCanEditSource(\WP_Post $post): bool
{
if ($post->post_type === 'wp_template_part') {
return current_user_can('edit_theme_options');
}
return current_user_can('edit_post', $post->ID);
}
// Walks the parsed-block tree the same way TagBlocks counts on the front-end
// so client blockIds line up with what the server resolves.
private static function findBlock(array $blocks, int $targetId)
{
$ignored = TagBlocks::$ignored;
$counter = 0;
$found = null;
$walk = function (array &$list, array $pathSoFar, int $skipDepth)
use (&$walk, &$counter, &$found, $targetId, $ignored) {
foreach ($list as $i => &$block) {
if (empty($block['blockName'])) {
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), $skipDepth);
if ($found !== null) {
return;
}
}
continue;
}
$isIgnored = in_array($block['blockName'], $ignored, true);
if ($isIgnored || $skipDepth > 0) {
if (!empty($block['innerBlocks'])) {
$walk(
$block['innerBlocks'],
array_merge($pathSoFar, [$i, 'innerBlocks']),
$skipDepth + ($isIgnored ? 1 : 0)
);
if ($found !== null) {
return;
}
}
continue;
}
$counter++;
if ($counter === $targetId) {
$found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
return;
}
if (!empty($block['innerBlocks'])) {
$walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']), 0);
if ($found !== null) {
return;
}
}
}
unset($block);
};
$walk($blocks, [], 0);
return $found;
}
// Path elements alternate index / 'innerBlocks' / index / 'innerBlocks' / ...
private static function replaceBlockAtPath(array $blocks, array $path, array $newBlock): array
{
if (empty($path)) {
return $blocks;
}
$head = $path[0];
$rest = array_slice($path, 1);
if (!is_int($head) || !isset($blocks[$head])) {
return $blocks;
}
if (empty($rest)) {
$blocks[$head] = $newBlock;
return $blocks;
}
if ($rest[0] === 'innerBlocks') {
$blocks[$head]['innerBlocks'] = self::replaceBlockAtPath(
$blocks[$head]['innerBlocks'] ?? [],
array_slice($rest, 1),
$newBlock
);
}
return $blocks;
}
}