PluginProbe
Sky Addons for Elementor / trunk
Sky Addons for Elementor vtrunk
4.0.0 3.8.5 3.8.4 3.8.3 3.8.2 3.8.0 3.8.1 3.3.3 3.3.2 trunk 1.0.0 1.0.10 1.0.2 1.0.6 1.0.7 1.0.8 1.0.9 1.5.0 2.0.0 2.0.8 2.5.10 2.5.11 2.5.12 2.5.13 2.5.15 All 53 releases
sky-elementor-addons / includes / admin / Classes / class-widgets-settings.php

class-widgets-settings.php in Sky Addons for Elementor trunk, at includes/admin/Classes/class-widgets-settings.php

1,508 lines 51.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Widgets Settings Handler
4 *
5 * @package Sky_Addons
6 * @since 2.7.0
7 */
8
9 namespace Sky_Addons\Classes;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use Sky_Addons\Admin\Sky_Addons_Admin;
16
17 /**
18 * Widgets Settings Handler
19 *
20 * @since 2.7.0
21 */
22 class Widgets_Settings {
23
24 private static $instance = null;
25
26 const WIDGETS_DB_KEY = 'sky_addons_inactive_widgets';
27 const WIDGETS_3RD_PARTY_DB_KEY = 'sky_addons_inactive_3rd_party_widgets';
28 const EXTENSIONS_DB_KEY = 'sky_addons_inactive_extensions';
29 const API_DB_KEY = 'sky_addons_api';
30 const ADVANCED_DB_KEY = 'sky_addons_advanced_settings';
31
32 /**
33 * Construct
34 */
35 public function __construct() {
36 add_action( 'wp_ajax_sky_addons_get_settings', [ $this, 'get_settings' ] );
37 add_action( 'wp_ajax_sky_addons_set_settings', [ $this, 'set_settings' ] );
38 }
39
40 /**
41 * Check the permissions for getting the settings
42 *
43 * @since 2.7.0
44 */
45 public function permissions_check() {
46 return current_user_can( 'manage_options' );
47 }
48
49 /**
50 * Set Sync
51 *
52 * @since 2.7.0
53 */
54 public function get_settings() {
55 if ( ! current_user_can( 'manage_options' ) ) {
56 wp_send_json_error( [ 'message' => esc_html__( 'Unauthorized access.', 'sky-elementor-addons' ) ], 403 );
57 }
58
59 check_ajax_referer( 'sky_addons_nonce', '_wpnonce' );
60
61 // phpcs:ignore
62 $action_type = isset( $_POST['action_type'] ) ? sanitize_text_field( wp_unslash( $_POST['action_type'] ) ) : false;
63
64 if ( ! $action_type ) {
65 wp_send_json_error( [ 'message' => esc_html__( 'Oops, Settings is not found.', 'sky-elementor-addons' ) ], 404 );
66 wp_die();
67 }
68
69 switch ( $action_type ) {
70 case 'dashboard':
71 return wp_send_json_success( $this->get_dashboard_summary() );
72
73 case 'widgets':
74 $widgets = $this->get_widgets_list( 'sky_addons_widgets' );
75 return wp_send_json_success( $widgets );
76
77 case 'extensions':
78 $extensions = $this->get_widgets_list( 'sky_addons_extensions' );
79 return wp_send_json_success( $extensions );
80
81 case '3rd_party':
82 $_3rd_party = $this->get_widgets_list( 'sky_addons_3rd_party_widget' );
83 return wp_send_json_success( $_3rd_party );
84
85 case 'api':
86 $api = Sky_Addons_Admin::get_element_list()['sky_addons_api'] ?? [];
87 return wp_send_json_success( $api );
88
89 case 'advanced_features':
90 $adv_features = array_values( Sky_Addons_Admin::get_element_list()['sky_addons_advanced_settings'] ?? [] );
91 return wp_send_json_success( $adv_features );
92
93 case 'asset_manager':
94 $bundle = null;
95 $log = [];
96 if ( class_exists( '\Sky_Addons\Optimizer\Asset_Manager' ) ) {
97 $bundle = ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info();
98 $log = \Sky_Addons\Optimizer\Optimizer::get_log();
99 }
100
101 $full_bundle = $this->get_shipped_bundle_size();
102
103 return wp_send_json_success(
104 [
105 'asset_manager' => function_exists( 'sky_addons_asset_mode' ) ? sky_addons_asset_mode() : 'per-widget',
106 'bundle' => $bundle,
107 'full_bundle' => $full_bundle,
108 'optimizer_log' => $log,
109 'optimizer_status' => self::get_optimizer_status(),
110 'progress' => class_exists( '\Sky_Addons\Optimizer\Optimizer' ) ? \Sky_Addons\Optimizer\Optimizer::get_progress() : null,
111 ]
112 );
113
114 default:
115 wp_send_json_error( [ 'message' => esc_html__( 'Oops, Action is not found.', 'sky-elementor-addons' ) ], 404 );
116 }
117 }
118
119 /**
120 * Set Settings
121 *
122 * @since 2.7.0
123 */
124 public function set_settings() {
125
126 if ( ! current_user_can( 'manage_options' ) ) {
127 wp_send_json_error( [ 'message' => esc_html__( 'Unauthorized access.', 'sky-elementor-addons' ) ], 403 );
128 }
129
130 check_ajax_referer( 'sky_addons_nonce', '_wpnonce' );
131
132 // phpcs:ignore
133 $action_type = isset( $_POST['action_type'] ) ? sanitize_text_field( wp_unslash( $_POST['action_type'] ) ) : false;
134 if ( ! $action_type ) {
135 wp_send_json_error( [ 'message' => esc_html__( 'Oops, Settings is not found.', 'sky-elementor-addons' ) ], 404 );
136 }
137
138 switch ( $action_type ) {
139 case 'widgets':
140 // phpcs:ignore
141 $widgets = $this->save_options( self::WIDGETS_DB_KEY, $_POST );
142 wp_send_json_success( $widgets );
143 break;
144
145 case 'extensions':
146 // phpcs:ignore
147 $extensions = $this->save_options( self::EXTENSIONS_DB_KEY, $_POST );
148 wp_send_json_success( $extensions );
149 break;
150
151 case '3rd_party':
152 // phpcs:ignore
153 $_3rd_party = $this->save_options( self::WIDGETS_3RD_PARTY_DB_KEY, $_POST );
154 wp_send_json_success( $_3rd_party );
155 break;
156
157 case 'asset_manager':
158 // phpcs:ignore
159 wp_send_json_success( $this->save_advanced_settings( $_POST ) );
160 break;
161
162 case 'regenerate_assets':
163 wp_send_json_success( $this->regenerate_assets() );
164 break;
165
166 case 'regenerate_status':
167 wp_send_json_success( $this->regenerate_status() );
168 break;
169
170 case 'dismiss_optimizer_status':
171 wp_send_json_success( $this->dismiss_optimizer_status() );
172 break;
173
174 case 'dismiss_getting_started':
175 wp_send_json_success( $this->dismiss_getting_started() );
176 break;
177
178 case 'disable_idle_widgets':
179 wp_send_json_success( $this->disable_idle_widgets() );
180 break;
181
182 case 'advanced_features':
183 $slug = isset( $_POST['feature'] ) ? sanitize_text_field( wp_unslash( $_POST['feature'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
184 $value = isset( $_POST['value'] ) && 'on' === $_POST['value'] ? 'on' : 'off'; // phpcs:ignore WordPress.Security.NonceVerification
185 if ( ! $slug ) {
186 wp_send_json_error( [ 'msg' => esc_html__( 'Unknown feature.', 'sky-elementor-addons' ) ] );
187 }
188 $_adv = (array) get_option( self::ADVANCED_DB_KEY, [] );
189 $_inactive = (array) ( $_adv['inactive'] ?? [] );
190 if ( 'off' === $value ) {
191 $_inactive[] = $slug;
192 } else {
193 $_inactive = array_diff( $_inactive, [ $slug ] );
194 }
195 $_adv['inactive'] = array_values( array_unique( $_inactive ) );
196 update_option( self::ADVANCED_DB_KEY, $_adv );
197 wp_send_json_success( [
198 'status' => 'success',
199 'title' => esc_html__( 'Successfully Updated.', 'sky-elementor-addons' ),
200 'msg' => esc_html__( 'The feature setting has been saved.', 'sky-elementor-addons' ),
201 ] );
202 break;
203
204 case 'api':
205 // phpcs:ignore
206 wp_send_json_success( $this->save_api_settings( $_POST ) );
207 break;
208
209 default:
210 wp_send_json_error( [ 'message' => esc_html__( 'Oops, Action is not found.', 'sky-elementor-addons' ) ], 404 );
211 }
212 }
213
214 /**
215 * Save the general optimizer settings.
216 *
217 * Turning the toggle ON dispatches a background regenerate (returns
218 * immediately with state=queued); the dashboard then polls regenerate_status.
219 * Turning the toggle OFF clears the bundle synchronously because deleting
220 * a few files is always fast.
221 *
222 * @param array $values Raw $_POST data.
223 */
224 public function save_advanced_settings( $values ) {
225 $post_value = is_array( $values ) ? $values : [];
226
227 $raw = isset( $post_value['asset_manager'] ) ? sanitize_text_field( wp_unslash( $post_value['asset_manager'] ) ) : 'per-widget';
228 // Normalise: accept legacy on/off as well as the 3 named modes.
229 if ( 'on' === $raw ) {
230 $raw = 'generated';
231 } elseif ( 'off' === $raw ) {
232 $raw = 'per-widget';
233 }
234 $mode = in_array( $raw, [ 'generated', 'full', 'per-widget' ], true ) ? $raw : 'per-widget';
235
236 $_adv = (array) get_option( self::ADVANCED_DB_KEY, [] );
237 $_adv['asset_manager'] = $mode;
238 update_option( self::ADVANCED_DB_KEY, $_adv );
239
240 $bundle = null;
241 $write_error = false;
242 $progress = null;
243
244 if ( class_exists( '\Sky_Addons\Optimizer\Asset_Manager' ) ) {
245 $manager = new \Sky_Addons\Optimizer\Asset_Manager();
246 $write_error = ! \Sky_Addons\Optimizer\Asset_Manager::is_upload_writable();
247
248 if ( 'generated' === $mode ) {
249 // Dispatch a background regenerate to build/refresh the uploads bundle.
250 if ( ! $write_error ) {
251 $progress = \Sky_Addons\Optimizer\Optimizer::dispatch_regenerate( 'manual' );
252 }
253 } elseif ( 'per-widget' === $mode ) {
254 // Clear the uploads bundle — nothing global is served in this mode.
255 $manager->clear();
256 \Sky_Addons\Optimizer\Optimizer::log_event( 'cleared', 'manual' );
257 }
258 // 'full' mode: keep any existing uploads bundle untouched; it is simply
259 // not used. No clear, no regenerate.
260
261 $bundle = $manager->get_bundle_info();
262 }
263
264 if ( 'generated' === $mode && $write_error ) {
265 $status = 'warning';
266 $msg = self::failure_message( 'upload_unwritable' );
267 } elseif ( 'generated' === $mode ) {
268 $status = 'queued';
269 $msg = esc_html__( 'Auto Optimize enabled. Custom bundle is being generated in the background.', 'sky-elementor-addons' );
270 } elseif ( 'full' === $mode ) {
271 $status = 'success';
272 $msg = esc_html__( 'Plugin Bundle mode enabled. The plugin-shipped combined file is now active.', 'sky-elementor-addons' );
273 } else {
274 $status = 'success';
275 $msg = esc_html__( 'Per Widget mode enabled. Each widget loads its own files on demand.', 'sky-elementor-addons' );
276 }
277
278 return [
279 'status' => $status,
280 'title' => esc_html__( 'Saved.', 'sky-elementor-addons' ),
281 'msg' => $msg,
282 'bundle' => $bundle,
283 'write_error' => $write_error,
284 'optimizer_log' => class_exists( '\Sky_Addons\Optimizer\Optimizer' ) ? \Sky_Addons\Optimizer\Optimizer::get_log() : [],
285 'optimizer_status' => self::get_optimizer_status(),
286 'progress' => $progress,
287 ];
288 }
289
290 /**
291 * Dispatch a background regenerate. Returns the initial progress snapshot
292 * so the dashboard can start polling immediately.
293 */
294 public function regenerate_assets() {
295 if ( ! class_exists( '\Sky_Addons\Optimizer\Asset_Manager' ) ) {
296 return [
297 'status' => 'error',
298 'title' => esc_html__( 'Regeneration Failed.', 'sky-elementor-addons' ),
299 'msg' => esc_html__( 'The optimizer is not available.', 'sky-elementor-addons' ),
300 ];
301 }
302
303 if ( ! \Sky_Addons\Optimizer\Asset_Manager::is_upload_writable() ) {
304 return [
305 'status' => 'error',
306 'title' => esc_html__( 'Permission Error.', 'sky-elementor-addons' ),
307 'msg' => sprintf(
308 /* translators: %s: upload directory path */
309 esc_html__( 'The upload directory is not writable. Per-widget loading is active. Fix write permissions on: %s', 'sky-elementor-addons' ),
310 esc_html( wp_upload_dir()['basedir'] )
311 ),
312 'write_error' => true,
313 'optimizer_status' => self::get_optimizer_status(),
314 ];
315 }
316
317 // Only 'generated' mode uses an uploads bundle — regenerating in 'full' or
318 // 'per-widget' mode would be a no-op or confusing.
319 if ( ! function_exists( 'sky_addons_asset_mode' ) || 'generated' !== sky_addons_asset_mode() ) {
320 return [
321 'status' => 'error',
322 'title' => esc_html__( 'Wrong Mode.', 'sky-elementor-addons' ),
323 'msg' => esc_html__( 'Switch to Auto Optimize mode to generate a custom bundle.', 'sky-elementor-addons' ),
324 ];
325 }
326
327 $progress = \Sky_Addons\Optimizer\Optimizer::dispatch_regenerate( 'manual' );
328
329 if ( null === $progress ) {
330 // Another runner is already active — return its current state so the
331 // dashboard latches onto the in-flight job instead of dispatching twice.
332 return [
333 'status' => 'queued',
334 'title' => esc_html__( 'Already Running.', 'sky-elementor-addons' ),
335 'msg' => esc_html__( 'A bundle regeneration is already in progress.', 'sky-elementor-addons' ),
336 'progress' => \Sky_Addons\Optimizer\Optimizer::get_progress(),
337 'bundle' => ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info(),
338 'optimizer_log' => \Sky_Addons\Optimizer\Optimizer::get_log(),
339 'optimizer_status' => self::get_optimizer_status(),
340 'write_error' => false,
341 ];
342 }
343
344 return [
345 'status' => 'queued',
346 'title' => esc_html__( 'Regenerating…', 'sky-elementor-addons' ),
347 'msg' => esc_html__( 'Bundle regeneration is running in the background.', 'sky-elementor-addons' ),
348 'progress' => $progress,
349 'bundle' => ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info(),
350 'optimizer_log' => \Sky_Addons\Optimizer\Optimizer::get_log(),
351 'optimizer_status' => self::get_optimizer_status(),
352 'write_error' => false,
353 ];
354 }
355
356 /**
357 * Polling endpoint. Returns current progress + a fresh bundle snapshot so
358 * the dashboard can refresh size/timestamp the moment a run completes.
359 */
360 public function regenerate_status() {
361 $progress = class_exists( '\Sky_Addons\Optimizer\Optimizer' )
362 ? \Sky_Addons\Optimizer\Optimizer::get_progress()
363 : null;
364
365 $bundle = class_exists( '\Sky_Addons\Optimizer\Asset_Manager' )
366 ? ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info()
367 : null;
368
369 return [
370 'status' => 'success',
371 'progress' => $progress,
372 'bundle' => $bundle,
373 'optimizer_log' => class_exists( '\Sky_Addons\Optimizer\Optimizer' ) ? \Sky_Addons\Optimizer\Optimizer::get_log() : [],
374 'optimizer_status' => self::get_optimizer_status(),
375 'write_error' => class_exists( '\Sky_Addons\Optimizer\Asset_Manager' ) ? ! \Sky_Addons\Optimizer\Asset_Manager::is_upload_writable() : false,
376 ];
377 }
378
379 /**
380 * Clear the persisted optimizer failure status so the dashboard warning
381 * disappears until the next failed regenerate.
382 */
383 public function dismiss_optimizer_status() {
384 delete_option( 'sky_addons_optimizer_status' );
385
386 return [
387 'status' => 'success',
388 'title' => esc_html__( 'Dismissed.', 'sky-elementor-addons' ),
389 'msg' => esc_html__( 'The optimizer warning has been dismissed.', 'sky-elementor-addons' ),
390 'optimizer_status' => null,
391 ];
392 }
393
394 /**
395 * Get the persisted failure payload, or null when the last build succeeded.
396 *
397 * @return array|null
398 */
399 private static function get_optimizer_status() {
400 $status = get_option( 'sky_addons_optimizer_status', null );
401
402 if ( ! is_array( $status ) || empty( $status['failed'] ) ) {
403 return null;
404 }
405
406 return $status;
407 }
408
409 /**
410 * Human message for an optimizer failure reason.
411 *
412 * @param string $reason One of: upload_unwritable, minify_failed, no_files, unknown.
413 */
414 private static function failure_message( $reason ) {
415 switch ( $reason ) {
416 case 'upload_unwritable':
417 return esc_html__( 'The uploads directory is not writable. The plugin-shipped combined assets are being served as a safe fallback.', 'sky-elementor-addons' );
418 case 'minify_failed':
419 return esc_html__( 'Bundle minification failed. The plugin-shipped combined assets are being served as a safe fallback.', 'sky-elementor-addons' );
420 case 'no_files':
421 return esc_html__( 'Bundle files are missing on disk after the build. The plugin-shipped combined assets are being served as a safe fallback.', 'sky-elementor-addons' );
422 default:
423 return esc_html__( 'The optimized bundle could not be generated. The plugin-shipped combined assets are being served as a safe fallback.', 'sky-elementor-addons' );
424 }
425 }
426
427 /**
428 * Save Options
429 */
430 public function save_options( $option_name, $values ) {
431 // Ensure $values is an array
432 $post_value = is_array( $values ) ? $values : [];
433
434 // Filter and sanitize the input values, keeping only those with the value 'off'
435 $filtered_values = [];
436 foreach ( $post_value as $key => $value ) {
437 if ( 'off' === $value ) {
438 $filtered_values[ $key ] = sanitize_text_field( $value );
439 }
440 }
441
442 // Retrieve the current saved option
443 $saved_option = get_option( $option_name, [] );
444
445 // Check if there are changes to save (order-insensitive comparison)
446 $new_inactive = array_keys( $filtered_values );
447 $old_inactive = array_values( (array) $saved_option );
448 sort( $new_inactive );
449 sort( $old_inactive );
450 if ( $new_inactive === $old_inactive ) {
451 return [
452 'status' => 'error',
453 'title' => esc_html__( 'Already Updated.', 'sky-elementor-addons' ),
454 'msg' => esc_html__( 'There is no change in your settings. So there is no need to save the settings again.', 'sky-elementor-addons' ),
455 ];
456 }
457
458 // Attempt to update the option
459 if ( update_option( $option_name, array_keys( $filtered_values ) ) ) {
460 // Active widget/extension set changed — dispatch a background rebuild
461 // so the save returns immediately. Dashboard latches onto the in-flight
462 // runner via the existing regenerate_status polling endpoint.
463 $progress = null;
464 $bundle = null;
465 $log = [];
466
467 if (
468 function_exists( 'sky_addons_is_asset_optimization_enabled' )
469 && sky_addons_is_asset_optimization_enabled()
470 && class_exists( '\Sky_Addons\Optimizer\Optimizer' )
471 && class_exists( '\Sky_Addons\Optimizer\Asset_Manager' )
472 ) {
473 $progress = \Sky_Addons\Optimizer\Optimizer::dispatch_regenerate( 'widgets_changed' );
474
475 // dispatch_regenerate() returns null when another runner is already
476 // active — surface its live progress so the dashboard can latch.
477 if ( null === $progress ) {
478 $progress = \Sky_Addons\Optimizer\Optimizer::get_progress();
479 }
480
481 $bundle = ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info();
482 $log = \Sky_Addons\Optimizer\Optimizer::get_log();
483 }
484
485 return [
486 'status' => 'success',
487 'title' => esc_html__( 'Successfully Updated.', 'sky-elementor-addons' ),
488 'msg' => esc_html__( 'Great, your settings saved successfully in your system.', 'sky-elementor-addons' ),
489 'progress' => $progress,
490 'bundle' => $bundle,
491 'optimizer_log' => $log,
492 ];
493 } else {
494 return [
495 'status' => 'error',
496 'title' => esc_html__( 'Update Failed.', 'sky-elementor-addons' ),
497 'msg' => esc_html__( 'There was an error updating your settings. Please try again.', 'sky-elementor-addons' ),
498 ];
499 }
500 }
501
502
503 /**
504 * Save API credentials.
505 *
506 * Only updates keys present in the API group definitions in admin.php. An empty string clears the key.
507 *
508 * @param array $values Raw $_POST data.
509 */
510 /**
511 * Sanitize a repeater field arriving as a JSON string.
512 *
513 * Entirely schema-driven: the columns, their types and which of them are required all come from
514 * the field's `row_fields`. This class therefore knows nothing about what any particular repeater
515 * stores — adding or removing a column later is a one-line change in admin.php, and the meaning
516 * of the data stays with whichever plugin actually consumes it.
517 *
518 * Row ids are the contract for anything referencing a row elsewhere: one is minted only when a
519 * row has none, derived from the first column for readability, and **never regenerated** — that
520 * is what lets a row be renamed without breaking whatever points at it.
521 *
522 * @param string $raw JSON array of rows.
523 * @param array $row_fields Column schema.
524 * @return string JSON, or an empty string when nothing survives.
525 */
526 private function sanitize_repeater_rows( $raw, $row_fields = [] ) {
527 $rows = json_decode( (string) $raw, true );
528
529 if ( ! is_array( $rows ) || empty( $row_fields ) ) {
530 return '';
531 }
532
533 $id_source = isset( $row_fields[0]['name'] ) ? $row_fields[0]['name'] : '';
534 $clean = [];
535 $seen = [];
536
537 foreach ( $rows as $row ) {
538 if ( ! is_array( $row ) ) {
539 continue;
540 }
541
542 $entry = [];
543 $valid = true;
544
545 foreach ( $row_fields as $row_field ) {
546 $name = isset( $row_field['name'] ) ? $row_field['name'] : '';
547
548 if ( '' === $name || 'id' === $name ) {
549 continue;
550 }
551
552 $type = isset( $row_field['type'] ) ? $row_field['type'] : 'input';
553 $value = isset( $row[ $name ] ) ? $row[ $name ] : '';
554
555 if ( 'url' === $type ) {
556 $value = $this->sanitize_repeater_url( $value );
557 } elseif ( 'textarea' === $type ) {
558 $value = sanitize_textarea_field( $value );
559 } else {
560 $value = sanitize_text_field( $value );
561 }
562
563 // A row missing something it cannot work without is dropped rather than stored
564 // half-filled, where it would show up as a broken choice elsewhere.
565 if ( ! empty( $row_field['required'] ) && '' === $value ) {
566 $valid = false;
567 break;
568 }
569
570 $entry[ $name ] = $value;
571 }
572
573 if ( ! $valid ) {
574 continue;
575 }
576
577 $id = isset( $row['id'] ) ? sanitize_key( $row['id'] ) : '';
578
579 if ( '' === $id && '' !== $id_source ) {
580 $id = sanitize_title( isset( $entry[ $id_source ] ) ? $entry[ $id_source ] : '' );
581 }
582
583 // sanitize_title() returns nothing for a value with no slug-able characters at all.
584 if ( '' === $id ) {
585 $id = 'item';
586 }
587
588 // Only reached when two rows want the same id. The first one keeps it.
589 if ( isset( $seen[ $id ] ) ) {
590 $base = $id;
591 $suffix = 2;
592
593 while ( isset( $seen[ $id ] ) ) {
594 $id = $base . '-' . $suffix;
595 $suffix++;
596 }
597 }
598
599 $seen[ $id ] = true;
600 $clean[] = array_merge( [ 'id' => $id ], $entry );
601 }
602
603 return $clean ? wp_json_encode( $clean ) : '';
604 }
605
606 /**
607 * Validate a repeater URL column.
608 *
609 * Checked before esc_url_raw() gets it: that function *prepends* http:// to a bare string, so a
610 * typo like "not-a-url" would otherwise be stored as the valid-looking "http://not-a-url".
611 *
612 * @param mixed $value
613 * @return string Empty when the value is not a usable http(s) URL.
614 */
615 private function sanitize_repeater_url( $value ) {
616 $typed = trim( (string) $value );
617
618 if ( '' === $typed || ! filter_var( $typed, FILTER_VALIDATE_URL ) ) {
619 return '';
620 }
621
622 $scheme = strtolower( (string) wp_parse_url( $typed, PHP_URL_SCHEME ) );
623
624 if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
625 return '';
626 }
627
628 return esc_url_raw( $typed, [ 'http', 'https' ] );
629 }
630
631 public function save_api_settings( $values ) {
632 $post_value = is_array( $values ) ? $values : [];
633 $saved = (array) get_option( self::API_DB_KEY, [] );
634 $api_groups = Sky_Addons_Admin::get_element_list()['sky_addons_api'] ?? [];
635 $known_keys = [];
636 $field_types = [];
637 $row_schemas = [];
638 $pro_active = function_exists( 'sky_addons_init_pro' ) && true === sky_addons_init_pro();
639
640 foreach ( $api_groups as $group ) {
641 // A group belonging to Pro is rendered locked when Pro is inactive, and its values are
642 // meaningless to this plugin. Skipping it here means a save from a site without Pro can
643 // never rewrite — or, because an empty value unsets, silently DELETE — settings only Pro
644 // understands. Without this, one save on a deactivated-Pro site wipes them.
645 if ( ! $pro_active && isset( $group['feature_type'] ) && 'pro' === $group['feature_type'] ) {
646 continue;
647 }
648
649 foreach ( (array) ( $group['input_box'] ?? [] ) as $field ) {
650 if ( ! empty( $field['name'] ) ) {
651 $known_keys[] = $field['name'];
652 $field_types[ $field['name'] ] = isset( $field['type'] ) ? $field['type'] : 'input';
653 $row_schemas[ $field['name'] ] = isset( $field['row_fields'] ) ? (array) $field['row_fields'] : [];
654 }
655 }
656 }
657
658 foreach ( $known_keys as $key ) {
659 if ( ! array_key_exists( $key, $post_value ) ) {
660 continue;
661 }
662
663 // sanitize_text_field() collapses newlines and would mangle JSON, so multi-line and
664 // repeater fields each need their own treatment.
665 if ( 'repeater' === $field_types[ $key ] ) {
666 $val = $this->sanitize_repeater_rows( wp_unslash( $post_value[ $key ] ), $row_schemas[ $key ] );
667 } elseif ( 'textarea' === $field_types[ $key ] ) {
668 $val = sanitize_textarea_field( wp_unslash( $post_value[ $key ] ) );
669 } else {
670 $val = sanitize_text_field( wp_unslash( $post_value[ $key ] ) );
671 }
672 if ( '' === $val ) {
673 unset( $saved[ $key ] );
674 } else {
675 $saved[ $key ] = $val;
676 }
677 }
678
679 update_option( self::API_DB_KEY, $saved );
680
681 return [
682 'status' => 'success',
683 'title' => esc_html__( 'Successfully Updated.', 'sky-elementor-addons' ),
684 'msg' => esc_html__( 'API settings saved successfully.', 'sky-elementor-addons' ),
685 ];
686 }
687
688 /**
689 * Get Widgets List
690 *
691 * @since 2.7.0
692 */
693 public function get_widgets_list( $list_name ) {
694
695 $widgets_fields = Sky_Addons_Admin::get_element_list();
696
697 $_widgets = $widgets_fields[ $list_name ];
698
699 return $_widgets;
700 }
701
702 /**
703 * Everything the dashboard home tab renders, in one request.
704 *
705 * The home tab is a status screen — counts, health and next actions — so it
706 * needs a slice of nearly every other tab's data. Bundling it here keeps the
707 * page to a single admin-ajax round trip.
708 *
709 * @since 4.0.0
710 * @return array
711 */
712 public function get_dashboard_summary() {
713 $elements = Sky_Addons_Admin::get_element_list();
714
715 $pro_active = (bool) apply_filters( 'sky_addons_pro_init', false );
716
717 // Integrations ("3rd party" internally) are placed in Elementor exactly like
718 // core widgets, so the headline widget numbers cover both. They keep their own
719 // sub-counts because they live under a separate option key.
720 $core = $this->count_feature_group( (array) ( $elements['sky_addons_widgets'] ?? [] ), $pro_active );
721 $integration = $this->count_feature_group( (array) ( $elements['sky_addons_3rd_party_widget'] ?? [] ), $pro_active );
722 $extensions = $this->count_feature_group( (array) ( $elements['sky_addons_extensions'] ?? [] ), $pro_active );
723
724 $top = array_merge( $core['top'], $integration['top'] );
725 usort(
726 $top,
727 function ( $a, $b ) {
728 return $b['count'] <=> $a['count'];
729 }
730 );
731
732 $bundle = class_exists( '\Sky_Addons\Optimizer\Asset_Manager' )
733 ? ( new \Sky_Addons\Optimizer\Asset_Manager() )->get_bundle_info()
734 : null;
735
736 // Computed once and shared with get_dashboard_health() — it reads the plugin
737 // file header, which is not worth doing twice in one request.
738 $elementor = $this->get_elementor_status();
739
740 return [
741 'version' => SKY_ADDONS_VERSION,
742 'pro' => [
743 'active' => $pro_active,
744 'version' => defined( 'SKY_ADDONS_PRO_VERSION' ) ? SKY_ADDONS_PRO_VERSION : '',
745 ],
746 'elementor' => $elementor,
747 // Headline numbers = core widgets + integrations, because both are widgets
748 // the user places in Elementor and both are swept by "disable idle".
749 'widgets' => [
750 'total' => $core['total'] + $integration['total'],
751 'enabled' => $core['enabled'] + $integration['enabled'],
752 'used' => $core['used'] + $integration['used'],
753 'idle' => $core['idle'] + $integration['idle'],
754 'locked' => $core['locked'] + $integration['locked'],
755 'top' => array_slice( $top, 0, 5 ),
756 ],
757 'core' => [
758 'total' => $core['total'],
759 'enabled' => $core['enabled'],
760 ],
761 'third_party' => [
762 'total' => $integration['total'],
763 'enabled' => $integration['enabled'],
764 'used' => $integration['used'],
765 'idle' => $integration['idle'],
766 ],
767 'extensions' => [
768 'total' => $extensions['total'],
769 'enabled' => $extensions['enabled'],
770 ],
771 // Two sizes, because they answer different questions: `total_bytes` is the
772 // optimized bundle built from active widgets (Auto Optimize), `full_bytes`
773 // is the combined file shipped in the plugin (Plugin Bundle). The card
774 // shows whichever the current mode actually serves.
775 'assets' => [
776 'mode' => function_exists( 'sky_addons_asset_mode' ) ? sky_addons_asset_mode() : 'per-widget',
777 'total_bytes' => isset( $bundle['total_bytes'] ) ? (int) $bundle['total_bytes'] : 0,
778 'full_bytes' => (int) $this->get_shipped_bundle_size()['total_bytes'],
779 'generated' => isset( $bundle['generated'] ) ? (int) $bundle['generated'] : 0,
780 ],
781 // Both are per-record toggles, so they report active-of-total like every
782 // other feature group rather than a bare count.
783 'theme_builder' => $this->count_records( 'wowdevs-hooks', 'wowdevs_theme_builder_status' ),
784 'custom_scripts' => $this->count_records( 'sky-custom-scripts', 'sky_script_status' ),
785 'recent' => $this->get_recent_elementor_posts(),
786 // Both take what has already been computed — get_element_list() rebuilds
787 // the whole element array and re-runs Elementor's usage query, so it must
788 // be called exactly once per request.
789 'health' => $this->get_dashboard_health( $elementor ),
790 'checklist' => $this->get_getting_started_checklist( $elements ),
791 'whats_new' => $this->get_whats_new(),
792 ];
793 }
794
795 /**
796 * Size of the combined bundle that ships inside the plugin — what "Plugin
797 * Bundle" mode actually serves. Includes Pro's combined files when Pro is
798 * installed, since both load together in that mode.
799 *
800 * @since 4.0.0
801 * @return array {css_bytes, js_bytes, total_bytes}
802 */
803 private function get_shipped_bundle_size() {
804 $files = [
805 SKY_ADDONS_ASSETS_PATH . 'css/sky-addons.css' => 'css_bytes',
806 SKY_ADDONS_ASSETS_PATH . 'js/sky-addons.min.js' => 'js_bytes',
807 ];
808
809 if ( defined( 'SKY_ADDONS_PRO_PATH' ) ) {
810 $files[ SKY_ADDONS_PRO_PATH . 'assets/css/sky-addons-pro.css' ] = 'css_bytes';
811 $files[ SKY_ADDONS_PRO_PATH . 'assets/js/sky-addons-pro.min.js' ] = 'js_bytes';
812 }
813
814 $bundle = [
815 'css_bytes' => 0,
816 'js_bytes' => 0,
817 ];
818
819 foreach ( $files as $path => $bucket ) {
820 if ( file_exists( $path ) ) {
821 $bundle[ $bucket ] += (int) filesize( $path );
822 }
823 }
824
825 $bundle['total_bytes'] = $bundle['css_bytes'] + $bundle['js_bytes'];
826
827 return $bundle;
828 }
829
830 /**
831 * Count one feature group (core widgets, integrations, or extensions).
832 *
833 * Pro-flagged items on a free install are teasers: listed in the panel, never
834 * usable. They are reported as `locked` and excluded from every other number,
835 * so "enabled" never overstates what the site can actually render.
836 *
837 * @since 4.0.0
838 * @param array $items Feature rows from the element list.
839 * @param bool $pro_active Whether the Pro plugin is running.
840 * @return array {total, enabled, used, idle, locked, top[]}
841 */
842 private function count_feature_group( $items, $pro_active ) {
843 $stats = [
844 'total' => 0,
845 'enabled' => 0,
846 'used' => 0,
847 'idle' => 0, // Enabled but not placed on any page — the disable-me candidates.
848 'locked' => 0,
849 'top' => [],
850 ];
851
852 foreach ( $items as $item ) {
853 if ( ! $pro_active && isset( $item['feature_type'] ) && 'pro' === $item['feature_type'] ) {
854 ++$stats['locked'];
855 continue;
856 }
857
858 ++$stats['total'];
859
860 $is_on = ! isset( $item['value'] ) || 'on' === $item['value'];
861 $used = isset( $item['total_used'] ) ? (int) $item['total_used'] : 0;
862
863 if ( $is_on ) {
864 ++$stats['enabled'];
865 }
866
867 if ( $used > 0 ) {
868 ++$stats['used'];
869 $stats['top'][] = [
870 'name' => $item['name'],
871 'label' => $item['label'],
872 'count' => $used,
873 ];
874 } elseif ( $is_on ) {
875 ++$stats['idle'];
876 }
877 }
878
879 return $stats;
880 }
881
882 /**
883 * Elementor presence + version, so the home tab can warn before a widget
884 * silently fails to register.
885 *
886 * @since 4.0.0
887 * @return array
888 */
889 private function get_elementor_status() {
890 $minimum = '3.0.0';
891 $version = defined( 'ELEMENTOR_VERSION' ) ? ELEMENTOR_VERSION : '';
892 $tested = $this->get_elementor_tested_version();
893
894 // Compare majors only — a patch bump on Elementor's side is not news, a
895 // major one is exactly when addon widgets break.
896 $untested = $version && $tested
897 && version_compare( $this->major_version( $version ), $this->major_version( $tested ), '>' );
898
899 return [
900 'active' => did_action( 'elementor/loaded' ) > 0,
901 'version' => $version,
902 'minimum' => $minimum,
903 'tested' => $tested,
904 'untested' => $untested,
905 'outdated' => $version && version_compare( $version, $minimum, '<' ),
906 ];
907 }
908
909 /**
910 * "Elementor tested up to" from this plugin's file header.
911 *
912 * @since 4.0.0
913 * @return string
914 */
915 private function get_elementor_tested_version() {
916 if ( ! defined( 'SKY_ADDONS__FILE__' ) ) {
917 return '';
918 }
919
920 $data = get_file_data( SKY_ADDONS__FILE__, [ 'tested' => 'Elementor tested up to' ] );
921
922 return isset( $data['tested'] ) ? trim( $data['tested'] ) : '';
923 }
924
925 /**
926 * Leading version segment, e.g. "4.2.0" → "4".
927 *
928 * @since 4.0.0
929 * @param string $version Full version string.
930 * @return string
931 */
932 private function major_version( $version ) {
933 $parts = explode( '.', $version );
934
935 return $parts[0];
936 }
937
938 /**
939 * Pending updates for core and Pro, read from the update transient WordPress
940 * already maintains — no extra HTTP request of our own.
941 *
942 * @since 4.0.0
943 * @return array
944 */
945 private function get_pending_updates() {
946 $updates = get_site_transient( 'update_plugins' );
947 $responses = isset( $updates->response ) ? (array) $updates->response : [];
948 $pending = [];
949
950 $plugins = [
951 'core' => defined( 'SKY_ADDONS_PLUGIN_BASE' ) ? SKY_ADDONS_PLUGIN_BASE : '',
952 'pro' => defined( 'SKY_ADDONS_PRO_PLUGIN_BASE' ) ? SKY_ADDONS_PRO_PLUGIN_BASE : '',
953 ];
954
955 foreach ( $plugins as $slug => $basename ) {
956 if ( ! $basename || empty( $responses[ $basename ]->new_version ) ) {
957 continue;
958 }
959
960 $pending[ $slug ] = $responses[ $basename ]->new_version;
961 }
962
963 return $pending;
964 }
965
966 /**
967 * Published record count for a toggleable post type, split by its own enable
968 * meta, so the dashboard can report "4 of 6 active" instead of a bare total.
969 *
970 * Zeroes out when the type is not registered — Theme Builder and Custom
971 * Scripts both register conditionally.
972 *
973 * @since 4.0.0
974 * @param string $post_type Post type slug.
975 * @param string $status_meta_key Meta key holding the `enabled` flag.
976 * @return array{total:int,enabled:int}
977 */
978 private function count_records( $post_type, $status_meta_key ) {
979 static $cache = [];
980
981 // The checklist asks for the same Theme Builder number the summary already
982 // counted, so memoize per request rather than query twice.
983 if ( isset( $cache[ $post_type ] ) ) {
984 return $cache[ $post_type ];
985 }
986
987 if ( ! post_type_exists( $post_type ) ) {
988 $cache[ $post_type ] = [
989 'total' => 0,
990 'enabled' => 0,
991 ];
992
993 return $cache[ $post_type ];
994 }
995
996 // wp_count_posts() is object-cached, so the total is free on a warm cache.
997 $counts = wp_count_posts( $post_type );
998 $total = (int) ( $counts->publish ?? 0 );
999
1000 // Both post types only load when their status meta reads `enabled`, so an
1001 // absent meta counts as off — the same rule the runtime queries apply.
1002 // IDs only, no meta/term priming: these are small post types and this runs
1003 // on the dashboard's single admin-ajax request, never on the front end.
1004 $enabled = $total > 0
1005 ? count(
1006 get_posts(
1007 [
1008 'post_type' => $post_type,
1009 'post_status' => 'publish',
1010 'posts_per_page' => -1,
1011 'fields' => 'ids',
1012 'no_found_rows' => true,
1013 'update_post_meta_cache' => false,
1014 'update_post_term_cache' => false,
1015 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1016 [
1017 'key' => $status_meta_key,
1018 'value' => 'enabled',
1019 'compare' => '=',
1020 ],
1021 ],
1022 ]
1023 )
1024 )
1025 : 0;
1026
1027 $cache[ $post_type ] = [
1028 'total' => $total,
1029 'enabled' => $enabled,
1030 ];
1031
1032 return $cache[ $post_type ];
1033 }
1034
1035 /**
1036 * The last few things edited with Elementor, so a returning admin can pick
1037 * up where they left off instead of hunting through the pages list.
1038 *
1039 * @since 4.0.0
1040 * @return array
1041 */
1042 private function get_recent_elementor_posts() {
1043 $query = new \WP_Query(
1044 [
1045 'post_type' => 'any',
1046 'post_status' => [ 'publish', 'draft', 'private' ],
1047 'posts_per_page' => 5,
1048 'orderby' => 'modified',
1049 'order' => 'DESC',
1050 'meta_key' => '_elementor_edit_mode', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1051 'no_found_rows' => true,
1052 'update_post_term_cache' => false,
1053 'ignore_sticky_posts' => true,
1054 ]
1055 );
1056
1057 // `_edit_last` is who touched it most recently; post_author is only who
1058 // created it, which is the wrong answer on any multi-author site.
1059 $editor_ids = [];
1060 foreach ( $query->posts as $post ) {
1061 $editor_ids[ $post->ID ] = (int) get_post_meta( $post->ID, '_edit_last', true );
1062
1063 if ( ! $editor_ids[ $post->ID ] ) {
1064 $editor_ids[ $post->ID ] = (int) $post->post_author;
1065 }
1066 }
1067
1068 // Prime the user cache in one query instead of one per row.
1069 $unique_ids = array_filter( array_unique( array_values( $editor_ids ) ) );
1070 if ( $unique_ids ) {
1071 cache_users( $unique_ids );
1072 }
1073
1074 $recent = [];
1075
1076 foreach ( $query->posts as $post ) {
1077 $post_type = get_post_type_object( $post->post_type );
1078 $editor_id = $editor_ids[ $post->ID ];
1079 $editor = $editor_id ? get_userdata( $editor_id ) : false;
1080
1081 $recent[] = [
1082 'id' => $post->ID,
1083 'title' => $post->post_title ? $post->post_title : esc_html__( '(no title)', 'sky-elementor-addons' ),
1084 'type' => $post_type ? $post_type->labels->singular_name : $post->post_type,
1085 'modified' => sprintf(
1086 /* translators: %s: human readable time difference, e.g. "2 hours". */
1087 esc_html__( '%s ago', 'sky-elementor-addons' ),
1088 human_time_diff( get_post_modified_time( 'U', true, $post ), time() )
1089 ),
1090 'author' => $editor ? $editor->display_name : '',
1091 'avatar' => $editor_id ? esc_url( get_avatar_url( $editor_id, [ 'size' => 48 ] ) ) : '',
1092 'edit_url' => esc_url( admin_url( 'post.php?post=' . $post->ID . '&action=elementor' ) ),
1093 ];
1094 }
1095
1096 wp_reset_postdata();
1097
1098 return $recent;
1099 }
1100
1101 /**
1102 * Problems worth interrupting the user for. Empty array means all clear —
1103 * the dashboard renders nothing rather than an empty box.
1104 *
1105 * @since 4.0.0
1106 * @param array|null $elementor Result of get_elementor_status(), reused when the
1107 * caller has already computed it.
1108 * @return array
1109 */
1110 private function get_dashboard_health( $elementor = null ) {
1111 $issues = [];
1112 $elementor = is_array( $elementor ) ? $elementor : $this->get_elementor_status();
1113
1114 if ( ! $elementor['active'] ) {
1115 $issues[] = [
1116 'id' => 'elementor_missing',
1117 'severity' => 'error',
1118 'title' => esc_html__( 'Elementor is not active', 'sky-elementor-addons' ),
1119 'msg' => esc_html__( 'Sky Addons widgets need Elementor. Install and activate Elementor to start building.', 'sky-elementor-addons' ),
1120 'url' => esc_url( admin_url( 'plugin-install.php?s=elementor&tab=search&type=term' ) ),
1121 'label' => esc_html__( 'Install Elementor', 'sky-elementor-addons' ),
1122 ];
1123 } elseif ( $elementor['outdated'] ) {
1124 $issues[] = [
1125 'id' => 'elementor_outdated',
1126 'severity' => 'warning',
1127 /* translators: %s: minimum supported Elementor version. */
1128 'title' => sprintf( esc_html__( 'Elementor %s or newer is required', 'sky-elementor-addons' ), $elementor['minimum'] ),
1129 'msg' => esc_html__( 'Some widgets may not register correctly on this Elementor version.', 'sky-elementor-addons' ),
1130 'url' => esc_url( admin_url( 'plugins.php' ) ),
1131 'label' => esc_html__( 'Update Elementor', 'sky-elementor-addons' ),
1132 ];
1133 }
1134
1135 // Elementor majors are where addon widgets break — surface the mismatch
1136 // before the user spends an hour debugging a blank widget.
1137 if ( $elementor['active'] && ! empty( $elementor['untested'] ) ) {
1138 $issues[] = [
1139 'id' => 'elementor_untested',
1140 'severity' => 'warning',
1141 /* translators: 1: running Elementor version. 2: version this plugin was tested against. */
1142 'title' => sprintf( esc_html__( 'Elementor %1$s is newer than the tested version (%2$s)', 'sky-elementor-addons' ), $elementor['version'], $elementor['tested'] ),
1143 'msg' => esc_html__( 'Widgets should still work, but if you see anything broken after the Elementor update, tell support which widget and we will patch it.', 'sky-elementor-addons' ),
1144 'tab' => 'faqs',
1145 'label' => esc_html__( 'Report an issue', 'sky-elementor-addons' ),
1146 ];
1147 }
1148
1149 $pending_updates = $this->get_pending_updates();
1150 if ( $pending_updates ) {
1151 $labels = [];
1152 if ( isset( $pending_updates['core'] ) ) {
1153 /* translators: %s: available version number. */
1154 $labels[] = sprintf( esc_html__( 'Core %s', 'sky-elementor-addons' ), $pending_updates['core'] );
1155 }
1156 if ( isset( $pending_updates['pro'] ) ) {
1157 /* translators: %s: available version number. */
1158 $labels[] = sprintf( esc_html__( 'Pro %s', 'sky-elementor-addons' ), $pending_updates['pro'] );
1159 }
1160
1161 $issues[] = [
1162 'id' => 'update_available',
1163 'severity' => 'info',
1164 'title' => esc_html__( 'An update is available', 'sky-elementor-addons' ),
1165 /* translators: %s: comma separated list of available versions, e.g. "Core 4.0.0, Pro 5.0.0". */
1166 'msg' => sprintf( esc_html__( '%s is ready to install. Updates carry the widget fixes and new controls.', 'sky-elementor-addons' ), implode( ', ', $labels ) ),
1167 'url' => esc_url( admin_url( 'plugins.php' ) ),
1168 'label' => esc_html__( 'Update now', 'sky-elementor-addons' ),
1169 ];
1170 }
1171
1172 if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
1173 $issues[] = [
1174 'id' => 'php_old',
1175 'severity' => 'warning',
1176 /* translators: %s: current PHP version. */
1177 'title' => sprintf( esc_html__( 'PHP %s is below the supported version', 'sky-elementor-addons' ), PHP_VERSION ),
1178 'msg' => esc_html__( 'Sky Addons needs PHP 7.4 or newer. Ask your host to upgrade.', 'sky-elementor-addons' ),
1179 ];
1180 }
1181
1182 if ( class_exists( '\Sky_Addons\Optimizer\Asset_Manager' ) && ! \Sky_Addons\Optimizer\Asset_Manager::is_upload_writable() ) {
1183 $issues[] = [
1184 'id' => 'uploads_unwritable',
1185 'severity' => 'warning',
1186 'title' => esc_html__( 'The uploads folder is not writable', 'sky-elementor-addons' ),
1187 'msg' => esc_html__( 'Optimized asset bundles cannot be written, so the plugin-shipped bundle is being served instead.', 'sky-elementor-addons' ),
1188 'tab' => 'advanced',
1189 'label' => esc_html__( 'Open Advanced', 'sky-elementor-addons' ),
1190 ];
1191 }
1192
1193 $optimizer_status = self::get_optimizer_status();
1194 if ( $optimizer_status ) {
1195 $issues[] = [
1196 'id' => 'optimizer_failed',
1197 'severity' => 'warning',
1198 'title' => esc_html__( 'The last bundle build failed', 'sky-elementor-addons' ),
1199 'msg' => self::failure_message( $optimizer_status['reason'] ?? 'unknown' ),
1200 'tab' => 'advanced',
1201 'label' => esc_html__( 'Open Advanced', 'sky-elementor-addons' ),
1202 ];
1203 }
1204
1205 return $issues;
1206 }
1207
1208 /**
1209 * First-run checklist. Every item derives from real state, so it doubles as
1210 * a progress mirror rather than a list the user has to tick manually.
1211 *
1212 * @since 4.0.0
1213 * @param array|null $elements Result of Sky_Addons_Admin::get_element_list(), passed
1214 * in when the caller has already built it.
1215 * @return array
1216 */
1217 private function get_getting_started_checklist( $elements = null ) {
1218 $advanced = (array) get_option( self::ADVANCED_DB_KEY, [] );
1219 $api = (array) get_option( self::API_DB_KEY, [] );
1220 $inactive = (array) get_option( self::WIDGETS_DB_KEY, [] );
1221 $dismissed = (bool) get_user_meta( get_current_user_id(), 'sky_addons_getting_started_done', true );
1222 $has_usage = false;
1223 // Reuse the caller's element list — building it again rebuilds the whole
1224 // element array and re-runs Elementor's usage query for nothing.
1225 $elements = is_array( $elements ) ? $elements : Sky_Addons_Admin::get_element_list();
1226
1227 foreach ( (array) ( $elements['sky_addons_widgets'] ?? [] ) as $widget ) {
1228 if ( ! empty( $widget['total_used'] ) ) {
1229 $has_usage = true;
1230 break;
1231 }
1232 }
1233
1234 return [
1235 'dismissed' => $dismissed,
1236 'items' => [
1237 [
1238 'id' => 'widgets',
1239 'label' => esc_html__( 'Choose the widgets you need', 'sky-elementor-addons' ),
1240 'desc' => esc_html__( 'Turn off what you will not use — fewer widgets means smaller CSS and JS.', 'sky-elementor-addons' ),
1241 'done' => ! empty( $inactive ),
1242 'tab' => 'widgets',
1243 ],
1244 [
1245 'id' => 'assets',
1246 'label' => esc_html__( 'Pick an asset delivery mode', 'sky-elementor-addons' ),
1247 'desc' => esc_html__( 'Auto Optimize builds a bundle from only the widgets you actually use.', 'sky-elementor-addons' ),
1248 'done' => isset( $advanced['asset_manager'] ),
1249 'tab' => 'advanced',
1250 ],
1251 [
1252 'id' => 'theme_builder',
1253 'label' => esc_html__( 'Build a header or footer', 'sky-elementor-addons' ),
1254 'desc' => esc_html__( 'Theme Builder replaces your theme template parts with Elementor designs.', 'sky-elementor-addons' ),
1255 'done' => $this->count_records( 'wowdevs-hooks', 'wowdevs_theme_builder_status' )['total'] > 0,
1256 'tab' => 'theme_builder',
1257 ],
1258 [
1259 'id' => 'build',
1260 'label' => esc_html__( 'Place your first Sky widget', 'sky-elementor-addons' ),
1261 'desc' => esc_html__( 'Open any page in Elementor and search for a Sky widget in the panel.', 'sky-elementor-addons' ),
1262 'done' => $has_usage,
1263 'url' => esc_url( admin_url( 'post-new.php?post_type=page' ) ),
1264 ],
1265 ],
1266 ];
1267 }
1268
1269 /**
1270 * Release notes for both plugins, read from their own changelog.txt files so
1271 * the dashboard can never drift from what actually shipped.
1272 *
1273 * @since 4.0.0
1274 * @return array {core: array|null, pro: array|null}
1275 */
1276 private function get_whats_new() {
1277 $pro_version = defined( 'SKY_ADDONS_PRO_VERSION' ) ? SKY_ADDONS_PRO_VERSION : '';
1278 $cache_key = 'sky_addons_whats_new';
1279 $stamp = SKY_ADDONS_VERSION . '|' . $pro_version;
1280
1281 $cached = get_transient( $cache_key );
1282 if ( is_array( $cached ) && ( $cached['stamp'] ?? '' ) === $stamp ) {
1283 return $cached;
1284 }
1285
1286 $whats_new = [
1287 'stamp' => $stamp,
1288 'core' => $this->parse_changelog( SKY_ADDONS_PATH . 'changelog.txt', SKY_ADDONS_VERSION ),
1289 'pro' => defined( 'SKY_ADDONS_PRO_PATH' )
1290 ? $this->parse_changelog( SKY_ADDONS_PRO_PATH . 'changelog.txt', $pro_version )
1291 : null,
1292 ];
1293
1294 set_transient( $cache_key, $whats_new, DAY_IN_SECONDS );
1295
1296 return $whats_new;
1297 }
1298
1299 /**
1300 * Pull one release block out of a changelog.txt.
1301 *
1302 * Prefers the block matching the installed version — the newest block is often
1303 * an unreleased "[WIP]" section, which nobody running the plugin has yet.
1304 * Falls back to the newest released block, then to whatever is on top.
1305 *
1306 * @since 4.0.0
1307 * @param string $file Absolute path to a changelog.txt.
1308 * @param string $installed Installed version of that plugin.
1309 * @return array|null
1310 */
1311 private function parse_changelog( $file, $installed ) {
1312 if ( ! file_exists( $file ) || ! is_readable( $file ) ) {
1313 return null;
1314 }
1315
1316 $handle = fopen( $file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1317 if ( ! $handle ) {
1318 return null;
1319 }
1320
1321 $blocks = [];
1322 $current = null;
1323
1324 while ( ( $line = fgets( $handle ) ) !== false ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition
1325 $line = trim( $line );
1326
1327 if ( '' === $line ) {
1328 continue;
1329 }
1330
1331 // Version heading: "= 4.0.0 [WIP] =" or "= 3.8.3 [1st June 2026] =".
1332 if ( preg_match( '/^=\s*([0-9.]+)\s*(?:\[([^\]]*)\])?/', $line, $match ) ) {
1333 if ( $current ) {
1334 $blocks[] = $current;
1335 }
1336
1337 $tag = isset( $match[2] ) ? trim( $match[2] ) : '';
1338
1339 $current = [
1340 'version' => $match[1],
1341 'date' => ( '' !== $tag && false === stripos( $tag, 'wip' ) ) ? $tag : '',
1342 'unreleased' => '' === $tag || false !== stripos( $tag, 'wip' ),
1343 'items' => [],
1344 ];
1345
1346 // Cap the scan; a site more than a few releases behind falls back to
1347 // the newest released block rather than reading the whole file.
1348 if ( count( $blocks ) >= 6 ) {
1349 break;
1350 }
1351
1352 continue;
1353 }
1354
1355 if ( $current && 0 === strpos( $line, '*' ) && count( $current['items'] ) < 6 ) {
1356 $entry = trim( ltrim( $line, '*' ) );
1357 if ( '' !== $entry ) {
1358 $current['items'][] = $entry;
1359 }
1360 }
1361 }
1362
1363 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1364
1365 if ( $current ) {
1366 $blocks[] = $current;
1367 }
1368
1369 // A block with no bullets is useless to the UI — and the scan cap above can
1370 // leave the last one empty, which would otherwise blank the What's New card
1371 // for anyone whose installed version happens to land on it.
1372 $blocks = array_values(
1373 array_filter(
1374 $blocks,
1375 function ( $block ) {
1376 return ! empty( $block['items'] );
1377 }
1378 )
1379 );
1380
1381 if ( ! $blocks ) {
1382 return null;
1383 }
1384
1385 foreach ( $blocks as $block ) {
1386 if ( $installed && version_compare( $block['version'], $installed, '==' ) ) {
1387 return $block;
1388 }
1389 }
1390
1391 foreach ( $blocks as $block ) {
1392 if ( ! $block['unreleased'] ) {
1393 return $block;
1394 }
1395 }
1396
1397 return $blocks[0];
1398 }
1399
1400 /**
1401 * Turn off every widget that is enabled but not placed on any page.
1402 *
1403 * Runs server-side so the set is computed from live usage data at the moment
1404 * of the click, rather than from a list the browser fetched minutes earlier.
1405 *
1406 * @since 4.0.0
1407 * @return array
1408 */
1409 public function disable_idle_widgets() {
1410 $elements = Sky_Addons_Admin::get_element_list();
1411 $pro_active = (bool) apply_filters( 'sky_addons_pro_init', false );
1412
1413 // Integrations are widgets too, and the dashboard counts them in the idle
1414 // figure — so the action has to sweep their option key as well, or the number
1415 // on screen would never reach zero.
1416 $groups = [
1417 self::WIDGETS_DB_KEY => (array) ( $elements['sky_addons_widgets'] ?? [] ),
1418 self::WIDGETS_3RD_PARTY_DB_KEY => (array) ( $elements['sky_addons_3rd_party_widget'] ?? [] ),
1419 ];
1420
1421 $pending = [];
1422 $disabled = 0;
1423
1424 foreach ( $groups as $option_key => $items ) {
1425 $values = [];
1426 $group_count = 0;
1427
1428 foreach ( $items as $item ) {
1429 $is_on = ! isset( $item['value'] ) || 'on' === $item['value'];
1430 $used = ! empty( $item['total_used'] );
1431 $is_local = ! isset( $item['feature_type'] ) || 'pro' !== $item['feature_type'];
1432
1433 // save_options() rebuilds the inactive list from what it is handed, so
1434 // already-disabled widgets must be repeated or they would switch back on.
1435 if ( ! $is_on ) {
1436 $values[ $item['name'] ] = 'off';
1437 continue;
1438 }
1439
1440 // On a free install every Pro widget is unused by definition. Sweeping
1441 // them into the inactive list would hide them from the panel and, worse,
1442 // keep them hidden after the user upgrades. Leave their state untouched —
1443 // this mirrors the Pro guard the Widgets tab already enforces on click.
1444 if ( ! $pro_active && ! $is_local ) {
1445 continue;
1446 }
1447
1448 if ( $used ) {
1449 continue; // Enabled and in use — leave it alone.
1450 }
1451
1452 $values[ $item['name'] ] = 'off';
1453 ++$group_count;
1454 }
1455
1456 // Only write a group that actually changed — an untouched option key must
1457 // stay untouched, and each save can dispatch a bundle rebuild.
1458 if ( $group_count > 0 ) {
1459 $pending[ $option_key ] = $values;
1460 $disabled += $group_count;
1461 }
1462 }
1463
1464 if ( ! $disabled ) {
1465 return [
1466 'status' => 'error',
1467 'title' => esc_html__( 'Nothing to disable.', 'sky-elementor-addons' ),
1468 'msg' => esc_html__( 'Every enabled widget is already in use on your site.', 'sky-elementor-addons' ),
1469 ];
1470 }
1471
1472 $result = [ 'status' => 'error' ];
1473
1474 foreach ( $pending as $option_key => $values ) {
1475 $saved = $this->save_options( $option_key, $values );
1476
1477 // One successful write is enough to report success overall.
1478 if ( 'success' === ( $saved['status'] ?? '' ) ) {
1479 $result = $saved;
1480 }
1481 }
1482
1483 if ( 'success' === ( $result['status'] ?? '' ) ) {
1484 $result['title'] = esc_html__( 'Idle widgets disabled.', 'sky-elementor-addons' );
1485 /* translators: %d: number of widgets that were turned off. */
1486 $result['msg'] = sprintf( esc_html__( '%d widgets that are not used on any page have been turned off.', 'sky-elementor-addons' ), $disabled );
1487 }
1488
1489 return $result;
1490 }
1491
1492 /**
1493 * Hide the getting started checklist for the current user.
1494 *
1495 * @since 4.0.0
1496 * @return array
1497 */
1498 public function dismiss_getting_started() {
1499 update_user_meta( get_current_user_id(), 'sky_addons_getting_started_done', 1 );
1500
1501 return [
1502 'status' => 'success',
1503 'title' => esc_html__( 'Hidden.', 'sky-elementor-addons' ),
1504 'msg' => esc_html__( 'The getting started checklist has been hidden.', 'sky-elementor-addons' ),
1505 ];
1506 }
1507 }
1508