$ids) { $order[$column] = implode(',', array_diff(array_filter(explode(',', (string) $ids)), ['baton-steps'])); } $order['normal'] = implode(',', array_merge(['baton-steps'], array_filter(explode(',', $order['normal'] ?? '')))); return $order; } public static function addMetaBoxes(): void { add_meta_box('baton-steps', __('Steps', 'baton'), [self::class, 'stepsBox'], null, 'normal', 'high'); add_meta_box('baton-audience', __('Who sees it', 'baton'), [self::class, 'audienceBox'], null, 'side'); add_meta_box('baton-preview', __('Preview', 'baton'), [self::class, 'previewBox'], null, 'side', 'high'); } public static function stepsBox(WP_Post $post): void { wp_nonce_field(self::NONCE, self::NONCE); $steps = Baton_Walkthroughs::steps($post->ID); $canPlay = Baton_Areas::isEnabled(Baton_Walkthroughs::area($post->ID)); include __DIR__ . '/views/steps-box.php'; } public static function audienceBox(WP_Post $post): void { $roles = Baton_Walkthroughs::roles($post->ID); $trigger = Baton_Walkthroughs::trigger($post->ID); $allRoles = wp_roles()->get_names(); include __DIR__ . '/views/audience-box.php'; } public static function previewBox(WP_Post $post): void { $steps = Baton_Walkthroughs::steps($post->ID); $url = self::previewUrl($post->ID); $area = Baton_Walkthroughs::area($post->ID); $areaOn = Baton_Areas::isEnabled($area); include __DIR__ . '/views/preview-box.php'; } // takes the walkthrough on the site, even as a draft; from step $step (counting from 0) when given public static function previewUrl(int $id, ?int $step = null): string { $steps = Baton_Walkthroughs::steps($id); if ($step === null || !isset($steps[$step])) { return add_query_arg(Baton_Launcher::START_PARAM, $id, Baton_Walkthroughs::startUrl($id)); } return add_query_arg([ Baton_Launcher::START_PARAM => $id, Baton_Launcher::STEP_PARAM => $step, ], Baton_Walkthroughs::stepUrl($steps[$step]['path'])); } /** * A page's name for people, from the browser tab title saved with the step. * * "General Settings ‹ My Site — WordPress" becomes "General Settings", "Shop – My Store" becomes "Shop". */ public static function pageName(array $step): string { $place = self::menuPlace($step['path'] ?? ''); if ($place !== '') { return $place; } // dashboard titles: "Screen ‹ Site — WordPress" $title = trim((string) preg_replace('/\s+‹\s.*$/u', '', $step['page_title'] ?? '')); $site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); $parts = array_values(array_filter( array_map('trim', (array) preg_split('/\s+[–—|·-]\s+/u', $title)), fn(string $part) => $part !== '' && $part !== 'WordPress' )); if (!$parts) { return $step['path'] ?? ''; } // the home page's title usually starts with the site's own name if (strcasecmp($parts[0], $site) === 0) { return __('the home page', 'baton'); } return $parts[0]; } /** * Where a dashboard page sits in the admin menu, like "Settings › Do Not Disturb AI Settings". * * Read from WordPress's own menu, so plugin pages get the names their plugin gave them. '' when unknown. */ public static function menuPlace(string $path): string { global $menu, $submenu; $admin = (string) wp_parse_url(admin_url('/'), PHP_URL_PATH); if (!is_array($menu) || !str_starts_with($path, $admin)) { return ''; } $relative = substr($path, strlen($admin)); $file = (string) wp_parse_url($relative, PHP_URL_PATH); parse_str((string) wp_parse_url($relative, PHP_URL_QUERY), $query); // the menu's own ids for this screen: plugin pages by ?page=, core screens by file and post type $file = $file === '' ? 'index.php' : $file; $postType = isset($query['post_type']) ? '?post_type=' . $query['post_type'] : ''; $wanted = array_filter([$query['page'] ?? '', $file . $postType, $file]); $topTitles = []; foreach ($menu as $item) { if (isset($item[0], $item[2]) && $item[0] !== '') { $topTitles[$item[2]] = self::menuText($item[0]); } } foreach ($wanted as $slug) { foreach ((array) $submenu as $parent => $items) { foreach ($items as $item) { if (($item[2] ?? '') === $slug && isset($topTitles[$parent])) { $child = self::menuText($item[0]); return $child === '' || $child === $topTitles[$parent] ? $topTitles[$parent] : $topTitles[$parent] . ' › ' . $child; } } } if (isset($topTitles[$slug])) { return $topTitles[$slug]; } } return ''; } // menu titles carry counters and screen reader text, like "Comments 3" private static function menuText(string $title): string { $title = (string) preg_replace('#]*>.*?#s', '', $title); return trim(wp_strip_all_tags($title)); } public static function rowActions(array $actions, WP_Post $post): array { if ($post->post_type !== Baton_Walkthroughs::POST_TYPE || !Baton_Walkthroughs::steps($post->ID) || !Baton_Areas::isEnabled(Baton_Walkthroughs::area($post->ID))) { return $actions; } $actions['baton_preview'] = sprintf( '%s', esc_url(self::previewUrl($post->ID)), esc_html__('Preview', 'baton') ); // for a "Take the tour" button or menu item; without the script, the link just opens the tour if ($post->post_status === 'publish') { $actions['baton_copy_link'] = sprintf( '%s', esc_url(self::tourLink($post->ID)), esc_html__('Copy tour link', 'baton') ); } return $actions; } /** * A stable link that starts a published walkthrough, for buttons and menu items. * * Only people in "Who sees it" get the tour; anyone else just lands on the page. */ public static function tourLink(int $id): string { return add_query_arg(Baton_Launcher::START_PARAM, $id, Baton_Walkthroughs::startUrl($id)); } public static function save(int $postId, WP_Post $post): void { if (!isset($_POST[self::NONCE]) || !wp_verify_nonce(sanitize_key(wp_unslash($_POST[self::NONCE])), self::NONCE)) { return; } if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || !current_user_can('edit_post', $postId)) { return; } // each row posts its recorded step as JSON plus the editable text, in the order shown $raw = isset($_POST['baton_step']) ? (array) wp_unslash($_POST['baton_step']) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded and cleaned by sanitizeSteps() $texts = isset($_POST['baton_step_text']) ? array_map('sanitize_text_field', (array) wp_unslash($_POST['baton_step_text'])) : []; $steps = []; foreach (array_values($raw) as $i => $json) { $step = is_string($json) ? json_decode($json, true) : null; if (is_array($step)) { $step['text'] = $texts[$i] ?? ''; $steps[] = $step; } } Baton_Walkthroughs::saveSteps($postId, Baton_Walkthroughs::fillMissingText(Baton_Walkthroughs::sanitizeSteps($steps))); // "Everyone who logs in" wins over any ticked roles $roles = isset($_POST['baton_roles']) && !isset($_POST['baton_roles_all']) ? array_map('sanitize_key', (array) wp_unslash($_POST['baton_roles'])) : []; update_post_meta($postId, Baton_Walkthroughs::ROLES_META, array_values(array_intersect($roles, array_keys(wp_roles()->get_names())))); $trigger = isset($_POST['baton_trigger']) ? sanitize_key(wp_unslash($_POST['baton_trigger'])) : ''; update_post_meta($postId, Baton_Walkthroughs::TRIGGER_META, $trigger === Baton_Walkthroughs::TRIGGER_UNTIL_DONE ? $trigger : Baton_Walkthroughs::TRIGGER_ONCE); } public static function enqueue(string $hook): void { $screen = get_current_screen(); if (!$screen || $screen->post_type !== Baton_Walkthroughs::POST_TYPE) { return; } $base = plugin_dir_url(BATON_FILE) . 'assets/'; wp_enqueue_style('baton-admin', $base . 'css/admin.css', [], Baton_Launcher::assetVersion('css/admin.css')); if ($hook === 'edit.php') { wp_enqueue_script('baton-list', $base . 'js/list.js', [], Baton_Launcher::assetVersion('js/list.js'), true); wp_localize_script('baton-list', 'batonList', [ 'copied' => __('Tour link copied. Add it to a button or menu item. Only people in “Who sees it” can take the tour.', 'baton'), 'manual' => __('Copy this tour link:', 'baton'), ]); } if ($hook !== 'post.php') { return; } wp_enqueue_script('baton-admin', $base . 'js/admin.js', ['jquery-ui-sortable'], Baton_Launcher::assetVersion('js/admin.js'), true); wp_localize_script('baton-admin', 'batonAdmin', [ 'rest' => esc_url_raw(rest_url(Baton_Rest::NAMESPACE . '/')), 'nonce' => wp_create_nonce('wp_rest'), 'l10n' => [ 'writing' => __('Writing…', 'baton'), 'write' => __('Write steps with AI', 'baton'), /* translators: %s: the save button's label, like "Save Draft", "Publish" or "Update" */ 'done' => __('Done. Check the steps, then click “%s” to keep them.', 'baton'), 'save' => __('Save', 'baton'), 'confirm' => __('Remove this step?', 'baton'), ], ]); } public static function howToRecord(): void { $screen = get_current_screen(); if (!$screen || $screen->id !== 'edit-' . Baton_Walkthroughs::POST_TYPE) { return; } $areas = Baton_Areas::enabled(); include __DIR__ . '/views/how-to-record.php'; } /** * WordPress prints "Required by: Baton Pro" and "cannot be deactivated" under Baton whenever Pro is * installed, even when Pro is switched off. Only true while Pro is active, so hide it otherwise. */ public static function quietDependencyNote(): void { if (!class_exists('WP_Plugin_Dependencies') || WP_Plugin_Dependencies::has_active_dependents(plugin_basename(BATON_FILE))) { return; } printf('', esc_attr(plugin_basename(BATON_FILE))); } public static function aiNotice(): void { $screen = get_current_screen(); $key = self::AI_NOTICE . get_current_user_id(); $note = get_transient($key); if (!$screen || $screen->id !== Baton_Walkthroughs::POST_TYPE || !is_string($note) || $note === '') { return; } delete_transient($key); printf( '

