PluginProbe
MarqueeX – Smooth Marquee Slider, News Ticker & Post Marquee for Block Editor & Elementor / 0.6.0
MarqueeX – Smooth Marquee Slider, News Ticker & Post Marquee for Block Editor & Elementor v0.6.0
0.6.0 0.5.4 0.5.3 0.5.2 0.5.1 0.5.0 0.4.0 0.3.3 0.3.2 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.1.0 0.1.1 0.1.2 0.2.0 0.2.1 0.2.2 0.2.3 0.3.0 0.3.1
marqueex / includes / Core / Settings.php

Settings.php in MarqueeX – Smooth Marquee Slider, News Ticker & Post Marquee for Block Editor & Elementor 0.6.0, at includes/Core/Settings.php

683 lines 22.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Wpxero\Marqueex\Core;
4
5 use Wpxero\Marqueex\Traits\Singleton;
6
7 if (! defined('ABSPATH')) {
8 exit;
9 }
10
11 // Import WordPress classes and functions
12 use WP_REST_Controller;
13 use WP_REST_Request;
14 use WP_REST_Response;
15 use WP_Error;
16
17 /**
18 * Class Settings
19 */
20 class Settings extends \WP_REST_Controller {
21 use Singleton;
22 /**
23 * Namespace.
24 *
25 * @var string
26 */
27 protected $namespace = 'wpxero/marqueex/v';
28
29 /**
30 * Version.
31 *
32 * @var string
33 */
34 protected $version = '1';
35
36 /**
37 * Name of the WordPress option that stores all plugin settings.
38 *
39 * @var string
40 */
41 const OPTION_NAME = 'marqueex_settings';
42
43 /**
44 * Per-request cache of the fully-merged (defaults ∪ stored) settings array.
45 *
46 * @var array|null
47 */
48 private static $cached_settings = null;
49
50 /**
51 * Settings constructor.
52 */
53 public function __construct() {
54 \add_action('rest_api_init', [$this, 'register_routes']);
55 \add_action('init', [$this, 'init_default_settings']);
56 }
57
58 /**
59 * Read a setting (or the whole settings tree) deep-merged with defaults.
60 *
61 * This is the single accessor every consumer should use instead of calling
62 * get_option() + open-coding its own `?? default` fallback — that pattern
63 * caused the same key to resolve to different defaults in different files.
64 *
65 * @param string|null $path Dot-path into the settings tree, e.g.
66 * 'builder_support.elementor.enabled'. Null/empty
67 * returns the entire merged array.
68 * @param mixed $default Value returned when the path is missing.
69 * @return mixed
70 */
71 public static function get($path = null, $default = null) {
72 if (self::$cached_settings === null) {
73 $instance = self::get_instance();
74 $stored = \get_option(self::OPTION_NAME, []);
75 if (! is_array($stored)) {
76 $stored = [];
77 }
78 // Deep merge so nested defaults are always backfilled for existing
79 // installs whose top-level keys predate a newly-added sub-key.
80 self::$cached_settings = $instance->deep_merge($instance->get_default_settings(), $stored);
81 }
82
83 if (empty($path)) {
84 return self::$cached_settings;
85 }
86
87 $value = self::$cached_settings;
88 foreach (explode('.', $path) as $segment) {
89 if (is_array($value) && array_key_exists($segment, $value)) {
90 $value = $value[$segment];
91 } else {
92 return $default;
93 }
94 }
95
96 return $value;
97 }
98
99 /**
100 * Invalidate the per-request settings cache after a write.
101 */
102 private static function flush_cache() {
103 self::$cached_settings = null;
104 }
105
106 /**
107 * Initialize default settings
108 */
109 public function init_default_settings() {
110 $current_settings = \get_option(self::OPTION_NAME, []);
111 if (! is_array($current_settings)) {
112 $current_settings = [];
113 }
114 $default_settings = $this->get_default_settings();
115
116 // Deep merge so nested defaults (e.g. a newly-added element key) are
117 // backfilled even when the parent key already exists in the stored value.
118 $merged_settings = $this->deep_merge($default_settings, $current_settings);
119
120 // Drop settings we no longer read. Upgrades leave orphans behind —
121 // `minify_css`, `lazy_loading`, `cache_styles`, `theme_compatibility` and
122 // `plugin_compatibility` all lived here at some point and none are read
123 // now. Limited to the flat flags in these two groups: element lists are
124 // left alone, because an integration may legitimately add keys we do not
125 // know about.
126 $merged_settings = $this->prune_removed_flags($merged_settings, $default_settings);
127
128 // Loose comparison ignores key ordering, so re-ordering from the merge
129 // alone never triggers a redundant DB write on every `init`.
130 if ($merged_settings != $current_settings) {
131 \update_option(self::OPTION_NAME, $merged_settings);
132 self::flush_cache();
133 }
134 }
135
136 /**
137 * Remove flat flags from `performance` and `compatibility` that are no
138 * longer part of the defaults.
139 *
140 * @param array $settings Merged settings.
141 * @param array $defaults Canonical defaults.
142 * @return array
143 */
144 private function prune_removed_flags(array $settings, array $defaults) {
145 foreach (['performance', 'compatibility'] as $group) {
146 if (!isset($settings[$group]) || !is_array($settings[$group])) {
147 continue;
148 }
149
150 foreach ($settings[$group] as $key => $value) {
151 // Never touch nested structures such as responsive_breakpoints.
152 if (is_array($value)) {
153 continue;
154 }
155
156 if (!array_key_exists($key, $defaults[$group] ?? [])) {
157 unset($settings[$group][$key]);
158 }
159 }
160 }
161
162 return $settings;
163 }
164
165 /**
166 * Get default settings
167 *
168 * @return array
169 */
170 public function get_default_settings() {
171 return [
172 'builder_support' => [
173 'gutenberg' => [
174 'enabled' => true,
175 'auto_detect' => true,
176 'elements' => [
177 'infinite_slider' => true,
178 'text_marquee' => true,
179 'image_marquee' => true,
180 'news_ticker' => true,
181 'post_marquee' => true,
182 ],
183 ],
184 'elementor' => [
185 // 'enabled' => $this->is_elementor_active(),
186 'enabled' => true,
187 'auto_detect' => true,
188 'elements' => [
189 'text_marquee' => true,
190 'news_ticker' => true,
191 'infinite_slider' => true,
192 'post_marquee' => true,
193 // Backfilled so the defaults describe the full registered
194 // widget set; previously these loaded only via `?? true`.
195 'image_marquee' => true,
196 'animated_heading' => true,
197 'animated_word_roller' => true,
198 'team_members_marquee' => true,
199 'testimonial_marquee' => true,
200 ],
201 ],
202 'shortcode' => [
203 'enabled' => true,
204 'auto_detect' => true,
205 'elements' => [
206 'text_marquee' => true,
207 'news_ticker' => true,
208 'infinite_slider' => true,
209 ],
210 ],
211 ],
212 'performance' => [
213 // Emit an HTML comment crediting MarqueeX on pages that use it.
214 // Read by Utils\SeoImprovements::add_seo_attribution().
215 'show_attribution' => false,
216 ],
217 'compatibility' => [
218 // Generate a meta description from marquee content when no SEO
219 // plugin is present. Off by default — see Utils\MetaDescription.
220 'meta_description' => false,
221 'responsive_breakpoints' => [
222 'mobile' => 480,
223 'tablet' => 1024,
224 'desktop' => 1200,
225 ],
226 ],
227 ];
228 }
229
230 /**
231 * Register rest routes.
232 */
233 public function register_routes() {
234 $namespace = $this->namespace . $this->version;
235
236 // Update Settings.
237 \register_rest_route(
238 $namespace,
239 '/update_settings/',
240 [
241 'methods' => ['POST'],
242 'callback' => [$this, 'update_settings'],
243 'permission_callback' => [$this, 'update_settings_permission'],
244 ]
245 );
246
247 // Get Settings.
248 \register_rest_route(
249 $namespace,
250 '/get_settings/',
251 [
252 'methods' => ['GET'],
253 'callback' => [$this, 'get_settings'],
254 'permission_callback' => [$this, 'get_settings_permission'],
255 ]
256 );
257
258 // Test Builder Integration.
259 \register_rest_route(
260 $namespace,
261 '/test_builder/',
262 [
263 'methods' => ['POST'],
264 'callback' => [$this, 'test_builder_integration'],
265 'permission_callback' => [$this, 'update_settings_permission'],
266 ]
267 );
268
269 // Dismiss the first-run onboarding panel.
270 \register_rest_route(
271 $namespace,
272 '/dismiss_onboarding/',
273 [
274 'methods' => ['POST'],
275 'callback' => [$this, 'dismiss_onboarding'],
276 'permission_callback' => [$this, 'update_settings_permission'],
277 ]
278 );
279 }
280
281 /**
282 * User meta key recording that this user has dismissed the onboarding panel.
283 */
284 const ONBOARDING_META = 'marqueex_onboarding_dismissed';
285
286 /**
287 * Whether the current user should still see the onboarding panel.
288 *
289 * Stored per user rather than per site: a second administrator joining an
290 * existing site has still never seen it.
291 *
292 * @return bool
293 */
294 public static function should_show_onboarding() {
295 $user_id = \get_current_user_id();
296 if (!$user_id) {
297 return false;
298 }
299
300 return '' === \get_user_meta($user_id, self::ONBOARDING_META, true);
301 }
302
303 /**
304 * Dismiss the onboarding panel for the current user, permanently.
305 *
306 * @param WP_REST_Request $req request object.
307 * @return mixed
308 */
309 public function dismiss_onboarding(WP_REST_Request $req) {
310 $user_id = \get_current_user_id();
311 if (!$user_id) {
312 return $this->error('no_user', \__('No user to dismiss for.', 'marqueex'));
313 }
314
315 \update_user_meta($user_id, self::ONBOARDING_META, '1');
316
317 return $this->success(['dismissed' => true]);
318 }
319
320 /**
321 * Get edit options permissions.
322 *
323 * @return bool
324 */
325 public function update_settings_permission() {
326 if (! \current_user_can('manage_options')) {
327 return $this->error('user_dont_have_permission', \__('User don\'t have permissions to change options.', 'marqueex'), true);
328 }
329
330 return true;
331 }
332
333 /**
334 * Get settings permissions.
335 *
336 * @return bool
337 */
338 public function get_settings_permission() {
339 if (! \current_user_can('manage_options')) {
340 return $this->error('user_dont_have_permission', \__('User don\'t have permissions to view options.', 'marqueex'), true);
341 }
342
343 return true;
344 }
345
346 /**
347 * Update Settings.
348 *
349 * @param WP_REST_Request $req request object.
350 *
351 * @return mixed
352 */
353 public function update_settings(WP_REST_Request $req) {
354 $new_settings = $req->get_param('settings');
355
356 if (is_array($new_settings)) {
357 $current_settings = get_option(self::OPTION_NAME, []);
358 // Recursive merge so partial updates ({builder_support:{gutenberg:{...}}})
359 // don't clobber sibling sub-trees. Numeric/leaf values are replaced.
360 $updated_settings = $this->deep_merge($current_settings, $new_settings);
361
362 // Validate settings before saving
363 $validated_settings = $this->validate_settings($updated_settings);
364
365 update_option(self::OPTION_NAME, $validated_settings);
366
367 // Clear any cached data
368 $this->clear_cache();
369 self::flush_cache();
370
371 return $this->success([
372 'message' => __('Settings updated successfully.', 'marqueex'),
373 'settings' => $validated_settings
374 ]);
375 }
376
377 return $this->error('invalid_settings', __('Invalid settings data provided.', 'marqueex'));
378 }
379
380 /**
381 * Recursive associative-array merge. Scalar/leaf values from $override win.
382 */
383 private function deep_merge(array $base, array $override) {
384 foreach ($override as $key => $value) {
385 if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
386 $base[$key] = $this->deep_merge($base[$key], $value);
387 } else {
388 $base[$key] = $value;
389 }
390 }
391 return $base;
392 }
393
394 /**
395 * Get Settings.
396 *
397 * @param WP_REST_Request $req request object.
398 *
399 * @return mixed
400 */
401 public function get_settings(WP_REST_Request $req) {
402 $settings = get_option(self::OPTION_NAME, []);
403 if (! is_array($settings)) {
404 $settings = [];
405 }
406 $default_settings = $this->get_default_settings();
407
408 // Deep merge with defaults so nested keys are present, matching the
409 // accessor used everywhere else (previously a shallow array_merge).
410 $complete_settings = $this->deep_merge($default_settings, $settings);
411
412 return $this->success($complete_settings);
413 }
414
415 /**
416 * Test Builder Integration.
417 *
418 * @param WP_REST_Request $req request object.
419 *
420 * @return mixed
421 */
422 public function test_builder_integration(WP_REST_Request $req) {
423 $builder = $req->get_param('builder');
424 $test_results = [];
425
426 switch ($builder) {
427 case 'elementor':
428 $test_results = $this->test_elementor_integration();
429 break;
430 case 'gutenberg':
431 $test_results = $this->test_gutenberg_integration();
432 break;
433 case 'shortcode':
434 $test_results = $this->test_shortcode_integration();
435 break;
436 default:
437 return $this->error('invalid_builder', __('Invalid builder specified.', 'marqueex'));
438 }
439
440 return $this->success($test_results);
441 }
442
443 /**
444 * Test Elementor Integration
445 *
446 * @return array
447 */
448 private function test_elementor_integration() {
449 $results = [
450 'status' => 'unknown',
451 'message' => '',
452 'details' => []
453 ];
454
455 // Check if Elementor is installed
456 if (!$this->is_elementor_installed()) {
457 $results['status'] = 'not_installed';
458 $results['message'] = __('Elementor is not installed. Please install Elementor to use this integration.', 'marqueex');
459 return $results;
460 }
461
462 // Check if Elementor is activated
463 if (!$this->is_elementor_active()) {
464 $results['status'] = 'not_active';
465 $results['message'] = __('Elementor is installed but not activated. Please activate Elementor to use this integration.', 'marqueex');
466 return $results;
467 }
468
469 // Check Elementor version
470 $elementor_version = defined('ELEMENTOR_VERSION') ? ELEMENTOR_VERSION : 'unknown';
471 $results['details']['version'] = $elementor_version;
472
473 // Check minimum version requirement
474 if (version_compare($elementor_version, '3.0.0', '<')) {
475 $results['status'] = 'version_incompatible';
476 /* translators: %s: detected Elementor version number. */
477 $results['message'] = sprintf(__('Elementor version %s is too old. Please update to version 3.0.0 or higher.', 'marqueex'), $elementor_version);
478 return $results;
479 }
480
481 // Check if we can register widgets
482 try {
483 $results['status'] = 'compatible';
484 /* translators: %s: detected Elementor version number. */
485 $results['message'] = sprintf(__('Elementor integration test successful. Version: %s', 'marqueex'), $elementor_version);
486 $results['details']['widgets_available'] = true;
487 $results['details']['pro_available'] = $this->is_elementor_pro_available();
488 } catch (Exception $e) {
489 $results['status'] = 'error';
490 $results['message'] = $e->getMessage();
491 }
492
493 return $results;
494 }
495
496 /**
497 * Check if Elementor is installed
498 *
499 * @return bool
500 */
501 private function is_elementor_installed() {
502 return file_exists(WP_PLUGIN_DIR . '/elementor/elementor.php');
503 }
504
505 /**
506 * Check if Elementor is active
507 *
508 * @return bool
509 */
510 private function is_elementor_active() {
511 return class_exists('\Elementor\Plugin');
512 }
513
514 /**
515 * Check if Elementor Pro is available
516 *
517 * @return bool
518 */
519 private function is_elementor_pro_available() {
520 return class_exists('\ElementorPro\Plugin');
521 }
522
523 /**
524 * Test Gutenberg Integration
525 *
526 * @return array
527 */
528 private function test_gutenberg_integration() {
529 $results = [
530 'status' => 'compatible',
531 'message' => __('Gutenberg integration is always available.', 'marqueex'),
532 'details' => [
533 'wp_version' => get_bloginfo('version'),
534 'gutenberg_available' => function_exists('register_block_type')
535 ]
536 ];
537
538 return $results;
539 }
540
541 /**
542 * Test Shortcode Integration
543 *
544 * @return array
545 */
546 private function test_shortcode_integration() {
547 $results = [
548 'status' => 'compatible',
549 'message' => __('Shortcode integration is always available.', 'marqueex'),
550 'details' => [
551 'shortcode_functions_available' => function_exists('add_shortcode')
552 ]
553 ];
554
555 return $results;
556 }
557
558 /**
559 * Validate settings before saving
560 *
561 * @param array $settings
562 * @return array
563 */
564 private function validate_settings($settings) {
565 $default_settings = $this->get_default_settings();
566
567 // Ensure all required keys exist
568 foreach ($default_settings as $key => $default_value) {
569 if (!isset($settings[$key])) {
570 $settings[$key] = $default_value;
571 }
572 }
573
574 // Validate builder support settings
575 if (isset($settings['builder_support'])) {
576 foreach ($settings['builder_support'] as $builder => $config) {
577 if (isset($config['enabled']) && !is_bool($config['enabled'])) {
578 $settings['builder_support'][$builder]['enabled'] = (bool) $config['enabled'];
579 }
580 if (isset($config['auto_detect']) && !is_bool($config['auto_detect'])) {
581 $settings['builder_support'][$builder]['auto_detect'] = (bool) $config['auto_detect'];
582 }
583 }
584 }
585
586 // Validate performance settings (all booleans)
587 if (isset($settings['performance']) && is_array($settings['performance'])) {
588 foreach ($settings['performance'] as $key => $value) {
589 if (!is_bool($value)) {
590 $settings['performance'][$key] = (bool) $value;
591 }
592 }
593 }
594
595 // Compatibility holds one boolean flag plus the breakpoint map.
596 if (isset($settings['compatibility']['meta_description'])) {
597 $settings['compatibility']['meta_description'] = (bool) $settings['compatibility']['meta_description'];
598 }
599
600 // Validate compatibility settings — clamp responsive breakpoints to
601 // a sane pixel range so users can't store 1e9 etc.
602 if (isset($settings['compatibility']['responsive_breakpoints'])) {
603 $defaults = $default_settings['compatibility']['responsive_breakpoints'];
604 foreach ($settings['compatibility']['responsive_breakpoints'] as $device => $value) {
605 $int = is_numeric($value) ? (int) $value : -1;
606 if ($int < 1 || $int > 3000) {
607 $settings['compatibility']['responsive_breakpoints'][$device] =
608 $defaults[$device] ?? 1024;
609 } else {
610 $settings['compatibility']['responsive_breakpoints'][$device] = $int;
611 }
612 }
613 }
614
615 return $settings;
616 }
617
618 /**
619 * Clear plugin-owned caches when settings are updated.
620 *
621 * Note: we deliberately do NOT call wp_cache_flush() here — flushing the
622 * entire object cache for one option write punishes every other plugin.
623 */
624 private function clear_cache() {
625 delete_transient('marqueex_builder_cache');
626
627 // Purge cached block-registration scans (see GutenbergIntegration).
628 global $wpdb;
629 if (isset($wpdb)) {
630 $value_like = $wpdb->esc_like('_transient_marqueex_registerable_blocks_') . '%';
631 $timeout_like = $wpdb->esc_like('_transient_timeout_marqueex_registerable_blocks_') . '%';
632 // Direct, prepared bulk delete of our own versioned transients. There is
633 // no core API for a wildcard transient purge, and caching the result of a
634 // cache-purge is meaningless — so the direct-query / no-cache notices are
635 // intentionally suppressed here.
636 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
637 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $value_like));
638 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
639 $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $timeout_like));
640 }
641 }
642
643 /**
644 * Success rest.
645 *
646 * @param mixed $response response data.
647 * @return mixed
648 */
649 public function success($response) {
650 return new WP_REST_Response(
651 [
652 'success' => true,
653 'response' => $response,
654 ],
655 200
656 );
657 }
658
659 /**
660 * Error rest.
661 *
662 * @param mixed $code error code.
663 * @param mixed $response response data.
664 * @param boolean $true_error use true error response to stop the code processing.
665 * @return mixed
666 */
667 public function error($code, $response, $true_error = false) {
668 if ($true_error) {
669 return new WP_Error($code, $response, ['status' => 401]);
670 }
671
672 return new WP_REST_Response(
673 [
674 'error' => true,
675 'success' => false,
676 'error_code' => $code,
677 'response' => $response,
678 ],
679 401
680 );
681 }
682 }
683