PluginProbe ʕ •ᴥ•ʔ
ECS – Ele Custom Skin for Elementor / 4.3.6
ECS – Ele Custom Skin for Elementor v4.3.6
4.3.6 4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.11 4.1.10 4.1.9 4.1.8 4.1.6 4.1.7 4.1.5 4.1.4 4.1.1 4.1.2 trunk 1.0.0 1.0.1 1.0.9 1.1.3 1.1.4 1.1.5 1.2.0 1.2.1 1.2.4 1.2.5 1.3.10 1.3.11 1.3.3 1.3.4 1.3.6 1.3.7 1.3.9 1.4.0 2.0.2 2.1.0 2.2.0 2.2.1 2.2.2 3.0.0 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 4.1.0
ele-custom-skin / modules / dynamic-repeater / class-ecs-dynamic-repeater-module.php
ele-custom-skin / modules / dynamic-repeater Last commit date
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