plugin = $plugin; $this->builder_bridge = $builder_bridge; } /** Register local lifecycle and capability metadata only. */ public function register() { add_action( 'divi_powers_expire_saved_job', array( $this, 'expire_saved_job' ) ); add_filter( 'divi_powers_capability_definitions', array( $this, 'register_capability_definitions' ) ); } /** Return reviewed operation metadata for an explicitly installed adapter. */ public function operation_definitions() { return $this->tool_definitions(); } /** Dispatch one reviewed operation after the caller establishes identity. */ public function dispatch_operation( $tool, array $args ) { switch ( $tool ) { case 'divi_powers_get_site_status': return $this->site_status(); case 'divi_powers_get_layout_contract': return $this->layout_contract(); case 'divi_powers_get_builder_context': return $this->get_builder_context( $args ); case 'divi_powers_get_page_layout': return $this->get_page_layout( $args ); case 'divi_powers_inspect_layout': return $this->inspect_layout( $args ); case 'divi_powers_validate_layout': return $this->validate_layout_tool( $args ); case 'divi_powers_export_layout_json': return $this->export_layout_json( $args ); case 'divi_powers_create_page_draft': return $this->create_page_draft( $args ); case 'divi_powers_save_page_layout': return $this->save_page_layout( $args ); case 'divi_powers_publish_working_copy': return $this->publish_working_copy( $args ); case 'divi_powers_restore_page_revision': return $this->restore_page_revision( $args ); case 'divi_powers_stage_page_layout': return $this->stage_page_layout( $args ); case 'divi_powers_get_job': return $this->get_job_result( $this->required_string( $args, 'job_id' ) ); case 'divi_powers_apply_staged_layout': return $this->apply_staged_layout( $args ); case 'divi_powers_rollback_page_layout': return $this->rollback_page_layout( $args ); default: return new WP_Error( 'divi_powers_unknown_tool', 'Unknown Divi Powers tool.' ); } } /** Return connection and capability state without page content. */ private function site_status() { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'divi_powers_access_denied', 'An authenticated editor is required.' ); } return array( 'connected' => true, 'product' => 'Divi Powers', 'version' => DIVI_POWERS_VERSION, 'site_name' => get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'wordpress_version' => get_bloginfo( 'version' ), 'builder_bridge' => $this->plugin->is_power_enabled( 'builder_bridge' ), 'power_map' => $this->plugin->is_power_enabled( 'builder_bridge' ), 'layout_contract' => 'divi5-native-layouts/2026-10-27', 'editing' => array( 'native_divi_5' => true, 'wordpress_workflow' => true, 'legacy_two_step_writes' => true, 'hash_race_protection' => true, 'wordpress_revisions' => true, 'provider_key_on_site' => class_exists( 'Divi_Powers_OpenAI' ) && Divi_Powers_OpenAI::is_configured(), ), ); } /** Return the stable native-layout contract used by approved AI clients. */ private function layout_contract() { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'divi_powers_access_denied', 'An authenticated editor is required.' ); } return array( 'contract' => 'divi5-native-layouts/2026-10-27', 'builder_version' => '5.11.0', 'hierarchy' => array( 'placeholder', 'section', 'row', 'column', 'native module or confirmed nested container' ), 'parent_rules' => Divi_Powers_Native_Layouts::ai_parent_rules(), 'supported_native_blocks' => Divi_Powers_Native_Layouts::ai_supported_module_names(), 'nested_containers' => array( 'group' => 'native modules, including nested groups', 'group-carousel' => 'group', 'accordion' => 'accordion-item', 'contact-form' => 'contact-field', 'counters' => 'counter', 'icon-list' => 'icon-list-item', 'post-filter' => 'post-filter-item', 'pricing-tables' => 'pricing-table', 'slider' => 'slide', 'social-media-follow' => 'social-media-follow-network', 'tabs' => 'tab', 'timeline' => 'timeline-item', 'video-slider' => 'video-slider-item', ), 'verified_settings' => array( 'native_animation' => array( 'attribute_path' => 'module.decoration.animation..value', 'example' => array( 'desktop' => array( 'value' => array( 'style' => 'fade', 'duration' => '700ms', 'delay' => '0ms', 'startingOpacity' => '0%', 'speedCurve' => 'ease-out', 'repeat' => 'once', ), ), ), 'allowed_styles' => array( 'fade', 'slide', 'bounce', 'zoom', 'flip', 'fold', 'roll', 'none' ), ), ), 'policy' => array( 'use_semantic_native_modules' => true, 'use_native_design_settings' => true, 'keep_content_editable' => true, 'use_native_responsive_values' => true, 'inherit_destination_brand' => true, 'custom_css_allowed_by_default' => false, 'inline_styles_allowed_by_default' => false, 'code_modules_allowed_by_default' => false, 'unverified_presets_allowed' => false, ), 'portability' => array( 'context' => 'et_builder', 'presets' => null, 'global_colors' => array(), 'global_variables' => array(), 'page_custom_css' => '', 'canvases' => array( 'local' => array(), 'global' => array() ), 'images' => array(), 'thumbnails' => array(), 'import_replaces_page_work'=> true, ), 'workflow' => array( 'read_or_create_target', 'generate_native_block_markup', 'validate_without_writing', 'save_as_draft_or_pending_review', 'create_working_copy_for_published_page', 'update_live_with_wordpress_revision_when_requested', 'restore_wordpress_revision_if_needed', 'export_portability_json_if_requested', ), 'methodology' => Divi_Powers_Native_Layouts::ai_methodology(), 'evidence' => Divi_Powers_Native_Layouts::ai_evidence_manifest(), ); } /** Return the saved page plus the most recent live Builder selection. */ private function get_builder_context( array $args ) { $post_id = absint( isset( $args['post_id'] ) ? $args['post_id'] : 0 ); if ( ! $post_id ) { $post_id = $this->builder_bridge->latest_post_id(); } if ( ! $post_id ) { return new WP_Error( 'divi_powers_builder_context_missing', 'No active Divi Builder page was found. Open the page in the Visual Builder, select an element, and keep the Builder open while you try again.' ); } $post_id = $this->editable_post_id( array( 'post_id' => $post_id ) ); $context = $this->builder_bridge->page_context( $post_id, ! empty( $args['include_content'] ) ); if ( empty( $context ) ) { return new WP_Error( 'divi_powers_page_missing', 'The requested page was not found.' ); } return $context; } /** Return exact saved Divi block markup and a bounded structural outline. */ private function get_page_layout( array $args ) { $post_id = $this->editable_post_id( $args ); $post = get_post( $post_id ); $content = (string) $post->post_content; return array( 'post_id' => $post_id, 'title' => get_the_title( $post ), 'status' => (string) $post->post_status, 'edit_url' => get_edit_post_link( $post_id, 'raw' ), 'preview_url' => get_preview_post_link( $post_id ), 'source_post_id' => absint( get_post_meta( $post_id, '_divi_powers_source_post_id', true ) ), 'content_sha256' => hash( 'sha256', $content ), 'content_bytes' => strlen( $content ), 'builder' => $this->builder_type( $content ), 'outline' => $this->layout_outline( $content ), 'content' => $content, ); } /** Inspect the current saved layout without returning its full content. */ private function inspect_layout( array $args ) { $post_id = $this->editable_post_id( $args ); $post = get_post( $post_id ); $content = (string) $post->post_content; $validation= $this->validate_content( $content, false ); return array( 'post_id' => $post_id, 'title' => get_the_title( $post ), 'status' => (string) $post->post_status, 'content_sha256' => hash( 'sha256', $content ), 'builder' => $this->builder_type( $content ), 'outline' => $this->layout_outline( $content ), 'validation' => $validation, ); } /** Validate proposed block content without staging or writing it. */ private function validate_layout_tool( array $args ) { $content = $this->required_string( $args, 'content', true ); return $this->validate_content( $content, true ); } /** Export one saved native page through the verified direct Builder envelope. */ private function export_layout_json( array $args ) { $post_id = $this->editable_post_id( $args ); $post = get_post( $post_id ); $content = (string) $post->post_content; $validation = $this->validate_content( $content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_export_invalid', implode( ' ', $validation['errors'] ) ); } $envelope = array( 'context' => 'et_builder', 'data' => array( (string) $post_id => $content ), 'presets' => null, 'global_colors' => array(), 'global_variables' => array(), 'page_settings_meta' => array( '_et_pb_custom_css' => array( '' ) ), 'canvases' => array( 'local' => array(), 'global' => array() ), 'images' => array(), 'thumbnails' => array(), ); $envelope_errors = $this->portability_envelope_errors( $envelope ); if ( ! empty( $envelope_errors ) ) { return new WP_Error( 'divi_powers_export_envelope', implode( ' ', $envelope_errors ) ); } $json = wp_json_encode( $envelope, JSON_UNESCAPED_SLASHES ); return array( 'post_id' => $post_id, 'filename' => sanitize_title( get_the_title( $post ) ?: 'divi-layout-' . $post_id ) . '.json', 'content_sha256' => hash( 'sha256', $content ), 'json_sha256' => hash( 'sha256', $json ), 'json' => $json, 'warning' => 'Direct Builder imports can replace the current page content. Back up existing work before importing.', ); } /** Create a native Divi 5 page without publishing it. */ private function create_page_draft( array $args ) { if ( ! function_exists( 'et_builder_d5_enabled' ) || ! et_builder_d5_enabled() ) { return new WP_Error( 'divi_powers_divi5_required', 'This workflow requires Divi 5.' ); } if ( ! current_user_can( 'edit_pages' ) ) { return new WP_Error( 'divi_powers_access_denied', 'You cannot create pages.' ); } $title = sanitize_text_field( $this->required_string( $args, 'title' ) ); $content = $this->required_string( $args, 'content', true ); $status = isset( $args['status'] ) ? sanitize_key( (string) $args['status'] ) : 'draft'; if ( ! in_array( $status, array( 'draft', 'pending' ), true ) || '' === $title ) { return new WP_Error( 'divi_powers_invalid_draft', 'Provide a title and choose draft or pending review.' ); } $validation = $this->validate_content( $content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_invalid_layout', implode( ' ', $validation['errors'] ) ); } $post_id = wp_insert_post( wp_slash( array( 'post_type' => 'page', 'post_status' => $status, 'post_title' => $title, 'post_content' => $content, 'post_author' => get_current_user_id(), 'meta_input' => array( '_et_pb_use_builder' => 'on' ), ) ), true ); if ( is_wp_error( $post_id ) || ! $post_id ) { return is_wp_error( $post_id ) ? $post_id : new WP_Error( 'divi_powers_save_failed', 'WordPress could not create the page.' ); } return $this->page_workflow_result( (int) $post_id, $status, 0 ); } /** Save a validated page with a single write, or prepare a linked working copy. */ private function save_page_layout( array $args ) { if ( ! function_exists( 'et_builder_d5_enabled' ) || ! et_builder_d5_enabled() ) { return new WP_Error( 'divi_powers_divi5_required', 'This workflow requires Divi 5.' ); } $post_id = $this->editable_post_id( $args ); $post = get_post( $post_id ); if ( 'page' !== $post->post_type ) { return new WP_Error( 'divi_powers_page_required', 'This workflow currently supports WordPress pages.' ); } $expected_hash = strtolower( trim( $this->required_string( $args, 'expected_sha256' ) ) ); $content = $this->required_string( $args, 'content', true ); $mode = isset( $args['mode'] ) ? sanitize_key( (string) $args['mode'] ) : 'save_draft'; $current_hash = hash( 'sha256', (string) $post->post_content ); if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) || ! hash_equals( $current_hash, $expected_hash ) ) { return new WP_Error( 'divi_powers_hash_mismatch', 'The page changed after it was read. Read it again before saving.' ); } if ( hash_equals( $current_hash, hash( 'sha256', $content ) ) ) { return new WP_Error( 'divi_powers_no_change', 'The proposed layout is identical to the saved page.' ); } $validation = $this->validate_content( $content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_invalid_layout', implode( ' ', $validation['errors'] ) ); } $change_issue = $this->proposal_change_issue( (string) $post->post_content, $content, ! empty( $args['whole_page'] ) ); if ( is_wp_error( $change_issue ) ) { return $change_issue; } if ( 'working_copy' === $mode && 'publish' === $post->post_status ) { $status = isset( $args['review_status'] ) ? sanitize_key( (string) $args['review_status'] ) : 'draft'; if ( ! in_array( $status, array( 'draft', 'pending' ), true ) ) { return new WP_Error( 'divi_powers_invalid_status', 'Choose draft or pending review.' ); } return $this->create_working_copy( $post, $content, $current_hash, $status ); } if ( 'update_live' === $mode && 'publish' === $post->post_status ) { if ( ! current_user_can( 'publish_pages' ) ) { return new WP_Error( 'divi_powers_access_denied', 'You cannot update a published page.' ); } $revision_id = $this->ensure_revision( $post_id, (string) $post->post_content ); if ( is_wp_error( $revision_id ) ) { return $revision_id; } $updated = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $content ) ), true ); if ( is_wp_error( $updated ) || ! $updated ) { return is_wp_error( $updated ) ? $updated : new WP_Error( 'divi_powers_save_failed', 'WordPress could not update the page.' ); } return $this->page_workflow_result( $post_id, 'publish', (int) $revision_id ); } if ( 'save_draft' === $mode && in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) { $updated = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $content ) ), true ); if ( is_wp_error( $updated ) || ! $updated ) { return is_wp_error( $updated ) ? $updated : new WP_Error( 'divi_powers_save_failed', 'WordPress could not save the draft.' ); } return $this->page_workflow_result( $post_id, (string) $post->post_status, 0 ); } return new WP_Error( 'divi_powers_invalid_workflow', 'Use save_draft for an unpublished page, or working_copy or update_live for a published page.' ); } /** Keep a published URL live while an editor reviews a proposed replacement. */ private function create_working_copy( $post, $content, $source_hash, $status ) { $source_id = (int) $post->ID; $draft_id = wp_insert_post( wp_slash( array( 'post_type' => 'page', 'post_status' => $status, 'post_title' => sprintf( '%s — Proposed update', get_the_title( $post ) ), 'post_content' => $content, 'post_author' => get_current_user_id(), 'post_parent' => (int) $post->post_parent, 'meta_input' => array( '_et_pb_use_builder' => 'on', '_wp_page_template' => get_post_meta( $source_id, '_wp_page_template', true ), '_divi_powers_source_post_id' => $source_id, '_divi_powers_source_sha256' => $source_hash, ), ) ), true ); if ( is_wp_error( $draft_id ) || ! $draft_id ) { return is_wp_error( $draft_id ) ? $draft_id : new WP_Error( 'divi_powers_save_failed', 'WordPress could not create a working copy.' ); } $thumbnail_id = get_post_thumbnail_id( $source_id ); if ( $thumbnail_id ) { set_post_thumbnail( $draft_id, $thumbnail_id ); } $result = $this->page_workflow_result( (int) $draft_id, $status, 0 ); $result['source_post_id'] = $source_id; $result['source_url'] = get_permalink( $source_id ); $result['source_unchanged'] = true; return $result; } /** Replace the linked live page only after checking both the draft and source. */ private function publish_working_copy( array $args ) { if ( ! function_exists( 'et_builder_d5_enabled' ) || ! et_builder_d5_enabled() ) { return new WP_Error( 'divi_powers_divi5_required', 'This workflow requires Divi 5.' ); } $draft_id = absint( isset( $args['draft_id'] ) ? $args['draft_id'] : 0 ); $draft = $draft_id ? get_post( $draft_id ) : null; if ( ! $draft || 'page' !== $draft->post_type || ! in_array( $draft->post_status, array( 'draft', 'pending' ), true ) || ! current_user_can( 'edit_post', $draft_id ) ) { return new WP_Error( 'divi_powers_draft_missing', 'The linked draft is unavailable or cannot be edited.' ); } $source_id = absint( get_post_meta( $draft_id, '_divi_powers_source_post_id', true ) ); $source = $source_id ? get_post( $source_id ) : null; $base_hash = (string) get_post_meta( $draft_id, '_divi_powers_source_sha256', true ); if ( ! $source || 'page' !== $source->post_type || 'publish' !== $source->post_status || ! current_user_can( 'edit_post', $source_id ) || ! current_user_can( 'publish_pages' ) ) { return new WP_Error( 'divi_powers_source_missing', 'The published source page is unavailable or cannot be updated.' ); } if ( ! preg_match( '/^[a-f0-9]{64}$/', $base_hash ) || ! hash_equals( $base_hash, hash( 'sha256', (string) $source->post_content ) ) ) { return new WP_Error( 'divi_powers_live_page_changed', 'The published page changed after this draft was created. Prepare a new working copy.' ); } $expected_draft_hash = strtolower( trim( $this->required_string( $args, 'expected_draft_sha256' ) ) ); if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_draft_hash ) || ! hash_equals( $expected_draft_hash, hash( 'sha256', (string) $draft->post_content ) ) ) { return new WP_Error( 'divi_powers_hash_mismatch', 'The working copy changed after it was read. Review it again before publishing.' ); } $validation = $this->validate_content( (string) $draft->post_content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_invalid_layout', implode( ' ', $validation['errors'] ) ); } $revision_id = $this->ensure_revision( $source_id, (string) $source->post_content ); if ( is_wp_error( $revision_id ) ) { return $revision_id; } $updated = wp_update_post( wp_slash( array( 'ID' => $source_id, 'post_content' => (string) $draft->post_content ) ), true ); if ( is_wp_error( $updated ) || ! $updated ) { return is_wp_error( $updated ) ? $updated : new WP_Error( 'divi_powers_save_failed', 'WordPress could not publish the working copy.' ); } update_post_meta( $draft_id, '_divi_powers_published_revision_id', (int) $revision_id ); if ( 'pending' === $draft->post_status ) { wp_update_post( array( 'ID' => $draft_id, 'post_status' => 'draft' ), true ); } $result = $this->page_workflow_result( $source_id, 'publish', (int) $revision_id ); $result['working_copy_id'] = $draft_id; $result['working_copy_status'] = (string) get_post_field( 'post_status', $draft_id ); return $result; } /** Restore a WordPress revision without requiring a second typed phrase. */ private function restore_page_revision( array $args ) { if ( ! function_exists( 'et_builder_d5_enabled' ) || ! et_builder_d5_enabled() ) { return new WP_Error( 'divi_powers_divi5_required', 'This workflow requires Divi 5.' ); } $post_id = $this->editable_post_id( $args ); $revision_id = absint( isset( $args['revision_id'] ) ? $args['revision_id'] : 0 ); $expected_hash = strtolower( trim( $this->required_string( $args, 'expected_sha256' ) ) ); $post = get_post( $post_id ); $revision = $revision_id ? wp_get_post_revision( $revision_id ) : null; if ( 'page' !== $post->post_type ) { return new WP_Error( 'divi_powers_page_required', 'This workflow currently supports WordPress pages.' ); } if ( 'publish' === $post->post_status && ! current_user_can( 'publish_pages' ) ) { return new WP_Error( 'divi_powers_access_denied', 'You cannot restore a published page.' ); } if ( ! $revision || (int) $revision->post_parent !== $post_id ) { return new WP_Error( 'divi_powers_revision_missing', 'That revision does not belong to the requested page.' ); } if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) || ! hash_equals( $expected_hash, hash( 'sha256', (string) $post->post_content ) ) ) { return new WP_Error( 'divi_powers_hash_mismatch', 'The page changed after it was read. Inspect it again before restoring.' ); } if ( ! wp_restore_post_revision( $revision_id ) ) { return new WP_Error( 'divi_powers_rollback_failed', 'WordPress could not restore the requested revision.' ); } return $this->page_workflow_result( $post_id, (string) $post->post_status, $revision_id ); } /** Return useful native WordPress links after one workflow action. */ private function page_workflow_result( $post_id, $status, $revision_id ) { $content = (string) get_post_field( 'post_content', $post_id, 'raw' ); return array( 'post_id' => (int) $post_id, 'status' => $status, 'content_sha256'=> hash( 'sha256', $content ), 'edit_url' => get_edit_post_link( $post_id, 'raw' ), 'preview_url' => get_preview_post_link( $post_id ), 'public_url' => 'publish' === $status ? get_permalink( $post_id ) : null, 'revision_id' => (int) $revision_id, ); } /** Stage a complete replacement against the exact current saved hash. */ private function stage_page_layout( array $args ) { $post_id = $this->editable_post_id( $args ); $post = get_post( $post_id ); $before = (string) $post->post_content; $expected_hash = strtolower( trim( $this->required_string( $args, 'expected_sha256' ) ) ); $current_hash = hash( 'sha256', $before ); $content = $this->required_string( $args, 'content', true ); $scope = isset( $args['scope'] ) ? sanitize_key( (string) $args['scope'] ) : 'standard'; if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) || ! hash_equals( $current_hash, $expected_hash ) ) { return new WP_Error( 'divi_powers_hash_mismatch', 'The page changed after it was read. Read the layout again before staging.' ); } if ( hash_equals( $current_hash, hash( 'sha256', $content ) ) ) { return new WP_Error( 'divi_powers_no_change', 'The proposed layout is identical to the saved page.' ); } $change_issue = $this->proposal_change_issue( $before, $content, 'whole_page' === $scope ); if ( is_wp_error( $change_issue ) ) { return $change_issue; } $validation = $this->validate_content( $content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_invalid_layout', implode( ' ', $validation['errors'] ) ); } $staged_assets = $this->normalize_staged_assets( isset( $args['staged_assets'] ) && is_array( $args['staged_assets'] ) ? $args['staged_assets'] : array(), $content ); if ( is_wp_error( $staged_assets ) ) { return $staged_assets; } $job_id = wp_generate_uuid4(); $job = array( 'job_id' => $job_id, 'status' => 'staged', 'post_id' => $post_id, 'user_id' => get_current_user_id(), 'created_at' => gmdate( 'c' ), 'base_sha256' => $current_hash, 'staged_sha256' => hash( 'sha256', $content ), 'summary' => isset( $args['summary'] ) ? substr( sanitize_text_field( (string) $args['summary'] ), 0, 500 ) : '', 'scope' => $scope, 'content' => $content, 'diff' => $this->diff_preview( $before, $content ), 'validation' => $validation, 'staged_assets' => $staged_assets, ); $saved = $this->save_job( $job ); if ( is_wp_error( $saved ) ) { return $saved; } return $this->public_job( $job ) + array( 'live_page_changed' => false, 'apply_confirmation'=> 'APPLY DIVI LAYOUT ' . $job_id, 'next_step' => 'Review the diff. Apply only after the user explicitly approves this staged layout.', ); } /** Stage a Builder-generated proposal through the shared guarded workflow. */ public function stage_builder_proposal( $post_id, $expected_sha256, $content, $summary, $scope = 'selected_element', array $staged_assets = array() ) { return $this->stage_page_layout( array( 'post_id' => absint( $post_id ), 'expected_sha256' => (string) $expected_sha256, 'content' => (string) $content, 'summary' => (string) $summary, 'scope' => sanitize_key( (string) $scope ), 'staged_assets' => $staged_assets, ) ); } /** Validate opaque mockup-crop staging records before storing them with a job. */ private function normalize_staged_assets( array $assets, $content ) { $uploads = wp_upload_dir(); $base_dir = ! empty( $uploads['basedir'] ) ? realpath( trailingslashit( $uploads['basedir'] ) . self::ASSET_STAGING_DIR ) : false; $tokens = array(); preg_match_all( '/\{\{DIVI_POWERS_STAGED_ASSET_[A-F0-9]{12}\}\}/', (string) $content, $matches ); $content_tokens = array_values( array_unique( isset( $matches[0] ) ? $matches[0] : array() ) ); if ( empty( $assets ) ) { return empty( $content_tokens ) ? array() : new WP_Error( 'divi_powers_asset_unknown', 'The proposed layout contains a visual-asset token without a private staged file.' ); } if ( false === $base_dir || 8 < count( $assets ) ) { return new WP_Error( 'divi_powers_asset_invalid', 'The staged visual assets are invalid.' ); } $normalized = array(); foreach ( $assets as $asset ) { if ( ! is_array( $asset ) ) { return new WP_Error( 'divi_powers_asset_invalid', 'Every staged visual asset must be a verified record.' ); } $token = isset( $asset['token'] ) ? (string) $asset['token'] : ''; $path = isset( $asset['path'] ) ? realpath( (string) $asset['path'] ) : false; $mime = isset( $asset['mime'] ) ? (string) $asset['mime'] : ''; $key = isset( $asset['staging_key'] ) ? strtolower( (string) $asset['staging_key'] ) : ''; $actual_mime = false !== $path && function_exists( 'wp_get_image_mime' ) ? wp_get_image_mime( $path ) : ''; if ( ! preg_match( '/^\{\{DIVI_POWERS_STAGED_ASSET_[A-F0-9]{12}\}\}$/', $token ) || isset( $tokens[ $token ] ) || false === $path || 0 !== strpos( $path, trailingslashit( $base_dir ) ) || ! is_file( $path ) || 4194304 < filesize( $path ) || ! in_array( $mime, array( 'image/jpeg', 'image/png', 'image/webp' ), true ) || $actual_mime !== $mime || ! preg_match( '/^[a-z0-9]{24}$/', $key ) || false === strpos( (string) $content, $token ) ) { return new WP_Error( 'divi_powers_asset_invalid', 'A staged visual asset did not match its protected layout token.' ); } $tokens[ $token ] = true; $normalized[] = array( 'token' => $token, 'path' => $path, 'mime' => $mime, 'filename' => sanitize_file_name( (string) ( $asset['filename'] ?? basename( $path ) ) ), 'alt_text' => substr( sanitize_text_field( (string) ( $asset['alt_text'] ?? '' ) ), 0, 300 ), 'label' => substr( sanitize_text_field( (string) ( $asset['label'] ?? '' ) ), 0, 160 ), 'staging_key' => $key, ); } if ( array_diff( $content_tokens, array_keys( $tokens ) ) || array_diff( array_keys( $tokens ), $content_tokens ) ) { return new WP_Error( 'divi_powers_asset_mismatch', 'The proposed layout and its private visual assets did not match exactly.' ); } return $normalized; } /** Validate a Builder-generated proposal before attempting to stage it. */ public function validate_builder_proposal( $content, $saved_content = '', $scope = 'selected_element' ) { $validation = $this->validate_content( (string) $content, true ); if ( '' !== (string) $saved_content ) { $change_issue = $this->proposal_change_issue( (string) $saved_content, (string) $content, 'whole_page' === sanitize_key( (string) $scope ) ); if ( is_wp_error( $change_issue ) ) { $validation['errors'][] = $change_issue->get_error_message(); $validation['errors'] = array_values( array_unique( $validation['errors'] ) ); $validation['valid'] = false; } } return $validation; } /** Return stable opening-block identifiers for a bounded AI patch response. */ public function builder_patch_catalog( $content ) { $catalog = array(); foreach ( $this->opening_block_records( (string) $content ) as $record ) { if ( empty( $record['attributes_valid'] ) ) { continue; } $catalog[] = array( 'block_ordinal' => $record['ordinal'], 'block_name' => $record['name'], 'block_sha256' => $record['sha256'], 'admin_label' => $this->nested_attribute_value( $record['attributes'], array( 'module', 'meta', 'adminLabel', 'desktop', 'value' ) ), 'content_excerpt' => $this->catalog_content_excerpt( $record['attributes'] ), ); if ( 400 <= count( $catalog ) ) { break; } } return $catalog; } /** Merge bounded setting operations into exact opening block comments only. */ public function apply_builder_patch_to_content( $content, array $operations ) { $content = (string) $content; $original_content = $content; if ( empty( $operations ) || 20 < count( $operations ) ) { return new WP_Error( 'divi_powers_patch_count', 'A proposal must contain between 1 and 20 targeted block operations.' ); } $records = $this->opening_block_records( $content ); $updates = array(); $seen = array(); foreach ( $operations as $operation ) { if ( ! is_array( $operation ) ) { return new WP_Error( 'divi_powers_patch_operation', 'Every patch operation must be an object.' ); } $ordinal = absint( isset( $operation['block_ordinal'] ) ? $operation['block_ordinal'] : 0 ); $block_name = isset( $operation['block_name'] ) ? (string) $operation['block_name'] : ''; $block_hash = isset( $operation['block_sha256'] ) ? strtolower( (string) $operation['block_sha256'] ) : ''; $path = isset( $operation['attribute_path'] ) ? (string) $operation['attribute_path'] : ''; $value_json = isset( $operation['value_json'] ) ? (string) $operation['value_json'] : ''; if ( ! $ordinal || ! isset( $records[ $ordinal - 1 ] ) ) { return new WP_Error( 'divi_powers_patch_target', 'A patch operation referenced a block outside the supplied catalog.' ); } $record = isset( $updates[ $ordinal ] ) ? $updates[ $ordinal ] : $records[ $ordinal - 1 ]; if ( empty( $record['attributes_valid'] ) || $block_name !== $record['name'] || ! preg_match( '/^[a-f0-9]{64}$/', $block_hash ) || ! hash_equals( $record['sha256'], $block_hash ) ) { return new WP_Error( 'divi_powers_patch_target', 'A patch target did not match the exact saved Divi block.' ); } $segments = $this->patch_path_segments( $path ); if ( is_wp_error( $segments ) ) { return $segments; } $operation_key = $ordinal . ':' . $path; if ( isset( $seen[ $operation_key ] ) ) { return new WP_Error( 'divi_powers_patch_duplicate', 'A proposal cannot update the same block setting more than once.' ); } $seen[ $operation_key ] = true; if ( ! $this->patch_path_is_allowed( $records[ $ordinal - 1 ]['attributes'], $path, $segments ) ) { return new WP_Error( 'divi_powers_patch_path', 'The proposed attribute path is neither present on the saved block nor a verified Divi Powers setting.' ); } if ( 50000 < strlen( $value_json ) ) { return new WP_Error( 'divi_powers_patch_value', 'A patch value exceeded the 50 KB safety limit.' ); } $value = json_decode( $value_json, true ); if ( JSON_ERROR_NONE !== json_last_error() || null === $value ) { return new WP_Error( 'divi_powers_patch_value', 'A patch value was not valid non-null JSON.' ); } $value_error = $this->validate_patch_value( $path, $value ); if ( is_wp_error( $value_error ) ) { return $value_error; } $record['attributes'] = $this->set_attribute_path( $record['attributes'], $segments, $value ); $updates[ $ordinal ] = $record; } $replacements = array(); foreach ( $updates as $record ) { $replacements[] = array( 'offset' => $record['offset'], 'length' => $record['length'], 'replacement' => '' : ' -->' ), ); } usort( $replacements, function ( $left, $right ) { return $right['offset'] <=> $left['offset']; } ); foreach ( $replacements as $replacement ) { $content = substr_replace( $content, $replacement['replacement'], $replacement['offset'], $replacement['length'] ); } $validation = $this->validate_content( $content, true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_patch_invalid', implode( ' ', $validation['errors'] ) ); } $change_issue = $this->proposal_change_issue( $original_content, $content ); if ( is_wp_error( $change_issue ) ) { return $change_issue; } return $content; } /** Apply a Builder proposal through the existing approval and revision guard. */ public function apply_builder_proposal( $job_id, $confirmation ) { return $this->apply_staged_layout( array( 'job_id' => (string) $job_id, 'confirmation' => (string) $confirmation, ) ); } /** Roll a Builder-applied proposal back through the guarded revision workflow. */ public function rollback_builder_proposal( $post_id, $revision_id, $expected_sha256, $confirmation, $job_id = '' ) { return $this->rollback_page_layout( array( 'post_id' => absint( $post_id ), 'revision_id' => absint( $revision_id ), 'expected_sha256' => (string) $expected_sha256, 'confirmation' => (string) $confirmation, 'job_id' => (string) $job_id, ) ); } /** Read one user-owned staged job without exposing its complete source. */ private function get_job_result( $job_id ) { $job = $this->get_job( $job_id ); if ( is_wp_error( $job ) ) { return $job; } $post = get_post( $job['post_id'] ); $current_hash = $post ? hash( 'sha256', (string) $post->post_content ) : ''; return $this->public_job( $job ) + array( 'current_sha256' => $current_hash, 'current_matches_base'=> hash_equals( (string) $job['base_sha256'], $current_hash ), 'apply_confirmation' => 'APPLY DIVI LAYOUT ' . $job_id, ); } /** Apply one explicitly approved staged layout and create a revision. */ private function apply_staged_layout( array $args ) { $job_id = $this->required_string( $args, 'job_id' ); $confirmation = $this->required_string( $args, 'confirmation' ); $expected = 'APPLY DIVI LAYOUT ' . $job_id; if ( ! hash_equals( $expected, trim( $confirmation ) ) ) { return new WP_Error( 'divi_powers_confirmation_required', 'Explicit confirmation is required. Pass exactly: ' . $expected ); } $job = $this->get_job( $job_id ); if ( is_wp_error( $job ) ) { return $job; } if ( 'staged' !== $job['status'] ) { return new WP_Error( 'divi_powers_job_not_staged', 'Only a staged layout job can be applied.' ); } $post_id = (int) $job['post_id']; if ( ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'divi_powers_access_denied', 'You cannot edit this page.' ); } $post = get_post( $post_id ); if ( ! $post || ! hash_equals( (string) $job['base_sha256'], hash( 'sha256', (string) $post->post_content ) ) ) { return new WP_Error( 'divi_powers_live_page_changed', 'The saved page changed after staging. Read it again and create a new staged layout.' ); } if ( ! isset( $job['content'] ) || ! is_string( $job['content'] ) ) { return new WP_Error( 'divi_powers_job_content_missing', 'The staged layout content is unavailable. Create a new proposal.' ); } $validation = $this->validate_content( $job['content'], true ); if ( empty( $validation['valid'] ) ) { return new WP_Error( 'divi_powers_invalid_layout', implode( ' ', $validation['errors'] ) ); } $change_issue = $this->proposal_change_issue( (string) $post->post_content, $job['content'], 'whole_page' === ( isset( $job['scope'] ) ? sanitize_key( (string) $job['scope'] ) : '' ) ); if ( is_wp_error( $change_issue ) ) { return $change_issue; } $revision_id = $this->ensure_revision( $post_id, (string) $post->post_content ); if ( is_wp_error( $revision_id ) ) { return $revision_id; } $resolved_content = $job['content']; $asset_import = array( 'content' => $resolved_content, 'attachment_ids' => array() ); if ( ! empty( $job['staged_assets'] ) && is_array( $job['staged_assets'] ) ) { $asset_import = $this->import_staged_assets( $job['staged_assets'], $resolved_content, $post_id ); if ( is_wp_error( $asset_import ) ) { return $asset_import; } $resolved_content = $asset_import['content']; $resolved_validation = $this->validate_content( $resolved_content, true ); if ( empty( $resolved_validation['valid'] ) ) { $this->delete_job_attachments( $asset_import['attachment_ids'] ); return new WP_Error( 'divi_powers_invalid_resolved_layout', implode( ' ', $resolved_validation['errors'] ) ); } } $updated = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $resolved_content, ) ), true ); if ( is_wp_error( $updated ) || ! $updated ) { $this->delete_job_attachments( $asset_import['attachment_ids'] ); return is_wp_error( $updated ) ? $updated : new WP_Error( 'divi_powers_save_failed', 'WordPress could not save the staged layout.' ); } $job['status'] = 'applied'; $job['applied_at'] = gmdate( 'c' ); $job['revision_id'] = (int) $revision_id; $job['created_attachment_ids'] = array_map( 'absint', $asset_import['attachment_ids'] ); unset( $job['content'], $job['staged_assets'] ); $saved = $this->save_job( $job ); $new_content = (string) get_post_field( 'post_content', $post_id, 'raw' ); return array( 'applied' => true, 'job_storage_warning' => is_wp_error( $saved ) ? 'The page was applied, but its job status could not be saved. Use the returned revision for rollback.' : '', 'job_id' => $job_id, 'post_id' => $post_id, 'revision_id' => (int) $revision_id, 'created_attachment_count' => count( $asset_import['attachment_ids'] ), 'new_sha256' => hash( 'sha256', $new_content ), 'reload_builder'=> true, 'rollback_confirmation' => 'ROLLBACK DIVI PAGE ' . $post_id . ' TO REVISION ' . (int) $revision_id, ); } /** Import approved private crops into the Media Library and resolve their layout tokens. */ private function import_staged_assets( array $assets, $content, $post_id ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $attachment_ids = array(); $directories = array(); foreach ( $assets as $asset ) { $path = isset( $asset['path'] ) ? (string) $asset['path'] : ''; if ( '' === $path || ! is_file( $path ) || false === strpos( (string) $content, (string) $asset['token'] ) ) { $this->delete_job_attachments( $attachment_ids ); return new WP_Error( 'divi_powers_asset_expired', 'A private staged visual expired. Create and approve a new proposal.' ); } $directories[] = dirname( $path ); $file = array( 'name' => sanitize_file_name( (string) $asset['filename'] ), 'tmp_name' => $path, 'type' => (string) $asset['mime'], 'error' => 0, 'size' => filesize( $path ), ); $description = '' !== (string) $asset['label'] ? (string) $asset['label'] : 'Divi Powers mockup visual'; $attachment_id = media_handle_sideload( $file, $post_id, $description ); if ( is_wp_error( $attachment_id ) ) { $this->delete_job_attachments( $attachment_ids ); return new WP_Error( 'divi_powers_asset_import', 'WordPress could not add an approved mockup visual to the Media Library.' ); } $attachment_ids[] = (int) $attachment_id; if ( '' !== (string) $asset['alt_text'] ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', (string) $asset['alt_text'] ); } $url = wp_get_attachment_url( $attachment_id ); if ( ! is_string( $url ) || '' === $url ) { $this->delete_job_attachments( $attachment_ids ); return new WP_Error( 'divi_powers_asset_import', 'WordPress could not resolve an approved mockup visual.' ); } $content = $this->hydrate_staged_image_modules( (string) $content, (string) $asset['token'], (string) $asset['alt_text'], esc_url_raw( $url ) ); if ( is_wp_error( $content ) ) { $this->delete_job_attachments( $attachment_ids ); return $content; } } if ( preg_match( '/\{\{DIVI_POWERS_STAGED_ASSET_[A-F0-9]{12}\}\}/', (string) $content ) ) { $this->delete_job_attachments( $attachment_ids ); return new WP_Error( 'divi_powers_asset_mismatch', 'One staged visual token could not be resolved.' ); } foreach ( array_unique( $directories ) as $directory ) { if ( is_dir( $directory ) ) { @rmdir( $directory ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } } return array( 'content' => $content, 'attachment_ids' => $attachment_ids ); } /** Populate approved Divi Image values with the verified native source shape. */ private function hydrate_staged_image_modules( $content, $token, $alt_text, $url ) { $content = preg_replace_callback( '/()/s', function ( $match ) use ( $token, $alt_text, $url ) { $attrs = json_decode( (string) $match[2], true ); if ( ! is_array( $attrs ) ) { return $match[0]; } $value = $attrs['image']['innerContent']['desktop']['value'] ?? array(); $src = is_array( $value ) ? (string) ( $value['src'] ?? '' ) : (string) $value; if ( false === strpos( $src, $token ) ) { return $match[0]; } $value = is_array( $value ) ? $value : array(); $value['src'] = str_replace( $token, $url, $src ); $value['alt'] = (string) $alt_text; unset( $value['id'], $value['width'], $value['height'], $value['srcset'], $value['sizes'] ); $attrs['image']['innerContent']['desktop']['value'] = $value; return $match[1] . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . $match[3]; }, (string) $content ); if ( null === $content || PREG_NO_ERROR !== preg_last_error() ) { return new WP_Error( 'divi_powers_asset_metadata', 'WordPress could not update the approved Divi image data.' ); } $content = str_replace( $token, $url, (string) $content ); return $content; } /** Delete attachments created solely for a failed apply or an explicit rollback. */ private function delete_job_attachments( array $attachment_ids ) { $count = 0; foreach ( array_values( array_unique( array_map( 'absint', $attachment_ids ) ) ) as $attachment_id ) { if ( $attachment_id && wp_delete_attachment( $attachment_id, true ) ) { $count++; } } return $count; } /** Ensure the exact pre-change page is available as a restorable revision. */ private function ensure_revision( $post_id, $content ) { $revision_id = wp_save_post_revision( $post_id ); if ( is_wp_error( $revision_id ) ) { return $revision_id; } if ( $revision_id ) { return (int) $revision_id; } $revisions = wp_get_post_revisions( $post_id, array( 'posts_per_page' => 20, 'order' => 'DESC', ) ); foreach ( $revisions as $revision ) { if ( hash_equals( hash( 'sha256', $content ), hash( 'sha256', (string) $revision->post_content ) ) ) { return (int) $revision->ID; } } return new WP_Error( 'divi_powers_revision_failed', 'WordPress could not create or locate an exact pre-change revision.' ); } /** Restore an owned page revision after exact confirmation and hash check. */ private function rollback_page_layout( array $args ) { $post_id = $this->editable_post_id( $args ); $revision_id = absint( isset( $args['revision_id'] ) ? $args['revision_id'] : 0 ); $confirmation = $this->required_string( $args, 'confirmation' ); $expected_hash = strtolower( trim( $this->required_string( $args, 'expected_sha256' ) ) ); $expected = 'ROLLBACK DIVI PAGE ' . $post_id . ' TO REVISION ' . $revision_id; if ( ! $revision_id || ! hash_equals( $expected, trim( $confirmation ) ) ) { return new WP_Error( 'divi_powers_confirmation_required', 'Explicit confirmation is required. Pass exactly: ' . $expected ); } $post = get_post( $post_id ); $revision = wp_get_post_revision( $revision_id ); if ( ! $revision || (int) $revision->post_parent !== $post_id ) { return new WP_Error( 'divi_powers_revision_missing', 'That revision does not belong to the requested page.' ); } if ( ! preg_match( '/^[a-f0-9]{64}$/', $expected_hash ) || ! hash_equals( hash( 'sha256', (string) $post->post_content ), $expected_hash ) ) { return new WP_Error( 'divi_powers_hash_mismatch', 'The page changed after it was read. Inspect it again before rolling back.' ); } $result = wp_restore_post_revision( $revision_id ); if ( ! $result ) { return new WP_Error( 'divi_powers_rollback_failed', 'WordPress could not restore the requested revision.' ); } $deleted_attachments = 0; $job_storage_warning = ''; $job_id = isset( $args['job_id'] ) ? sanitize_text_field( (string) $args['job_id'] ) : ''; if ( '' !== $job_id ) { $job = $this->get_job( $job_id ); if ( ! is_wp_error( $job ) && (int) $job['post_id'] === $post_id && isset( $job['revision_id'] ) && (int) $job['revision_id'] === $revision_id ) { $deleted_attachments = $this->delete_job_attachments( isset( $job['created_attachment_ids'] ) && is_array( $job['created_attachment_ids'] ) ? $job['created_attachment_ids'] : array() ); $job['status'] = 'rolled_back'; $job['rolled_back_at'] = gmdate( 'c' ); unset( $job['created_attachment_ids'] ); $saved = $this->save_job( $job ); if ( is_wp_error( $saved ) ) { $job_storage_warning = 'The page was restored, but its job status could not be saved.'; } } } $content = (string) get_post_field( 'post_content', $post_id, 'raw' ); return array( 'rolled_back' => true, 'job_storage_warning' => $job_storage_warning, 'post_id' => $post_id, 'revision_id' => $revision_id, 'deleted_attachment_count' => $deleted_attachments, 'new_sha256' => hash( 'sha256', $content ), 'reload_builder'=> true, ); } /** Validate native Divi block markup and report policy warnings. */ private function validate_content( $content, $strict ) { $errors = Divi_Powers_Native_Layouts::executable_content_errors( $content ); $warnings = array(); $bytes = strlen( $content ); if ( 0 === $bytes ) { $errors[] = 'Layout content cannot be empty.'; } if ( $bytes > self::MAX_BYTES ) { $errors[] = 'Layout content exceeds the 2 MB safety limit.'; } if ( false === strpos( $content, '' !== substr( ltrim( $content ), 0, strlen( '' ) ) ) { $errors[] = 'Block content must start with the native divi/placeholder envelope.'; } if ( '' !== substr( rtrim( $content ), -strlen( '' ) ) ) { $errors[] = 'Block content must end with the native divi/placeholder envelope.'; } if ( preg_match( '/<\?(?:php|=)/i', $content ) ) { $errors[] = 'Executable PHP is not permitted in page layouts.'; } if ( preg_match( '/opening_block_records( $content ) as $record ) { if ( empty( $record['attributes_valid'] ) ) { $errors[] = sprintf( 'Invalid JSON attributes were found on %1$s (%2$s).', (string) $record['name'], (string) ( $record['attribute_error'] ?? 'unknown JSON error' ) ); } if ( 'divi/row' === $record['name'] ) { $sizing = isset( $record['attributes']['module']['decoration']['sizing'] ) && is_array( $record['attributes']['module']['decoration']['sizing'] ) ? $record['attributes']['module']['decoration']['sizing'] : array(); foreach ( $sizing as $breakpoint ) { $value = isset( $breakpoint['value'] ) && is_array( $breakpoint['value'] ) ? $breakpoint['value'] : array(); if ( array_key_exists( 'width', $value ) || array_key_exists( 'maxWidth', $value ) ) { $warnings[] = 'A Row overrides the destination site width or max-width. Keep it only when the design intentionally requires that geometry.'; break; } } } } $outline = $this->layout_outline( $content ); if ( empty( $outline['divi_blocks'] ) ) { $errors[] = 'WordPress could not parse a native Divi block structure.'; } if ( $strict && ! empty( $outline['truncated'] ) ) { $warnings[] = 'The returned outline is truncated, but the complete content remains staged.'; } if ( $strict ) { $errors = array_merge( $errors, $this->hierarchy_errors( parse_blocks( $content ) ) ); } return array( 'valid' => empty( $errors ), 'content_bytes' => $bytes, 'content_sha256'=> hash( 'sha256', $content ), 'divi_blocks' => isset( $outline['divi_blocks'] ) ? (int) $outline['divi_blocks'] : 0, 'errors' => $errors, 'warnings' => array_values( array_unique( $warnings ) ), ); } /** Enforce structural and confirmed native nested-container relationships. */ private function hierarchy_errors( array $blocks, $parent = '' ) { $errors = array(); $content_parents = array( 'divi/column', 'divi/group' ); $parent_rules = Divi_Powers_Native_Layouts::ai_parent_rules(); unset( $parent_rules['other confirmed leaf modules'] ); foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; if ( '' === $name ) { continue; } if ( 0 !== strpos( $name, 'divi/' ) ) { $errors[] = 'Only native Divi blocks may appear in a generated Divi layout.'; continue; } $normalized_parent = '' === $parent ? 'layout root' : $parent; if ( isset( $parent_rules[ $name ] ) ) { if ( ! in_array( $normalized_parent, $parent_rules[ $name ], true ) ) { $errors[] = sprintf( 'Invalid parent for %1$s: %2$s.', $name, $normalized_parent ); } } elseif ( ! in_array( $parent, $content_parents, true ) ) { $errors[] = sprintf( 'Leaf module %1$s must be inside divi/column or divi/group, found %2$s.', $name, $normalized_parent ); } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $errors = array_merge( $errors, $this->hierarchy_errors( $block['innerBlocks'], $name ) ); } } return array_values( array_unique( $errors ) ); } /** Validate the direct Builder envelope produced by the PHP portability builder. */ private function portability_envelope_errors( array $envelope ) { $errors = array(); if ( 'et_builder' !== ( $envelope['context'] ?? '' ) ) { $errors[] = 'Expected direct Builder context et_builder.'; } if ( empty( $envelope['data'] ) || ! is_array( $envelope['data'] ) ) { $errors[] = 'The direct Builder data collection must contain native block content.'; } $css = $envelope['page_settings_meta']['_et_pb_custom_css'] ?? null; if ( ! in_array( $css, array( null, array(), array( '' ) ), true ) ) { $errors[] = 'The portability envelope contains page custom CSS.'; } foreach ( array( 'global_colors', 'global_variables', 'images', 'thumbnails' ) as $key ) { if ( ! isset( $envelope[ $key ] ) || ! is_array( $envelope[ $key ] ) ) { $errors[] = sprintf( 'The portability envelope is missing the %s collection.', $key ); } } if ( ! isset( $envelope['canvases']['local'], $envelope['canvases']['global'] ) ) { $errors[] = 'The portability envelope is missing local or global canvases.'; } return $errors; } /** Index every native Divi opening comment without reserializing the page. */ private function opening_block_records( $content ) { $pattern = '//s'; preg_match_all( $pattern, (string) $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); $records = array(); foreach ( $matches as $index => $match ) { $comment = isset( $match[0][0] ) ? (string) $match[0][0] : ''; $raw_attributes = isset( $match[2][0] ) ? (string) $match[2][0] : ''; $attributes = '' !== $raw_attributes ? json_decode( $raw_attributes, true ) : array(); $json_error = json_last_error(); $valid = '' === $raw_attributes || ( JSON_ERROR_NONE === $json_error && is_array( $attributes ) ); $records[] = array( 'ordinal' => $index + 1, 'name' => isset( $match[1][0] ) ? (string) $match[1][0] : '', 'sha256' => hash( 'sha256', $comment ), 'attributes' => is_array( $attributes ) ? $attributes : array(), 'attributes_valid' => $valid, 'attribute_error' => $valid ? '' : ( function_exists( 'json_last_error_msg' ) ? json_last_error_msg() : 'JSON error ' . $json_error ), 'offset' => isset( $match[0][1] ) ? (int) $match[0][1] : 0, 'length' => strlen( $comment ), 'self_closing' => isset( $match[3][0] ) && '/' === $match[3][0], ); } return $records; } /** Read one scalar from an exact nested attribute path. */ private function nested_attribute_value( array $attributes, array $segments ) { $value = $attributes; foreach ( $segments as $segment ) { if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) { return ''; } $value = $value[ $segment ]; } return is_scalar( $value ) ? substr( sanitize_text_field( (string) $value ), 0, 200 ) : ''; } /** Return a short human-readable clue for matching a live Builder selection. */ private function catalog_content_excerpt( array $attributes ) { $parts = array(); $this->collect_catalog_text( $attributes, $parts ); $text = preg_replace( '/\s+/', ' ', implode( ' ', array_unique( $parts ) ) ); return substr( trim( (string) $text ), 0, 260 ); } /** Collect text only from content and admin-label attribute groups. */ private function collect_catalog_text( $value, array &$parts, $depth = 0, $collect = false ) { if ( 9 < $depth || 10 <= count( $parts ) ) { return; } if ( is_array( $value ) ) { foreach ( $value as $key => $child ) { $next_collect = $collect || in_array( (string) $key, array( 'innerContent', 'adminLabel' ), true ); $this->collect_catalog_text( $child, $parts, $depth + 1, $next_collect ); } return; } if ( ! $collect || ! is_scalar( $value ) ) { return; } $text = trim( wp_strip_all_tags( html_entity_decode( (string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ); if ( '' !== $text && 0 !== strpos( $text, 'http://' ) && 0 !== strpos( $text, 'https://' ) && 0 !== strpos( $text, 'data:' ) ) { $parts[] = substr( $text, 0, 180 ); } } /** Parse and bound a dotted block-attribute path. */ private function patch_path_segments( $path ) { if ( ! preg_match( '/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){1,11}$/', (string) $path ) ) { return new WP_Error( 'divi_powers_patch_path', 'A patch attribute path was malformed or too deep.' ); } $segments = explode( '.', (string) $path ); foreach ( $segments as $segment ) { if ( in_array( strtolower( $segment ), array( '__proto__', 'prototype', 'constructor' ), true ) ) { return new WP_Error( 'divi_powers_patch_path', 'A patch attribute path contained a prohibited segment.' ); } } return $segments; } /** Allow an observed saved path or the exact verified native animation path. */ private function patch_path_is_allowed( array $attributes, $path, array $segments ) { if ( ! in_array( $segments[0], array( 'module', 'content' ), true ) ) { return false; } if ( preg_match( '/(?:^|\.)(?:css|customcss|freeformcss|cssfields|htmlattributes|code|script)(?:\.|$)/i', (string) $path ) ) { return false; } if ( 'module.decoration.animation.desktop.value' === $path ) { return true; } $value = $attributes; foreach ( $segments as $segment ) { if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) { return false; } $value = $value[ $segment ]; } return true; } /** Validate bounded patch values and the verified animation shape. */ private function validate_patch_value( $path, $value ) { $encoded = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( ! is_string( $encoded ) || 50000 < strlen( $encoded ) ) { return new WP_Error( 'divi_powers_patch_value', 'A patch value exceeded the 50 KB safety limit.' ); } if ( 'module.decoration.animation.desktop.value' !== $path ) { return null; } if ( ! is_array( $value ) || $this->is_list_array( $value ) ) { return new WP_Error( 'divi_powers_patch_animation', 'A native animation value must be an object.' ); } $allowed_keys = array( 'style', 'direction', 'duration', 'delay', 'intensity', 'startingOpacity', 'speedCurve', 'repeat' ); if ( array_diff( array_keys( $value ), $allowed_keys ) ) { return new WP_Error( 'divi_powers_patch_animation', 'The animation patch contained an unsupported setting.' ); } if ( empty( $value['style'] ) || ! in_array( $value['style'], array( 'fade', 'slide', 'bounce', 'zoom', 'flip', 'fold', 'roll', 'none' ), true ) ) { return new WP_Error( 'divi_powers_patch_animation', 'The animation style was not supported.' ); } foreach ( array( 'duration', 'delay' ) as $timing ) { if ( isset( $value[ $timing ] ) && ! preg_match( '/^(?:0|[1-9][0-9]{0,4})ms$/', (string) $value[ $timing ] ) ) { return new WP_Error( 'divi_powers_patch_animation', 'Animation timing values must be bounded milliseconds.' ); } if ( isset( $value[ $timing ] ) ) { $milliseconds = (int) rtrim( (string) $value[ $timing ], 'ms' ); $maximum = 'duration' === $timing ? 10000 : 5000; if ( $milliseconds > $maximum ) { return new WP_Error( 'divi_powers_patch_animation', 'Animation timing values exceeded the safe duration limit.' ); } } } if ( isset( $value['startingOpacity'] ) && ! preg_match( '/^(?:100|[0-9]{1,2})%$/', (string) $value['startingOpacity'] ) ) { return new WP_Error( 'divi_powers_patch_animation', 'Animation starting opacity must be between 0% and 100%.' ); } if ( isset( $value['speedCurve'] ) && ! in_array( $value['speedCurve'], array( 'ease-in-out', 'ease', 'ease-in', 'ease-out', 'linear' ), true ) ) { return new WP_Error( 'divi_powers_patch_animation', 'The animation speed curve was not supported.' ); } if ( isset( $value['repeat'] ) && ! in_array( $value['repeat'], array( 'once', 'loop' ), true ) ) { return new WP_Error( 'divi_powers_patch_animation', 'The animation repeat value was not supported.' ); } if ( isset( $value['direction'] ) && ! in_array( $value['direction'], array( 'center', 'top', 'bottom', 'left', 'right' ), true ) ) { return new WP_Error( 'divi_powers_patch_animation', 'The animation direction was not supported.' ); } if ( isset( $value['intensity'] ) && ! preg_match( '/^(?:100|[0-9]{1,2})%?$/', (string) $value['intensity'] ) ) { return new WP_Error( 'divi_powers_patch_animation', 'Animation intensity must be between 0 and 100.' ); } return null; } /** Set one already-validated nested attribute value. */ private function set_attribute_path( array $attributes, array $segments, $value ) { $cursor =& $attributes; $last = array_pop( $segments ); foreach ( $segments as $segment ) { if ( ! isset( $cursor[ $segment ] ) || ! is_array( $cursor[ $segment ] ) ) { $cursor[ $segment ] = array(); } $cursor =& $cursor[ $segment ]; } $cursor[ $last ] = $value; unset( $cursor ); return $attributes; } /** Serialize block attributes using WordPress' comment-safe encoding. */ private function encode_block_attributes( array $attributes ) { if ( function_exists( 'serialize_block_attributes' ) ) { return serialize_block_attributes( $attributes ); } $encoded = wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return str_replace( array( '--', '<', '>', '&', '\\"' ), array( '\\u002d\\u002d', '\\u003c', '\\u003e', '\\u0026', '\\u0022' ), (string) $encoded ); } /** Reject formatting-only drafts and full-page formatting churn. */ private function proposal_change_issue( $before, $after, $allow_full_rebuild = false ) { if ( hash_equals( $this->semantic_content_hash( $before ), $this->semantic_content_hash( $after ) ) ) { return new WP_Error( 'divi_powers_no_meaningful_change', 'The draft did not change any native Divi setting or content; it only changed block formatting.' ); } $before_lines = substr_count( str_replace( "\r\n", "\n", (string) $before ), "\n" ) + 1; $after_lines = substr_count( str_replace( "\r\n", "\n", (string) $after ), "\n" ) + 1; if ( ! $allow_full_rebuild && 20 <= $before_lines && $after_lines < max( 3, (int) floor( $before_lines / 4 ) ) ) { return new WP_Error( 'divi_powers_formatting_churn', 'The draft collapsed the saved page formatting. Preserve the original block line breaks and make only the requested native setting changes.' ); } return null; } /** Hash the parsed Divi structure while ignoring insignificant block whitespace. */ private function semantic_content_hash( $content ) { $semantic = $this->semantic_blocks( parse_blocks( (string) $content ) ); $encoded = wp_json_encode( $semantic, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return hash( 'sha256', is_string( $encoded ) ? $encoded : '' ); } /** Build a stable representation of parsed blocks and their meaningful content. */ private function semantic_blocks( array $blocks ) { $semantic = array(); foreach ( $blocks as $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; $inner_html = isset( $block['innerHTML'] ) ? str_replace( "\r\n", "\n", (string) $block['innerHTML'] ) : ''; if ( '' === $name ) { if ( '' !== trim( $inner_html ) ) { $semantic[] = array( 'freeform' => $inner_html ); } continue; } $item = array( 'name' => $name, 'attributes' => $this->canonical_value( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array() ), 'children' => $this->semantic_blocks( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array() ), ); if ( '' !== trim( $inner_html ) ) { $item['inner_html'] = $inner_html; } $semantic[] = $item; } return $semantic; } /** Sort object keys recursively while preserving meaningful list order. */ private function canonical_value( $value ) { if ( ! is_array( $value ) ) { return $value; } if ( ! $this->is_list_array( $value ) ) { ksort( $value, SORT_STRING ); } foreach ( $value as $key => $child ) { $value[ $key ] = $this->canonical_value( $child ); } return $value; } /** PHP 7.4-compatible list detection. */ private function is_list_array( array $value ) { $expected = 0; foreach ( array_keys( $value ) as $key ) { if ( $expected !== $key ) { return false; } $expected++; } return true; } /** Produce a compact recursive block outline. */ private function layout_outline( $content ) { $blocks = parse_blocks( $content ); $items = array(); $count = 0; $this->walk_blocks( $blocks, $items, $count, 0 ); return array( 'divi_blocks' => $count, 'truncated' => $count > 300, 'items' => array_slice( $items, 0, 300 ), ); } /** Walk parsed blocks without returning full attributes or content. */ private function walk_blocks( array $blocks, array &$items, &$count, $depth ) { foreach ( $blocks as $index => $block ) { $name = isset( $block['blockName'] ) ? (string) $block['blockName'] : ''; if ( 0 === strpos( $name, 'divi/' ) ) { $count++; if ( count( $items ) < 300 ) { $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $items[] = array( 'path' => $depth . '.' . $index, 'depth' => (int) $depth, 'block' => $name, 'id' => $this->find_attribute_scalar( $attrs, array( 'moduleId', 'id', 'adminLabel' ) ), ); } } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $this->walk_blocks( $block['innerBlocks'], $items, $count, $depth + 1 ); } } } /** Find one harmless scalar from a nested attribute object. */ private function find_attribute_scalar( $value, array $keys, $depth = 0 ) { if ( $depth > 5 || ! is_array( $value ) ) { return ''; } foreach ( $keys as $key ) { if ( isset( $value[ $key ] ) && is_scalar( $value[ $key ] ) ) { return substr( sanitize_text_field( (string) $value[ $key ] ), 0, 200 ); } } foreach ( $value as $child ) { if ( is_array( $child ) ) { $found = $this->find_attribute_scalar( $child, $keys, $depth + 1 ); if ( '' !== $found ) { return $found; } } } return ''; } /** Return a bounded line-oriented preview suitable for human approval. */ private function diff_preview( $before, $after ) { $old = preg_split( '/\R/', $before ); $new = preg_split( '/\R/', $after ); $old = is_array( $old ) ? $old : array(); $new = is_array( $new ) ? $new : array(); $prefix = 0; $limit = min( count( $old ), count( $new ) ); while ( $prefix < $limit && $old[ $prefix ] === $new[ $prefix ] ) { $prefix++; } $suffix = 0; while ( $suffix < count( $old ) - $prefix && $suffix < count( $new ) - $prefix && $old[ count( $old ) - 1 - $suffix ] === $new[ count( $new ) - 1 - $suffix ] ) { $suffix++; } $removed = array_slice( $old, $prefix, count( $old ) - $prefix - $suffix ); $added = array_slice( $new, $prefix, count( $new ) - $prefix - $suffix ); $removed_preview = array(); $added_preview = array(); $truncated = count( $removed ) > 20 || count( $added ) > 20; for ( $i = 0; $i < min( 20, max( count( $removed ), count( $added ) ) ); $i++ ) { $start = 0; if ( isset( $removed[ $i ], $added[ $i ] ) && $removed[ $i ] !== $added[ $i ] ) { // Long serialized Divi lines often differ far beyond their opening block. $common = 0; $length = min( strlen( $removed[ $i ] ), strlen( $added[ $i ] ) ); while ( $common < $length && $removed[ $i ][ $common ] === $added[ $i ][ $common ] ) { $common++; } if ( max( strlen( $removed[ $i ] ), strlen( $added[ $i ] ) ) > 500 ) { $start = max( 0, $common - 120 ); } } if ( isset( $removed[ $i ] ) ) { $removed_preview[] = $this->preview_line( $removed[ $i ], $start ); $truncated = $truncated || $start > 0 || strlen( $removed[ $i ] ) > 500; } if ( isset( $added[ $i ] ) ) { $added_preview[] = $this->preview_line( $added[ $i ], $start ); $truncated = $truncated || $start > 0 || strlen( $added[ $i ] ) > 500; } } return array( 'first_changed_line'=> $prefix + 1, 'removed_line_count'=> count( $removed ), 'added_line_count' => count( $added ), 'removed_preview' => $removed_preview, 'added_preview' => $added_preview, 'preview_truncated' => $truncated, 'preview_note' => $truncated ? 'Excerpt only: long lines are focused near their first difference; additional changes may be omitted. Review the complete proposed layout before approval.' : '', ); } /** Truncate one diff line without altering stored content. */ public function preview_line( $line, $start = 0 ) { $line = (string) $line; $length = strlen( $line ); $start = max( 0, min( (int) $start, $length ) ); // Keep JSON responses valid when an excerpt boundary falls inside UTF-8. while ( $start > 0 && $start < $length && ( ord( $line[ $start ] ) & 0xc0 ) === 0x80 ) { $start--; } $end = min( $length, $start + 500 ); while ( $end < $length && $end > $start && ( ord( $line[ $end ] ) & 0xc0 ) === 0x80 ) { $end--; } return ( $start > 0 ? '... ' : '' ) . substr( $line, $start, $end - $start ) . ( $end < $length ? ' ...' : '' ); } /** Resolve a post and enforce the native WordPress edit capability. */ private function editable_post_id( array $args ) { $post_id = absint( isset( $args['post_id'] ) ? $args['post_id'] : 0 ); if ( ! $post_id || ! get_post( $post_id ) ) { throw new InvalidArgumentException( 'post_id must identify an existing page.' ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { throw new RuntimeException( 'The authenticated user cannot edit this page.' ); } return $post_id; } /** Persist ASCII JSON outside evictable transients; never autoload layout source. */ private function save_job( array $job ) { global $wpdb; $id = isset( $job['job_id'] ) ? $job['job_id'] : ''; if ( ! preg_match( '/^[a-f0-9-]{36}$/i', $id ) ) { return new WP_Error( 'divi_powers_job_invalid', 'Invalid staged job ID.' ); } $key = self::JOB_OPTION_PREFIX . $id; $existing = json_decode( (string) get_option( $key, '' ), true ); $expires = is_array( $existing ) && isset( $existing['expires_at'] ) ? (int) $existing['expires_at'] : time() + self::JOB_TTL; // Default JSON encoding escapes Unicode, including emoji, for older DB charsets. $encoded = wp_json_encode( array( 'expires_at' => $expires, 'job' => $job ) ); if ( ! is_string( $encoded ) ) { return new WP_Error( 'divi_powers_job_save_failed', 'The staged layout could not be encoded. Nothing was staged.' ); } update_option( $key, $encoded, false ); // Verify the database, not a potentially optimistic object-cache entry. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reading the persisted value is the purpose of this durability check. $stored = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) ); if ( ! is_string( $stored ) || ! hash_equals( $encoded, $stored ) ) { return new WP_Error( 'divi_powers_job_save_failed', 'WordPress could not persist the staged layout. Check database storage and try staging again.' ); } delete_transient( self::JOB_PREFIX . $id ); if ( ! wp_next_scheduled( 'divi_powers_expire_saved_job', array( $id ) ) ) { wp_schedule_single_event( $expires, 'divi_powers_expire_saved_job', array( $id ) ); } return true; } /** Expiry also runs on read, so delayed WP-Cron never extends approval life. */ public function expire_saved_job( $id ) { if ( ! is_string( $id ) || ! preg_match( '/^[a-f0-9-]{36}$/i', $id ) ) { return; } $key = self::JOB_OPTION_PREFIX . $id; $record = json_decode( (string) get_option( $key, '' ), true ); if ( is_array( $record ) && isset( $record['expires_at'] ) && (int) $record['expires_at'] <= time() ) { delete_option( $key ); } } /** Load one current-user job, retaining legacy jobs until their normal expiry. */ private function get_job( $job_id ) { if ( ! preg_match( '/^[a-f0-9-]{36}$/i', $job_id ) ) { return new WP_Error( 'divi_powers_job_invalid', 'The layout job ID is invalid.' ); } $key = self::JOB_OPTION_PREFIX . $job_id; $record = get_option( $key, false ); if ( false !== $record ) { $record = json_decode( (string) $record, true ); $job = is_array( $record ) && isset( $record['expires_at'], $record['job'] ) && (int) $record['expires_at'] > time() ? $record['job'] : false; $this->expire_saved_job( $job_id ); } else { $job = get_transient( self::JOB_PREFIX . $job_id ); } if ( ! is_array( $job ) || ! isset( $job['user_id'] ) || (int) $job['user_id'] !== get_current_user_id() ) { return new WP_Error( 'divi_powers_job_missing', 'The staged layout job was not found or has expired.' ); } return $job; } /** Remove full source from a staged job response. */ private function public_job( array $job ) { $job['staged_asset_count'] = isset( $job['staged_assets'] ) && is_array( $job['staged_assets'] ) ? count( $job['staged_assets'] ) : 0; $job['created_attachment_count'] = isset( $job['created_attachment_ids'] ) && is_array( $job['created_attachment_ids'] ) ? count( $job['created_attachment_ids'] ) : 0; unset( $job['content'], $job['user_id'], $job['staged_assets'], $job['created_attachment_ids'] ); return $job; } /** Detect the saved page format. */ private function builder_type( $content ) { if ( false !== strpos( $content, '