1 && ! Applier::bulk_enabled() ) { self::finish( array( 'message' => __( 'Applying many links at once is turned off on this site. Apply them one at a time.', 'linkcortex' ) ) ); } $res = Applier::apply( $ids ); self::finish( array( 'results' => $res['results'] ) ); } self::finish( array( 'message' => __( 'This confirmation expired. Please select the links again.', 'linkcortex' ) ) ); } elseif ( 'accept_above' === $post_action ) { check_admin_referer( 'ailsug_accept_above' ); $min = isset( $_POST['min'] ) ? max( 1, min( 100, (int) $_POST['min'] ) ) : 80; $res = self::accept_above( $min ); self::finish( array( 'results' => $res['results'] ) ); }//end if } /** * Integer list from the request. * * @param string $key Key. * @return int[] */ private static function request_ids( $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked by caller; values cast to int. $raw = isset( $_REQUEST[ $key ] ) ? wp_unslash( $_REQUEST[ $key ] ) : ( 'ids' === $key && isset( $_REQUEST['id'] ) ? wp_unslash( $_REQUEST['id'] ) : array() ); return array_values( array_filter( array_map( 'absint', is_array( $raw ) ? $raw : explode( ',', (string) $raw ) ) ) ); } /** * Perform an action. * * @param string $action Action. * @param int[] $ids Suggestion IDs. * @param int[] $applied_ids Applied record IDs. * @param string $batch Batch ID. * @return void */ private static function dispatch( $action, array $ids, array $applied_ids, $batch ) { switch ( $action ) { case 'accept': case 'apply': if ( ! $ids ) { self::finish( array( 'message' => __( 'Select at least one suggestion to accept.', 'linkcortex' ) ) ); } if ( count( $ids ) > 1 && ! Applier::bulk_enabled() ) { self::finish( array( 'message' => __( 'Applying many links at once is turned off on this site. Apply them one at a time.', 'linkcortex' ) ) ); } $res = Applier::apply( $ids ); self::finish( array( 'results' => $res['results'] ) ); break; case 'reject': $n = 0; foreach ( $ids as $id ) { $s = Store::get( $id ); if ( $s && 'applied' !== $s['status'] && current_user_can( 'edit_post', (int) $s['source_post_id'] ) ) { $n += Store::update( $id, array( 'status' => 'rejected' ) ) ? 1 : 0; } } self::finish( array( /* translators: %s: count. */ 'message' => sprintf( _n( '%s suggestion rejected. It will not be suggested again.', '%s suggestions rejected. They will not be suggested again.', $n, 'linkcortex' ), number_format_i18n( $n ) ), ) ); break; case 'revert': $records = Reverter::records( $applied_ids ); self::finish( array( 'results' => $records ? Reverter::revert( $records ) : array() ) ); break; case 'revert_batch': $records = Reverter::records( array(), $batch ); self::finish( array( 'results' => $records ? Reverter::revert( $records ) : array() ) ); break; }//end switch } /** * Accept and apply all pending suggestions at or above a score (that the user may edit). * * @param int $min Minimum score. * @return array{batch_id?:string,results:array[]} */ private static function accept_above( $min ) { global $wpdb; $t = Activator::tables(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT s.id, s.source_post_id FROM %i s WHERE s.status = 'pending' AND s.score >= %d", $t['suggestions'], (int) $min ), ARRAY_A ); $ids = array(); foreach ( (array) $rows as $row ) { if ( current_user_can( 'edit_post', (int) $row['source_post_id'] ) ) { $ids[] = (int) $row['id']; } } if ( ! $ids ) { return array( 'results' => array( array( 'status' => 'skipped', 'post_id' => 0, 'message' => __( 'No pending suggestions met that relevance threshold.', 'linkcortex' ), ), ), ); } if ( count( $ids ) > 1 && ! Applier::bulk_enabled() ) { return array( 'results' => array( array( 'status' => 'error', 'post_id' => 0, 'message' => __( 'Applying many links at once is turned off on this site. Apply them one at a time.', 'linkcortex' ), ), ), ); } return Applier::apply( $ids ); } /** * Read and consume a confirmation token. * * @param string $token Token. * @return int[] */ private static function take_token( $token ) { $key = 'ailsug_apply_' . get_current_user_id() . '_' . $token; $ids = get_transient( $key ); delete_transient( $key ); return is_array( $ids ) ? array_map( 'intval', $ids ) : array(); } /** * Store a result notice and redirect back to the list. * * @param array $notice Notice data. * @return void */ private static function finish( array $notice ) { set_transient( 'ailsug_notice_' . get_current_user_id(), $notice, 5 * MINUTE_IN_SECONDS ); $referer = wp_get_referer(); $url = $referer && false !== strpos( $referer, 'page=' . Menu::SLUG . '-suggestions' ) ? remove_query_arg( array( 'ailsug_action', '_wpnonce', 'ids', 'applied_ids', 'batch', 'confirm' ), $referer ) : Menu::url( 'suggestions' ); wp_safe_redirect( $url ); exit; } /** * Render. * * @return void */ public static function render() { if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'linkcortex' ) ); } echo '
'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Token is user-bound and verified on submit. $token = isset( $_GET['confirm'] ) ? sanitize_key( wp_unslash( $_GET['confirm'] ) ) : ''; if ( $token ) { self::render_confirm( $token ); echo '
'; return; } echo '

