PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / MCP / Tools / PageContentTools.php

PageContentTools.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/MCP/Tools/PageContentTools.php

1,037 lines 34.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PageContentTools — MCP abilities for WPFunnels Step Page composition and copy editing.
4 *
5 * @package WPFunnels\MCP\Tools
6 * @since 3.13.0
7 */
8
9 namespace WPFunnels\MCP\Tools;
10
11 defined( 'ABSPATH' ) || exit;
12
13 use WPFunnels\AI\AIInit;
14 use WPFunnels\AI\Settings\AISettings;
15 use WPFunnels\MCP\Helpers\MCPHelper;
16 use WPFunnels\MCP\Support\ElementorLayoutBuilder;
17
18 /**
19 * Class PageContentTools
20 */
21 class PageContentTools {
22
23 /**
24 * Register tool definitions.
25 *
26 * @return array
27 */
28 public static function definitions() {
29 return [
30 'wpfunnels/compose-step-page' => [
31 'description' => 'Build a complete layout and content structure for a step page in the active page builder.',
32 'category' => 'content',
33 'readonly' => false,
34 'destructive' => true,
35 'parameters' => [
36 'type' => 'object',
37 'properties' => [
38 'step_id' => [
39 'type' => 'integer',
40 'description' => 'ID of the step.',
41 ],
42 'headline' => [
43 'type' => 'string',
44 'description' => 'Primary page headline.',
45 ],
46 'subheadline' => [
47 'type' => 'string',
48 'description' => 'Supporting subheadline or value proposition.',
49 ],
50 'cta_text' => [
51 'type' => 'string',
52 'description' => 'Call-to-action button text.',
53 ],
54 'benefits' => [
55 'type' => 'array',
56 'description' => 'List of benefit bullet points.',
57 'items' => [ 'type' => 'string' ],
58 ],
59 ],
60 'required' => [ 'step_id', 'headline' ],
61 ],
62 'callback' => [ __CLASS__, 'composeStepPage' ],
63 ],
64
65 'wpfunnels/compose-step-layout' => [
66 'label' => __( 'Compose Step Page Layout', 'wpfnl' ),
67 'description' => 'Build a real Elementor widget layout for a step page using WPFunnels\' own widgets (opt-in, checkout, offer, next-step), chosen from the step\'s type. Buttons automatically pick up the site\'s Elementor brand color when one is set — no need to pass a color. Also updates the same copy fields as compose-step-page.',
68 'input_schema' => [
69 'type' => 'object',
70 'properties' => [
71 'step_id' => [
72 'type' => 'integer',
73 'description' => 'ID of the step.',
74 ],
75 'headline' => [
76 'type' => 'string',
77 'description' => 'Primary page headline.',
78 ],
79 'subheadline' => [
80 'type' => 'string',
81 'description' => 'Supporting subheadline or value proposition.',
82 ],
83 'cta_text' => [
84 'type' => 'string',
85 'description' => 'Call-to-action button text.',
86 ],
87 'benefits' => [
88 'type' => 'array',
89 'description' => 'List of benefit bullet points.',
90 'items' => [ 'type' => 'string' ],
91 ],
92 'include_order_bump' => [
93 'type' => 'boolean',
94 'description' => 'For checkout steps only: also insert an order-bump widget placeholder. Leave off unless an order bump has already been configured via upsert-order-bump.',
95 'default' => false,
96 ],
97 ],
98 'required' => [ 'step_id', 'headline' ],
99 ],
100 'execute_callback' => [ __CLASS__, 'composeStepLayout' ],
101 'permission_callback' => MCPHelper::currentUserCan(),
102 'annotations' => [ 'destructive' ],
103 ],
104
105 'wpfunnels/rewrite-step-copy' => [
106 'description' => 'Perform surgical copy editing on a specific element of a step page (headline, subheadline, or CTA text).',
107 'category' => 'content',
108 'readonly' => false,
109 'destructive' => true,
110 'parameters' => [
111 'type' => 'object',
112 'properties' => [
113 'step_id' => [
114 'type' => 'integer',
115 'description' => 'ID of the step.',
116 ],
117 'element' => [
118 'type' => 'string',
119 'enum' => [ 'headline', 'subheadline', 'cta_text', 'guarantee_text' ],
120 'description' => 'Element to update.',
121 ],
122 'content' => [
123 'type' => 'string',
124 'description' => 'New copy content.',
125 ],
126 ],
127 'required' => [ 'step_id', 'element', 'content' ],
128 ],
129 'callback' => [ __CLASS__, 'rewriteStepCopy' ],
130 ],
131
132 'wpfunnels/generate-step-image' => [
133 'label' => __( 'Generate Step Image', 'wpfnl' ),
134 'description' => 'Generate an image from a text prompt (OpenAI DALL-E 3) and attach it to a step, either as its featured image or as a stored content image. Requires OpenAI connected as the active provider under WPFunnels → Settings → AI — other providers do not support image generation here.',
135 'input_schema' => [
136 'type' => 'object',
137 'properties' => [
138 'step_id' => [
139 'type' => 'integer',
140 'description' => 'ID of the step.',
141 ],
142 'prompt' => [
143 'type' => 'string',
144 'description' => 'Description of the image to generate.',
145 ],
146 'placement' => [
147 'type' => 'string',
148 'enum' => [ 'featured', 'content' ],
149 'default' => 'featured',
150 'description' => 'featured sets it as the step\'s featured image; content stores it for use in the page body.',
151 ],
152 'size' => [
153 'type' => 'string',
154 'enum' => [ '1024x1024', '1792x1024', '1024x1792' ],
155 'default' => '1024x1024',
156 ],
157 ],
158 'required' => [ 'step_id', 'prompt' ],
159 ],
160 'execute_callback' => [ __CLASS__, 'generateStepImage' ],
161 'permission_callback' => MCPHelper::currentUserCan(),
162 'annotations' => [ 'destructive' ],
163 ],
164
165 'wpfunnels/get-step-outline' => [
166 'description' => 'Get the AI-authored placeholder copy fields (headline/subheadline/cta_text/benefits) set by compose-step-page/rewrite-step-copy. Falls back to generic defaults when unset — these do NOT reflect the page\'s actual builder content. For a Gutenberg page\'s real on-page text, use wpfunnels/list-gutenberg-text instead.',
167 'category' => 'content',
168 'readonly' => true,
169 'destructive' => false,
170 'parameters' => [
171 'type' => 'object',
172 'properties' => [
173 'step_id' => [
174 'type' => 'integer',
175 'description' => 'ID of the step.',
176 ],
177 ],
178 'required' => [ 'step_id' ],
179 ],
180 'callback' => [ __CLASS__, 'getStepOutline' ],
181 ],
182
183 'wpfunnels/detect-page-builder' => [
184 'label' => __( 'Detect Page Builder', 'wpfnl' ),
185 'description' => 'Find which page builder authored a funnel step\'s page — Gutenberg, Elementor, Bricks, another builder, or unknown.',
186 'input_schema' => [
187 'type' => 'object',
188 'properties' => [
189 'funnel_step_id' => [
190 'type' => 'integer',
191 'description' => 'ID of the funnel step.',
192 ],
193 ],
194 'required' => [ 'funnel_step_id' ],
195 ],
196 'execute_callback' => [ __CLASS__, 'detectPageBuilder' ],
197 'permission_callback' => MCPHelper::currentUserCan(),
198 'annotations' => [ 'readonly' ],
199 ],
200
201 'wpfunnels/get-funnel-step-page' => [
202 'label' => __( 'Get Funnel Step Page', 'wpfnl' ),
203 'description' => 'Get the WordPress page connected to a funnel step: its ID, title, post type, status, and detected page builder.',
204 'input_schema' => [
205 'type' => 'object',
206 'properties' => [
207 'funnel_step_id' => [
208 'type' => 'integer',
209 'description' => 'ID of the funnel step.',
210 ],
211 ],
212 'required' => [ 'funnel_step_id' ],
213 ],
214 'execute_callback' => [ __CLASS__, 'getFunnelStepPage' ],
215 'permission_callback' => MCPHelper::currentUserCan(),
216 'annotations' => [ 'readonly' ],
217 ],
218
219 'wpfunnels/list-gutenberg-text' => [
220 'label' => __( 'List Gutenberg Page Text', 'wpfnl' ),
221 'description' => 'List the actual visible text found in a Gutenberg page\'s blocks, in reading order, each tagged with its block type. Call this BEFORE update-gutenberg-text to get the real target_text — do not guess it from get-step-outline or the step title, those are separate AI-authored placeholder fields that may not match what is actually on the page.',
222 'input_schema' => [
223 'type' => 'object',
224 'properties' => [
225 'page_id' => [
226 'type' => 'integer',
227 'description' => 'WordPress page/post ID (from get-funnel-step-page).',
228 ],
229 ],
230 'required' => [ 'page_id' ],
231 ],
232 'execute_callback' => [ __CLASS__, 'listGutenbergText' ],
233 'permission_callback' => MCPHelper::currentUserCan(),
234 'annotations' => [ 'readonly' ],
235 ],
236
237 'wpfunnels/update-gutenberg-text' => [
238 'label' => __( 'Update Gutenberg Text', 'wpfnl' ),
239 'description' => 'Replace one piece of text inside a Gutenberg page\'s blocks, keeping block markup intact. Get target_text from wpfunnels/list-gutenberg-text rather than guessing — matching tolerates whitespace differences but not wrong text. Fails if the page is not Gutenberg or the target text is not found.',
240 'input_schema' => [
241 'type' => 'object',
242 'properties' => [
243 'page_id' => [
244 'type' => 'integer',
245 'description' => 'WordPress page/post ID (from get-funnel-step-page).',
246 ],
247 'target_text' => [
248 'type' => 'string',
249 'description' => 'Existing text to find inside the page\'s blocks, as returned by wpfunnels/list-gutenberg-text.',
250 ],
251 'new_text' => [
252 'type' => 'string',
253 'description' => 'Replacement text.',
254 ],
255 ],
256 'required' => [ 'page_id', 'target_text', 'new_text' ],
257 ],
258 'execute_callback' => [ __CLASS__, 'updateGutenbergText' ],
259 'permission_callback' => MCPHelper::currentUserCan(),
260 'annotations' => [ 'destructive' ],
261 ],
262 ];
263 }
264
265 /**
266 * Compose step page.
267 *
268 * @param array $input Tool input.
269 * @return array|\WP_Error
270 */
271 public static function composeStepPage( $input = [] ) {
272 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
273 if ( is_wp_error( $step ) ) {
274 return $step;
275 }
276
277 $headline = sanitize_text_field( $input['headline'] );
278 $subheadline = isset( $input['subheadline'] ) ? sanitize_text_field( $input['subheadline'] ) : '';
279 $cta_text = isset( $input['cta_text'] ) ? sanitize_text_field( $input['cta_text'] ) : 'Get Started';
280 $benefits = isset( $input['benefits'] ) && is_array( $input['benefits'] ) ? array_map( 'sanitize_text_field', $input['benefits'] ) : [];
281
282 update_post_meta( $step->ID, '_wpfnl_page_headline', $headline );
283 if ( '' !== $subheadline ) {
284 update_post_meta( $step->ID, '_wpfnl_page_subheadline', $subheadline );
285 }
286 update_post_meta( $step->ID, '_wpfnl_page_cta_text', $cta_text );
287 if ( ! empty( $benefits ) ) {
288 update_post_meta( $step->ID, '_wpfnl_page_benefits', $benefits );
289 }
290
291 return [
292 'composed' => true,
293 'step_id' => (int) $step->ID,
294 'headline' => $headline,
295 'subheadline' => $subheadline,
296 'cta_text' => $cta_text,
297 'benefits' => $benefits,
298 ];
299 }
300
301 /**
302 * Build a real Elementor widget layout for a step, chosen from its step
303 * type, and keep the placeholder copy fields (compose-step-page) in sync
304 * with what was actually rendered.
305 *
306 * @param array $input Tool input.
307 * @return array|\WP_Error
308 */
309 public static function composeStepLayout( $input = [] ) {
310 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
311 if ( is_wp_error( $step ) ) {
312 return $step;
313 }
314
315 $copy = self::composeStepPage( $input );
316 if ( is_wp_error( $copy ) ) {
317 return $copy;
318 }
319
320 $step_type = get_post_meta( $step->ID, '_step_type', true ) ?: 'custom';
321 $include_order_bump = ! empty( $input['include_order_bump'] );
322 $design_tokens = MCPHelper::getSiteDesignTokens();
323
324 $builders = [
325 'landing' => 'buildOptinTree',
326 'optin' => 'buildOptinTree',
327 'checkout' => 'buildCheckoutTree',
328 'upsell' => 'buildOfferTree',
329 'downsell' => 'buildOfferTree',
330 'thankyou' => 'buildThankyouTree',
331 ];
332 $builder = isset( $builders[ $step_type ] ) ? $builders[ $step_type ] : 'buildCustomTree';
333
334 ElementorLayoutBuilder::reset();
335 $tree = self::$builder(
336 $copy['headline'],
337 $copy['subheadline'],
338 $copy['cta_text'],
339 $copy['benefits'],
340 [
341 'step_type' => $step_type,
342 'include_order_bump' => $include_order_bump,
343 'primary_color' => $design_tokens['primary_color'],
344 ]
345 );
346
347 self::writeElementorTree( $step->ID, $tree );
348
349 return array_merge(
350 $copy,
351 [
352 'step_type' => $step_type,
353 'elementor_data_written' => true,
354 'widgets_used' => self::collectWidgetTypes( $tree ),
355 ]
356 );
357 }
358
359 /**
360 * Write a node tree to `_elementor_data` and the companion metas Elementor
361 * expects on a page it built itself, so the editor treats it the same as
362 * content authored by hand.
363 *
364 * @param int $step_id Step post id.
365 * @param array $tree Array of section nodes.
366 * @return void
367 */
368 private static function writeElementorTree( $step_id, $tree ) {
369 update_post_meta( $step_id, '_elementor_data', wp_slash( wp_json_encode( $tree ) ) );
370 update_post_meta( $step_id, '_elementor_edit_mode', 'builder' );
371 update_post_meta( $step_id, '_elementor_version', defined( 'ELEMENTOR_VERSION' ) ? ELEMENTOR_VERSION : '3.5.0' );
372
373 // Force Elementor to regenerate its CSS cache file rather than trusting
374 // a stale or absent one.
375 delete_post_meta( $step_id, '_elementor_css' );
376 delete_post_meta( $step_id, '_elementor_page_assets' );
377 }
378
379 /**
380 * Landing / opt-in layout: headline block, then an opt-in capture widget.
381 *
382 * @param string $headline Headline.
383 * @param string $subheadline Subheadline.
384 * @param string $cta_text CTA text.
385 * @param array $benefits Benefit bullets.
386 * @param array $options step_type, include_order_bump, primary_color.
387 * @return array
388 */
389 private static function buildOptinTree( $headline, $subheadline, $cta_text, $benefits, $options ) {
390 return [
391 ElementorLayoutBuilder::section(
392 [
393 ElementorLayoutBuilder::column(
394 array_values(
395 array_filter(
396 [
397 ElementorLayoutBuilder::headingWidget( $headline, 'h1' ),
398 '' !== $subheadline ? ElementorLayoutBuilder::textWidget( $subheadline ) : null,
399 ElementorLayoutBuilder::benefitsWidget( $benefits ),
400 ]
401 )
402 )
403 ),
404 ]
405 ),
406 ElementorLayoutBuilder::sectionFor(
407 ElementorLayoutBuilder::optinWidget(
408 [
409 'cta_text' => $cta_text,
410 'primary_color' => $options['primary_color'],
411 ]
412 )
413 ),
414 ];
415 }
416
417 /**
418 * Checkout layout: optional headline block, then the checkout widget
419 * (and an order-bump placeholder only when explicitly requested).
420 *
421 * @param string $headline Headline.
422 * @param string $subheadline Subheadline.
423 * @param string $cta_text CTA text.
424 * @param array $benefits Benefit bullets.
425 * @param array $options step_type, include_order_bump, primary_color.
426 * @return array
427 */
428 private static function buildCheckoutTree( $headline, $subheadline, $cta_text, $benefits, $options ) {
429 $widgets = [
430 ElementorLayoutBuilder::checkoutWidget(
431 [
432 'cta_text' => $cta_text,
433 'subheadline' => $subheadline,
434 'primary_color' => $options['primary_color'],
435 ]
436 ),
437 ];
438
439 if ( ! empty( $options['include_order_bump'] ) ) {
440 $widgets[] = ElementorLayoutBuilder::orderBumpWidget();
441 }
442
443 return [
444 ElementorLayoutBuilder::sectionFor( ElementorLayoutBuilder::headingWidget( $headline, 'h1' ) ),
445 ElementorLayoutBuilder::section( [ ElementorLayoutBuilder::column( $widgets ) ] ),
446 ];
447 }
448
449 /**
450 * Upsell / downsell layout: headline block, then accept + decline offer
451 * widgets side by side — `wpfnl-offer` is single-valued per instance.
452 *
453 * @param string $headline Headline.
454 * @param string $subheadline Subheadline.
455 * @param string $cta_text CTA text.
456 * @param array $benefits Benefit bullets.
457 * @param array $options step_type (upsell|downsell), include_order_bump.
458 * @return array
459 */
460 private static function buildOfferTree( $headline, $subheadline, $cta_text, $benefits, $options ) {
461 $offer_button_type = 'downsell' === $options['step_type'] ? 'downsell' : 'upsell';
462
463 $accept = ElementorLayoutBuilder::offerWidget(
464 [
465 'offer_button_type' => $offer_button_type,
466 'offer_type' => 'accept',
467 'text' => '' !== $cta_text ? $cta_text : 'Yes, Add This To My Order!',
468 'primary_color' => $options['primary_color'],
469 ]
470 );
471 $decline = ElementorLayoutBuilder::offerWidget(
472 [
473 'offer_button_type' => $offer_button_type,
474 'offer_type' => 'reject',
475 'text' => 'No, thanks',
476 ]
477 );
478
479 return [
480 ElementorLayoutBuilder::section(
481 [
482 ElementorLayoutBuilder::column(
483 array_values(
484 array_filter(
485 [
486 ElementorLayoutBuilder::headingWidget( $headline, 'h1' ),
487 '' !== $subheadline ? ElementorLayoutBuilder::textWidget( $subheadline ) : null,
488 ElementorLayoutBuilder::benefitsWidget( $benefits ),
489 ]
490 )
491 )
492 ),
493 ]
494 ),
495 ElementorLayoutBuilder::section(
496 [
497 ElementorLayoutBuilder::column( [ $accept ], [ '_column_size' => 50 ] ),
498 ElementorLayoutBuilder::column( [ $decline ], [ '_column_size' => 50 ] ),
499 ]
500 ),
501 ];
502 }
503
504 /**
505 * Thank-you layout: headline block, then a next-step widget pointing the
506 * visitor at what comes after the funnel.
507 *
508 * @param string $headline Headline.
509 * @param string $subheadline Subheadline.
510 * @param string $cta_text CTA text.
511 * @param array $benefits Benefit bullets.
512 * @param array $options step_type, include_order_bump, primary_color.
513 * @return array
514 */
515 private static function buildThankyouTree( $headline, $subheadline, $cta_text, $benefits, $options ) {
516 return [
517 ElementorLayoutBuilder::section(
518 [
519 ElementorLayoutBuilder::column(
520 array_values(
521 array_filter(
522 [
523 ElementorLayoutBuilder::headingWidget( $headline, 'h1' ),
524 '' !== $subheadline ? ElementorLayoutBuilder::textWidget( $subheadline ) : null,
525 ElementorLayoutBuilder::benefitsWidget( $benefits ),
526 ]
527 )
528 )
529 ),
530 ]
531 ),
532 ElementorLayoutBuilder::sectionFor(
533 ElementorLayoutBuilder::nextStepWidget(
534 [
535 'cta_text' => $cta_text,
536 'primary_color' => $options['primary_color'],
537 ]
538 )
539 ),
540 ];
541 }
542
543 /**
544 * Fallback layout for `custom` or unrecognized step types: headline block
545 * only, no WPFunnels-specific widget.
546 *
547 * @param string $headline Headline.
548 * @param string $subheadline Subheadline.
549 * @param string $cta_text CTA text.
550 * @param array $benefits Benefit bullets.
551 * @param array $options step_type, include_order_bump, primary_color.
552 * @return array
553 */
554 private static function buildCustomTree( $headline, $subheadline, $cta_text, $benefits, $options ) {
555 return [
556 ElementorLayoutBuilder::section(
557 [
558 ElementorLayoutBuilder::column(
559 array_values(
560 array_filter(
561 [
562 ElementorLayoutBuilder::headingWidget( $headline, 'h1' ),
563 '' !== $subheadline ? ElementorLayoutBuilder::textWidget( $subheadline ) : null,
564 ElementorLayoutBuilder::benefitsWidget( $benefits ),
565 ]
566 )
567 )
568 ),
569 ]
570 ),
571 ];
572 }
573
574 /**
575 * Recursively collect distinct `widgetType` values from a node tree.
576 *
577 * @param array $nodes Section/column/widget nodes.
578 * @return array
579 */
580 private static function collectWidgetTypes( $nodes ) {
581 $types = [];
582
583 foreach ( $nodes as $node ) {
584 if ( isset( $node['widgetType'] ) ) {
585 $types[ $node['widgetType'] ] = true;
586 }
587 if ( ! empty( $node['elements'] ) ) {
588 foreach ( self::collectWidgetTypes( $node['elements'] ) as $type ) {
589 $types[ $type ] = true;
590 }
591 }
592 }
593
594 return array_keys( $types );
595 }
596
597 /**
598 * Rewrite copy for a specific element.
599 *
600 * @param array $input Tool input.
601 * @return array|\WP_Error
602 */
603 public static function rewriteStepCopy( $input = [] ) {
604 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
605 if ( is_wp_error( $step ) ) {
606 return $step;
607 }
608
609 $element = sanitize_text_field( $input['element'] );
610 $content = sanitize_text_field( $input['content'] );
611
612 update_post_meta( $step->ID, '_wpfnl_page_' . $element, $content );
613
614 return [
615 'updated' => true,
616 'step_id' => (int) $step->ID,
617 'element' => $element,
618 'content' => $content,
619 ];
620 }
621
622 /**
623 * Generate an image from a prompt and attach it to a step.
624 *
625 * @param array $input Tool input.
626 * @return array|\WP_Error
627 */
628 public static function generateStepImage( $input = [] ) {
629 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
630 if ( is_wp_error( $step ) ) {
631 return $step;
632 }
633
634 $prompt = isset( $input['prompt'] ) ? sanitize_textarea_field( $input['prompt'] ) : '';
635 if ( '' === $prompt ) {
636 return MCPHelper::error( 'missing_prompt', 'Describe the image to generate.' );
637 }
638
639 if ( 'openai' !== AISettings::getActiveProvider() ) {
640 return MCPHelper::error( 'openai_required', 'Image generation needs OpenAI connected as the active AI provider (WPFunnels → Settings → AI).' );
641 }
642
643 $provider = AIInit::makeProvider( 'openai', AISettings::getApiKey( 'openai' ) );
644 $size = isset( $input['size'] ) && in_array( $input['size'], [ '1024x1024', '1792x1024', '1024x1792' ], true ) ? $input['size'] : '1024x1024';
645
646 $result = $provider->generateImage( $prompt, $size );
647 if ( is_wp_error( $result ) ) {
648 return $result;
649 }
650
651 $b64 = isset( $result['data'][0]['b64_json'] ) ? $result['data'][0]['b64_json'] : '';
652 if ( '' === $b64 ) {
653 return MCPHelper::error( 'image_generation_failed', 'The provider did not return image data.' );
654 }
655
656 $upload = wp_upload_bits( 'wpfnl-ai-' . $step->ID . '-' . uniqid() . '.png', null, base64_decode( $b64 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
657 if ( ! empty( $upload['error'] ) ) {
658 return MCPHelper::error( 'upload_failed', $upload['error'] );
659 }
660
661 $attachment_id = wp_insert_attachment(
662 [
663 'post_mime_type' => 'image/png',
664 'post_title' => $prompt,
665 'post_status' => 'inherit',
666 ],
667 $upload['file']
668 );
669 if ( is_wp_error( $attachment_id ) ) {
670 return $attachment_id;
671 }
672
673 require_once ABSPATH . 'wp-admin/includes/image.php';
674 wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) );
675
676 $placement = isset( $input['placement'] ) && 'content' === $input['placement'] ? 'content' : 'featured';
677 if ( 'featured' === $placement ) {
678 set_post_thumbnail( $step->ID, $attachment_id );
679 } else {
680 update_post_meta( $step->ID, '_wpfnl_page_image_id', $attachment_id );
681 }
682
683 return [
684 'generated' => true,
685 'step_id' => (int) $step->ID,
686 'attachment_id' => $attachment_id,
687 'url' => wp_get_attachment_url( $attachment_id ),
688 'placement' => $placement,
689 ];
690 }
691
692 /**
693 * Get step outline.
694 *
695 * @param array $input Tool input.
696 * @return array|\WP_Error
697 */
698 public static function getStepOutline( $input = [] ) {
699 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
700 if ( is_wp_error( $step ) ) {
701 return $step;
702 }
703
704 return [
705 'step_id' => (int) $step->ID,
706 'step_title' => $step->post_title,
707 'step_type' => get_post_meta( $step->ID, '_step_type', true ),
708 'headline' => get_post_meta( $step->ID, '_wpfnl_page_headline', true ) ?: $step->post_title,
709 'subheadline' => get_post_meta( $step->ID, '_wpfnl_page_subheadline', true ) ?: '',
710 'cta_text' => get_post_meta( $step->ID, '_wpfnl_page_cta_text', true ) ?: 'Buy Now',
711 'benefits' => get_post_meta( $step->ID, '_wpfnl_page_benefits', true ) ?: [],
712 'note' => 'These are AI-authored placeholder fields, not necessarily the page\'s live builder content — they read as generic defaults when unset. For a Gutenberg page\'s actual on-page text, call wpfunnels/list-gutenberg-text instead.',
713 ];
714 }
715
716 /**
717 * Detect the page builder behind a funnel step's page.
718 *
719 * A step post IS its own page/post in WPFunnels, so the step id and the
720 * page id are the same value — no separate lookup needed.
721 *
722 * @param array $input Tool input.
723 * @return array|\WP_Error
724 */
725 public static function detectPageBuilder( $input = [] ) {
726 $step = MCPHelper::requireStep( isset( $input['funnel_step_id'] ) ? $input['funnel_step_id'] : 0 );
727 if ( is_wp_error( $step ) ) {
728 return $step;
729 }
730
731 return [
732 'page_id' => (int) $step->ID,
733 'builder' => MCPHelper::detectPageBuilder( $step->ID ),
734 ];
735 }
736
737 /**
738 * Get the page connected to a funnel step, with basic info and its
739 * detected builder.
740 *
741 * @param array $input Tool input.
742 * @return array|\WP_Error
743 */
744 public static function getFunnelStepPage( $input = [] ) {
745 $step = MCPHelper::requireStep( isset( $input['funnel_step_id'] ) ? $input['funnel_step_id'] : 0 );
746 if ( is_wp_error( $step ) ) {
747 return $step;
748 }
749
750 return [
751 'funnel_step_id' => (int) $step->ID,
752 'page_id' => (int) $step->ID,
753 'page_title' => $step->post_title,
754 'post_type' => $step->post_type,
755 'status' => $step->post_status,
756 'builder' => MCPHelper::detectPageBuilder( $step->ID ),
757 ];
758 }
759
760 /**
761 * List the actual visible text sitting in a Gutenberg page's blocks, in
762 * reading order, so the model has real strings to pass to
763 * update-gutenberg-text instead of guessing from unrelated placeholder
764 * fields (get-step-outline) or the step title.
765 *
766 * @param array $input Tool input.
767 * @return array|\WP_Error
768 */
769 public static function listGutenbergText( $input = [] ) {
770 $page_id = isset( $input['page_id'] ) ? (int) $input['page_id'] : 0;
771 $post = $page_id ? get_post( $page_id ) : null;
772
773 if ( ! $post ) {
774 return MCPHelper::error( 'page_not_found', sprintf( 'No page found with ID %d.', $page_id ) );
775 }
776
777 $builder = MCPHelper::detectPageBuilder( $page_id );
778 if ( 'gutenberg' !== $builder ) {
779 return MCPHelper::error(
780 'unsupported_builder',
781 sprintf( 'Page %d uses "%s", not Gutenberg. This tool only reads Gutenberg block content.', $page_id, $builder ),
782 [
783 'page_id' => $page_id,
784 'builder' => $builder,
785 ]
786 );
787 }
788
789 $blocks = parse_blocks( $post->post_content );
790 $text_nodes = [];
791 self::collectBlockText( $blocks, $text_nodes );
792
793 return [
794 'page_id' => $page_id,
795 'text_nodes' => $text_nodes,
796 'count' => count( $text_nodes ),
797 ];
798 }
799
800 /**
801 * Recursively collect non-empty visible text per block, tag-stripped.
802 *
803 * A block that wraps other blocks (group, columns, column…) carries no
804 * text of its own in its own `innerHTML` — its children are walked
805 * separately — so it's naturally skipped by the empty-text check.
806 *
807 * Displayed text is cleaned up (entities decoded, zero-width characters
808 * dropped) so the model isn't asked to retype invisible junk it can't
809 * see — update-gutenberg-text's lenient matching reconciles that cleanup
810 * against whatever the raw stored HTML actually contains.
811 *
812 * @param array $blocks Blocks (parse_blocks() output).
813 * @param array $text_nodes Accumulator, by reference.
814 * @return void
815 */
816 private static function collectBlockText( $blocks, &$text_nodes ) {
817 foreach ( $blocks as $block ) {
818 if ( ! empty( $block['blockName'] ) && ! empty( $block['innerHTML'] ) ) {
819 $text = self::cleanDisplayText( wp_strip_all_tags( $block['innerHTML'] ) );
820 if ( '' !== $text ) {
821 $text_nodes[] = [
822 'block_type' => $block['blockName'],
823 'text' => $text,
824 ];
825 }
826 }
827
828 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
829 self::collectBlockText( $block['innerBlocks'], $text_nodes );
830 }
831 }
832 }
833
834 /**
835 * Decode entities and strip zero-width characters for display, so the
836 * model sees the same text a visitor would rather than raw markup noise
837 * (`&nbsp;`, `​`) that happens to be baked into the stored HTML.
838 *
839 * @param string $text Raw tag-stripped text.
840 * @return string
841 */
842 private static function cleanDisplayText( $text ) {
843 $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
844 $text = preg_replace( '/[\x{200B}\x{200C}\x{200D}\x{FEFF}]/u', '', $text );
845 return trim( $text );
846 }
847
848 /**
849 * Replace one piece of text inside a Gutenberg page, without disturbing
850 * block structure.
851 *
852 * @param array $input Tool input.
853 * @return array|\WP_Error
854 */
855 public static function updateGutenbergText( $input = [] ) {
856 $page_id = isset( $input['page_id'] ) ? (int) $input['page_id'] : 0;
857 $post = $page_id ? get_post( $page_id ) : null;
858
859 if ( ! $post ) {
860 return MCPHelper::error( 'page_not_found', sprintf( 'No page found with ID %d.', $page_id ) );
861 }
862
863 $builder = MCPHelper::detectPageBuilder( $page_id );
864 if ( 'gutenberg' !== $builder ) {
865 return MCPHelper::error(
866 'unsupported_builder',
867 sprintf( 'Page %d uses "%s", not Gutenberg. This tool only edits Gutenberg block content.', $page_id, $builder ),
868 [
869 'page_id' => $page_id,
870 'builder' => $builder,
871 ]
872 );
873 }
874
875 $target_text = isset( $input['target_text'] ) ? (string) $input['target_text'] : '';
876 if ( '' === trim( $target_text ) ) {
877 return MCPHelper::error( 'missing_target_text', 'Provide the exact text to find inside the page.' );
878 }
879 $new_text = isset( $input['new_text'] ) ? wp_kses_post( $input['new_text'] ) : '';
880
881 $blocks = parse_blocks( $post->post_content );
882 $replacements = self::replaceTextInBlocks( $blocks, $target_text, $new_text );
883
884 // Exact match failed — retry tolerating whitespace differences (line
885 // breaks/indentation the model can't see) before giving up, since that's
886 // the most common reason correct-looking text doesn't match verbatim.
887 if ( 0 === $replacements ) {
888 $replacements = self::replaceTextInBlocks( $blocks, $target_text, $new_text, true );
889 }
890
891 if ( 0 === $replacements ) {
892 return [
893 'success' => false,
894 'page_id' => $page_id,
895 'updated' => false,
896 'message' => 'Target text was not found on this page. Call wpfunnels/list-gutenberg-text to see the exact text available, then retry with one of those strings.',
897 ];
898 }
899
900 $updated = wp_update_post(
901 [
902 'ID' => $page_id,
903 'post_content' => serialize_blocks( $blocks ),
904 ],
905 true
906 );
907
908 if ( is_wp_error( $updated ) ) {
909 return $updated;
910 }
911
912 return [
913 'success' => true,
914 'page_id' => $page_id,
915 'updated' => true,
916 'message' => sprintf( 'Replaced %d occurrence(s) of the target text.', $replacements ),
917 ];
918 }
919
920 /**
921 * Recursively replace text inside parsed blocks' `innerContent` chunks,
922 * leaving block attrs and comment delimiters untouched — only the
923 * rendered HTML fragments are edited, so unrelated blocks and unrelated
924 * text within a matching block are never touched.
925 *
926 * @param array $blocks Blocks, by reference (parse_blocks() output).
927 * @param string $target Text to find.
928 * @param string $replace Replacement text.
929 * @param bool $fuzzy Tolerate whitespace/`&nbsp;`, quote-glyph, and
930 * zero-width-character differences instead of
931 * requiring an exact substring — see
932 * fuzzyTextPattern().
933 * @return int Number of occurrences replaced.
934 */
935 private static function replaceTextInBlocks( &$blocks, $target, $replace, $fuzzy = false ) {
936 $count = 0;
937 $pattern = $fuzzy ? self::fuzzyTextPattern( $target ) : null;
938
939 foreach ( $blocks as &$block ) {
940 if ( ! empty( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) {
941 foreach ( $block['innerContent'] as &$chunk ) {
942 if ( ! is_string( $chunk ) ) {
943 continue;
944 }
945 if ( $fuzzy ) {
946 if ( null === $pattern ) {
947 continue;
948 }
949 // preg_replace_callback, not preg_replace, so $replace's
950 // literal content (e.g. a stray '$1' or backslash) is
951 // never reinterpreted as a backreference.
952 $replaced = preg_replace_callback(
953 $pattern,
954 static function () use ( $replace ) {
955 return $replace;
956 },
957 $chunk,
958 -1,
959 $matched
960 );
961 if ( null !== $replaced ) {
962 $chunk = $replaced;
963 $count += $matched;
964 }
965 } elseif ( false !== strpos( $chunk, $target ) ) {
966 $count += substr_count( $chunk, $target );
967 $chunk = str_replace( $target, $replace, $chunk );
968 }
969 }
970 unset( $chunk );
971
972 $block['innerHTML'] = implode( '', array_filter( $block['innerContent'], 'is_string' ) );
973 }
974
975 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
976 $count += self::replaceTextInBlocks( $block['innerBlocks'], $target, $replace, $fuzzy );
977 }
978 }
979 unset( $block );
980
981 return $count;
982 }
983
984 /**
985 * Build a case-sensitive regex that matches `$target` against the raw
986 * stored HTML while tolerating the exact kinds of noise
987 * list-gutenberg-text cleans up for display, since that's where the
988 * model's copy of the text comes from:
989 * - zero-width characters (U+200B/200C/200D/FEFF) anywhere, incl.
990 * between every character — list-gutenberg-text strips these, but
991 * they may sit mid-word in the stored HTML;
992 * - straight vs curly quote glyphs for `'` and `"`;
993 * - a run of whitespace matching either a literal space/newline or a
994 * literal `&nbsp;`/`\xA0` — covers both HTML line-wrapping and the
995 * stored-as-text-not-a-real-space `&nbsp;` artifacts some templates
996 * have baked into their copy.
997 *
998 * Built character-by-character (not word-by-word) since the mismatch can
999 * fall inside what looks like a single word, e.g. an apostrophe glyph.
1000 *
1001 * @param string $target Text to find (as shown by list-gutenberg-text).
1002 * @return string|null Regex pattern, or null if target is empty.
1003 */
1004 private static function fuzzyTextPattern( $target ) {
1005 $target = trim( $target );
1006 if ( '' === $target ) {
1007 return null;
1008 }
1009
1010 $chars = preg_split( '//u', $target, -1, PREG_SPLIT_NO_EMPTY );
1011 if ( empty( $chars ) ) {
1012 return null;
1013 }
1014
1015 $quote_classes = [
1016 "'" => "['’‘`´]",
1017 '"' => '["“”„]',
1018 ];
1019
1020 $zwsp = '[\x{200B}\x{200C}\x{200D}\x{FEFF}]*';
1021 $pattern = $zwsp;
1022
1023 foreach ( $chars as $char ) {
1024 if ( isset( $quote_classes[ $char ] ) ) {
1025 $pattern .= $quote_classes[ $char ];
1026 } elseif ( preg_match( '/\s/u', $char ) ) {
1027 $pattern .= '(?:\s|&nbsp;|\x{00A0})+';
1028 } else {
1029 $pattern .= preg_quote( $char, '/' );
1030 }
1031 $pattern .= $zwsp;
1032 }
1033
1034 return '/' . $pattern . '/u';
1035 }
1036 }
1037