notes = $notes; $this->upgrade = $upgrade; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_menu' ) ); // The admin bar, and this node with it, renders on the front end too. add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_badge_style' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_badge_style' ) ); add_filter( 'aiblackout_menu_title', array( $this, 'filter_menu_title' ) ); // After AdminBarStatus adds the node this one writes into. add_action( 'admin_bar_menu', array( $this, 'badge_admin_bar' ), 110 ); // Runs after Onboarding's activation redirect. add_action( 'admin_init', array( $this, 'maybe_redirect' ), 20 ); // After core_update_footer(), which is on this filter at 10. add_filter( 'update_footer', array( $this, 'filter_update_footer' ), 11 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's own dynamic hook name, built from this plugin's basename. add_filter( 'plugin_action_links_' . AIBLACKOUT_BASENAME, array( $this, 'filter_action_links' ) ); } /** * Returns the URL of this screen. * * @return string Admin URL. */ public static function url(): string { return admin_url( 'admin.php?page=' . self::SLUG ); } /** * Registers the page without putting anything in the menu. * * @return void */ public function add_menu(): void { add_submenu_page( self::HIDDEN_PARENT, __( "What's new in AI Blackout", 'ai-blackout' ), __( "What's new", 'ai-blackout' ), self::CAPABILITY, self::SLUG, array( $this, 'render' ) ); } /** * Puts the count on the top-level menu item, not on the submenu. * * @param string $title The menu title. * @return string The title, with a count appended when something is unread. */ public function filter_menu_title( $title ) { $unread = count( $this->get_unread() ); if ( $unread < 1 ) { return $title; } return $title . ' ' . sprintf( '%3$s', absint( $unread ), esc_html( number_format_i18n( $unread ) ), esc_html( $this->count_label( $unread ) ) ); } /** * Returns the count as a sentence, for assistive technology. * * @param int $unread Number of unread releases. * @return string Translated label. */ private function count_label( int $unread ): string { return sprintf( /* translators: %s: Number of unread release notes. */ _n( '%s unread release note', '%s unread release notes', $unread, 'ai-blackout' ), number_format_i18n( $unread ) ); } /** * Gives the admin bar count its shape. * * @return void */ public function enqueue_badge_style(): void { if ( ! is_admin_bar_showing() || ! current_user_can( self::CAPABILITY ) || ! $this->has_unread() ) { return; } $css = '#wpadminbar .aiblackout-ab-count{display:inline-block;box-sizing:border-box;' . 'min-width:18px;height:18px;margin-left:6px;padding:0 5px;border-radius:9px;' . 'text-align:center;}' . '#wpadminbar .aiblackout-ab-count,' . '#wpadminbar .aiblackout-ab-count span{font-size:11px;line-height:18px;}'; if ( ! is_admin() ) { $css .= '#wpadminbar .aiblackout-ab-count{background-color:#d63638;color:#fff;}'; } wp_add_inline_style( 'admin-bar', $css ); } /** * Puts the same count in the admin bar, on the node AdminBarStatus owns. * * @param \WP_Admin_Bar $wp_admin_bar Core admin bar instance. * @return void */ public function badge_admin_bar( \WP_Admin_Bar $wp_admin_bar ): void { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $unread = count( $this->get_unread() ); if ( $unread < 1 ) { return; } $node = $wp_admin_bar->get_node( AdminBarStatus::ID ); if ( null === $node ) { return; } $wp_admin_bar->add_node( array( 'id' => AdminBarStatus::ID, 'title' => $node->title . sprintf( ' %2$s', esc_html( number_format_i18n( $unread ) ), esc_html( $this->count_label( $unread ) ) ), 'href' => self::url(), ) ); } /** * Puts a permanent way back in the admin footer, on this plugin's screens. * * @param string $content What core is about to print. * @return string The replacement, or the content untouched elsewhere. */ public function filter_update_footer( $content ) { if ( ! MainScreen::is_own_screen() || null === $this->notes->latest() ) { return $content; } return sprintf( '%1$s · %3$s', esc_html( sprintf( /* translators: %s: Plugin version number. */ __( 'AI Blackout %s', 'ai-blackout' ), AIBLACKOUT_VERSION ) ), esc_url( self::url() ), esc_html__( "What's new", 'ai-blackout' ) ); } /** * Adds a permanent way back, on the Plugins screen. * * @param array $links Action links for this plugin's row. * @return array The links, with ours appended. */ public function filter_action_links( $links ) { if ( ! is_array( $links ) || null === $this->notes->latest() ) { return $links; } $links[] = sprintf( '%2$s', esc_url( self::url() ), esc_html__( "What's new", 'ai-blackout' ) ); return $links; } /** * Sends an administrator here once, when they open an AI Blackout screen. * * @return void */ public function maybe_redirect(): void { if ( wp_doing_ajax() || ! current_user_can( self::CAPABILITY ) ) { return; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'GET' !== $method ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading which admin screen was asked for; nothing is acted on. $query = wp_unslash( $_GET ); if ( ! is_array( $query ) || array( 'page' ) !== array_keys( $query ) ) { return; } $page = sanitize_key( (string) $query['page'] ); if ( self::SLUG === $page || 0 !== strpos( $page, MainScreen::SLUG ) ) { return; } if ( ! $this->has_unread() ) { return; } wp_safe_redirect( self::url() ); exit; } /** * Whether this administrator has release notes they have not read. * * @return bool True when something is unread. */ public function has_unread(): bool { return array() !== $this->get_unread(); } /** * Returns the releases this administrator has not read, newest first. * * @return array[] Unread entries. */ public function get_unread(): array { if ( null !== $this->unread ) { return $this->unread; } $floor = $this->upgrade->get_previous_version(); if ( '' === $floor ) { $this->unread = array(); return $this->unread; } $seen = (string) get_user_meta( get_current_user_id(), self::META_SEEN, true ); if ( '' !== $seen && version_compare( $seen, $floor, '>' ) ) { $floor = $seen; } $this->unread = $this->notes->since( $floor ); return $this->unread; } /** * Renders the screen. * * @return void */ public function render(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } $unread = $this->get_unread(); $releases = array() !== $unread ? $unread : array_filter( array( $this->notes->latest() ) ); $this->mark_read(); echo '
'; printf( '