' . esc_html__( 'Link suggestions', 'linkcortex' ) . '

'; $f = Suggestions_Table::filters(); $export = wp_nonce_url( add_query_arg( array_filter( array( 'action' => 'ailsug_export_suggestions', 'status' => $f['status'], 'post_type_filter' => $f['post_type'], 'min_score' => $f['min_score'], 'source' => $f['source'], ) ), admin_url( 'admin-post.php' ) ), 'ailsug_export_suggestions' ); echo ' ' . esc_html__( 'Export CSV', 'linkcortex' ) . ''; echo ' ' . esc_html__( 'Export applied links', 'linkcortex' ) . ''; echo '
'; echo '

' . esc_html__( 'Review each idea and accept suggestions to instantly add links to your pages. Every change is saved as a page revision and can be undone here.', 'linkcortex' ) . '

'; self::render_notice(); Keywords_Page::progress_box( Queue::active_run( array( 'regenerate', 'scan' ) ) ); if ( 'pending' === $f['status'] ) { echo '
'; wp_nonce_field( 'ailsug_accept_above' ); echo ''; echo ' '; echo ' '; submit_button( __( 'Accept & Apply', 'linkcortex' ), 'secondary', 'submit', false ); echo '
'; } $table = new Suggestions_Table(); $table->prepare_items(); $table->views(); echo '
'; echo ''; echo ''; if ( $f['source'] ) { echo ''; } $table->search_box( __( 'Search', 'linkcortex' ), 'ailsug-sugg-search' ); $table->display(); echo '
'; } /** * Confirmation screen before changing pages. * * @param string $token Token. * @return void */ private static function render_confirm( $token ) { $ids = get_transient( 'ailsug_apply_' . get_current_user_id() . '_' . $token ); echo '

' . esc_html__( 'Apply links to your pages?', 'linkcortex' ) . '

'; if ( ! is_array( $ids ) ) { echo '

' . esc_html__( 'This confirmation expired. Please select the links again.', 'linkcortex' ) . '

' . esc_html__( 'Back to suggestions', 'linkcortex' ) . '

'; return; } $preview = Applier::preview( $ids ); echo '
'; /* translators: 1: links, 2: pages. */ echo '

' . esc_html( sprintf( __( '%1$s links will be added to %2$s pages.', 'linkcortex' ), number_format_i18n( $preview['links'] ), number_format_i18n( $preview['posts'] ) ) ) . '

'; echo '

' . esc_html__( 'Only the link is added; nothing else in your pages changes. Each page is saved as a new revision, and you can undo every link from the Applied list.', 'linkcortex' ) . '

'; if ( $preview['links'] > 1 && ! Applier::bulk_enabled() ) { echo '

' . esc_html__( 'Applying many links at once is turned off on this site. Apply them one at a time.', 'linkcortex' ) . '

'; } echo '
'; echo '
'; wp_nonce_field( 'ailsug_confirm_apply' ); echo ''; submit_button( __( 'Apply links', 'linkcortex' ), 'primary', 'submit', false, $preview['links'] ? array() : array( 'disabled' => 'disabled' ) ); echo ' ' . esc_html__( 'Cancel', 'linkcortex' ) . ''; echo '
'; } /** * Show the stored result notice. * * @return void */ private static function render_notice() { $key = 'ailsug_notice_' . get_current_user_id(); $notice = get_transient( $key ); if ( ! is_array( $notice ) ) { return; } delete_transient( $key ); if ( ! empty( $notice['message'] ) ) { echo '

