Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.35% covered (warning)
63.35%
178 / 281
34.04% covered (danger)
34.04%
16 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
Launchpad_Task_Lists
63.35% covered (warning)
63.35%
178 / 281
34.04% covered (danger)
34.04%
16 / 47
1105.27
0.00% covered (danger)
0.00%
0 / 1
 get_instance
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 register_task_list
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 register_task
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 unregister_task_list
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 unregister_task
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 get_task_list
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_task_list_enabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_task_list_dismissed
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_task_list_dismissible
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 set_task_list_dismissed
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 set_task_list_dismissed_until
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 is_temporally_dismissed
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 get_task_list_dismissed_status
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_task_list_dismissed_until
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 has_task_lists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_all_task_lists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_task
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_required_task_ids
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_require_last_task_completion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_task_list_completed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_all_tasks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 is_visible
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 build_task
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 load_value_from_callback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 load_extra_data
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 load_title
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 load_subtitle
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 load_repetition_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 load_calypso_path
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 is_valid_admin_url_or_absolute_path
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 is_task_disabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_task_complete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_task_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_task_id_complete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 validate_task_list
83.78% covered (warning)
83.78%
31 / 37
0.00% covered (danger)
0.00%
0 / 1
13.72
 get_active_tasks
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_completed_tasks
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 has_active_tasks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_hooks_for_active_tasks
14.29% covered (danger)
14.29%
2 / 14
0.00% covered (danger)
0.00%
0 / 1
87.20
 mark_task_complete
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 mark_task_complete_if_active
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_disable_fullscreen_launchpad
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 validate_task
61.11% covered (warning)
61.11%
11 / 18
0.00% covered (danger)
0.00%
0 / 1
20.47
 is_fullscreen_launchpad_enabled
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 disable_fullscreen_launchpad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_task_list_title
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Launchpad Task Lists Registry
4 *
5 * @package automattic/jetpack-mu-wpcom
6 * @since 1.5.0
7 */
8
9// Type aliases used in a bunch of places in this file. Unfortunately Phan doesn't have a way to set these more globally than copy-pasting them into each file needing them.
10<<<'PHAN'
11@phan-type Task_List = array{id:string, task_ids:string[], required_task_ids?:string[], visible_tasks_callback?:callable, require_last_task_completion?:bool, get_title?:callable, is_dismissible?:bool, is_enabled_callback?:callable}
12@phan-type Task = array{id:string, title?:string, get_title?:callable, id_map?:string, add_listener_callback?:callable, badge_text_callback?:callable, extra_data_callback?:callable, get_calypso_path?:callable, is_complete_callback?:callable, is_disabled_callback?:callable, isLaunchTask?:bool, is_visible_callback?:callable, target_repetitions?:int, repetition_count_callback?:callable, subtitle?:callable, completed?:bool}
13PHAN;
14
15/**
16 * Launchpad Task List
17 *
18 * This file provides a Launchpad Task List class that manages the current list
19 * of Launchpad checklists that are available to be used.
20 *
21 * @package automattic/jetpack-mu-wpcom
22 */
23class Launchpad_Task_Lists {
24    /**
25     * Internal storage for registered Launchpad Task Lists
26     *
27     * @var Task_List[]
28     */
29    private $task_list_registry = array();
30
31    /**
32     * Internal storage for registered Launchpad Task Lists
33     *
34     * @var Task[]
35     */
36    private $task_registry = array();
37
38    /**
39     * Internal reference for the current site slug.
40     *
41     * @var string|null
42     */
43    private $site_slug = null;
44
45    /**
46     * Singleton instance
47     *
48     * @var Launchpad_Task_Lists
49     */
50    private static $instance = null;
51
52    /**
53     * Get the singleton instance
54     *
55     * @return Launchpad_Task_Lists
56     */
57    public static function get_instance() {
58        if ( ! self::$instance ) {
59            self::$instance = new self();
60        }
61
62        return self::$instance;
63    }
64
65    /**
66     * Register a new Launchpad Task List
67     *
68     * @param Task_List $task_list Task List definition.
69     *
70     * @return bool True if successfully registered, false if not.
71     */
72    public function register_task_list( $task_list = array() ) {
73        if ( self::validate_task_list( $task_list ) !== null ) {
74            return false;
75        }
76
77        // If no is_completed_callback is set, use the default.
78        if ( ! isset( $task_list['is_completed_callback'] ) ) {
79            $task_list['is_completed_callback'] = 'wpcom_default_launchpad_task_list_completed';
80        }
81
82        $this->task_list_registry[ $task_list['id'] ] = $task_list;
83        return true;
84    }
85
86    /**
87     * Register a new Launchpad Task
88     *
89     * @param Task $task Task definition.
90     *
91     * @return bool True if successful, false if not.
92     */
93    public function register_task( $task = array() ) {
94        if ( ! static::validate_task( $task ) ) {
95            return false;
96        }
97        // TODO: Handle duplicate tasks
98        $this->task_registry[ $task['id'] ] = $task;
99        return true;
100    }
101
102    /**
103     * Unregister a Launchpad Task List
104     *
105     * @param string $id Task List id.
106     *
107     * @return bool True if successfully unregistered, false if not found.
108     */
109    public function unregister_task_list( $id ) {
110        if ( ! array_key_exists( $id, $this->task_list_registry ) ) {
111            return false;
112        }
113
114        unset( $this->task_list_registry[ $id ] );
115        return true;
116    }
117
118    /**
119     * Unregister a Launchpad Task
120     *
121     * @param string $id Task id.
122     *
123     * @return bool True if successful, false if not.
124     */
125    public function unregister_task( $id ) {
126        if ( ! array_key_exists( $id, $this->task_registry ) ) {
127            return false;
128        }
129
130        unset( $this->task_registry[ $id ] );
131        return true;
132    }
133
134    /**
135     * Get a Launchpad Task List definition
136     *
137     * @param string $id Task List id.
138     *
139     * @return Task_List Task List.
140     */
141    protected function get_task_list( $id ) {
142        if ( ! array_key_exists( $id, $this->task_list_registry ) ) {
143            return array();
144        }
145
146        return $this->task_list_registry[ $id ];
147    }
148
149    /**
150     * Check if a task list is enabled by checking its is_enabled_callback callback.
151     *
152     * @param string $id Task List id.
153     * @return bool|null True if enabled, false if not, null if not found.
154     */
155    public function is_task_list_enabled( $id ) {
156        $task_list = $this->get_task_list( $id );
157
158        return $this->load_value_from_callback( $task_list, 'is_enabled_callback', null );
159    }
160
161    /**
162     * Check if a task list was dismissed by the user.
163     *
164     * @param string $id Task List id.
165     * @return bool|null True if dismissed, false if not.
166     */
167    public function is_task_list_dismissed( $id ) {
168        $task_list_dismissed_status = $this->get_task_list_dismissed_status();
169        $is_dismissed               = isset( $task_list_dismissed_status[ $id ] ) && true === $task_list_dismissed_status[ $id ];
170
171        // Return true if the task list is on the dismissed status array and its value is true.
172        return $is_dismissed || $this->is_temporally_dismissed( $id );
173    }
174
175    /**
176     * Check if a task list is dismissible.
177     *
178     * @param string $id Task List id.
179     * @return bool True if dismissible, false if not.
180     */
181    public function is_task_list_dismissible( $id ) {
182        $task_list = $this->get_task_list( $id );
183        if ( ! isset( $task_list['is_dismissible'] ) ) {
184            return false;
185        }
186        return $task_list['is_dismissible'];
187    }
188
189    /**
190     * Set wether a task list is dismissed or not for a site.
191     *
192     * @param string $id Task List id.
193     * @param bool   $is_dismissed True if dismissed, false if not.
194     */
195    public function set_task_list_dismissed( $id, $is_dismissed ) {
196        $task_list        = $this->get_task_list( $id );
197        $launchpad_config = get_option( 'wpcom_launchpad_config', array() );
198
199        if ( empty( $id ) || empty( $task_list ) ) {
200            return;
201        }
202
203        $task_list_dismissed_status = $this->get_task_list_dismissed_status();
204        $is_dismissed               = (bool) $is_dismissed;
205
206        if ( $is_dismissed ) {
207            $task_list_dismissed_status[ $id ] = true;
208        } else {
209            unset( $task_list_dismissed_status[ $id ] );
210        }
211
212        $launchpad_config['task_list_dismissed_status'] = $task_list_dismissed_status;
213        update_option( 'wpcom_launchpad_config', $launchpad_config );
214    }
215
216    /**
217     * Set the date until a task list is dismissed.
218     *
219     * @param string $checklist_slug Checklist slug.
220     * @param int    $dismissed_until Timestamp with the date until the task list is dismissed.
221     */
222    public function set_task_list_dismissed_until( $checklist_slug, $dismissed_until ) {
223
224        if ( empty( $checklist_slug ) ) {
225            return;
226        }
227
228        $task_list_dismissed_until = $this->get_task_list_dismissed_until();
229
230        if ( isset( $dismissed_until ) ) {
231            $task_list_dismissed_until[ $checklist_slug ] = $dismissed_until;
232        } else {
233            unset( $task_list_dismissed_until[ $checklist_slug ] );
234        }
235
236        $launchpad_config                              = get_option( 'wpcom_launchpad_config', array() );
237        $launchpad_config['task_list_dismissed_until'] = $task_list_dismissed_until;
238
239        update_option( 'wpcom_launchpad_config', $launchpad_config );
240    }
241
242    /**
243     * Returns true if the task list is temporally dismissed.
244     *
245     * @param string $checklist_slug Checklist slug.
246     * @return bool True if temporally dismissed, false if not.
247     */
248    protected function is_temporally_dismissed( $checklist_slug ): bool {
249        $task_list_dismissed_until = $this->get_task_list_dismissed_until();
250
251        if ( ! isset( $task_list_dismissed_until ) || ! isset( $task_list_dismissed_until[ $checklist_slug ] ) ) {
252            return false;
253        }
254
255        $task_list_dismissed_until = $task_list_dismissed_until[ $checklist_slug ];
256        $current_time              = new DateTime( 'now', new DateTimeZone( 'UTC' ) );
257        $dismissed_until           = new DateTime( '@' . $task_list_dismissed_until, new DateTimeZone( 'UTC' ) );
258
259        return $current_time <= $dismissed_until;
260    }
261
262    /**
263     * Get the task list visibility status for a site.
264     *
265     * @return array
266     */
267    protected function get_task_list_dismissed_status() {
268        $launchpad_config = get_option( 'wpcom_launchpad_config', array() );
269        if ( ! isset( $launchpad_config['task_list_dismissed_status'] ) || ! is_array( $launchpad_config['task_list_dismissed_status'] ) ) {
270            return array();
271        }
272
273        return $launchpad_config['task_list_dismissed_status'];
274    }
275
276    /**
277     * Get the task list dismissed until date when available.
278     *
279     * @return array
280     */
281    public function get_task_list_dismissed_until() {
282        $launchpad_config = get_option( 'wpcom_launchpad_config', array() );
283
284        if ( ! isset( $launchpad_config['task_list_dismissed_until'] ) || ! is_array( $launchpad_config['task_list_dismissed_until'] ) ) {
285            return array();
286        }
287
288        return $launchpad_config['task_list_dismissed_until'];
289    }
290
291    /**
292     * See if the task list registry has any task lists.
293     *
294     * @return bool True if there are task lists, false if not.
295     */
296    public function has_task_lists() {
297        return is_countable( $this->task_list_registry ) && count( $this->task_list_registry ) > 0;
298    }
299
300    /**
301     * Get all registered Launchpad Task Lists.
302     *
303     * @return Task_List[] All registered Launchpad Task Lists.
304     */
305    public function get_all_task_lists() {
306        return $this->task_list_registry;
307    }
308
309    /**
310     * Get a Launchpad Task definition
311     *
312     * @param string $id Task id.
313     *
314     * @return Task Task.
315     */
316    public function get_task( $id ) {
317        if ( ! array_key_exists( $id, $this->task_registry ) ) {
318            return array();
319        }
320
321        return $this->task_registry[ $id ];
322    }
323
324    /**
325     * Get the required task ids for a given task list.
326     *
327     * @param string $task_list_id Task list ID.
328     * @return array Required task ids.
329     */
330    public function get_required_task_ids( $task_list_id ) {
331        $task_list = $this->get_task_list( $task_list_id );
332        if ( ! isset( $task_list['required_task_ids'] ) ) {
333            return array();
334        }
335        return $task_list['required_task_ids'];
336    }
337
338    /**
339     * Check if the task list requires the last task to be completed in order to consider
340     * the task list complete.
341     *
342     * @param string $task_list_id Task list id.
343     * @return bool True if the last task must be completed, false if not.
344     */
345    public function get_require_last_task_completion( $task_list_id ) {
346        $task_list = $this->get_task_list( $task_list_id );
347        if ( ! isset( $task_list['require_last_task_completion'] ) ) {
348            return false;
349        }
350        return $task_list['require_last_task_completion'];
351    }
352
353    /**
354     * Check if a task list is completed.
355     *
356     * @param string $task_list_id Task list id.
357     * @return bool True if the task list is completed, false if not.
358     */
359    public function is_task_list_completed( $task_list_id ) {
360        $task_list = $this->get_task_list( $task_list_id );
361        return $this->load_value_from_callback( $task_list, 'is_completed_callback', false );
362    }
363
364    /**
365     * Get all registered Launchpad Tasks.
366     *
367     * @return Task[] All registered Launchpad Tasks.
368     */
369    public function get_all_tasks() {
370        return $this->task_registry;
371    }
372
373    /**
374     * Builds a collection of tasks for a given task list
375     *
376     * @param string      $id Task list id.
377     * @param string|null $launchpad_context Optional. Screen in which launchpad is loading.
378     *
379     * @return Task[] Collection of tasks associated with a task list.
380     */
381    public function build( $id, $launchpad_context = null ) {
382        $task_list           = $this->get_task_list( $id );
383        $tasks_for_task_list = array();
384
385        if ( empty( $task_list['task_ids'] ) ) {
386            return $tasks_for_task_list;
387        }
388
389        // Filter the task list's task ids to only include visible tasks if a callback is provided.
390        $task_ids = $this->load_value_from_callback( $task_list, 'visible_tasks_callback', $task_list['task_ids'] );
391
392        // Takes a registered task list, looks at its associated task ids,
393        // and returns a collection of associated tasks.
394        foreach ( $task_ids as $task_id ) {
395            $task_definition = $this->get_task( $task_id );
396
397            // if task can't be found don't add anything
398            if ( $this->is_visible( $task_definition, $launchpad_context ) ) {
399                $tasks_for_task_list[] = $this->build_task( $task_definition, $launchpad_context );
400            }
401        }
402
403        return $tasks_for_task_list;
404    }
405
406    /**
407     * Allows a function to be called to determine if a task should be visible.
408     * For instance: we don't even want to show the verify_email task if it's already done.
409     *
410     * Public so callers that build their own task lists from the catalog (e.g. the
411     * AI Launchpad REST controller) can apply the same visibility gate as build(),
412     * mirroring the public load_calypso_path().
413     *
414     * @param Task        $task_definition A task definition.
415     * @param string|null $launchpad_context Optional. Screen in which launchpad is loading.
416     * @return boolean True if task is visible, false if not.
417     */
418    public function is_visible( $task_definition, $launchpad_context = null ) {
419        if ( empty( $task_definition ) ) {
420            return false;
421        }
422
423        $data = array(
424            'launchpad_context' => $launchpad_context,
425        );
426
427        return $this->load_value_from_callback( $task_definition, 'is_visible_callback', true, $data );
428    }
429
430    /**
431     * Builds a single task with current state
432     *
433     * @param Task        $task Task definition.
434     * @param string|null $launchpad_context Optional. Screen where Launchpad is loading.
435     * @return Task Task with current state.
436     */
437    private function build_task( $task, $launchpad_context = null ) {
438        $built_task = array(
439            'id' => $task['id'],
440        );
441
442        $built_task['title']        = $this->load_title( $task );
443        $built_task['completed']    = $this->is_task_complete( $task );
444        $built_task['disabled']     = $this->is_task_disabled( $task );
445        $built_task['subtitle']     = $this->load_subtitle( $task );
446        $built_task['badge_text']   = $this->load_value_from_callback( $task, 'badge_text_callback' );
447        $built_task['isLaunchTask'] = $task['isLaunchTask'] ?? false;
448        $extra_data                 = $this->load_extra_data( $task );
449
450        if ( is_array( $extra_data ) && array() !== $extra_data ) {
451            $built_task['extra_data'] = $extra_data;
452        }
453
454        if ( isset( $task['target_repetitions'] ) ) {
455            $built_task['target_repetitions'] = $task['target_repetitions'];
456            $built_task['repetition_count']   = min( $this->load_repetition_count( $task ), $task['target_repetitions'] );
457        }
458
459        if ( isset( $task['get_calypso_path'] ) ) {
460            $calypso_path = $this->load_calypso_path( $task, $launchpad_context );
461
462            if ( ! empty( $calypso_path ) ) {
463                $built_task['calypso_path'] = $calypso_path;
464            }
465        }
466
467        return $built_task;
468    }
469
470    /**
471     * Given a task or task list definition and a possible callback, call it and return the value.
472     *
473     * @param Task|Task_List $item     The task or task list definition.
474     * @param string         $callback The callback to attempt to call.
475     * @param mixed          $default  The default value, passed to the callback if it exists.
476     * @param array          $data     Any additional data specific to the callback.
477     * @return mixed The value returned by the callback, or the default value.
478     */
479    private function load_value_from_callback( $item, $callback, $default = '', $data = array() ) {
480        if ( isset( $item[ $callback ] ) && is_callable( $item[ $callback ] ) ) {
481            return call_user_func_array( $item[ $callback ], array( $item, $default, $data ) );
482        }
483        return $default;
484    }
485
486    /**
487     * Loads any extra data for a task, calling the `extra_data_callback` callback to get the data if the callback is defined.
488     * Returns null if there is no callback or the callback returns an empty array or a non-array.
489     *
490     * @param Task $task A task definition.
491     * @return array|null The extra data for the task.
492     */
493    private function load_extra_data( $task ) {
494        $extra_data = $this->load_value_from_callback( $task, 'extra_data_callback' );
495        if ( is_array( $extra_data ) && array() !== $extra_data ) {
496            return $extra_data;
497        }
498
499        return null;
500    }
501
502    /**
503     * Loads a title for a task, calling the 'get_title' callback if it exists,
504     * or falling back on the value for the 'title' key if it is set.
505     * We prefer the callback so we can defer the translation until after the
506     * user's locale has been set up.
507     *
508     * @param Task $task A task definition.
509     * @return string The title for the task.
510     */
511    private function load_title( $task ) {
512        $title = $this->load_value_from_callback( $task, 'get_title' );
513        if ( ! empty( $title ) ) {
514            return $title;
515        }
516
517        if ( isset( $task['title'] ) ) {
518            return $task['title'];
519        }
520
521        return '';
522    }
523
524    /**
525     * Loads a subtitle for a task, calling the callback if it exists.
526     *
527     * @param Task $task A task definition.
528     * @return string The subtitle for the task.
529     */
530    private function load_subtitle( $task ) {
531        $subtitle = $this->load_value_from_callback( $task, 'subtitle' );
532        if ( ! empty( $subtitle ) ) {
533            return $subtitle;
534        }
535        // if it wasn't a callback, but still a string, return it.
536        if ( isset( $task['subtitle'] ) ) {
537            $task['subtitle'];
538        }
539        return '';
540    }
541
542    /**
543     * Loads the repetition count for a task, calling the callback if it exists.
544     *
545     * @param Task $task A task definition.
546     * @return int|null The repetition count for the task.
547     */
548    private function load_repetition_count( $task ) {
549        return $this->load_value_from_callback( $task, 'repetition_count_callback', 0 );
550    }
551
552    /**
553     * Helper function to load the Calypso path for a task.
554     *
555     * Public so other features (e.g. the AI Launchpad REST controller) can resolve
556     * a task's CTA path through the same builder + validation rather than copying
557     * it, and reuse this instance's cached site slug.
558     *
559     * @param Task        $task A task definition.
560     * @param string|null $launchpad_context Optional. Screen where Launchpad is loading.
561     * @return string|null
562     */
563    public function load_calypso_path( $task, $launchpad_context = null ) {
564        if ( null === $this->site_slug ) {
565            $this->site_slug = wpcom_get_site_slug();
566        }
567
568        $data = array(
569            'site_slug'         => $this->site_slug,
570            'site_slug_encoded' => rawurlencode( $this->site_slug ),
571            'launchpad_context' => $launchpad_context,
572        );
573
574        $calypso_path = $this->load_value_from_callback( $task, 'get_calypso_path', null, $data );
575
576        if ( ! is_string( $calypso_path ) ) {
577            return null;
578        }
579
580        if ( ! $this->is_valid_admin_url_or_absolute_path( $calypso_path ) ) {
581            return null;
582        }
583
584        return $calypso_path;
585    }
586
587    /**
588     * Checks if a string is a Stripe connection, valid admin URL, or absolute path.
589     *
590     * @param string $input The string to check.
591     * @return boolean
592     */
593    private function is_valid_admin_url_or_absolute_path( $input ) {
594        // Allow Stripe connection URLs for `set_up_payments` task.
595        if ( strpos( $input, 'https://connect.stripe.com' ) === 0 ) {
596            return true;
597        }
598
599        // Checks if the string is URL starting with the admin URL.
600        if ( strpos( $input, admin_url() ) === 0 ) {
601            return true;
602        }
603
604        // Require that the string start with a slash, but not two slashes.
605        if ( str_starts_with( $input, '/' ) && ! str_starts_with( $input, '//' ) ) {
606            return true;
607        }
608
609        return false;
610    }
611
612    /**
613     * Checks if a task is disabled
614     *
615     * @param Task $task Task definition.
616     * @return boolean
617     */
618    public function is_task_disabled( $task ) {
619        return $this->load_value_from_callback( $task, 'is_disabled_callback', false );
620    }
621
622    /**
623     * Checks if a task is complete, relying on task-defined callbacks if available
624     *
625     * @param Task $task Task definition.
626     * @return boolean
627     */
628    public function is_task_complete( $task ) {
629        // First we calculate the value from our statuses option. This will get passed to the callback, if it exists.
630        // Othewise there is the temptation for the callback to fall back to the option, which would cause infinite recursion
631        // as it continues to calculate the callback which falls back to the option: âˆž.
632        $statuses    = get_option( 'launchpad_checklist_tasks_statuses', array() );
633        $key         = $this->get_task_key( $task );
634        $is_complete = $statuses[ $key ] ?? false;
635
636        return (bool) $this->load_value_from_callback( $task, 'is_complete_callback', $is_complete );
637    }
638
639    /**
640     * Gets the task key, which is used to store and retrieve the task's status.
641     * Either the task's id_map or id is used.
642     *
643     * @param Task $task Task definition.
644     * @return string The task key to use.
645     */
646    public function get_task_key( $task ) {
647        return $task['id_map'] ?? $task['id'];
648    }
649
650    /**
651     * Checks if a task wight given ID is complete.
652     *
653     * @param string $task_id The task ID.
654     * @return boolean
655     */
656    public function is_task_id_complete( $task_id ) {
657        $task = $this->get_task( $task_id );
658        if ( empty( $task ) ) {
659            return false;
660        }
661        return $this->is_task_complete( $task );
662    }
663
664    /**
665     * Validate a Launchpad Task List
666     *
667     * @param Task_List $task_list Task List.
668     *
669     * @return null|WP_Error Null if valid, WP_Error if not.
670     */
671    public static function validate_task_list( $task_list ) {
672        $error_code     = 'validate_task_list';
673        $error_messages = array();
674
675        if ( ! is_array( $task_list ) ) {
676            // Ensure we have a valid task list array.
677            $msg = 'Invalid task list';
678            _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
679            return new WP_Error( $error_code, $msg );
680        }
681
682        if ( ! isset( $task_list['id'] ) ) {
683            // Ensure we have an id.
684            $msg = 'The Launchpad task list being registered requires a "id" attribute';
685            _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
686            $error_messages[] = $msg;
687        }
688
689        if ( ! isset( $task_list['task_ids'] ) ) {
690            // Ensure we have task_ids.
691            $msg = 'The Launchpad task list being registered requires a "task_ids" attribute';
692            _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
693            $error_messages[] = $msg;
694        } elseif ( isset( $task_list['required_task_ids'] ) ) {
695            // Ensure we have a valid array.
696            if ( ! is_array( $task_list['required_task_ids'] ) ) {
697                $msg = 'The required_task_ids attribute must be an array';
698                _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
699                $error_messages[] = $msg;
700                // Ensure all required tasks actually exist in the task list - we need the value to be an array for this to work.
701            } elseif ( array_intersect( $task_list['required_task_ids'], $task_list['task_ids'] ) !== $task_list['required_task_ids'] ) {
702                $msg = 'The required_task_ids must be a subset of the task_ids';
703                _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
704                $error_messages[] = $msg;
705            }
706        }
707
708        if ( isset( $task_list['visible_tasks_callback'] ) && ! is_callable( $task_list['visible_tasks_callback'] ) ) {
709            $msg = 'The visible_tasks_callback attribute must be callable';
710            _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
711            $error_messages[] = $msg;
712        }
713
714        if ( isset( $task_list['require_last_task_completion'] ) && ! is_bool( $task_list['require_last_task_completion'] ) ) {
715            $msg = 'The require_last_task_completion attribute must be a boolean';
716            _doing_it_wrong( 'validate_task_list', esc_html( $msg ), '6.1' );
717            $error_messages[] = $msg;
718        }
719
720        if ( array() !== $error_messages ) {
721            $wp_error = new WP_Error();
722
723            foreach ( $error_messages as $error_message ) {
724                $wp_error->add( $error_code, $error_message );
725            }
726
727            return $wp_error;
728        }
729
730        return null;
731    }
732
733    /**
734     * Get currently active tasks.
735     *
736     * @param string $task_list_id Optional. Will default to `site_intent` option.
737     * @return Task[] Array of active tasks.
738     */
739    private function get_active_tasks( $task_list_id = null ) {
740        $task_list_id = $task_list_id ? $task_list_id : get_option( 'site_intent' );
741        if ( ! $task_list_id ) {
742            return array();
743        }
744        $task_list = $this->get_task_list( $task_list_id );
745        if ( empty( $task_list ) ) {
746            return array();
747        }
748        $built_tasks = $this->build( $task_list_id );
749        // filter for incomplete tasks
750        return wp_list_filter( $built_tasks, array( 'completed' => false ) );
751    }
752
753    /**
754     * Gets a list of completed tasks.
755     *
756     * @param string $task_list_id Optional. Will default to `site_intent` option.
757     * @return Task[] Array of completed tasks.
758     */
759    private function get_completed_tasks( $task_list_id = null ) {
760        $task_list_id = $task_list_id ? $task_list_id : get_option( 'site_intent' );
761        if ( ! $task_list_id ) {
762            return array();
763        }
764        $task_list = $this->get_task_list( $task_list_id );
765        if ( empty( $task_list ) ) {
766            return array();
767        }
768        $built_tasks = $this->build( $task_list_id );
769        // filter for incomplete tasks
770        return wp_list_filter( $built_tasks, array( 'completed' => true ) );
771    }
772
773    /**
774     * Checks if there are any active tasks.
775     *
776     * @param string|null $task_list_id Optional. Will default to `site_intent` option.
777     * @return boolean True if there are active tasks, false if not.
778     */
779    private function has_active_tasks( $task_list_id = null ) {
780        return ! empty( $this->get_active_tasks( $task_list_id ) );
781    }
782
783    /**
784     * Adds task-defined `add_listener_callback` hooks for incomplete tasks.
785     *
786     * @param string $task_list_id Optional. Will default to `site_intent` option.
787     * @return void
788     */
789    public function add_hooks_for_active_tasks( $task_list_id = null ) {
790        // leave things alone if Launchpad is not enabled.
791        if ( ! $this->is_fullscreen_launchpad_enabled() ) {
792            return;
793        }
794
795        $task_list_id = $task_list_id ? $task_list_id : get_option( 'site_intent' );
796        // Sites without a `site_intent` option will not have any tasks.
797        if ( ! $task_list_id ) {
798            return;
799        }
800
801        $task_list = $this->get_task_list( $task_list_id );
802        if ( empty( $task_list ) || ! isset( $task_list['task_ids'] ) ) {
803            return;
804        }
805
806        foreach ( $task_list['task_ids'] as $task_id ) {
807            $task_definition = $this->get_task( $task_id );
808            if ( isset( $task_definition['add_listener_callback'] ) && is_callable( $task_definition['add_listener_callback'] ) ) {
809                // We only need to know the built completion status if the task has an `add_listener_callback` property.
810                // Small optimization to not run `is_complete_callback` as often.
811                $task = $this->build_task( $task_definition );
812                if ( ! $task['completed'] && is_callable( $task_definition['add_listener_callback'] ) ) {
813                    call_user_func_array( $task_definition['add_listener_callback'], array( $task, $task_definition ) );
814                }
815            }
816        }
817    }
818
819    /**
820     * Marks a task as complete.
821     *
822     * @param string $task_id The task ID.
823     * @return bool True if successful, false if not.
824     */
825    public function mark_task_complete( $task_id ) {
826        $result = wpcom_mark_launchpad_task_complete( $task_id );
827
828        $this->maybe_disable_fullscreen_launchpad();
829
830        return $result;
831    }
832
833    /**
834     * Marks a task as complete if it is active for this site. This is a bit of a hacky way to be able to share a callback
835     * among several tasks, calling several completion IDs from the same callback.
836     *
837     * @param string $task_id The task ID.
838     * @return bool True if successful, false if not.
839     */
840    public function mark_task_complete_if_active( $task_id ) {
841        // Ensure that the task is an active one
842        $active_tasks_by_task_id = wp_list_filter( $this->get_active_tasks(), array( 'id' => $task_id ) );
843        $is_active               = ! empty( $active_tasks_by_task_id );
844
845        /**
846         * Filters whether a task counts as active for completion.
847         *
848         * `get_active_tasks()` only knows about the site's `site_intent` task list.
849         * Features that select their own task set outside that list (the AI
850         * Launchpad) hook this to let their tasks complete.
851         *
852         * @param bool   $is_active Whether the task is active per the site_intent task list.
853         * @param string $task_id   The task being completed.
854         */
855        $is_active = apply_filters( 'wpcom_launchpad_is_task_active_for_completion', $is_active, $task_id );
856
857        if ( ! $is_active ) {
858            return false;
859        }
860
861        return $this->mark_task_complete( $task_id );
862    }
863
864    /**
865     * Disables fullscreen Launchpad if all tasks are complete.
866     *
867     * @return void
868     */
869    public function maybe_disable_fullscreen_launchpad() {
870        $completed_site_launched_task = wp_list_filter(
871            $this->get_completed_tasks(),
872            array(
873                'isLaunchTask' => true,
874            )
875        );
876
877        $site_launched = ! empty( $completed_site_launched_task );
878
879        if ( $site_launched || ! $this->has_active_tasks() ) {
880            $this->disable_fullscreen_launchpad();
881        }
882    }
883
884    /**
885     * Validate a Launchpad Task
886     *
887     * @param Task $task Task.
888     *
889     * @return bool True if valid, false if not.
890     */
891    public static function validate_task( $task ) {
892        if ( ! is_array( $task ) ) {
893            return false;
894        }
895
896        if ( ! isset( $task['id'] ) ) {
897            _doing_it_wrong( 'validate_task', 'The Launchpad task being registered requires a "id" attribute', '6.1' );
898            return false;
899        }
900
901        // For now, allow the 'title' attribute.
902        $has_valid_title = isset( $task['title'] ) || ( isset( $task['get_title'] ) && is_callable( $task['get_title'] ) );
903
904        if ( ! $has_valid_title ) {
905            _doing_it_wrong( 'validate_task', 'The Launchpad task being registered requires a "title" attribute or a "get_title" callback', '6.2' );
906            return false;
907        }
908
909        $has_any_repetition_properties  = isset( $task['target_repetitions'] ) || isset( $task['repetition_count_callback'] );
910        $has_both_repetition_properties = isset( $task['target_repetitions'] ) && isset( $task['repetition_count_callback'] );
911
912        if ( $has_any_repetition_properties && ! $has_both_repetition_properties ) {
913            _doing_it_wrong( 'validate_task', 'The Launchpad task being registered requires both a "target_repetitions" attribute and a "repetition_count_callback" callback', '6.3' );
914            return false;
915        }
916
917        if ( isset( $task['target_repetitions'] ) && ! is_int( $task['target_repetitions'] ) ) {
918            _doing_it_wrong( 'validate_task', 'The Launchpad task being registered requires a "target_repetitions" attribute that is an integer', '6.4' );
919            return false;
920        }
921
922        return true;
923    }
924
925    /**
926     * Checks if Launchpad is enabled.
927     *
928     * @return boolean
929     */
930    public function is_fullscreen_launchpad_enabled() {
931        $launchpad_screen = get_option( 'launchpad_screen' );
932        if ( 'full' !== $launchpad_screen ) {
933            return false;
934        }
935
936        return $this->has_active_tasks();
937    }
938    /**
939     * Disables Launchpad by setting the `launchpad_screen` option to `off`.
940     *
941     * @return bool True if successful, false if not.
942     */
943    private function disable_fullscreen_launchpad() {
944        return update_option( 'launchpad_screen', 'off' );
945    }
946
947    /**
948     * Gets the title for a task list.
949     *
950     * @param string $id Task list id.
951     * @return string|null The title for the task list.
952     */
953    public function get_task_list_title( $id ) {
954        $task_list = $this->get_task_list( $id );
955
956        return $this->load_value_from_callback( $task_list, 'get_title', null );
957    }
958}