%2$s

', esc_url( AIBLACKOUT_URL . 'assets/brand-mark.svg' ), esc_html__( "What's new in AI Blackout", 'ai-blackout' ) ); if ( array() === $releases ) { printf( '

%s

', esc_html__( 'There are no release notes for this version.', 'ai-blackout' ) ); return; } $this->render_lede( $unread ); foreach ( $releases as $release ) { $this->render_release( $release ); } $this->render_footer(); echo ''; } /** * Prints the line that says why this screen just appeared. * * @param array[] $unread Unread entries, newest first. * @return void */ private function render_lede( array $unread ): void { if ( array() === $unread ) { printf( '

%s

', esc_html__( 'You have read these already. They are kept here so you can come back to them.', 'ai-blackout' ) ); return; } $previous = $this->upgrade->get_previous_version(); $lede = Upgrade::UNKNOWN_PREVIOUS === $previous ? sprintf( /* translators: %s: The version now installed. */ __( 'AI Blackout has been updated to %s. Your settings, your crawler choices and your dated record are unchanged — but some things have moved, so here is where they went.', 'ai-blackout' ), AIBLACKOUT_VERSION ) : sprintf( /* translators: 1: The version previously installed. 2: The version now installed. */ __( 'AI Blackout has been updated from %1$s to %2$s. Your settings, your crawler choices and your dated record are unchanged — but some things have moved, so here is where they went.', 'ai-blackout' ), $previous, AIBLACKOUT_VERSION ); printf( '

%s

', esc_html( $lede ) ); } /** * Prints one release. * * @param array $release A normalised entry from ReleaseNotes. * @return void */ private function render_release( array $release ): void { echo '
'; printf( '