%s %s

', esc_html__('The steps were saved with plain wording.', 'baton'), esc_html($note) ); } public static function columns(array $columns): array { $date = $columns['date'] ?? null; unset($columns['date']); $columns['baton_area'] = __('Where', 'baton'); $columns['baton_steps'] = __('Steps', 'baton'); $columns['baton_audience'] = __('Shown to', 'baton'); $columns['baton_trigger'] = __('When', 'baton'); if ($date !== null) { $columns['date'] = $date; } return $columns; } public static function column(string $column, int $postId): void { switch ($column) { case 'baton_area': echo esc_html(Baton_Areas::label(Baton_Walkthroughs::area($postId))); break; case 'baton_steps': echo esc_html((string) count(Baton_Walkthroughs::steps($postId))); break; case 'baton_audience': $names = wp_roles()->get_names(); $roles = array_map(fn($role) => translate_user_role($names[$role] ?? $role), Baton_Walkthroughs::roles($postId)); /** * Who a walkthrough is shown to, as the list says it. * * @param string $label * @param int $postId */ echo esc_html(apply_filters('baton_audience_label', $roles ? implode(', ', $roles) : __('Everyone who logs in', 'baton'), $postId)); break; case 'baton_trigger': echo esc_html(self::triggerLabel(Baton_Walkthroughs::trigger($postId))); break; } } public static function triggerLabel(string $trigger): string { return $trigger === Baton_Walkthroughs::TRIGGER_UNTIL_DONE ? __('Every login until they finish it', 'baton') : __('Once, on their next login', 'baton'); } // "Post published" reads oddly for a walkthrough public static function messages(array $messages): array { $messages[Baton_Walkthroughs::POST_TYPE] = [ 1 => __('Walkthrough updated.', 'baton'), 4 => __('Walkthrough updated.', 'baton'), 6 => __('Walkthrough is live. It launches the next time a matching user logs in.', 'baton'), 7 => __('Walkthrough saved.', 'baton'), 10 => __('Draft saved. Publish it to switch it on.', 'baton'), ]; return $messages; } } }