' . esc_html( $notice['message'] ) . '

'; } if ( ! isset( $notice['results'] ) ) { return; } $counts = array(); foreach ( $notice['results'] as $r ) { $counts[ $r['status'] ] = ( $counts[ $r['status'] ] ?? 0 ) + 1; } $labels = array( 'applied' => __( 'added', 'linkcortex' ), 'reverted' => __( 'removed', 'linkcortex' ), 'skipped' => __( 'skipped', 'linkcortex' ), 'stale' => __( 'out of date', 'linkcortex' ), 'manual' => __( 'need your attention', 'linkcortex' ), 'error' => __( 'failed', 'linkcortex' ), ); $parts = array(); foreach ( $counts as $status => $n ) { $parts[] = number_format_i18n( $n ) . ' ' . ( $labels[ $status ] ?? $status ); } $ok = empty( $counts['error'] ) && empty( $counts['manual'] ); $type = $ok ? 'notice-success' : 'notice-warning'; echo '

' . esc_html__( 'Links:', 'linkcortex' ) . ' ' . esc_html( $parts ? implode( ', ', $parts ) : __( 'nothing to do', 'linkcortex' ) ) . '

'; $details = array_filter( $notice['results'], static function ( $r ) { return ! in_array( $r['status'], array( 'applied', 'reverted' ), true ); } ); if ( $details ) { echo ''; } echo '
'; } /** * CSV of suggestions (current filters). * * @return void */ public static function export_suggestions() { global $wpdb; if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_export_suggestions' ); $f = Suggestions_Table::filters(); $from_params = Suggestions_Table::from_params(); $w_params = Suggestions_Table::where_params( $f, true ); $args = array_merge( $from_params, $w_params ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table + posts table. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Dynamic filter parameters passed as array. $wpdb->prepare( "SELECT s.*, src.title AS source_title, tgt.title AS target_title FROM %i s LEFT JOIN %i src ON src.post_id = s.source_post_id LEFT JOIN %i tgt ON tgt.post_id = s.target_post_id INNER JOIN %i p ON p.ID = s.source_post_id WHERE ( %s = 'all' OR s.status = %s ) AND ( %d = 0 OR s.score >= %d ) AND ( %s = '' OR src.post_type = %s ) AND ( %d = 0 OR EXISTS (SELECT 1 FROM %i tr INNER JOIN %i tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tr.object_id = s.source_post_id AND tt.term_id = %d) ) AND ( %d = 0 OR tgt.inbound_count = 0 ) AND ( %d = 0 OR s.source_post_id = %d ) AND ( %s = '' OR s.anchor_text LIKE %s OR src.title LIKE %s OR tgt.title LIKE %s ) AND ( %d = 0 OR p.post_author = %d ) ORDER BY s.score DESC", $args ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $out[] = array( get_permalink( (int) $r['source_post_id'] ), $r['source_title'], $r['anchor_text'], get_permalink( (int) $r['target_post_id'] ), $r['target_title'], $r['score'], $r['reason'], $r['status'], $r['created_at'] ); } Csv::download( 'internal-link-suggestions-' . gmdate( 'Y-m-d' ) . '.csv', array( 'Page URL', 'Page title', 'Link text', 'Target URL', 'Target title', 'Relevance', 'Reason', 'Status', 'Created (UTC)' ), $out ); } /** * CSV of applied links (history). * * @return void */ public static function export_applied() { global $wpdb; if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_export_applied' ); $t = Activator::tables(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC', $t['applied'] ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $user = get_userdata( (int) $r['applied_by'] ); $out[] = array( get_permalink( (int) $r['post_id'] ), $r['anchor_text'], get_permalink( (int) $r['target_post_id'] ), $r['applied_at'], $user ? $user->user_login : '', $r['reverted_at'] ? $r['reverted_at'] : '', $r['batch_id'], $r['revision_id'] ); } Csv::download( 'internal-links-applied-' . gmdate( 'Y-m-d' ) . '.csv', array( 'Page URL', 'Link text', 'Target URL', 'Applied (UTC)', 'Applied by', 'Undone (UTC)', 'Batch', 'Revision ID' ), $out ); } }