%1$s%2$s

', sprintf( /* translators: %s: Version number. */ esc_html__( 'Version %s', 'ai-blackout' ), esc_html( $release['version'] ) ), '' === $release['date'] ? '' : sprintf( ' %s', esc_html( $this->format_date( $release['date'] ) ) ) ); if ( '' !== $release['headline'] ) { printf( '

%s

', esc_html( $release['headline'] ) ); } if ( '' !== $release['summary'] ) { printf( '

%s

', esc_html( $release['summary'] ) ); } $this->render_moved( $release['moved'] ); $this->render_items( $release['items'] ); echo '
'; } /** * Prints the "where it went" map. * * @param array[] $moved Normalised rows. * @return void */ private function render_moved( array $moved ): void { if ( array() === $moved ) { return; } printf( '

%s

', esc_html__( 'Where things went', 'ai-blackout' ) ); echo ''; printf( '', esc_html__( 'Used to be', 'ai-blackout' ) ); printf( '', esc_html__( 'Now', 'ai-blackout' ) ); echo ''; foreach ( $moved as $row ) { $to = esc_html( $row['to'] ); if ( '' !== $row['page'] ) { $href = admin_url( 'admin.php?page=' . $row['page'] ); // Tabs are hash fragments, read back by assets/admin.js on load. if ( '' !== $row['tab'] ) { $href .= '#' . $row['tab']; } $to = sprintf( '%2$s', esc_url( $href ), $to ); } printf( '', esc_html( $row['from'] ), wp_kses( $to, array( 'a' => array( 'href' => array() ) ) ) ); } echo '
%s%s
%1$s%2$s
'; } /** * Prints the illustrated sections of one release. * * @param array[] $items Normalised sections. * @return void */ private function render_items( array $items ): void { if ( array() === $items ) { return; } echo '
'; foreach ( $items as $item ) { echo '
'; printf( '

%s

', esc_html( $item['title'] ) ); if ( '' !== $item['text'] ) { printf( '

%s

', esc_html( $item['text'] ) ); } if ( null !== $item['before'] && null !== $item['after'] ) { echo '
'; $this->render_figure( $item['before'], __( 'Before', 'ai-blackout' ) ); $this->render_figure( $item['after'], __( 'After', 'ai-blackout' ) ); echo '
'; } elseif ( null !== $item['image'] ) { $this->render_figure( $item['image'], '' ); } echo '
'; } echo '
'; } /** * Prints one bundled image. * * @param array $image Resolved image, from ReleaseNotes. * @param string $caption Optional caption above the image. * @return void */ private function render_figure( array $image, string $caption ): void { echo '
'; if ( '' !== $caption ) { printf( '
%s
', esc_html( $caption ) ); } printf( '%2$s', esc_url( $image['url'] ), esc_attr( $image['alt'] ), $image['width'] > 0 && $image['height'] > 0 ? sprintf( ' width="%1$d" height="%2$d"', absint( $image['width'] ), absint( $image['height'] ) ) : '' ); echo '
'; } /** * Prints the way out. * * @return void */ private function render_footer(): void { echo '

'; printf( '%2$s ', esc_url( MainScreen::url() ), esc_html__( 'Continue to AI Blackout', 'ai-blackout' ) ); printf( '%2$s', esc_url( self::CHANGELOG_URL ), esc_html__( 'Full changelog', 'ai-blackout' ) ); echo '

'; } /** * Records that this administrator has read up to the running version. * * @return void */ private function mark_read(): void { update_user_meta( get_current_user_id(), self::META_SEEN, AIBLACKOUT_VERSION ); // Clear the unread list once the screen body has been drawn. $this->unread = array(); } /** * Formats a release date in the site's own format and language. * * @param string $date A Y-m-d date from the notes file. * @return string The formatted date, or the raw value if it will not parse. */ private function format_date( string $date ): string { $timestamp = strtotime( $date ); return false === $timestamp ? $date : date_i18n( (string) get_option( 'date_format' ), $timestamp ); } }