store = $store; } /** * Enqueues the bulk-scan progress script, on this screen only. * * @since 1.5.0 * * @return void */ public function enqueue_assets() { wp_enqueue_script( self::SCRIPT_HANDLE, AIVE_PLUGIN_URL . 'assets/js/answer-density-admin.js', array(), AIVE_VERSION, true ); wp_localize_script( self::SCRIPT_HANDLE, 'aiveAnswerDensity', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( AnswerDensity::NONCE_ACTION ), 'scanAction' => AnswerDensity::AJAX_SCAN, 'bulkAction' => AnswerDensity::AJAX_BULK, 'i18n' => array( 'starting' => __( 'Starting…', 'summitcite' ), 'scanning' => __( 'Scanning…', 'summitcite' ), /* translators: 1: number processed, 2: total number of items. */ 'progress' => __( 'Scanned %1$d of %2$d.', 'summitcite' ), 'done' => __( 'Scan complete. Reloading…', 'summitcite' ), 'error' => __( 'The scan could not continue. Reload the page and try again.', 'summitcite' ), 'scanOne' => __( 'Scan', 'summitcite' ), 'scanOneBusy' => __( 'Scanning…', 'summitcite' ), 'scanOneDone' => __( 'Scanned.', 'summitcite' ), 'scanOneError' => __( 'Could not scan this item.', 'summitcite' ), /* translators: 1: coverage ratio, two decimal places. 2: coverage points, one decimal place. 3: quality points, one decimal place. 4: structure points, one decimal place. 5: algorithm version number. */ 'scoreBreakdown' => __( 'Coverage %1$s (%2$s pts) · Quality %3$s pts · Structure %4$s pts · Algorithm v%5$d', 'summitcite' ), ), ) ); } /** * Renders the screen. * * The capability check is repeated here because a page callback is reachable * by URL independently of the menu that registered it. * * @since 1.5.0 * * @return void */ public function render() { if ( ! current_user_can( Admin::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view the SummitCite answer density screen.', 'summitcite' ), '', array( 'response' => 403 ) ); } $enabled = Helpers::is_module_enabled( AnswerDensity::SLUG ); $types = $enabled ? AnswerDensity::configured_post_types() : array(); ?>

render_intro(); ?> render_disabled_notice(); ?> render_no_types_notice(); ?> render_summary( $types ); $this->render_scan_all( $types ); $this->render_table( $types ); ?>
%s

', esc_html__( 'Answer Density measures how much of a published item\'s heading structure reads as a question, and how much of that is answered immediately rather than buried further down. It is a deterministic, local heuristic about structure only.', 'summitcite' ) ); printf( '

%s

', esc_html__( 'A score says nothing about whether an answer is factually correct, complete or well written, does not predict how any AI system will treat the page, and is not a ranking or citation guarantee.', 'summitcite' ) ); } /** * Renders the disabled-module notice. No score table follows it. * * @since 1.5.0 * * @return void */ private function render_disabled_notice() { printf( '

%1$s %2$s %4$s

', esc_html__( 'Switched off:', 'summitcite' ), esc_html__( 'Answer Density is disabled, so no scan runs and no score is shown. Stored results from before it was switched off are kept.', 'summitcite' ), esc_url( admin_url( 'admin.php?page=' . Settings::PAGE_SLUG . '#' . Settings::SECTION_ANSWER_DENSITY ) ), esc_html__( 'Open Answer Density settings', 'summitcite' ) ); } /** * Renders the notice shown when no post type is configured at all. * * @since 1.5.0 * * @return void */ private function render_no_types_notice() { printf( '

%1$s %2$s %4$s

', esc_html__( 'Nothing configured:', 'summitcite' ), esc_html__( 'No post type is currently configured for scoring, so there is nothing to scan or list.', 'summitcite' ), esc_url( admin_url( 'admin.php?page=' . Settings::PAGE_SLUG . '#' . Settings::SECTION_ANSWER_DENSITY ) ), esc_html__( 'Choose post types', 'summitcite' ) ); } /** * Renders the average score, scanned count and per-type breakdown. * * @since 1.5.0 * * @param array $types Currently configured and allowed post types. * @return void */ private function render_summary( array $types ) { $summary = $this->store->summary( $types ); if ( null === $summary ) { printf( '

%s

', esc_html__( 'The summary could not be loaded.', 'summitcite' ) ); return; } ?>

$count ) : ?>
$types Currently configured and allowed post types. * @return void */ private function render_scan_all( array $types ) { $snapshot = $this->store->eligible_snapshot( $types ); $total = null === $snapshot ? 0 : count( $snapshot ); ?>

%s

', esc_html__( 'There is no eligible published content to scan yet.', 'summitcite' ) ); } } /** * Renders the type filter and the worst-first results table. * * @since 1.5.0 * * @param array $types Currently configured and allowed post types. * @return void */ private function render_table( array $types ) { $filter = $this->requested_type_filter( $types ); $paged = $this->requested_page(); $offset = ( $paged - 1 ) * self::PER_PAGE; $page = $this->store->listing( $types, $filter, self::PER_PAGE, $offset ); ?>

render_filter_form( $types, $filter ); ?> %s

', esc_html__( 'The list could not be loaded.', 'summitcite' ) ); return; } if ( 0 === $page['total'] ) { printf( '

%s

', esc_html__( 'No eligible published content matches this filter yet.', 'summitcite' ) ); return; } ?> render_row( $row ); ?>
render_pagination( $page['total'], $paged, $filter ); } /** * Renders one table row. * * @since 1.5.0 * * @param array $row Row from AnswerDensityStore::listing(). * @return void */ private function render_row( array $row ) { $edit_link = get_edit_post_link( $row['post_id'], 'raw' ); $title = get_the_title( $row['post_id'] ); $title = '' === $title ? __( '(no title)', 'summitcite' ) : $title; ?> score_cell( $row ) ); ?> breakdown_cell( $row ) ); ?> score_breakdown_cell( $row ) ); ?> scanned_at_cell( $row ) ); ?> $row Row from AnswerDensityStore::listing(). * @return string Cell text. */ private function score_cell( array $row ) { if ( null === $row['state'] ) { return __( 'Not yet scanned', 'summitcite' ); } if ( AnswerDensityParser::STATE_ERROR === $row['state'] ) { return __( 'Error — content too large to scan', 'summitcite' ); } return sprintf( /* translators: %d: score out of 100. */ __( '%d / 100', 'summitcite' ), (int) $row['score'] ); } /** * Returns the text content of the direct/buried/unanswered cell. * * @since 1.5.0 * * @param array $row Row from AnswerDensityStore::listing(). * @return string Cell text. */ private function breakdown_cell( array $row ) { if ( null === $row['state'] || AnswerDensityParser::STATE_ERROR === $row['state'] ) { return '—'; } return sprintf( '%1$d / %2$d / %3$d', (int) $row['direct'], (int) $row['buried'], (int) $row['unanswered'] ); } /** * Returns the text content of the transparent score breakdown cell. * * Surfaces the exact values {@see \PressRoost\AIVE\AnswerDensity\AnswerDensityScorer::summarize()} * already computes - the contract requires the UI itself to show the raw * coverage ratio, each point contribution and the algorithm version, not * only the final score. * * @since 1.7.1 * * @param array $row Row from AnswerDensityStore::listing(). * @return string Cell text. */ private function score_breakdown_cell( array $row ) { if ( null === $row['state'] || AnswerDensityParser::STATE_ERROR === $row['state'] ) { return '—'; } $breakdown = $row['score_breakdown']; if ( ! isset( $breakdown['coverage_raw'], $breakdown['coverage_points'], $breakdown['quality_points'], $breakdown['structure_points'] ) || null === $row['algorithm_version'] ) { return '—'; } return sprintf( /* translators: 1: coverage ratio, two decimal places. 2: coverage points, one decimal place. 3: quality points, one decimal place. 4: structure points, one decimal place. 5: algorithm version number. */ __( 'Coverage %1$s (%2$s pts) · Quality %3$s pts · Structure %4$s pts · Algorithm v%5$d', 'summitcite' ), number_format_i18n( (float) $breakdown['coverage_raw'], 2 ), number_format_i18n( (float) $breakdown['coverage_points'], 1 ), number_format_i18n( (float) $breakdown['quality_points'], 1 ), number_format_i18n( (float) $breakdown['structure_points'], 1 ), (int) $row['algorithm_version'] ); } /** * Returns the text content of the last-scanned cell, in the site timezone. * * @since 1.5.0 * * @param array $row Row from AnswerDensityStore::listing(). * @return string Cell text. */ private function scanned_at_cell( array $row ) { if ( null === $row['scanned_at'] ) { return __( 'Never', 'summitcite' ); } $timestamp = strtotime( $row['scanned_at'] . ' UTC' ); if ( false === $timestamp ) { return __( 'Unknown', 'summitcite' ); } return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ); } /** * Renders the type filter form. * * @since 1.5.0 * * @param array $types Currently configured and allowed post types. * @param string $filter Requested type, or '' for every configured type. * @return void */ private function render_filter_form( array $types, $filter ) { ?>
self::PAGE_SLUG ); if ( '' !== $filter ) { $base_args['aive_density_type'] = $filter; } ?>

1 ) : ?>

$types Currently configured and allowed post types. * @return string Validated type, or ''. */ private function requested_type_filter( array $types ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A read-only GET filter that changes no state; capability is checked in render(). if ( ! isset( $_GET['aive_density_type'] ) || ! is_scalar( $_GET['aive_density_type'] ) ) { return ''; } $requested = sanitize_key( wp_unslash( $_GET['aive_density_type'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- See above. return in_array( $requested, $types, true ) ? $requested : ''; } /** * Returns the display label for a post type. * * @since 1.5.0 * * @param string $type Post type name. * @return string Label. */ private static function type_label( $type ) { $object = get_post_type_object( $type ); return null === $object ? $type : (string) $object->labels->singular_name; } }