'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 );
}
}