assets
2 weeks ago
sources
2 months ago
class-ecs-drb-mapper.php
2 months ago
class-ecs-drb-sources.php
2 months ago
class-ecs-dynamic-repeater-module.php
2 weeks ago
class-ecs-dynamic-repeater-module.php
546 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Module: Dynamic Repeater Builder |
| 4 | * |
| 5 | * Populates any Elementor repeater from Posts, Terms, ACF Repeater, or Raw JSON. |
| 6 | * Supports one-time generation in the editor and runtime binding on the frontend. |
| 7 | */ |
| 8 | |
| 9 | if ( ! defined( 'ABSPATH' ) ) { |
| 10 | exit; |
| 11 | } |
| 12 | |
| 13 | class ECS_Dynamic_Repeater_Module extends ECS_Module_Base { |
| 14 | |
| 15 | const OPTION_PRESETS = 'ecs_drb_presets'; |
| 16 | const META_BINDINGS = '_ecs_drb_bindings'; |
| 17 | const OPTION_BINDING_INDEX = 'ecs_drb_binding_index'; // element_id:control_key → doc_post_id |
| 18 | const CACHE_PREFIX = 'ecs_drb_src_'; |
| 19 | const CACHE_TTL = 300; |
| 20 | |
| 21 | // ── Identity ────────────────────────────────────────────────────────────── |
| 22 | |
| 23 | public function get_id(): string { |
| 24 | return 'dynamic_repeater'; |
| 25 | } |
| 26 | |
| 27 | public function get_title(): string { |
| 28 | return __( 'Dynamic Repeater Builder', 'ele-custom-skin' ); |
| 29 | } |
| 30 | |
| 31 | public function get_description(): string { |
| 32 | return __( 'Populate any Elementor repeater from Posts, Terms, ACF, or JSON. Supports one-time generation and runtime data binding.', 'ele-custom-skin' ); |
| 33 | } |
| 34 | |
| 35 | public function is_pro(): bool { |
| 36 | return true; |
| 37 | } |
| 38 | |
| 39 | // ── Boot ────────────────────────────────────────────────────────────────── |
| 40 | |
| 41 | public function boot(): void { |
| 42 | $this->load_dependencies(); |
| 43 | |
| 44 | $ajax_actions = [ |
| 45 | 'ecs_drb_get_source_schema', |
| 46 | 'ecs_drb_get_available_fields', |
| 47 | 'ecs_drb_generate', |
| 48 | 'ecs_drb_get_presets', |
| 49 | 'ecs_drb_save_preset', |
| 50 | 'ecs_drb_delete_preset', |
| 51 | 'ecs_drb_save_binding', |
| 52 | 'ecs_drb_break_binding', |
| 53 | 'ecs_drb_get_bindings', |
| 54 | 'ecs_drb_get_terms', |
| 55 | 'ecs_drb_get_acf_repeaters', |
| 56 | ]; |
| 57 | |
| 58 | foreach ( $ajax_actions as $action ) { |
| 59 | add_action( "wp_ajax_{$action}", [ $this, 'handle_ajax' ] ); |
| 60 | } |
| 61 | |
| 62 | // Intercept _elementor_data reads to inject binding data before Elementor |
| 63 | // parses the JSON into element objects — this is the earliest possible point. |
| 64 | add_filter( 'get_post_metadata', [ $this, 'filter_elementor_data' ], 10, 4 ); |
| 65 | |
| 66 | // Prevent Elementor from using its element-level HTML cache for documents |
| 67 | // with bindings — each post must render fresh so our injected data applies. |
| 68 | add_filter( 'get_post_metadata', [ $this, 'suppress_element_cache' ], 10, 4 ); |
| 69 | } |
| 70 | |
| 71 | private function load_dependencies(): void { |
| 72 | $src = $this->module_path() . 'sources/'; |
| 73 | require_once $src . 'class-ecs-drb-source-base.php'; |
| 74 | require_once $src . 'class-ecs-drb-source-posts.php'; |
| 75 | require_once $src . 'class-ecs-drb-source-terms.php'; |
| 76 | require_once $src . 'class-ecs-drb-source-acf.php'; |
| 77 | require_once $src . 'class-ecs-drb-source-json.php'; |
| 78 | require_once $this->module_path() . 'class-ecs-drb-sources.php'; |
| 79 | require_once $this->module_path() . 'class-ecs-drb-mapper.php'; |
| 80 | |
| 81 | $sources = ECS_DRB_Sources::instance(); |
| 82 | $sources->register( new ECS_DRB_Source_Posts() ); |
| 83 | $sources->register( new ECS_DRB_Source_Terms() ); |
| 84 | $sources->register( new ECS_DRB_Source_ACF() ); |
| 85 | $sources->register( new ECS_DRB_Source_JSON() ); |
| 86 | |
| 87 | do_action( 'ecs_drb_register_sources', $sources ); |
| 88 | } |
| 89 | |
| 90 | // ── Assets ──────────────────────────────────────────────────────────────── |
| 91 | |
| 92 | public function enqueue_editor_assets(): void { |
| 93 | wp_enqueue_style( |
| 94 | 'ecs-drb-editor', |
| 95 | $this->module_url() . 'assets/css/ecs-dynamic-repeater-editor.css', |
| 96 | [], |
| 97 | ECS_VERSION |
| 98 | ); |
| 99 | |
| 100 | wp_enqueue_script( |
| 101 | 'ecs-drb-editor', |
| 102 | $this->module_url() . 'assets/js/ecs-dynamic-repeater-editor.js', |
| 103 | [ 'jquery', 'elementor-editor' ], |
| 104 | ECS_VERSION, |
| 105 | true |
| 106 | ); |
| 107 | |
| 108 | wp_localize_script( 'ecs-drb-editor', 'ecsDrb', [ |
| 109 | 'ajaxUrl' => admin_url( 'admin-ajax.php' ), |
| 110 | 'nonce' => wp_create_nonce( 'ecs_drb' ), |
| 111 | 'sources' => ECS_DRB_Sources::instance()->get_list_for_js(), |
| 112 | 'bindingIndex' => (object) get_option( self::OPTION_BINDING_INDEX, [] ), |
| 113 | 'l10n' => [ |
| 114 | 'title' => __( 'Dynamic Repeater Builder', 'ele-custom-skin' ), |
| 115 | 'btnLabel' => __( 'Dynamic Populate', 'ele-custom-skin' ), |
| 116 | 'tabSource' => __( 'Source', 'ele-custom-skin' ), |
| 117 | 'tabMapping' => __( 'Mapping', 'ele-custom-skin' ), |
| 118 | 'tabOptions' => __( 'Options', 'ele-custom-skin' ), |
| 119 | 'tabPresets' => __( 'Presets', 'ele-custom-skin' ), |
| 120 | 'tabBinding' => __( 'Binding', 'ele-custom-skin' ), |
| 121 | 'generate' => __( 'Generate', 'ele-custom-skin' ), |
| 122 | 'apply' => __( 'Apply', 'ele-custom-skin' ), |
| 123 | 'preview' => __( 'Preview', 'ele-custom-skin' ), |
| 124 | 'loading' => __( 'Loading…', 'ele-custom-skin' ), |
| 125 | 'fetchFields' => __( 'Fill from First Item', 'ele-custom-skin' ), |
| 126 | 'noFields' => __( 'Configure source first, then fetch available fields.', 'ele-custom-skin' ), |
| 127 | 'savePreset' => __( 'Save as Preset', 'ele-custom-skin' ), |
| 128 | 'loadPreset' => __( 'Load', 'ele-custom-skin' ), |
| 129 | 'deletePreset' => __( 'Delete', 'ele-custom-skin' ), |
| 130 | 'bindToSource' => __( 'Bind to Source', 'ele-custom-skin' ), |
| 131 | 'breakSync' => __( 'Break Sync', 'ele-custom-skin' ), |
| 132 | 'refreshNow' => __( 'Refresh Now', 'ele-custom-skin' ), |
| 133 | 'bound' => __( 'Bound — data is generated at runtime on every page load.', 'ele-custom-skin' ), |
| 134 | 'notBound' => __( 'Not bound. Apply once or bind to enable runtime generation.', 'ele-custom-skin' ), |
| 135 | 'autoSuggest' => __( 'Auto-suggest Mapping', 'ele-custom-skin' ), |
| 136 | 'previewRows' => __( '%d rows generated', 'ele-custom-skin' ), |
| 137 | 'appliedRows' => __( '%d rows applied to repeater', 'ele-custom-skin' ), |
| 138 | 'cached' => __( '(cached)', 'ele-custom-skin' ), |
| 139 | ], |
| 140 | ] ); |
| 141 | } |
| 142 | |
| 143 | // ── AJAX dispatcher ─────────────────────────────────────────────────────── |
| 144 | |
| 145 | public function handle_ajax(): void { |
| 146 | $action = preg_replace( '/^wp_ajax_(nopriv_)?ecs_drb_/', '', current_action() ); |
| 147 | $method = 'ajax_' . $action; |
| 148 | if ( method_exists( $this, $method ) ) { |
| 149 | $this->$method(); |
| 150 | } else { |
| 151 | wp_send_json_error( [ 'message' => 'Unknown action' ] ); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // ── AJAX handlers ───────────────────────────────────────────────────────── |
| 156 | |
| 157 | private function ajax_get_source_schema(): void { |
| 158 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 159 | |
| 160 | $source_id = sanitize_key( $_POST['source_type'] ?? '' ); |
| 161 | $source_config = json_decode( stripslashes( $_POST['source_config'] ?? '{}' ), true ) ?: []; |
| 162 | |
| 163 | $source = ECS_DRB_Sources::instance()->get( $source_id ); |
| 164 | if ( ! $source ) { |
| 165 | wp_send_json_error( [ 'message' => 'Unknown source type: ' . $source_id ] ); |
| 166 | } |
| 167 | |
| 168 | wp_send_json_success( [ |
| 169 | 'schema' => $source->get_config_schema(), |
| 170 | 'fields' => $source->get_available_fields( $source_config ), |
| 171 | ] ); |
| 172 | } |
| 173 | |
| 174 | private function ajax_get_available_fields(): void { |
| 175 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 176 | |
| 177 | $source_id = sanitize_key( $_POST['source_type'] ?? '' ); |
| 178 | $source_config = json_decode( stripslashes( $_POST['source_config'] ?? '{}' ), true ) ?: []; |
| 179 | $current_post_id = intval( $_POST['current_post_id'] ?? 0 ); |
| 180 | if ( $current_post_id && empty( $source_config['post_id'] ) ) { |
| 181 | $source_config['_current_post_id'] = $current_post_id; |
| 182 | } |
| 183 | |
| 184 | $source = ECS_DRB_Sources::instance()->get( $source_id ); |
| 185 | if ( ! $source ) { |
| 186 | wp_send_json_error( [ 'message' => 'Unknown source type' ] ); |
| 187 | } |
| 188 | |
| 189 | wp_send_json_success( [ 'fields' => $source->get_available_fields( $source_config ) ] ); |
| 190 | } |
| 191 | |
| 192 | private function ajax_generate(): void { |
| 193 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 194 | |
| 195 | $source_id = sanitize_key( $_POST['source_type'] ?? '' ); |
| 196 | $source_config = json_decode( stripslashes( $_POST['source_config'] ?? '{}' ), true ) ?: []; |
| 197 | $mapping = json_decode( stripslashes( $_POST['mapping'] ?? '[]' ), true ) ?: []; |
| 198 | $field_types = json_decode( stripslashes( $_POST['field_types'] ?? '{}' ), true ) ?: []; |
| 199 | $use_cache = filter_var( $_POST['use_cache'] ?? true, FILTER_VALIDATE_BOOLEAN ); |
| 200 | $cache_ttl = intval( $_POST['cache_ttl'] ?? self::CACHE_TTL ); |
| 201 | $current_post_id = intval( $_POST['current_post_id'] ?? 0 ); |
| 202 | if ( $current_post_id && empty( $source_config['post_id'] ) ) { |
| 203 | $source_config['_current_post_id'] = $current_post_id; |
| 204 | } |
| 205 | |
| 206 | $source = ECS_DRB_Sources::instance()->get( $source_id ); |
| 207 | if ( ! $source ) { |
| 208 | wp_send_json_error( [ 'message' => 'Unknown source type' ] ); |
| 209 | } |
| 210 | |
| 211 | $cache_key = self::CACHE_PREFIX . md5( $source_id . wp_json_encode( $source_config ) ); |
| 212 | $source_rows = false; |
| 213 | |
| 214 | if ( $use_cache ) { |
| 215 | $source_rows = get_transient( $cache_key ); |
| 216 | } |
| 217 | |
| 218 | if ( false === $source_rows ) { |
| 219 | $source_rows = $source->get_rows( $source_config ); |
| 220 | if ( $use_cache ) { |
| 221 | set_transient( $cache_key, $source_rows, max( 60, $cache_ttl ) ); |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | $rows = ECS_DRB_Mapper::resolve( $source_rows, $mapping, $field_types ); |
| 226 | |
| 227 | wp_send_json_success( [ |
| 228 | 'rows' => $rows, |
| 229 | 'source_count' => count( $source_rows ), |
| 230 | 'result_count' => count( $rows ), |
| 231 | 'cached' => isset( $source_rows ) && get_transient( $cache_key ) !== false, |
| 232 | ] ); |
| 233 | } |
| 234 | |
| 235 | private function ajax_get_presets(): void { |
| 236 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 237 | wp_send_json_success( [ 'presets' => array_values( (array) get_option( self::OPTION_PRESETS, [] ) ) ] ); |
| 238 | } |
| 239 | |
| 240 | private function ajax_save_preset(): void { |
| 241 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 242 | |
| 243 | $name = sanitize_text_field( $_POST['name'] ?? '' ); |
| 244 | $config = json_decode( stripslashes( $_POST['config'] ?? '{}' ), true ) ?: []; |
| 245 | |
| 246 | if ( empty( $name ) ) { |
| 247 | wp_send_json_error( [ 'message' => 'Preset name is required' ] ); |
| 248 | } |
| 249 | |
| 250 | $presets = (array) get_option( self::OPTION_PRESETS, [] ); |
| 251 | $id = 'preset_' . substr( md5( uniqid( '', true ) ), 0, 8 ); |
| 252 | $presets[ $id ] = [ |
| 253 | 'id' => $id, |
| 254 | 'name' => $name, |
| 255 | 'config' => $config, |
| 256 | 'created' => time(), |
| 257 | ]; |
| 258 | update_option( self::OPTION_PRESETS, $presets ); |
| 259 | |
| 260 | wp_send_json_success( [ 'id' => $id, 'presets' => array_values( $presets ) ] ); |
| 261 | } |
| 262 | |
| 263 | private function ajax_delete_preset(): void { |
| 264 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 265 | |
| 266 | $id = sanitize_key( $_POST['id'] ?? '' ); |
| 267 | $presets = (array) get_option( self::OPTION_PRESETS, [] ); |
| 268 | unset( $presets[ $id ] ); |
| 269 | update_option( self::OPTION_PRESETS, $presets ); |
| 270 | |
| 271 | wp_send_json_success( [ 'presets' => array_values( $presets ) ] ); |
| 272 | } |
| 273 | |
| 274 | private function ajax_save_binding(): void { |
| 275 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 276 | |
| 277 | $post_id = intval( $_POST['post_id'] ?? 0 ); |
| 278 | $element_id = sanitize_text_field( $_POST['element_id'] ?? '' ); |
| 279 | $control_key = sanitize_text_field( $_POST['control_key'] ?? '' ); |
| 280 | $config = json_decode( stripslashes( $_POST['config'] ?? '{}' ), true ) ?: []; |
| 281 | |
| 282 | if ( ! $post_id || ! $element_id || ! $control_key ) { |
| 283 | wp_send_json_error( [ 'message' => 'Missing required parameters' ] ); |
| 284 | } |
| 285 | |
| 286 | $bindings = (array) get_post_meta( $post_id, self::META_BINDINGS, true ); |
| 287 | $key = $element_id . ':' . $control_key; |
| 288 | $bindings[ $key ] = [ |
| 289 | 'element_id' => $element_id, |
| 290 | 'control_key' => $control_key, |
| 291 | 'config' => $config, |
| 292 | 'updated' => time(), |
| 293 | ]; |
| 294 | update_post_meta( $post_id, self::META_BINDINGS, $bindings ); |
| 295 | |
| 296 | // Update global index: element_key → document post_id. |
| 297 | $index = (array) get_option( self::OPTION_BINDING_INDEX, [] ); |
| 298 | $index[ $key ] = $post_id; |
| 299 | update_option( self::OPTION_BINDING_INDEX, $index, false ); |
| 300 | |
| 301 | wp_send_json_success( [ 'binding_key' => $key ] ); |
| 302 | } |
| 303 | |
| 304 | private function ajax_break_binding(): void { |
| 305 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 306 | |
| 307 | $post_id = intval( $_POST['post_id'] ?? 0 ); |
| 308 | $element_id = sanitize_text_field( $_POST['element_id'] ?? '' ); |
| 309 | $control_key = sanitize_text_field( $_POST['control_key'] ?? '' ); |
| 310 | |
| 311 | $bindings = (array) get_post_meta( $post_id, self::META_BINDINGS, true ); |
| 312 | $key = $element_id . ':' . $control_key; |
| 313 | unset( $bindings[ $key ] ); |
| 314 | update_post_meta( $post_id, self::META_BINDINGS, $bindings ); |
| 315 | |
| 316 | // Remove from global index. |
| 317 | $index = (array) get_option( self::OPTION_BINDING_INDEX, [] ); |
| 318 | unset( $index[ $key ] ); |
| 319 | update_option( self::OPTION_BINDING_INDEX, $index, false ); |
| 320 | |
| 321 | wp_send_json_success(); |
| 322 | } |
| 323 | |
| 324 | private function ajax_get_bindings(): void { |
| 325 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 326 | |
| 327 | $post_id = intval( $_POST['post_id'] ?? 0 ); |
| 328 | $bindings = $post_id ? (array) get_post_meta( $post_id, self::META_BINDINGS, true ) : []; |
| 329 | |
| 330 | wp_send_json_success( [ 'bindings' => array_values( $bindings ) ] ); |
| 331 | } |
| 332 | |
| 333 | private function ajax_get_terms(): void { |
| 334 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 335 | |
| 336 | $taxonomy = sanitize_key( $_POST['taxonomy'] ?? '' ); |
| 337 | $search = sanitize_text_field( $_POST['search'] ?? '' ); |
| 338 | |
| 339 | if ( ! $taxonomy || ! taxonomy_exists( $taxonomy ) ) { |
| 340 | wp_send_json_success( [ 'terms' => [] ] ); |
| 341 | return; |
| 342 | } |
| 343 | |
| 344 | $terms = get_terms( [ |
| 345 | 'taxonomy' => $taxonomy, |
| 346 | 'hide_empty' => false, |
| 347 | 'number' => 30, |
| 348 | 'search' => $search, |
| 349 | 'fields' => 'all', |
| 350 | ] ); |
| 351 | |
| 352 | if ( is_wp_error( $terms ) ) { |
| 353 | wp_send_json_success( [ 'terms' => [] ] ); |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | $result = array_map( fn( $t ) => [ |
| 358 | 'slug' => $t->slug, |
| 359 | 'name' => $t->name, |
| 360 | 'count' => $t->count, |
| 361 | ], $terms ); |
| 362 | |
| 363 | wp_send_json_success( [ 'terms' => $result ] ); |
| 364 | } |
| 365 | |
| 366 | private function ajax_get_acf_repeaters(): void { |
| 367 | check_ajax_referer( 'ecs_drb', 'nonce' ); |
| 368 | wp_send_json_success( [ 'fields' => ECS_DRB_Source_ACF::get_repeater_field_names() ] ); |
| 369 | } |
| 370 | |
| 371 | // ── Runtime binding ─────────────────────────────────────────────────────── |
| 372 | |
| 373 | /** |
| 374 | * Return null for _elementor_element_cache reads on any document that contains |
| 375 | * bound elements. This prevents Elementor from serving a cached container HTML |
| 376 | * that was built for a different post's data. |
| 377 | */ |
| 378 | public function suppress_element_cache( $value, $object_id, $meta_key, $single = null ) { |
| 379 | if ( $meta_key !== '_elementor_element_cache' ) { |
| 380 | return $value; |
| 381 | } |
| 382 | if ( is_admin() || wp_doing_ajax() ) { |
| 383 | return $value; |
| 384 | } |
| 385 | $index = (array) get_option( self::OPTION_BINDING_INDEX, [] ); |
| 386 | if ( empty( $index ) ) { |
| 387 | return $value; |
| 388 | } |
| 389 | foreach ( $index as $doc_post_id ) { |
| 390 | if ( (int) $doc_post_id === $object_id ) { |
| 391 | // Return empty array — Elementor treats this as "no cache". |
| 392 | return $single ? '' : []; |
| 393 | } |
| 394 | } |
| 395 | return $value; |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * Filter _elementor_data postmeta to inject binding data before Elementor |
| 400 | * parses the JSON. This is earlier than any widget render hook and ensures |
| 401 | * the repeater settings are correct from the moment the element is built. |
| 402 | * |
| 403 | * Only runs on the frontend; skipped in editor and AJAX (except Elementor |
| 404 | * frontend-preview AJAX which also renders on the frontend). |
| 405 | */ |
| 406 | public function filter_elementor_data( $value, $object_id, $meta_key, $single = null ) { |
| 407 | if ( $meta_key !== '_elementor_data' ) { |
| 408 | return $value; |
| 409 | } |
| 410 | |
| 411 | // Only intercept on regular frontend page loads. |
| 412 | if ( is_admin() || wp_doing_ajax() ) { |
| 413 | return $value; |
| 414 | } |
| 415 | if ( class_exists( '\Elementor\Plugin' ) && \Elementor\Plugin::$instance->editor->is_edit_mode() ) { |
| 416 | return $value; |
| 417 | } |
| 418 | |
| 419 | $index = (array) get_option( self::OPTION_BINDING_INDEX, [] ); |
| 420 | if ( empty( $index ) ) { |
| 421 | return $value; |
| 422 | } |
| 423 | |
| 424 | // Check if this post_id is a document that contains bound elements. |
| 425 | $bound_in_doc = []; |
| 426 | foreach ( $index as $index_key => $doc_post_id ) { |
| 427 | if ( (int) $doc_post_id === $object_id ) { |
| 428 | $bound_in_doc[ $index_key ] = true; |
| 429 | } |
| 430 | } |
| 431 | if ( empty( $bound_in_doc ) ) { |
| 432 | return $value; |
| 433 | } |
| 434 | |
| 435 | // Load the real postmeta value (bypass this filter to avoid recursion). |
| 436 | remove_filter( 'get_post_metadata', [ $this, 'filter_elementor_data' ], 10 ); |
| 437 | $raw = get_post_meta( $object_id, '_elementor_data', true ); |
| 438 | add_filter( 'get_post_metadata', [ $this, 'filter_elementor_data' ], 10, 4 ); |
| 439 | |
| 440 | $elements = json_decode( $raw, true ); |
| 441 | if ( ! is_array( $elements ) ) { |
| 442 | return $value; |
| 443 | } |
| 444 | |
| 445 | // Load all bindings for this document. |
| 446 | $doc_bindings = (array) get_post_meta( $object_id, self::META_BINDINGS, true ); |
| 447 | if ( empty( $doc_bindings ) ) { |
| 448 | return $value; |
| 449 | } |
| 450 | |
| 451 | $current_post = get_queried_object_id() ?: get_the_ID(); |
| 452 | |
| 453 | // Walk the element tree and inject rows where bound. |
| 454 | $modified = $this->inject_binding_rows( $elements, $doc_bindings, $current_post ); |
| 455 | |
| 456 | // Return as a single-element array (how WP returns $single=true metadata). |
| 457 | if ( $single ) { |
| 458 | return wp_json_encode( $modified ); |
| 459 | } |
| 460 | return [ wp_json_encode( $modified ) ]; |
| 461 | } |
| 462 | |
| 463 | /** |
| 464 | * Recursively walk the Elementor element tree and replace repeater settings |
| 465 | * for any element that has a binding configured. |
| 466 | */ |
| 467 | private function inject_binding_rows( array $elements, array $doc_bindings, int $current_post ): array { |
| 468 | foreach ( $elements as &$element ) { |
| 469 | $element_id = $element['id'] ?? ''; |
| 470 | |
| 471 | // Check if this element has any binding. |
| 472 | foreach ( $doc_bindings as $binding ) { |
| 473 | if ( ( $binding['element_id'] ?? '' ) !== $element_id ) { |
| 474 | continue; |
| 475 | } |
| 476 | |
| 477 | $cfg = $binding['config'] ?? []; |
| 478 | $source_id = sanitize_key( $cfg['source_type'] ?? '' ); |
| 479 | $s_config = $cfg['source_config'] ?? []; |
| 480 | $mapping = $cfg['mapping'] ?? []; |
| 481 | $field_types = $cfg['field_types'] ?? []; |
| 482 | $apply_mode = $cfg['apply_mode'] ?? 'replace'; |
| 483 | $use_cache = (bool) ( $cfg['use_cache'] ?? true ); |
| 484 | $cache_ttl = intval( $cfg['cache_ttl'] ?? self::CACHE_TTL ); |
| 485 | $control_key = $binding['control_key'] ?? ''; |
| 486 | |
| 487 | if ( ! $control_key || ! $source_id ) { |
| 488 | continue; |
| 489 | } |
| 490 | |
| 491 | $source = ECS_DRB_Sources::instance()->get( $source_id ); |
| 492 | if ( ! $source ) { |
| 493 | continue; |
| 494 | } |
| 495 | |
| 496 | $runtime_post_id = empty( $s_config['post_id'] ) ? $current_post : 0; |
| 497 | $cache_key = self::CACHE_PREFIX . md5( $source_id . wp_json_encode( $s_config ) . ':' . $runtime_post_id ); |
| 498 | $source_rows = false; |
| 499 | |
| 500 | if ( $use_cache ) { |
| 501 | $source_rows = get_transient( $cache_key ); |
| 502 | } |
| 503 | |
| 504 | if ( false === $source_rows ) { |
| 505 | if ( empty( $s_config['post_id'] ) && $current_post ) { |
| 506 | $s_config['_current_post_id'] = $current_post; |
| 507 | } |
| 508 | $source_rows = $source->get_rows( $s_config ); |
| 509 | if ( $use_cache ) { |
| 510 | set_transient( $cache_key, $source_rows, max( 60, $cache_ttl ) ); |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | if ( empty( $source_rows ) ) { |
| 515 | continue; |
| 516 | } |
| 517 | |
| 518 | $new_rows = ECS_DRB_Mapper::resolve( $source_rows, $mapping, $field_types ); |
| 519 | |
| 520 | if ( $apply_mode === 'append' ) { |
| 521 | $existing = $element['settings'][ $control_key ] ?? []; |
| 522 | $new_rows = array_merge( is_array( $existing ) ? $existing : [], $new_rows ); |
| 523 | } |
| 524 | |
| 525 | // Elementor expects each repeater row to have a unique _id. |
| 526 | foreach ( $new_rows as &$row ) { |
| 527 | if ( empty( $row['_id'] ) ) { |
| 528 | $row['_id'] = substr( md5( uniqid( '', true ) ), 0, 7 ); |
| 529 | } |
| 530 | } |
| 531 | unset( $row ); |
| 532 | |
| 533 | $element['settings'][ $control_key ] = $new_rows; |
| 534 | } |
| 535 | |
| 536 | // Recurse into child elements. |
| 537 | if ( ! empty( $element['elements'] ) ) { |
| 538 | $element['elements'] = $this->inject_binding_rows( $element['elements'], $doc_bindings, $current_post ); |
| 539 | } |
| 540 | } |
| 541 | unset( $element ); |
| 542 | |
| 543 | return $elements; |
| 544 | } |
| 545 | } |
| 546 |