markup into the raw post content. * * @package AILSUG */ namespace AILSUG\Suggest; use AILSUG\Activator; use AILSUG\Index\Collector; use AILSUG\Index\Eligibility; use AILSUG\Index\Indexer; use AILSUG\Index\LinkGraph; use AILSUG\Settings; defined( 'ABSPATH' ) || exit; /** * Surgical edits only: everything outside the inserted link stays byte-for-byte identical. * Every suggestion is revalidated against the current content right before applying. */ final class Applier { /** * Whether bulk apply is enabled (feature flag; on once the apply→revert corpus tests pass). * * @return bool */ public static function bulk_enabled() { /** * Filters whether applying many suggestions at once is enabled. * * @param bool $enabled Enabled. */ return (bool) apply_filters( 'ailsug_bulk_apply_enabled', defined( 'AILSUG_BULK_APPLY' ) ? (bool) AILSUG_BULK_APPLY : true ); } /** * Count what an apply would touch (for the confirmation dialog). * * @param int[] $ids Suggestion IDs. * @return array{posts:int,links:int} */ public static function preview( array $ids ) { $posts = array(); $links = 0; foreach ( $ids as $id ) { $s = Store::get( (int) $id ); if ( $s && in_array( $s['status'], array( 'pending', 'accepted' ), true ) && current_user_can( 'edit_post', (int) $s['source_post_id'] ) ) { $posts[ (int) $s['source_post_id'] ] = true; ++$links; } } return array( 'posts' => count( $posts ), 'links' => $links, ); } /** * Apply suggestions. * * @param int[] $ids Suggestion IDs. * @return array{batch_id:string,results:array[]} */ public static function apply( array $ids ) { $batch = wp_generate_uuid4(); $results = array(); $by_post = array(); foreach ( array_unique( array_map( 'intval', $ids ) ) as $id ) { $s = Store::get( $id ); if ( ! $s ) { $results[] = self::result( $id, 0, 'error', __( 'Suggestion not found.', 'linkcortex' ) ); continue; } if ( ! in_array( $s['status'], array( 'pending', 'accepted' ), true ) ) { $results[] = self::result( $id, (int) $s['source_post_id'], 'skipped', __( 'Only open suggestions can be applied.', 'linkcortex' ) ); continue; } $by_post[ (int) $s['source_post_id'] ][] = $s; } foreach ( $by_post as $post_id => $suggestions ) { try { $results = array_merge( $results, self::apply_to_post( $post_id, $suggestions, $batch ) ); } catch ( \Throwable $e ) { foreach ( $suggestions as $s ) { $results[] = self::result( (int) $s['id'], $post_id, 'error', __( 'Unexpected error; this page was not changed.', 'linkcortex' ) ); } } } /** * Fires after an apply batch. * * @param string $batch Batch ID. * @param array $results Results. */ do_action( 'ailsug_applied', $batch, $results ); return array( 'batch_id' => $batch, 'results' => $results, ); } /** * Apply several suggestions to one post with a single save (one revision). * * @param int $post_id Post ID. * @param array[] $suggestions Suggestion rows. * @param string $batch Batch ID. * @return array[] Results. */ private static function apply_to_post( $post_id, array $suggestions, $batch ) { global $wpdb; $t = Activator::tables(); $post = get_post( $post_id ); $out = array(); $fail = static function ( $message, $status = 'skipped' ) use ( $suggestions, $post_id ) { $r = array(); foreach ( $suggestions as $s ) { $r[] = self::result( (int) $s['id'], $post_id, $status, $message ); } return $r; }; if ( ! $post || ! Eligibility::is_eligible( $post ) ) { foreach ( $suggestions as $s ) { Store::update( (int) $s['id'], array( 'status' => 'stale' ) ); } return $fail( __( 'This page is no longer published or included, so the suggestion is out of date.', 'linkcortex' ), 'stale' ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return $fail( __( 'You are not allowed to edit this page.', 'linkcortex' ) ); } if ( ! function_exists( 'wp_check_post_lock' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } $lock = wp_check_post_lock( $post_id ); if ( $lock ) { $user = get_userdata( $lock ); /* translators: %s: user display name. */ return $fail( sprintf( __( '%s is editing this page right now. Try again later.', 'linkcortex' ), $user ? $user->display_name : __( 'Someone', 'linkcortex' ) ) ); } if ( Eligibility::is_page_builder( $post_id ) ) { return $fail( __( 'This page is built with a page builder. View only: copy the link and add it in the builder.', 'linkcortex' ) ); } $original = (string) $post->post_content; $content = $original; $hash = Collector::content_hash( $original ); $linked = array_flip( LinkGraph::targets_of( $post_id ) ); $limit = (int) Settings::get( 'max_links_per_apply', 5 ); $distance = (int) Settings::get( 'min_link_distance', 50 ); $new_tab = (bool) Settings::get( 'new_tab' ); $edits = array(); $positions = array(); $prev_active = wp_list_pluck( Content_Writer::active_records( $post_id ), 'inserted_markup' ); usort( $suggestions, static function ( $a, $b ) { return (int) $b['score'] <=> (int) $a['score']; } ); foreach ( $suggestions as $s ) { $sid = (int) $s['id']; $target = (int) $s['target_post_id']; if ( ! Eligibility::is_linkable_target( $target ) ) { Store::update( $sid, array( 'status' => 'stale' ) ); $out[] = self::result( $sid, $post_id, 'stale', __( 'The target page is no longer published.', 'linkcortex' ) ); continue; } if ( isset( $linked[ $target ] ) ) { $out[] = self::result( $sid, $post_id, 'skipped', __( 'This page already links to the target.', 'linkcortex' ) ); continue; } if ( count( $edits ) >= $limit ) { $out[] = self::result( $sid, $post_id, 'skipped', __( 'The limit of new links per page for one apply was reached. Apply again to add more.', 'linkcortex' ) ); continue; } // Revalidate against the current content (including links added in this batch). $map = new TextMap( $content ); $occ = $map->find( (string) $s['anchor_text'], (string) $s['chunk_id'] ); $where = 'chunk'; if ( ! $occ ) { $occ = $map->find( (string) $s['anchor_text'] ); $where = 'anywhere'; } if ( ! $occ ) { Store::update( $sid, array( 'status' => 'stale' ) ); $out[] = self::result( $sid, $post_id, 'stale', __( 'The anchor text is no longer in the page. Re-generate suggestions for this page.', 'linkcortex' ) ); continue; } $o = $occ[0]; $position = $map->word_position( $o ); foreach ( $positions as $p ) { if ( abs( $p - $position ) < $distance ) { $out[] = self::result( $sid, $post_id, 'skipped', __( 'Too close to another new link on this page.', 'linkcortex' ) ); continue 2; } } $all = $map->find( (string) $s['anchor_text'] ); $occurrence = 0; foreach ( $all as $i => $candidate ) { if ( $candidate['raw_start'] === $o['raw_start'] ) { $occurrence = $i; break; } } $url = get_permalink( $target ); $open = ''; $inner = substr( $content, $o['raw_start'], $o['raw_end'] - $o['raw_start'] ); $markup = $open . $inner . ''; /** * Filters the inserted link markup. Must remain "" + the anchor bytes + "". * * @param string $markup Markup. * @param array $s Suggestion. */ $filtered = (string) apply_filters( 'ailsug_link_markup', $markup, $s ); if ( 0 === strpos( $filtered, ' $s, 'markup' => $markup, 'start' => $o['raw_start'], 'occurrence' => $occurrence, 'where' => $where, 'context' => $map->context( $o ), ); }//end foreach if ( ! $edits ) { return $out; } $revision = Content_Writer::snapshot( $post_id ); $saved = Content_Writer::save( $post_id, $content ); if ( is_wp_error( $saved ) ) { foreach ( $edits as $e ) { $out[] = self::result( (int) $e['suggestion']['id'], $post_id, 'error', $saved->get_error_message() ); } return $out; } $modified = $saved !== $content; $markups = array_merge( $prev_active, wp_list_pluck( $edits, 'markup' ) ); $now = current_time( 'mysql', true ); foreach ( $edits as $e ) { $pos = null; $best = PHP_INT_MAX; foreach ( Content_Writer::positions( $saved, $e['markup'] ) as $candidate ) { $gap = abs( $candidate - $e['start'] ); if ( $candidate >= $e['start'] && $gap < $best ) { $best = $gap; $pos = $candidate; } } $sid = (int) $e['suggestion']['id']; if ( null === $pos ) { $out[] = self::result( $sid, $post_id, 'error', __( 'Another plugin changed the page while saving; this link could not be confirmed.', 'linkcortex' ) ); continue; } list( $before, $after ) = Content_Writer::contexts( $saved, $pos, strlen( $e['markup'] ), $markups ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Own table. $wpdb->insert( $t['applied'], array( 'suggestion_id' => $sid, 'post_id' => $post_id, 'target_post_id' => (int) $e['suggestion']['target_post_id'], 'batch_id' => $batch, 'content_hash_before' => $hash, 'content_hash_after' => Collector::content_hash( $saved ), 'inserted_markup' => $e['markup'], 'anchor_text' => (string) $e['suggestion']['anchor_text'], 'context_before' => $before, 'context_after' => $after, 'occurrence' => (int) $e['occurrence'], 'revision_id' => $revision, 'applied_by' => get_current_user_id(), 'applied_at' => $now, ), array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s' ) ); Store::update( $sid, array( 'status' => 'applied', 'applied_at' => $now, 'context_snippet' => $e['context']['before'] . "\u{2063}" . $e['context']['match'] . "\u{2063}" . $e['context']['after'], ) ); $message = 'chunk' === $e['where'] ? __( 'Link added.', 'linkcortex' ) : __( 'Link added (the paragraph had changed, so the first matching place was used).', 'linkcortex' ); if ( $modified ) { $message .= ' ' . __( 'Note: another plugin also adjusted the page while saving.', 'linkcortex' ); } $out[] = self::result( $sid, $post_id, 'applied', $message ); }//end foreach self::refresh_open_suggestions( $post_id, $saved ); Indexer::index_post( $post_id ); return $out; } /** * After a page changed through us, keep its other open suggestions if they still validate. * * @param int $post_id Post ID. * @param string $content New content. * @return void */ public static function refresh_open_suggestions( $post_id, $content ) { global $wpdb; $t = Activator::tables(); $hash = Collector::content_hash( $content ); $map = new TextMap( $content ); $linked = array_flip( LinkGraph::targets_of( $post_id ) ); foreach ( Content_Writer::active_records( $post_id ) as $r ) { $linked[ (int) $r['target_post_id'] ] = true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $open = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE source_post_id = %d AND status IN ('pending','accepted')", $t['suggestions'], (int) $post_id ), ARRAY_A ); foreach ( (array) $open as $s ) { $ok = ! isset( $linked[ (int) $s['target_post_id'] ] ) && $map->find( (string) $s['anchor_text'], (string) $s['chunk_id'] ); if ( $ok ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $wpdb->update( $t['suggestions'], array( 'source_hash' => $hash ), array( 'id' => (int) $s['id'] ), array( '%s' ), array( '%d' ) ); } else { Store::update( (int) $s['id'], array( 'status' => 'stale' ) ); } } } /** * Result row. * * @param int $id Suggestion ID. * @param int $post_id Post ID. * @param string $status applied|skipped|stale|error. * @param string $message Message. * @return array */ private static function result( $id, $post_id, $status, $message ) { return array( 'id' => (int) $id, 'post_id' => (int) $post_id, 'status' => $status, 'message' => $message, ); } }