Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.45% covered (success)
99.45%
182 / 183
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_Task_Registry
99.45% covered (success)
99.45%
182 / 183
88.89% covered (warning)
88.89%
8 / 9
22
0.00% covered (danger)
0.00%
0 / 1
 definitions
100.00% covered (success)
100.00%
139 / 139
100.00% covered (success)
100.00%
1 / 1
1
 plugin_active
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 plugin_install_path
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 task_ids
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_complete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mark_complete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_visible
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 build
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2/**
3 * Task definitions owned by the AI Launchpad rather than the shared launchpad catalog.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8/**
9 * The AI Launchpad's own task registry.
10 *
11 * Deliberately NOT merged into wpcom_launchpad_get_task_definitions(): that catalog is shared
12 * with the legacy launchpad, and routing foreign definitions through
13 * wpcom_launchpad_checklists() would depend on it accepting entries it never registered. These
14 * tasks are built here instead, and available_task_ids() offers them to the model alongside the
15 * catalog's, so an AI Launchpad task is selectable without the catalog knowing it exists.
16 *
17 * Each definition supplies the same fields build_tasks() resolves from the catalog. definitions()
18 * is rebuilt on every call, including from has() and task_ids(), which is what decides the shape:
19 *
20 * - `is_complete`, `is_visible` and `draft_id` MUST stay callables. They inspect site state — an
21 *   option, a WP_Query, an active plugin — so resolving them eagerly would put that work behind
22 *   every id check.
23 * - The string fields are callables only to match. Deferring __() keeps it off the has() path too,
24 *   but a new entry that used plain translated strings there would be correct.
25 */
26class AI_Launchpad_Task_Registry {
27
28    /**
29     * The registry.
30     *
31     * Keys are task ids. Each definition supports:
32     * - `title`             (callable) the card title.
33     * - `in_progress_title` (callable, optional) the title while a marker draft is unpublished.
34     * - `default_subtitle`  (callable) the subtitle used when the AI supplies none.
35     * - `is_complete`       (callable) whether the task is done.
36     * - `is_visible`        (callable, optional) whether this site can render the task at all; absent means
37     *                        always. This is the registry's equivalent of the catalog's visibility callback:
38     *                        a task whose precondition is missing must never be offered or rendered, since
39     *                        its CTA would lead nowhere. Omit it rather than returning a constant true, so a
40     *                        universally renderable task reads as one.
41     * - `calypso_path`      (callable, optional) the CTA destination, already resolved: an absolute admin URL,
42     *                        or the Calypso router path a Simple site needs for a screen wp-admin does not
43     *                        serve there. Omit it for a task whose CTA the client resolves itself, as the
44     *                        gallery does by creating its page.
45     * - `draft_id`          (callable, optional) the in-progress draft's post id, or null.
46     *
47     * @return array
48     */
49    private static function definitions() {
50        $definitions = array(
51
52            /*
53             * The page is hand-authored markup — a heading, one AI-written line, and one empty core/gallery
54             * block — and no longer built from the WordPress.com pattern library. The library carries no
55             * topical tags at all, so the niche scoring that chose the pattern was dead: production logged
56             * `picked_score: 0, fallback: first_usable` on every event, and the pattern it always settled on
57             * shipped three photographs hotlinked from dotcompatterns.wordpress.com, with that site's own
58             * attachment ids on them. A gallery page whose whole purpose is the user's own work is the worst
59             * place on the menu to leave a stranger's photographs.
60             *
61             * No `is_visible`: core/gallery is a core block, so the task asks nothing of the site. No
62             * `calypso_path` either — the CTA creates the page client-side, as the other page tasks do.
63             */
64            'add_gallery_page'    => array(
65                'title'             => static function () {
66                    return __( 'Create your first gallery', 'jetpack-mu-wpcom' );
67                },
68                'in_progress_title' => static function () {
69                    return __( 'Continue working on your gallery', 'jetpack-mu-wpcom' );
70                },
71                'default_subtitle'  => static function () {
72                    return __( 'Show your work in a beautiful photo gallery.', 'jetpack-mu-wpcom' );
73                },
74                'is_complete'       => static function () {
75                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
76                    return ! empty( $statuses['add_gallery_page'] );
77                },
78                'draft_id'          => static function () {
79                    return AI_Launchpad_Gallery_Page_Listener::get_draft_id();
80                },
81            ),
82
83            /*
84             * The page is hand-authored markup — a heading, one AI-written line, and a Jetpack contact form —
85             * not a block pattern. The pattern library carries no topical tags, so niche scoring could never
86             * fire for a contact page, and the pattern it would settle on is map-only with a hardcoded address
87             * and phone number on it. A wrong address stated confidently on a real business's contact page is
88             * worse than none, so the form is the only channel the page offers.
89             *
90             * No `is_visible`: the form block ships with Jetpack, which is active on every site this feature
91             * runs on, so the task asks nothing of the site. No `calypso_path` either — the CTA creates the
92             * page client-side, as the gallery's does.
93             */
94            'add_contact_page'    => array(
95                'title'             => static function () {
96                    return __( 'Add a contact page', 'jetpack-mu-wpcom' );
97                },
98                'in_progress_title' => static function () {
99                    return __( 'Continue working on your contact page', 'jetpack-mu-wpcom' );
100                },
101                'default_subtitle'  => static function () {
102                    return __( 'Give visitors a simple way to reach you, with a contact form ready to go.', 'jetpack-mu-wpcom' );
103                },
104                'is_complete'       => static function () {
105                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
106                    return ! empty( $statuses['add_contact_page'] );
107                },
108                'draft_id'          => static function () {
109                    return AI_Launchpad_Contact_Page_Listener::get_draft_id();
110                },
111            ),
112
113            /*
114             * A scaffold, not a finished page: a heading, one AI-written line, and three blank event entries.
115             * There is no core or Jetpack events block to fill it with, and the pattern library's events
116             * patterns are fabricated tour listings whose every date falls in 2024-2025, so using one would
117             * publish a page of expired events on day one. Nobody but the user knows when their events are, so
118             * the page offers a visible blank instead of a plausible invention.
119             *
120             * No `is_visible`: the entries are core blocks, which every site has. No `calypso_path` either —
121             * the CTA creates the page client-side, as the contact page's does.
122             */
123            'add_events_page'     => array(
124                'title'             => static function () {
125                    return __( 'Add an events page', 'jetpack-mu-wpcom' );
126                },
127                'in_progress_title' => static function () {
128                    return __( 'Continue working on your events page', 'jetpack-mu-wpcom' );
129                },
130                'default_subtitle'  => static function () {
131                    return __( 'Give people one place to find out what is coming up and when.', 'jetpack-mu-wpcom' );
132                },
133                'is_complete'       => static function () {
134                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
135                    return ! empty( $statuses['add_events_page'] );
136                },
137                'draft_id'          => static function () {
138                    return AI_Launchpad_Events_Page_Listener::get_draft_id();
139                },
140            ),
141
142            /*
143             * A heading, one AI-written line, and a single empty core/video block. Deliberately core/video and
144             * not VideoPress: VideoPress is a plan-gated Jetpack module, so building the page on it would mean
145             * declaring `is_visible` and withholding the task from every site that has not bought the plan —
146             * and this is the only thing on the menu serving sites whose work is watched. core/video is on
147             * every site, needs no plugin or plan, and takes a URL as readily as an upload.
148             *
149             * Not a block pattern either, and here that is a rights problem rather than a taste one. The
150             * pattern library has no video category at all, and its only video-ish patterns each embed a
151             * specific real third-party YouTube video, which no rewrite pass would strip.
152             *
153             * No `calypso_path` — the CTA creates the page client-side, as the contact and events pages do.
154             */
155            'add_video_page'      => array(
156                'title'             => static function () {
157                    return __( 'Add a video page', 'jetpack-mu-wpcom' );
158                },
159                'in_progress_title' => static function () {
160                    return __( 'Continue working on your video page', 'jetpack-mu-wpcom' );
161                },
162                'default_subtitle'  => static function () {
163                    return __( 'Give your videos a home on your site, ready for you to upload the first one.', 'jetpack-mu-wpcom' );
164                },
165                'is_complete'       => static function () {
166                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
167                    return ! empty( $statuses['add_video_page'] );
168                },
169                'draft_id'          => static function () {
170                    return AI_Launchpad_Video_Page_Listener::get_draft_id();
171                },
172            ),
173
174            /*
175             * One project, written up on a page of its own: an empty image block for the work, and one blank
176             * line, prompted, for what it was, who it was for, and what the user did.
177             *
178             * Deliberately an ordinary page, and NOT Jetpack's `jetpack-portfolio` custom post type the proof
179             * of concept opened the editor on. That CPT is registered only where the `jetpack_portfolio`
180             * option is on or the theme declares support, and neither holds on a new site with a block theme —
181             * so gating this task on the CPT would hide it from precisely the sites it exists for, and not
182             * gating it would open an editor for a post type that does not exist. Turning the option on from
183             * the CTA was the third option and is worse than both: no route the client can reach writes it on
184             * Simple and Atomic alike, and it is a silent, site-wide structural change — a new admin menu, two
185             * taxonomies, an archive, a rewrite flush — bought by clicking a card that offered to add one page.
186             * A page needs none of that, and it is where the theme's own navigation puts it.
187             *
188             * No `default_subtitle` invention beyond the card: the page itself carries no AI-written copy at
189             * all, which is why this is the one page task with no `page_intros` key. The other five open with a
190             * line about the site; a project write-up is facts about one job only the user has.
191             *
192             * No `is_visible`: core/image and core/paragraph are on every site. No `calypso_path` either — the
193             * CTA creates the page client-side, as the other page tasks do.
194             */
195            'add_portfolio_piece' => array(
196                'title'             => static function () {
197                    return __( 'Add your first portfolio piece', 'jetpack-mu-wpcom' );
198                },
199                'in_progress_title' => static function () {
200                    return __( 'Continue working on your portfolio piece', 'jetpack-mu-wpcom' );
201                },
202                'default_subtitle'  => static function () {
203                    return __( 'Give one project a page of its own, with room for the work and the story behind it.', 'jetpack-mu-wpcom' );
204                },
205                'is_complete'       => static function () {
206                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
207                    return ! empty( $statuses['add_portfolio_piece'] );
208                },
209                'draft_id'          => static function () {
210                    return AI_Launchpad_Portfolio_Piece_Listener::get_draft_id();
211                },
212            ),
213            'add_site_icon'       => array(
214                'title'            => static function () {
215                    return __( 'Add your logo or site icon', 'jetpack-mu-wpcom' );
216                },
217                'default_subtitle' => static function () {
218                    return __( 'Upload your logo so your site is recognizable in browser tabs and search results.', 'jetpack-mu-wpcom' );
219                },
220                // Read live from the option core writes, so no listener is needed and removing the icon
221                // un-completes the task. Deliberately not has_site_icon(), which additionally resolves the
222                // attachment's URL and so reports false for an icon whose attachment is not readable yet.
223                'is_complete'      => static function () {
224                    return ! empty( get_option( 'site_icon' ) );
225                },
226                'calypso_path'     => static function () {
227                    return admin_url( 'options-general.php' );
228                },
229            ),
230            'pick_fonts_colors'   => array(
231                'title'            => static function () {
232                    return __( 'Customize fonts and colors', 'jetpack-mu-wpcom' );
233                },
234                'default_subtitle' => static function () {
235                    return __( 'Try a style variation to set the mood of your whole site at once.', 'jetpack-mu-wpcom' );
236                },
237                // Applying a style variation fires nothing this context can watch, so the task is on the
238                // complete-on-click allowlist and its completion arrives here through mark_complete().
239                'is_complete'      => static function () {
240                    $statuses = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
241                    return ! empty( $statuses['pick_fonts_colors'] );
242                },
243                // The Styles screen belongs to the Site Editor, which only a block theme has; on a classic
244                // theme the CTA would open an editor with no Styles route to land on.
245                'is_visible'       => static function () {
246                    return wp_is_block_theme();
247                },
248                // `p` is the query arg the Site Editor mounts its router on (`path` is the pre-6.8 spelling
249                // and now only survives as a redirect), and `section` is the Styles screen's own sub-route,
250                // which is what opens the variations browser rather than the Styles root.
251                'calypso_path'     => static function () {
252                    return admin_url( 'site-editor.php?p=/styles&section=/variations' );
253                },
254            ),
255
256            /*
257             * Plugin discovery: recommend a plugin only a particular kind of site wants, so that the pick is
258             * itself the personalization — the model reaching for Sensei is the model saying "this site
259             * teaches courses". A plugin every site would benefit from carries no such signal and would just
260             * spend one of six slots.
261             *
262             * Only Automattic's own plugins belong here. This is an official Automattic surface, so it does
263             * not send people to third-party products, however well those would fit a niche. That rules out
264             * the obvious SEO, page-builder and events candidates and is why Sensei is currently alone; a
265             * niche with no first-party plugin behind it wants a hand-authored task instead, the way
266             * `add_gallery_page` covers visual sites.
267             *
268             * No `is_visible`: an already-active plugin makes the task *complete*, not hidden. Completion is
269             * what lets the card tick when the user acts on the recommendation, and the completion diff on
270             * read is the only signal that says whether the recommendation landed. Withholding it from a site
271             * that already has the plugin comes free anyway — available_task_ids() drops every complete task
272             * from the actionable menu.
273             */
274            'install_sensei_lms'  => array(
275                'title'            => static function () {
276                    return __( 'Build your courses with Sensei LMS', 'jetpack-mu-wpcom' );
277                },
278                'default_subtitle' => static function () {
279                    return __( 'Install Sensei to turn what you teach into structured lessons, quizzes, and student progress.', 'jetpack-mu-wpcom' );
280                },
281                'is_complete'      => static function () {
282                    return self::plugin_active( 'sensei-lms/sensei-lms.php' );
283                },
284                'calypso_path'     => static function () {
285                    return self::plugin_install_path( 'sensei-lms' );
286                },
287            ),
288        );
289
290        /**
291         * Filters the AI Launchpad's own task definitions.
292         *
293         * The registry is otherwise closed, and this is the only seam through which it can hold more than
294         * its shipped entries — which is what makes it testable with a second definition at all. Callbacks
295         * must be cheap: this map is rebuilt on every has() / task_ids() / is_complete() / is_visible() call.
296         *
297         * @since $$next-version$$
298         *
299         * @param array $definitions Task definitions keyed by task id. See the docblock above for the shape.
300         */
301        return apply_filters( 'wpcom_ai_launchpad_task_registry_definitions', $definitions );
302    }
303
304    /**
305     * Whether a plugin is active, from anywhere the registry's definitions are resolved.
306     *
307     * `is_plugin_active()` lives in an admin include that is not loaded during a REST request. Every REST
308     * caller of this registry already loads it before asking (get_current_tasks(), available_task_ids() and
309     * build_tasks() each require it), so this guard is for the callers that do not: the completion-check
310     * route, anything reached through the definitions filter, and the tests.
311     *
312     * @param string $plugin_file The plugin's `directory/file.php` entry.
313     * @return bool
314     */
315    private static function plugin_active( $plugin_file ) {
316        if ( ! function_exists( 'is_plugin_active' ) ) {
317            require_once ABSPATH . 'wp-admin/includes/plugin.php';
318        }
319
320        return is_plugin_active( $plugin_file );
321    }
322
323    /**
324     * The CTA for installing a plugin from the WordPress.org directory.
325     *
326     * `tab=plugin-information` opens that one plugin's install screen directly, so the destination does not
327     * depend on how the directory's search happens to rank the slug that day.
328     *
329     * Run through the Simple rewrite here rather than in build_tasks(), which returns before the point where
330     * it rewrites catalog CTAs: Simple sites cannot reach either wp-admin plugins screen, and the slug the
331     * Calypso deep link needs is known here and nowhere else.
332     *
333     * @param string $plugin_slug The plugin's WordPress.org slug.
334     * @return string
335     */
336    private static function plugin_install_path( $plugin_slug ) {
337        return wpcom_ai_launchpad_to_simple_plugins_path(
338            admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . rawurlencode( $plugin_slug ) ),
339            $plugin_slug
340        );
341    }
342
343    /**
344     * The ids this registry defines.
345     *
346     * @return string[]
347     */
348    public static function task_ids() {
349        return array_keys( self::definitions() );
350    }
351
352    /**
353     * Whether this registry owns the given task id.
354     *
355     * @param string $task_id The task id.
356     * @return bool
357     */
358    public static function has( $task_id ) {
359        return isset( self::definitions()[ $task_id ] );
360    }
361
362    /**
363     * Whether a registry task is already complete. False for an unknown id.
364     *
365     * Separate from build() so a caller that only needs the flag — available_task_ids(), which runs on every
366     * wizard prewarm — pays for the completion check alone. build() additionally resolves the in-progress draft,
367     * a WP_Query whose result that caller would throw away.
368     *
369     * @param string $task_id The task id.
370     * @return bool
371     */
372    public static function is_complete( $task_id ) {
373        $definition = self::definitions()[ $task_id ] ?? null;
374        return null !== $definition && (bool) $definition['is_complete']();
375    }
376
377    /**
378     * Marks a registry task complete. No-ops (and reports false) for an id the registry does not own.
379     *
380     * The shared status option is written directly because wpcom_mark_launchpad_task_complete() cannot do it:
381     * wpcom_launchpad_update_task_status() resolves the id against wpcom_launchpad_get_task_definitions() and
382     * silently skips anything absent from it — which is every registry id, by construction. Routing the
383     * complete-on-click endpoint through that helper would answer 200 and write nothing.
384     *
385     * Only reaches tasks whose `is_complete` reads that option. One computing completion from live site state
386     * (`add_site_icon`) ignores the write entirely, which is why a registry task on the complete-on-click
387     * allowlist has to define its completion against this option.
388     *
389     * @param string $task_id The task id.
390     * @return bool Whether the task was marked complete.
391     */
392    public static function mark_complete( $task_id ) {
393        if ( ! self::has( $task_id ) ) {
394            return false;
395        }
396
397        $statuses             = (array) get_option( 'launchpad_checklist_tasks_statuses', array() );
398        $statuses[ $task_id ] = true;
399        update_option( 'launchpad_checklist_tasks_statuses', $statuses );
400
401        return true;
402    }
403
404    /**
405     * Whether this site can render a registry task at all. False for an unknown id.
406     *
407     * `is_visible` is optional, so a definition without one is visible everywhere — the shape the gallery
408     * relies on. Same rationale as is_complete() for living outside build(): available_task_ids() needs the
409     * flag alone, on every wizard prewarm.
410     *
411     * @param string $task_id The task id.
412     * @return bool
413     */
414    public static function is_visible( $task_id ) {
415        $definition = self::definitions()[ $task_id ] ?? null;
416        if ( null === $definition ) {
417            return false;
418        }
419
420        return ! isset( $definition['is_visible'] ) || (bool) $definition['is_visible']();
421    }
422
423    /**
424     * Builds a registry task into the card shape build_tasks() emits, or null for an unknown id.
425     *
426     * An unpublished marker draft puts the task in progress: the card reopens that draft and takes
427     * the in-progress title, matching how build_tasks() treats catalog site-editor tasks.
428     *
429     * Visibility is deliberately not checked here, mirroring the catalog: the gate belongs to the callers
430     * that decide whether a task surfaces, since build_tasks() has to be able to bypass it for the
431     * `?all_tasks=1` testing view.
432     *
433     * @param string $task_id  The task id.
434     * @param string $subtitle The AI-written subtitle; falls back to the registry default when empty.
435     * @return array|null
436     */
437    public static function build( $task_id, $subtitle ) {
438        $definition = self::definitions()[ $task_id ] ?? null;
439        if ( null === $definition ) {
440            return null;
441        }
442
443        // Via the accessor, not the callable, so the card and the availability menu can never disagree.
444        $completed = self::is_complete( $task_id );
445
446        // Resolved regardless of completion, matching build_tasks(): a done card still links to where it was
447        // done, so the user can go back and change the icon or try another variation.
448        $calypso_path = isset( $definition['calypso_path'] ) ? $definition['calypso_path']() : null;
449
450        $in_progress = false;
451        if ( ! $completed && isset( $definition['draft_id'] ) ) {
452            $draft_id = $definition['draft_id']();
453            if ( null !== $draft_id ) {
454                $in_progress  = true;
455                $calypso_path = admin_url( 'post.php?post=' . $draft_id . '&action=edit' );
456            }
457        }
458
459        $title = $in_progress && isset( $definition['in_progress_title'] )
460            ? $definition['in_progress_title']()
461            : $definition['title']();
462
463        return array(
464            'id'           => $task_id,
465            'subtitle'     => '' !== $subtitle ? $subtitle : $definition['default_subtitle'](),
466            'title'        => $title,
467            'completed'    => $completed,
468            'in_progress'  => $in_progress,
469            'disabled'     => false,
470            'calypso_path' => $calypso_path,
471        );
472    }
473}