PluginProbe
Cool Timeline (Horizontal & Vertical Timeline) / trunk
Cool Timeline (Horizontal & Vertical Timeline) vtrunk
3.4.0 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 2.2.3 2.3.3 2.4 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.6.1 2.7.1 2.8.3 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 All 79 releases
cool-timeline / admin / cp-onboarding / framework / src / class-framework.php

class-framework.php in Cool Timeline (Horizontal & Vertical Timeline) trunk, at admin/cp-onboarding/framework/src/class-framework.php

544 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable WordPress.WP.I18n.TextDomainMismatch,WordPress.NamingConventions.PrefixAllGlobals -- Framework uses runtime text domains and plugin-prefixed dynamic hooks.
3 /**
4 * Onboarding framework — admin page, assets, AJAX.
5 *
6 * One instance per plugin. All plugin-specific behaviour comes from the
7 * injected Config, so the same code serves every Cool Plugins product and
8 * every Free/Pro, Full/Liter edition.
9 *
10 * @package CoolPlugins\Onboarding
11 */
12
13 namespace CoolPlugins\Onboarding;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Framework controller.
21 */
22 final class Framework {
23
24 /**
25 * @var Config
26 */
27 private $config;
28
29 /**
30 * @var Telemetry|null
31 */
32 private $telemetry = null;
33
34 /**
35 * @param Config $config Plugin config.
36 */
37 public function __construct( Config $config ) {
38 $this->config = $config;
39
40 if ( $config->telemetry_enabled() ) {
41 $this->telemetry = new Telemetry( $config );
42 }
43 }
44
45 /**
46 * Register hooks. Safe to call once per plugin.
47 *
48 * @return void
49 */
50 public function init() {
51 add_action( 'admin_menu', array( $this, 'register_submenu' ), 9 );
52 add_action( 'admin_init', array( $this, 'maybe_set_admin_page_title' ) );
53
54 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
55
56 add_action(
57 'wp_ajax_' . $this->config->ajax_action( 'prepare' ),
58 array( $this, 'ajax_prepare' )
59 );
60
61 add_action(
62 'wp_ajax_' . $this->config->ajax_action( 'install' ),
63 array( $this, 'ajax_install' )
64 );
65
66 if ( $this->telemetry ) {
67 add_action(
68 'wp_ajax_' . $this->config->ajax_action( 'track' ),
69 array( $this, 'ajax_track' )
70 );
71 }
72 }
73
74 /**
75 * Page slug for this plugin's onboarding screen.
76 *
77 * @return string
78 */
79 public function page_slug() {
80 return $this->config->slug() . '-getting-started';
81 }
82
83 /**
84 * Register the "Getting Started" submenu.
85 *
86 * @return void
87 */
88 public function register_submenu() {
89
90 $menu_title = $this->config->page( 'menu_title' );
91 if ( '' === $menu_title ) {
92 $menu_title = 'Getting Started';
93 }
94
95 // Surface the page under the plugin's main menu when configured; fall back
96 // to an orphan (URL-accessible) page when no parent slug is provided.
97 $parent = $this->config->parent_slug();
98 if ( '' === $parent ) {
99 $parent = null;
100 }
101
102 $hook = add_submenu_page(
103 $parent,
104 $menu_title,
105 $menu_title,
106 $this->config->capability(),
107 $this->page_slug(),
108 array( $this, 'render_page' )
109 );
110
111 if ( $hook ) {
112 add_action( 'load-' . $hook, array( $this, 'set_admin_page_title' ) );
113 }
114 }
115
116 /**
117 * Set global $title before admin-header.php.
118 *
119 * Orphan pages (no parent slug) are not in the top-level $menu, so WordPress
120 * cannot resolve a title and passes null to strip_tags() in admin-header.php.
121 * admin_init runs before admin-header regardless of the resolved page hook.
122 *
123 * @return void
124 */
125 public function maybe_set_admin_page_title() {
126 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen detection.
127 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
128 if ( $this->page_slug() !== $page ) {
129 return;
130 }
131
132 global $title;
133
134 if ( ! empty( $title ) ) {
135 return;
136 }
137
138 $this->set_admin_page_title();
139 }
140
141 /**
142 * Assign the onboarding screen title to the global $title.
143 *
144 * @return void
145 */
146 public function set_admin_page_title() {
147 global $title;
148
149 $menu_title = $this->config->page( 'menu_title' );
150 if ( empty( $menu_title ) ) {
151 $menu_title = 'Getting Started';
152 }
153
154 $title = $menu_title;
155 }
156
157
158
159 /**
160 * Enqueue CSS/JS only on this plugin's onboarding screen.
161 *
162 * @param string $hook_suffix Current screen.
163 * @return void
164 */
165 public function enqueue_assets( $hook_suffix ) {
166 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- screen detection only.
167 $current = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
168
169 $on_screen = ( $this->config->parent_slug() . '_page_' . $this->page_slug() === $hook_suffix )
170 || ( $this->page_slug() === $current );
171
172 if ( ! $on_screen ) {
173 return;
174 }
175
176 $base = $this->config->plugin_url() . 'admin/cp-onboarding/framework/assets/';
177 $ver = $this->config->version();
178
179 wp_enqueue_style( 'dashicons' );
180 wp_enqueue_style( $this->config->handle(), $base . 'onboarding.css', array(), $ver );
181
182 // Inline the brand colors so a single shared stylesheet themes per plugin.
183 $colors = $this->config->colors();
184 $root = sprintf(
185 '.wrap.cpo-onboarding-page{--cpo-primary:%1$s;--cpo-primary-dark:%2$s;}',
186 esc_html( $colors['primary'] ),
187 esc_html( $colors['primary_dark'] )
188 );
189 wp_add_inline_style( $this->config->handle(), $root );
190
191 wp_enqueue_script( $this->config->handle(), $base . 'onboarding.js', array(), $ver, true );
192
193 wp_localize_script( $this->config->handle(), $this->config->js_global(), $this->script_data() );
194 }
195
196 /**
197 * Data passed to the front-end script. All strings are pre-translated by
198 * the plugin's text domain (the framework never owns a text domain).
199 *
200 * @return array
201 */
202 private function script_data() {
203 $td = $this->config->text_domain();
204
205 $data = array(
206 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
207 'action' => $this->config->ajax_action( 'prepare' ),
208 'nonce' => wp_create_nonce( $this->config->option( 'prepare' ) ),
209 'labels' => array(
210 'loading' => __( 'Please wait…', 'default' ),
211 'redirecting' => __( 'Redirecting…', 'default' ),
212 ),
213 );
214
215 // Allow the plugin to override/extend labels in its own text domain.
216 $data['labels'] = wp_parse_args(
217 (array) apply_filters( $this->config->prefix() . '_onboarding_labels', array(), $td ),
218 $data['labels']
219 );
220
221 // Default wiring for the 'manually' (custom AJAX) addon install button.
222 // Points at the framework's built-in handler so it works out of the box;
223 // a plugin can still override 'action'/'labels' via the script-data filter
224 // below (e.g. to use its own richer handler).
225 $data['install'] = array(
226 'action' => $this->config->ajax_action( 'install' ),
227 'labels' => array(
228 'installing' => __( 'Installing…', 'default' ),
229 'activating' => __( 'Activating…', 'default' ),
230 'activated' => __( 'Activated', 'default' ),
231 'setupGuide' => __( 'Check Setup Guide', 'default' ),
232 'error' => __( 'Plugin could not be installed. Please try again.', 'default' ),
233 ),
234 );
235
236 if ( $this->telemetry ) {
237 $data['track'] = array(
238 'action' => $this->config->ajax_action( 'track' ),
239 'nonce' => wp_create_nonce( $this->config->option( 'track' ) ),
240 );
241 }
242
243 // Static fallbacks per method (used if AJAX fails). Pulled from config.
244 $redirects = array();
245 foreach ( $this->config->methods() as $method ) {
246 if ( ! empty( $method['type'] ) && ! empty( $method['fallback_url'] ) ) {
247 $redirects[ $method['type'] ] = esc_url_raw( $method['fallback_url'] );
248 }
249 }
250 $data['redirects'] = $redirects;
251
252 /**
253 * Final chance for a plugin to adjust localized data.
254 *
255 * @param array $data Script data.
256 * @param Config $config Plugin config.
257 */
258 return apply_filters( $this->config->prefix() . '_onboarding_script_data', $data, $this->config );
259 }
260
261 /**
262 * Render the onboarding page via the shared view.
263 *
264 * @return void
265 */
266 public function render_page() {
267 if ( ! current_user_can( $this->config->capability() ) ) {
268 wp_die( esc_html__( 'You do not have permission to access this page.', 'default' ) );
269 }
270
271 if ( $this->telemetry ) {
272 $this->telemetry->seed_selection();
273 }
274
275 $config = $this->config; // Exposed to the view.
276 $telemetry = $this->telemetry; // Exposed to the view.
277
278 include CPO_ONBOARDING_DIR . '/views/onboarding-page.php';
279 }
280
281 /**
282 * AJAX: record a telemetry event. Always returns empty success.
283 *
284 * @return void
285 */
286 public function ajax_track() {
287 check_ajax_referer( $this->config->option( 'track' ), 'nonce' );
288
289 if ( ! current_user_can( $this->config->capability() ) || ! $this->telemetry ) {
290 wp_send_json_success();
291 }
292
293 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
294 $event = isset( $_POST['event'] ) ? sanitize_key( wp_unslash( $_POST['event'] ) ) : '';
295 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
296 $bucket = isset( $_POST['bucket'] ) ? sanitize_key( wp_unslash( $_POST['bucket'] ) ) : '';
297
298 if ( 'method_picked' === $event && '' !== $bucket ) {
299 $this->telemetry->set_selection( $bucket );
300 update_option( $this->config->option( 'method' ), $bucket, false );
301 }
302
303 $this->telemetry->track( $event, $bucket );
304
305 wp_send_json_success();
306 }
307
308 /**
309 * AJAX: prepare the selected method.
310 *
311 * - A method with a demo config runs the generator and returns a URL.
312 * - Otherwise returns the method's configured redirect/fallback URL.
313 *
314 * @return void
315 */
316 public function ajax_prepare() {
317 check_ajax_referer( $this->config->option( 'prepare' ), 'nonce' );
318
319 if ( ! current_user_can( $this->config->capability() ) ) {
320 wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'default' ) ), 403 );
321 }
322
323 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
324 $type = isset( $_POST['method_type'] ) ? sanitize_key( wp_unslash( $_POST['method_type'] ) ) : '';
325
326 $method = $this->find_method_by_type( $type );
327 if ( ! $method ) {
328 wp_send_json_error( array( 'message' => __( 'Invalid selection.', 'default' ) ), 400 );
329 }
330
331 // Method that just redirects (no demo generation).
332 if ( empty( $method['demo'] ) ) {
333 $url = ! empty( $method['redirect_url'] )
334 ? esc_url_raw( $method['redirect_url'] )
335 : ( ! empty( $method['fallback_url'] ) ? esc_url_raw( $method['fallback_url'] ) : '' );
336
337 if ( '' === $url ) {
338 wp_send_json_error( array( 'message' => __( 'No destination configured.', 'default' ) ), 400 );
339 }
340
341 wp_send_json_success(
342 array(
343 'redirectUrl' => $url,
344 'type' => $type,
345 )
346 );
347 }
348
349 // Demo-generating method.
350 $demo_config = $this->config->demo();
351 if ( empty( $demo_config ) ) {
352 wp_send_json_error( array( 'message' => __( 'Demo generator is unavailable.', 'default' ) ), 500 );
353 }
354 if ( ! class_exists( __NAMESPACE__ . '\\Demo_Generator' ) ) {
355 wp_send_json_error( array( 'message' => __( 'Demo generator is unavailable.', 'default' ) ), 500 );
356 }
357 $generator = new Demo_Generator( $this->config, $demo_config );
358 $result = $generator->generate();
359
360 if ( is_wp_error( $result ) ) {
361 wp_send_json_error(
362 array(
363 'message' => $result->get_error_message(),
364 'code' => $result->get_error_code(),
365 ),
366 500
367 );
368 }
369
370 $redirect = ! empty( $result['preview_url'] ) ? $result['preview_url'] : $result['redirect_url'];
371
372 wp_send_json_success(
373 array(
374 'redirectUrl' => $redirect,
375 'editUrl' => $result['redirect_url'],
376 'pageId' => (int) $result['page_id'],
377 'postIds' => array_map( 'absint', (array) $result['post_ids'] ),
378 'already' => ! empty( $result['already'] ),
379 'type' => $type,
380 )
381 );
382 }
383
384 /**
385 * AJAX: install (or activate) a configured cross-sell addon from WordPress.org.
386 *
387 * Only addons declared in config with the 'manually' install method are allowed
388 * (the slug allow-list is derived from config, never from the request). Reuses the
389 * standard WP.org install → activate flow. Plugins that need richer behaviour
390 * (e.g. Pro) point `data.install.action` at their own handler instead.
391 *
392 * @return void
393 */
394 public function ajax_install() {
395 check_ajax_referer( $this->config->option( 'install' ), 'wp_nonce' );
396
397 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
398 $slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : '';
399 if ( '' === $slug ) {
400 wp_send_json_error( array( 'errorMessage' => __( 'No plugin specified.', 'default' ) ), 400 );
401 }
402
403 if ( ! in_array( $slug, $this->installable_slugs(), true ) ) {
404 wp_send_json_error( array( 'errorMessage' => __( 'This plugin cannot be installed from here.', 'default' ) ), 403 );
405 }
406
407 if ( ! function_exists( 'get_plugins' ) ) {
408 require_once ABSPATH . 'wp-admin/includes/plugin.php';
409 }
410
411 // Already installed (including paid plugins not on wordpress.org): just activate.
412 foreach ( get_plugins() as $file => $data ) {
413 if ( dirname( $file ) !== $slug ) {
414 continue;
415 }
416 if ( ! current_user_can( 'activate_plugins' ) ) {
417 wp_send_json_error(
418 array( 'errorMessage' => __( 'Sorry, you are not allowed to activate plugins on this site.', 'default' ) ),
419 403
420 );
421 }
422 $this->activate_installed( array( 'file' => $file ) );
423 }
424
425 if ( ! current_user_can( 'install_plugins' ) ) {
426 wp_send_json_error(
427 array( 'errorMessage' => __( 'Sorry, you are not allowed to install plugins on this site.', 'default' ) ),
428 403
429 );
430 }
431
432 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
433 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
434
435 $api = plugins_api(
436 'plugin_information',
437 array(
438 'slug' => $slug,
439 'fields' => array( 'sections' => false ),
440 )
441 );
442
443 if ( is_wp_error( $api ) ) {
444 wp_send_json_error( array( 'errorMessage' => $api->get_error_message() ), 500 );
445 }
446
447 $skin = new \WP_Ajax_Upgrader_Skin();
448 $upgrader = new \Plugin_Upgrader( $skin );
449 $result = $upgrader->install( $api->download_link );
450
451 // Already installed: activate the existing copy instead of failing.
452 if ( is_wp_error( $skin->result ) && 'folder_exists' === $skin->result->get_error_code() ) {
453 $this->activate_installed( install_plugin_install_status( $api ) );
454 }
455
456 if ( is_wp_error( $result ) ) {
457 wp_send_json_error( array( 'errorMessage' => $result->get_error_message() ), 500 );
458 }
459
460 if ( is_wp_error( $skin->result ) ) {
461 wp_send_json_error( array( 'errorMessage' => $skin->result->get_error_message() ), 500 );
462 }
463
464 if ( $skin->get_errors()->has_errors() ) {
465 wp_send_json_error( array( 'errorMessage' => $skin->get_error_messages() ), 500 );
466 }
467
468 if ( null === $result ) {
469 wp_send_json_error(
470 array( 'errorMessage' => __( 'Unable to connect to the filesystem. Please confirm your credentials.', 'default' ) ),
471 500
472 );
473 }
474
475 $this->activate_installed( install_plugin_install_status( $api ) );
476 }
477
478 /**
479 * Activate a freshly installed plugin and return a JSON success response.
480 *
481 * @param array $install_status Result of install_plugin_install_status().
482 * @return void Sends a JSON response and exits.
483 */
484 private function activate_installed( $install_status ) {
485 $file = isset( $install_status['file'] ) ? $install_status['file'] : '';
486
487 if ( '' !== $file && current_user_can( 'activate_plugin', $file ) && is_plugin_inactive( $file ) ) {
488 $network_wide = is_multisite();
489 $activated = activate_plugin( $file, '', $network_wide );
490
491 if ( is_wp_error( $activated ) ) {
492 wp_send_json_error( array( 'errorMessage' => $activated->get_error_message() ), 500 );
493 }
494 }
495
496 wp_send_json_success( array( 'activated' => true ) );
497 }
498
499 /**
500 * Slugs that the built-in installer is allowed to install: configured addons
501 * whose install method resolves to 'manually'.
502 *
503 * @return string[]
504 */
505 private function installable_slugs() {
506 $slugs = array();
507
508 foreach ( Addons::resolve( $this->config->addons(), $this->config ) as $addon ) {
509 if ( 'manually' === $addon['install_method'] ) {
510 $slugs[] = $addon['slug'];
511 }
512 }
513
514 return $slugs;
515 }
516
517 /**
518 * Find a configured method by its public 'type'.
519 *
520 * @param string $type Method type.
521 * @return array|null
522 */
523 private function find_method_by_type( $type ) {
524 if ( '' === $type ) {
525 return null;
526 }
527 foreach ( $this->config->methods() as $method ) {
528 if ( isset( $method['type'] ) && $method['type'] === $type ) {
529 return $method;
530 }
531 }
532 return null;
533 }
534
535 /**
536 * Accessor for the config (used by integrations).
537 *
538 * @return Config
539 */
540 public function config() {
541 return $this->config;
542 }
543 }
544