'POST', 'callback' => array( __CLASS__, 'find' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/links/apply', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'apply' ), 'permission_callback' => $auth ) ); register_rest_route( $ns, '/links/undo', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'undo' ), 'permission_callback' => $auth ) ); } /** The path of an address, lowercase, without a trailing slash ("/" for the home page). */ public static function path( $url ) { $p = wp_parse_url( (string) $url, PHP_URL_PATH ); $p = strtolower( rtrim( (string) $p, '/' ) ); return '' === $p ? '/' : $p; } /** Whether the content already links to the address. */ public static function links_to( $html, $url ) { $target = self::path( $url ); if ( ! preg_match_all( '/]*\bhref\s*=\s*["\']([^"\']+)["\']/i', $html, $m ) ) { return false; } foreach ( $m[1] as $href ) { if ( self::path( html_entity_decode( $href ) ) === $target ) { return true; } } return false; } /** The phrase as a pattern: whole words, any case, any run of spaces between words. */ private static function pattern( $phrase ) { $words = preg_split( '/[\s-]+/', trim( (string) $phrase ) ); $parts = array_map( function ( $w ) { return preg_quote( $w, '/' ); }, array_filter( $words, 'strlen' ) ); // Words may be split by spaces, hyphens or a non-breaking space ("white-label" matches "white label"). return '/(?|<[^>]+>)/s', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE ); $depth = 0; foreach ( $tokens as $t ) { list( $text, $offset ) = $t; if ( '' === $text ) { continue; } if ( '<' === $text[0] ) { if ( preg_match( '/^<\s*(\/)?\s*([a-zA-Z0-9]+)/', $text, $tag ) && in_array( strtolower( $tag[2] ), self::SKIP, true ) ) { if ( '/' === $tag[1] ) { $depth = max( 0, $depth - 1 ); } elseif ( '/>' !== substr( rtrim( $text ), -2 ) ) { ++$depth; } } continue; } if ( $depth > 0 ) { continue; } if ( preg_match( $pattern, $text, $m, PREG_OFFSET_CAPTURE ) ) { return array( $offset + $m[0][1], $m[0][0] ); } } return null; } /** Wrap the first linkable occurrence of the phrase in a link; null when there is none. */ public static function link_first( $html, $phrase, $url, $id ) { $hit = self::locate( $html, $phrase ); if ( ! $hit ) { return null; } list( $offset, $text ) = $hit; $a = '' . $text . ''; return substr( $html, 0, $offset ) . $a . substr( $html, $offset + strlen( $text ) ); } /** Remove the links Gralus added with these ids, keeping their text. Returns array( html, removed ). */ public static function unlink( $html, $ids ) { $removed = 0; foreach ( $ids as $id ) { $pattern = '/]*\b' . preg_quote( self::ATTR, '/' ) . '="' . preg_quote( (string) $id, '/' ) . '"[^>]*>(.*?)<\/a>/s'; $html = preg_replace( $pattern, '$1', $html, -1, $count ); $removed += $count; } return array( $html, $removed ); } /** A short piece of text around the phrase, so the reviewer sees where the link goes. */ private static function snippet( $html, $offset, $length ) { $before = wp_strip_all_tags( substr( $html, max( 0, $offset - 400 ), min( 400, $offset ) ) ); $after = wp_strip_all_tags( substr( $html, $offset + $length, 400 ) ); $before = mb_substr( preg_replace( '/\s+/', ' ', $before ), -70 ); $after = mb_substr( preg_replace( '/\s+/', ' ', $after ), 0, 70 ); return array( trim( $before ), trim( $after ) ); } /** Save content as-is (no kses: the author's own embeds must survive), keeping a revision for the record. */ private static function save( $post_id, $html ) { if ( function_exists( 'wp_save_post_revision' ) ) { wp_save_post_revision( $post_id ); } kses_remove_filters(); $r = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $html ) ), true ); kses_init_filters(); return ! is_wp_error( $r ); } /** * Body: { targets: [{ url, phrases: string[] }], perSource?: int, limit?: int }. * Returns the link opportunities in published posts and pages, newest first. */ public static function find( WP_REST_Request $request ) { $p = $request->get_json_params(); $targets = isset( $p['targets'] ) && is_array( $p['targets'] ) ? array_slice( $p['targets'], 0, 60 ) : array(); $per_source = isset( $p['perSource'] ) ? max( 1, min( 5, (int) $p['perSource'] ) ) : 3; $limit = isset( $p['limit'] ) ? max( 1, min( 300, (int) $p['limit'] ) ) : 150; $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'post_status' => 'publish', 'posts_per_page' => 600, 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true, ) ); $out = array(); foreach ( $posts as $post ) { $html = (string) $post->post_content; if ( '' === trim( $html ) ) { continue; } $url = get_permalink( $post ); $found = 0; foreach ( $targets as $t ) { $target = isset( $t['url'] ) ? esc_url_raw( (string) $t['url'] ) : ''; if ( '' === $target || self::path( $target ) === self::path( $url ) || self::links_to( $html, $target ) ) { continue; } foreach ( (array) ( $t['phrases'] ?? array() ) as $phrase ) { $hit = self::locate( $html, (string) $phrase ); if ( ! $hit ) { continue; } list( $before, $after ) = self::snippet( $html, $hit[0], strlen( $hit[1] ) ); $out[] = array( 'postId' => (int) $post->ID, 'title' => get_the_title( $post ), 'url' => $url, 'phrase' => $hit[1], 'targetUrl' => $target, 'before' => $before, 'after' => $after, ); ++$found; break; } if ( $found >= $per_source || count( $out ) >= $limit ) { break; } } if ( count( $out ) >= $limit ) { break; } } return array( 'links' => $out, 'scanned' => count( $posts ) ); } /** Body: { links: [{ id, postId, phrase, targetUrl }] }. Adds each link where its phrase is still unlinked. */ public static function apply( WP_REST_Request $request ) { $p = $request->get_json_params(); $links = isset( $p['links'] ) && is_array( $p['links'] ) ? array_slice( $p['links'], 0, 300 ) : array(); $by_post = array(); foreach ( $links as $l ) { $by_post[ (int) ( $l['postId'] ?? 0 ) ][] = $l; } $added = array(); $skipped = array(); foreach ( $by_post as $post_id => $list ) { $post = $post_id ? get_post( $post_id ) : null; if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) { foreach ( $list as $l ) { $skipped[] = (string) ( $l['id'] ?? '' ); } continue; } $html = (string) $post->post_content; $changed = false; foreach ( $list as $l ) { $id = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) ( $l['id'] ?? '' ) ); $target = esc_url_raw( (string) ( $l['targetUrl'] ?? '' ) ); $next = ( '' === $id || '' === $target || self::links_to( $html, $target ) ) ? null : self::link_first( $html, (string) ( $l['phrase'] ?? '' ), $target, $id ); if ( null === $next ) { $skipped[] = $id; continue; } $html = $next; $changed = true; $added[] = $id; } if ( $changed && ! self::save( $post_id, $html ) ) { $skipped = array_merge( $skipped, $added ); $added = array(); } } return array( 'added' => $added, 'skipped' => array_values( array_filter( $skipped ) ) ); } /** Body: { links: [{ id, postId }] }. Removes the links Gralus added, keeping their text. */ public static function undo( WP_REST_Request $request ) { $p = $request->get_json_params(); $links = isset( $p['links'] ) && is_array( $p['links'] ) ? $p['links'] : array(); $by_post = array(); foreach ( $links as $l ) { $by_post[ (int) ( $l['postId'] ?? 0 ) ][] = preg_replace( '/[^a-zA-Z0-9-]/', '', (string) ( $l['id'] ?? '' ) ); } $removed = 0; foreach ( $by_post as $post_id => $ids ) { $post = $post_id ? get_post( $post_id ) : null; if ( ! $post ) { continue; } list( $html, $n ) = self::unlink( (string) $post->post_content, array_filter( $ids ) ); if ( $n && self::save( $post_id, $html ) ) { $removed += $n; } } return array( 'removed' => $removed ); } }