get_error_code() ) { return; // Transient outage: keep yesterday's report rather than a hole. } $previous = get_option( 'coverground_report' ); $previous = is_array( $previous ) ? $previous : array(); // A status outage keeps the last known state, so it cannot end a lapse // and re-arm a dismissed notice when the status endpoint returns. $available = is_wp_error( $status ) ? (bool) ( $previous['available'] ?? true ) : (bool) ( $status['routes_available'] ?? true ); // The lapse keeps the timestamp it started with; only a new lapse (after // being available again) gets a new one, and with it a new notice hash. $lapsed_since = null; if ( ! $available ) { $lapsed_since = ( ! ( $previous['available'] ?? true ) && ! empty( $previous['lapsed_since'] ) ) ? (int) $previous['lapsed_since'] : time(); } $report = array( 'checked_at' => time(), 'available' => $available, 'lapsed_since' => $lapsed_since, 'stale' => array(), 'unpublished' => array(), ); if ( ! is_wp_error( $routes ) ) { $updated = array(); foreach ( $routes as $route ) { $updated[ $route['ref'] ] = $route['updated_at'] ?? null; } foreach ( coverground_find_route_blocks() as $found ) { $entry = array( 'post_id' => $found['post_id'], 'post_title' => $found['post_title'], 'ref' => $found['ref'], ); if ( ! array_key_exists( $found['ref'], $updated ) ) { $report['unpublished'][] = $entry; } elseif ( $found['seededAt'] && $updated[ $found['ref'] ] && strtotime( $updated[ $found['ref'] ] ) > strtotime( $found['seededAt'] ) ) { $report['stale'][] = $entry; } } } $report['hashes'] = array( 'status' => $report['available'] ? '' : md5( 'unavailable' . $report['lapsed_since'] ), 'stale' => $report['stale'] ? md5( wp_json_encode( $report['stale'] ) ) : '', 'unpublished' => $report['unpublished'] ? md5( wp_json_encode( $report['unpublished'] ) ) : '', ); update_option( 'coverground_report', $report, false ); } /** * Every coverground/route block in published content, with the attributes the * report needs. The LIKE pre-filter narrows the scan; parse_blocks walks * nested structures so cards inside groups/columns are found too. * * @return array */ function coverground_find_route_blocks(): array { $query = new WP_Query( array( 'post_type' => 'any', 'post_status' => 'publish', 's' => 'wp:coverground/route', 'posts_per_page' => 200, 'fields' => 'ids', 'no_found_rows' => true, ) ); $found = array(); foreach ( $query->posts as $post_id ) { $post = get_post( $post_id ); foreach ( coverground_collect_route_blocks( parse_blocks( $post->post_content ) ) as $attrs ) { if ( ! empty( $attrs['routeRef'] ) ) { $found[] = array( 'post_id' => $post_id, 'post_title' => $post->post_title, 'ref' => (string) $attrs['routeRef'], 'seededAt' => (string) ( $attrs['seededAt'] ?? '' ), ); } } } return $found; } /** @return array Attribute sets of route blocks, nested included. */ function coverground_collect_route_blocks( array $blocks ): array { $attrs = array(); foreach ( $blocks as $block ) { if ( 'coverground/route' === ( $block['blockName'] ?? '' ) ) { $attrs[] = $block['attrs'] ?? array(); } if ( ! empty( $block['innerBlocks'] ) ) { $attrs = array_merge( $attrs, coverground_collect_route_blocks( $block['innerBlocks'] ) ); } } return $attrs; } /** * The setup notice: the plugin does nothing until the site is connected, and * nothing on the Plugins screen you land on after activating says so. * * Shown until the connection is made OR attempted. "Attempted" rather than * "succeeded" because someone who reached the consent screen and declined has * been told where this lives; repeating it would be nagging, and the Plugins * screen now carries a Settings link for when they change their mind. * * NOT dismissible, and that is the one thing separating it from the other * three. Those report findings that come and go, so dismissing one is a * judgement about that finding. This reports an unfinished setup: a dismiss * button would hide the only pointer to the step that makes the plugin work, * permanently, on the say-so of one click. It clears itself the moment the * setup is dealt with, which is what a dismissal would have been for. * * Not shown on the plugin's own settings page — the Connect button is already * on screen there, and a notice pointing at the page you are reading is noise. */ function coverground_setup_notice(): void { // Only the people who can actually connect the site. if ( ! current_user_can( 'manage_options' ) ) { return; } if ( coverground_is_connected() || get_option( 'coverground_connect_attempted' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->base, array( 'plugins', 'dashboard', 'edit' ), true ) ) { return; } // "Coverground route" is the block's registered title (src/block.json) — // the name the user will actually read in the inserter. The plugin already // calls this thing a route card elsewhere, which is right when talking // about one placed in a post; here nothing is placed yet, so the name to // use is the one they will go looking for. $message = sprintf( /* translators: %s: link to the Coverground settings page. */ esc_html__( 'Coverground is active but not connected to an account. The Coverground route block has no routes to list until you connect. %s.', 'coverground' ), sprintf( '%2$s', esc_url( coverground_settings_url() ), // Names its destination on its own. This notice can render on the // Plugins screen among a column of other plugins' links, where // "Connect" or "Settings" alone says nothing about what to. Naming // the page rather than the act also keeps the name honest: this is // a link to a screen, not the button that does the connecting. esc_html__( 'Connect Coverground on the settings page', 'coverground' ) ) ); // wp_admin_notice() interpolates the message verbatim — escaping is the // caller's job, done per part above. // // Two constraints for whoever touches this next. Keep it in normal flow: // position:sticky or fixed would let it cover a focused row action in a // long plugin list (2.4.11). And if a hide control is ever wanted, do NOT // reuse the bare X from coverground_render_notice() — that X means // "dismiss until this finding changes" everywhere else in this plugin, and // a lookalike meaning something else is a 3.2.4 failure. Use a labelled // button, and note that notices.js focuses .coverground-notice on removal, // which this class does not match. wp_admin_notice( $message, array( 'type' => 'info', 'dismissible' => false, 'additional_classes' => array( 'coverground-setup-notice' ), ) ); } add_action( 'admin_notices', 'coverground_setup_notice' ); /** * Render the notices — only on screens where they are actionable (dashboard, * post lists, our settings page), never as a site-wide nag. */ function coverground_admin_notices(): void { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! in_array( $screen->base, array( 'dashboard', 'edit', 'settings_page_coverground' ), true ) ) { return; } $report = get_option( 'coverground_report' ); if ( ! is_array( $report ) ) { return; } $dismissed = (array) get_user_meta( get_current_user_id(), 'coverground_dismissed', true ); if ( ! $report['available'] && current_user_can( 'manage_options' ) && ( $dismissed['status'] ?? '' ) !== $report['hashes']['status'] ) { coverground_render_notice( 'error', 'status', $report['hashes']['status'], '

' . esc_html__( 'Your Coverground subscription has lapsed: visitors currently get an unavailable page instead of your routes. Renew it on Coverground to bring them back.', 'coverground' ) . '

' ); } if ( ! current_user_can( 'edit_posts' ) ) { return; } if ( $report['stale'] && ( $dismissed['stale'] ?? '' ) !== $report['hashes']['stale'] ) { $summary = sprintf( /* translators: %d: number of route cards. */ _n( '%d Coverground route card is out of date — the route changed on Coverground after the card was placed.', '%d Coverground route cards are out of date — the routes changed on Coverground after the cards were placed.', count( $report['stale'] ), 'coverground' ), count( $report['stale'] ) ); coverground_render_notice( 'warning', 'stale', $report['hashes']['stale'], '

' . esc_html( $summary ) . '

' . coverground_edit_links_list( $report['stale'] ) ); } if ( $report['unpublished'] && ( $dismissed['unpublished'] ?? '' ) !== $report['hashes']['unpublished'] ) { $summary = sprintf( /* translators: %d: number of route cards. */ _n( '%d Coverground route card points to a route that is no longer published — its button leads visitors to a missing page.', '%d Coverground route cards point to routes that are no longer published — their buttons lead visitors to missing pages.', count( $report['unpublished'] ), 'coverground' ), count( $report['unpublished'] ) ); coverground_render_notice( 'error', 'unpublished', $report['hashes']['unpublished'], '

' . esc_html( $summary ) . '

' . coverground_edit_links_list( $report['unpublished'] ) ); } } add_action( 'admin_notices', 'coverground_admin_notices' ); /** Uniquely-named, same-tab edit links — never N bare "Edit" links. */ function coverground_edit_links_list( array $entries ): string { $items = ''; foreach ( $entries as $entry ) { $link = get_edit_post_link( $entry['post_id'] ); if ( $link ) { $items .= '
  • ' . sprintf( /* translators: %s: post title. */ esc_html__( 'Edit “%s”', 'coverground' ), esc_html( $entry['post_title'] ) ) . '
  • '; } } return $items ? '' : ''; } /** * Our own dismiss button — NOT core's `.is-dismissible` — because core's * removes the node with focus still inside it. Our handler (notices.js) moves * focus first, then removes, then persists the hash per user. */ function coverground_render_notice( string $type, string $notice_type, string $hash, string $content ): void { printf( '
    %4$s
    ', esc_attr( $type ), esc_attr( $notice_type ), esc_attr( $hash ), $content, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts above. esc_html__( 'Dismiss this Coverground notice.', 'coverground' ) ); } /** The dismiss handler script, only where notices can render. */ function coverground_notices_assets( string $hook ): void { if ( ! in_array( $hook, array( 'index.php', 'edit.php', 'settings_page_coverground' ), true ) ) { return; } wp_enqueue_script( 'coverground-notices', plugins_url( 'assets/notices.js', COVERGROUND_PLUGIN_FILE ), array( 'wp-a11y', 'wp-i18n' ), COVERGROUND_VERSION, true ); wp_set_script_translations( 'coverground-notices', 'coverground' ); wp_add_inline_script( 'coverground-notices', 'window.covergroundNotices = ' . wp_json_encode( array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'coverground_dismiss' ), ) ) . ';', 'before' ); } add_action( 'admin_enqueue_scripts', 'coverground_notices_assets' ); /** Persist a dismissal per user, per notice type, keyed by the report hash. */ function coverground_handle_dismiss(): void { check_ajax_referer( 'coverground_dismiss' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( null, 403 ); } $type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : ''; $hash = isset( $_POST['hash'] ) ? sanitize_text_field( wp_unslash( $_POST['hash'] ) ) : ''; if ( ! in_array( $type, array( 'status', 'stale', 'unpublished' ), true ) || '' === $hash ) { wp_send_json_error( null, 400 ); } $dismissed = (array) get_user_meta( get_current_user_id(), 'coverground_dismissed', true ); $dismissed[ $type ] = $hash; update_user_meta( get_current_user_id(), 'coverground_dismissed', $dismissed ); wp_send_json_success(); } add_action( 'wp_ajax_coverground_dismiss', 'coverground_handle_dismiss' );