post_content, get_post( $target_id ) ); if ( null === $content ) { return new WP_Error( 'lynkora_no_mention', __( 'The title is no longer mentioned in plain text there. It may already be linked.', 'lynkora' ) ); } return self::save( $source_id, $content ); } /** * Add a link from the source to the target: the first plain-text mention * of the title becomes the link; with no mention, a "See also" line is * added at the end. * * @param int $source_id Post to add the link to. * @param int $target_id Post to link to. * @return string|WP_Error 'wrapped' or 'appended'. */ public static function add_link( $source_id, $target_id ) { $check = self::check_pair( $source_id, $target_id ); if ( is_wp_error( $check ) ) { return $check; } $source = get_post( $source_id ); $target = get_post( $target_id ); $content = self::wrap_mention( $source->post_content, $target ); $how = 'wrapped'; if ( null === $content ) { $how = 'appended'; $line = sprintf( /* translators: %s: link to a page */ __( 'See also: %s', 'lynkora' ), sprintf( '%2$s', esc_url( get_permalink( $target ) ), esc_html( Lynkora_Related::plain_title( $target->post_title ) ) ) ); $block = has_blocks( $source->post_content ); $content = rtrim( $source->post_content ) . "\n\n" . ( $block ? "\n
" . $line . "
\n" : '' . $line . '
' ); } $saved = self::save( $source_id, $content ); return is_wp_error( $saved ) ? $saved : $how; } /** * @param int $source_id Source post. * @param int $target_id Target post. * @return true|WP_Error */ private static function check_pair( $source_id, $target_id ) { $source = get_post( $source_id ); $target = get_post( $target_id ); if ( ! $source || ! $target || $source->ID === $target->ID || 'publish' !== $target->post_status ) { return new WP_Error( 'lynkora_missing', __( 'One of those pages no longer exists or is not published.', 'lynkora' ) ); } if ( ! current_user_can( 'edit_post', $source->ID ) ) { return new WP_Error( 'lynkora_cap', __( 'You are not allowed to edit that content.', 'lynkora' ) ); } return true; } /** * Link the first plain-text mention of a title. Text inside existing * links, headings, code, buttons, HTML comments (block markup) and * shortcodes is left alone. * * @param string $content Content. * @param WP_Post $target Post to link to. * @return string|null Changed content, or null if there is no mention. */ public static function wrap_mention( $content, $target ) { $title = Lynkora_Related::plain_title( $target->post_title ); if ( ! Lynkora_Related::title_is_specific( $title ) ) { return null; } $pattern = '/(?|<[^>]+>|\[[^\]]*\])#s', $content, -1, PREG_SPLIT_DELIM_CAPTURE ); $inside = 0; if ( ! is_array( $parts ) ) { return null; } foreach ( $parts as $i => $part ) { if ( 1 === $i % 2 ) { // A tag, comment or shortcode. if ( preg_match( '#^<(/?)([a-z][a-z0-9]*)#i', $part, $tag ) && in_array( strtolower( $tag[2] ), self::NO_LINK_TAGS, true ) && '/>' !== substr( $part, -2 ) ) { $inside = '/' === $tag[1] ? max( 0, $inside - 1 ) : $inside + 1; } continue; } if ( $inside || ! preg_match( $pattern, $part, $m, PREG_OFFSET_CAPTURE ) ) { continue; } $link = sprintf( '%2$s', esc_url( get_permalink( $target ) ), $m[0][0] ); $parts[ $i ] = substr( $part, 0, $m[0][1] ) . $link . substr( $part, $m[0][1] + strlen( $m[0][0] ) ); return implode( '', $parts ); } return null; } /** * Regex for a title as it may appear in HTML text: "&" may be written * as an entity, spaces may be non-breaking, apostrophes may be curly. * * @param string $title Plain title. * @return string */ private static function title_pattern( $title ) { $out = ''; foreach ( preg_split( '//u', preg_replace( '/\s+/u', ' ', $title ), -1, PREG_SPLIT_NO_EMPTY ) as $char ) { if ( '&' === $char ) { $out .= '(?:&|?38;|&)'; } elseif ( ' ' === $char ) { $out .= '(?:\s| | )+'; } elseif ( "'" === $char || "\u{2019}" === $char ) { $out .= "(?:'|?39;|’|’|\u{2019})"; } else { $out .= preg_quote( $char, '/' ); } } return $out; } // ------------------------------------------------------------------ // Removing and repointing links // ------------------------------------------------------------------ /** * Remove broken links but keep their text. Menu items that point to a * missing page are removed from the menu. * * @param array $row_ids Index row IDs from the broken links report. * @return array { removed: int, skipped: int } */ public static function unlink( array $row_ids ) { $report = new Lynkora_Report(); $allowed = array(); foreach ( $report->broken() as $row ) { if ( in_array( $row['problem'], self::REMOVABLE, true ) ) { $allowed[ $row['row_id'] ] = $row; } } $by_source = array(); $skipped = 0; $removed = 0; $menus = false; foreach ( array_unique( array_map( 'intval', $row_ids ) ) as $row_id ) { if ( ! isset( $allowed[ $row_id ] ) ) { ++$skipped; continue; } $row = $allowed[ $row_id ]; if ( 'menu' === $row['source_type'] ) { $item_id = self::menu_item_id( $row['source_ref'] ); if ( $item_id && current_user_can( 'edit_theme_options' ) && 'nav_menu_item' === get_post_type( $item_id ) ) { wp_delete_post( $item_id, true ); ++$removed; $menus = true; } else { ++$skipped; } continue; } if ( ! in_array( $row['source_type'], array( 'post', 'pattern', 'template' ), true ) ) { ++$skipped; // Widgets are edited on the Widgets screen. continue; } $by_source[ $row['source_id'] ][ $row['url'] ] = true; } $resolver = new Lynkora_Resolver(); foreach ( $by_source as $post_id => $urls ) { $post = get_post( $post_id ); if ( ! $post || ! current_user_can( 'edit_post', $post_id ) ) { $skipped += count( $urls ); continue; } $count = 0; $content = self::each_link( $post->post_content, function ( $m ) use ( $resolver, $urls, &$count ) { $absolute = $resolver->absolute_internal( self::href( $m['href'] ) ); if ( null === $absolute || ! isset( $urls[ $absolute ] ) ) { return null; } // A button without a link would break the block; leave it. if ( preg_match( '#class\s*=\s*["\'][^"\']*(wp-block-button__link|wp-element-button)#i', $m['open'] ) ) { return null; } ++$count; return $m['inner']; } ); if ( $count && ! is_wp_error( self::save( $post_id, $content ) ) ) { $removed += count( $urls ); } else { $skipped += count( $urls ); } } if ( $menus ) { ( new Lynkora_Indexer() )->index_menus(); } return array( 'removed' => $removed, 'skipped' => $skipped, ); } /** * Point every link to a page (or, for a page that no longer exists, to an * address) at another page. Covers content, synced patterns, templates * and menu items; widgets must be edited by hand. * * @param int $from_id Post the links point to now, or 0. * @param string $from_url Address the links point to now. * @param int $to_id Published post to point them at. * @return array|WP_Error { changed: int, skipped: int } */ public static function replace( $from_id, $from_url, $to_id ) { $to = get_post( $to_id ); if ( ! $to || 'publish' !== $to->post_status || ! in_array( $to->post_type, Lynkora_Indexer::content_types(), true ) ) { return new WP_Error( 'lynkora_target', __( 'Choose a published page to point the links at.', 'lynkora' ) ); } if ( $from_id && (int) $from_id === (int) $to_id ) { return new WP_Error( 'lynkora_same', __( 'Links already point to that page.', 'lynkora' ) ); } $resolver = new Lynkora_Resolver(); $new_url = (string) get_permalink( $to ); $changed = 0; $skipped = 0; $menus = false; $done = array(); foreach ( Lynkora_DB::pointing_to( $from_id, $from_url ) as $row ) { if ( 0 === strpos( (string) $row['source_ref'], 'auto:' ) ) { continue; // Automatic lists update themselves. } if ( 'menu' === $row['source_type'] ) { $result = self::repoint_menu_item( self::menu_item_id( $row['source_ref'] ), $from_id, $to, $new_url ); if ( $result ) { ++$changed; $menus = true; } else { ++$skipped; } continue; } if ( 'widget' === $row['source_type'] ) { ++$skipped; continue; } $post_id = (int) $row['source_id']; if ( isset( $done[ $post_id ] ) ) { continue; } $done[ $post_id ] = true; $post = get_post( $post_id ); if ( ! $post || ! current_user_can( 'edit_post', $post_id ) ) { ++$skipped; continue; } $count = 0; $content = self::each_link( $post->post_content, function ( $m ) use ( $resolver, $from_id, $from_url, $new_url, &$count ) { $href = self::href( $m['href'] ); $absolute = $resolver->absolute_internal( $href ); if ( null === $absolute ) { return null; } $resolved = $resolver->resolve( $href ); $matches = $absolute === $from_url || ( $from_id && $resolved && (int) $resolved['target_id'] === (int) $from_id ); if ( ! $matches ) { return null; } ++$count; return self::with_href( $m, $new_url . self::fragment( $href ) ); } ); if ( $count && ! is_wp_error( self::save( $post_id, $content ) ) ) { $changed += $count; } elseif ( ! $count ) { continue; // Nothing in this post's own content (already changed). } else { ++$skipped; } } if ( $menus ) { ( new Lynkora_Indexer() )->index_menus(); } return array( 'changed' => $changed, 'skipped' => $skipped, ); } /** * @param int $item_id Menu item ID. * @param int $from_id Old target post, or 0. * @param WP_Post $to New target. * @param string $new_url New address. * @return bool */ private static function repoint_menu_item( $item_id, $from_id, $to, $new_url ) { if ( ! $item_id || ! current_user_can( 'edit_theme_options' ) || 'nav_menu_item' !== get_post_type( $item_id ) ) { return false; } $type = get_post_meta( $item_id, '_menu_item_type', true ); if ( 'custom' === $type ) { update_post_meta( $item_id, '_menu_item_url', esc_url_raw( $new_url ) ); return true; } if ( 'post_type' === $type && $from_id && (int) get_post_meta( $item_id, '_menu_item_object_id', true ) === (int) $from_id ) { update_post_meta( $item_id, '_menu_item_object_id', (int) $to->ID ); update_post_meta( $item_id, '_menu_item_object', $to->post_type ); return true; } return false; } // ------------------------------------------------------------------ // Linked pages // ------------------------------------------------------------------ /** * Restore a linked page from the trash, with the status it had before. * * @param int $post_id Post ID. * @return true|WP_Error */ public static function restore( $post_id ) { $post = get_post( $post_id ); if ( ! $post || 'trash' !== $post->post_status ) { return new WP_Error( 'lynkora_state', __( 'That page is not in the trash.', 'lynkora' ) ); } if ( ! current_user_can( 'delete_post', $post_id ) ) { return new WP_Error( 'lynkora_cap', __( 'You are not allowed to restore that page.', 'lynkora' ) ); } // Since WordPress 5.6 restored posts become drafts unless asked otherwise. add_filter( 'wp_untrash_post_status', 'wp_untrash_post_set_previous_status', 10, 3 ); $result = wp_untrash_post( $post_id ); remove_filter( 'wp_untrash_post_status', 'wp_untrash_post_set_previous_status', 10 ); if ( ! $result ) { return new WP_Error( 'lynkora_failed', __( 'The page could not be restored.', 'lynkora' ) ); } ( new Lynkora_Indexer() )->reindex_sources_for( $post_id ); return true; } /** * Publish a linked draft or pending page. * * @param int $post_id Post ID. * @return true|WP_Error */ public static function publish( $post_id ) { $post = get_post( $post_id ); if ( ! $post || ! in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) { return new WP_Error( 'lynkora_state', __( 'That page is not a draft.', 'lynkora' ) ); } if ( ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( 'publish_post', $post_id ) ) { return new WP_Error( 'lynkora_cap', __( 'You are not allowed to publish that page.', 'lynkora' ) ); } $result = wp_update_post( array( 'ID' => $post_id, 'post_status' => 'publish', ), true ); if ( is_wp_error( $result ) ) { return $result; } ( new Lynkora_Indexer() )->reindex_sources_for( $post_id ); return true; } /** * Add a published page to the end of a menu. * * @param int $post_id Post ID. * @param int $menu_id Menu term ID. * @return true|WP_Error */ public static function add_to_menu( $post_id, $menu_id ) { if ( ! current_user_can( 'edit_theme_options' ) ) { return new WP_Error( 'lynkora_cap', __( 'You are not allowed to edit menus.', 'lynkora' ) ); } $post = get_post( $post_id ); $menu = wp_get_nav_menu_object( $menu_id ); if ( ! $post || 'publish' !== $post->post_status || ! $menu ) { return new WP_Error( 'lynkora_missing', __( 'That page or menu no longer exists.', 'lynkora' ) ); } foreach ( (array) wp_get_nav_menu_items( $menu->term_id ) as $item ) { if ( 'post_type' === $item->type && (int) $item->object_id === (int) $post_id ) { return new WP_Error( 'lynkora_exists', __( 'That page is already in this menu.', 'lynkora' ) ); } } $item = wp_update_nav_menu_item( $menu->term_id, 0, array( 'menu-item-object-id' => (int) $post_id, 'menu-item-object' => $post->post_type, 'menu-item-type' => 'post_type', 'menu-item-status' => 'publish', ) ); if ( is_wp_error( $item ) ) { return $item; } ( new Lynkora_Indexer() )->index_menus(); return true; } /** * Move content to draft or to the trash. * * @param array $ids Post IDs. * @param string $action 'draft' or 'trash'. * @return array { done: int, skipped: int } */ public static function bulk_status( array $ids, $action ) { $done = 0; $skipped = 0; foreach ( array_unique( array_map( 'intval', $ids ) ) as $id ) { $post = get_post( $id ); if ( ! $post || 'publish' !== $post->post_status ) { ++$skipped; continue; } if ( 'trash' === $action ) { $ok = current_user_can( 'delete_post', $id ) && wp_trash_post( $id ); } else { $ok = current_user_can( 'edit_post', $id ) && ! is_wp_error( wp_update_post( array( 'ID' => $id, 'post_status' => 'draft', ), true ) ); } if ( $ok ) { ++$done; } else { ++$skipped; } } return array( 'done' => $done, 'skipped' => $skipped, ); } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ /** * Run a callback on every link in some HTML. The callback gets * { open, href, inner } and returns replacement HTML, or null to keep * the link as it is. * * @param string $html HTML. * @param callable $callback Callback. * @return string */ private static function each_link( $html, $callback ) { $result = preg_replace_callback( '#(]*?href\s*=\s*)(["\'])(.*?)\2([^>]*>)(.*?)#is', function ( $m ) use ( $callback ) { $replacement = $callback( array( 'prefix' => $m[1], 'quote' => $m[2], 'href' => $m[3], 'rest' => $m[4], 'inner' => $m[5], 'open' => $m[1] . $m[2] . $m[3] . $m[2] . $m[4], ) ); return null === $replacement ? $m[0] : $replacement; }, $html ); return null === $result ? $html : $result; } /** * @param array $m Link parts from each_link(). * @param string $href New address. * @return string */ private static function with_href( $m, $href ) { return $m['prefix'] . $m['quote'] . esc_url( $href ) . $m['quote'] . $m['rest'] . $m['inner'] . ''; } /** * @param string $raw href as written in HTML. * @return string */ private static function href( $raw ) { return html_entity_decode( trim( $raw ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * @param string $href Link. * @return string "#section" part, or ''. */ private static function fragment( $href ) { $hash = strpos( $href, '#' ); return false === $hash ? '' : substr( $href, $hash ); } /** * @param string $ref Menu source_ref ("Menu name#item ID"). * @return int */ private static function menu_item_id( $ref ) { $hash = strrpos( (string) $ref, '#' ); return false === $hash ? 0 : (int) substr( $ref, $hash + 1 ); } /** * Save content as a new revision. * * @param int $post_id Post ID. * @param string $content New content. * @return true|WP_Error */ private static function save( $post_id, $content ) { $result = wp_update_post( array( 'ID' => (int) $post_id, 'post_content' => wp_slash( $content ), ), true ); return is_wp_error( $result ) ? $result : true; } }