Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.45% |
182 / 183 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
| AI_Launchpad_Task_Registry | |
99.45% |
182 / 183 |
|
88.89% |
8 / 9 |
22 | |
0.00% |
0 / 1 |
| definitions | |
100.00% |
139 / 139 |
|
100.00% |
1 / 1 |
1 | |||
| plugin_active | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| plugin_install_path | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| task_ids | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| has | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_complete | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| mark_complete | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| is_visible | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| build | |
100.00% |
23 / 23 |
|
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 | */ |
| 26 | class 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§ion=/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 | } |