Lynkora: scan, overview with history, the report views with
* link suggestions and one-click fixes, old page addresses, CSV export and
* settings. Also adds a "Links in / out" column to the Posts and Pages
* lists. The changes themselves are made by Lynkora_Editor and
* Lynkora_Fixer.
*
* @package Lynkora
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Lynkora_Admin {
const SLUG = 'lynkora';
const CAPABILITY = 'edit_others_posts';
const PER_PAGE = 50;
const NOTICE = 'lynkora_notice_';
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_page' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
add_action( 'admin_notices', array( $this, 'show_notice' ) );
add_action( 'wp_ajax_lynkora_scan', array( $this, 'ajax_scan' ) );
add_action( 'admin_post_lynkora_ignore', array( $this, 'handle_ignore' ) );
add_action( 'admin_post_lynkora_fix', array( $this, 'handle_fix' ) );
add_action( 'admin_post_lynkora_export', array( $this, 'handle_export' ) );
add_action( 'admin_post_lynkora_settings', array( $this, 'handle_settings' ) );
add_action( 'admin_post_lynkora_do', array( $this, 'handle_do' ) );
add_action( 'admin_post_lynkora_bulk', array( $this, 'handle_bulk' ) );
add_action( 'admin_post_lynkora_replace', array( $this, 'handle_replace' ) );
add_action( 'admin_post_lynkora_menu_add', array( $this, 'handle_menu_add' ) );
add_action( 'admin_init', array( $this, 'register_columns' ) );
add_filter( 'plugin_action_links_' . plugin_basename( LYNKORA_FILE ), array( $this, 'action_links' ) );
}
public function register_page() {
add_management_page(
__( 'Lynkora', 'lynkora' ),
__( 'Lynkora', 'lynkora' ),
self::CAPABILITY,
self::SLUG,
array( $this, 'render_page' )
);
}
/**
* @param string $hook Screen hook.
*/
public function enqueue( $hook ) {
if ( 'tools_page_' . self::SLUG !== $hook ) {
return;
}
wp_enqueue_style( 'lynkora-admin', LYNKORA_URL . 'assets/admin.css', array(), LYNKORA_VERSION );
wp_enqueue_script( 'lynkora-admin', LYNKORA_URL . 'assets/admin.js', array(), LYNKORA_VERSION, true );
wp_localize_script(
'lynkora-admin',
'lynkoraAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'lynkora_scan' ),
'i18n' => array(
'starting' => __( 'Starting scan...', 'lynkora' ),
/* translators: 1: items scanned, 2: total items */
'posts' => __( 'Reading links: %1$s of %2$s pages and posts', 'lynkora' ),
/* translators: %s: number of addresses checked */
'check' => __( 'Checking addresses that are not pages or posts: %s checked', 'lynkora' ),
'done' => __( 'Scan complete. Loading results...', 'lynkora' ),
'failed' => __( 'The scan stopped because of an error. Try again.', 'lynkora' ),
'noneSel' => __( 'Select at least one item first.', 'lynkora' ),
),
)
);
}
/**
* @param array $links Plugin row links.
* @return array
*/
public function action_links( $links ) {
array_unshift( $links, sprintf( '%s ', esc_url( self::url() ), esc_html__( 'Open Lynkora', 'lynkora' ) ) );
return $links;
}
/**
* @param array $args Query args.
* @return string
*/
public static function url( $args = array() ) {
return add_query_arg( array_merge( array( 'page' => self::SLUG ), $args ), admin_url( 'tools.php' ) );
}
// ------------------------------------------------------------------
// Page
// ------------------------------------------------------------------
private static function views() {
return array(
'overview' => __( 'Overview', 'lynkora' ),
'orphans' => __( 'Orphans', 'lynkora' ),
'deadends' => __( 'Dead ends', 'lynkora' ),
'broken' => __( 'Broken links', 'lynkora' ),
'deep' => __( 'Hard to reach', 'lynkora' ),
'mentions' => __( 'Unlinked mentions', 'lynkora' ),
'all' => __( 'All content', 'lynkora' ),
'redirects' => __( 'Old addresses', 'lynkora' ),
'settings' => __( 'Settings', 'lynkora' ),
);
}
public function render_page() {
if ( ! current_user_can( self::CAPABILITY ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only view switching and filtering.
$view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'overview';
$views = self::views();
if ( ! isset( $views[ $view ] ) ) {
$view = 'overview';
}
// phpcs:enable
$state = Lynkora_Indexer::get_state();
?>
$label ) : ?>
render_settings();
} elseif ( 'redirects' === $view ) {
$this->render_redirects();
} elseif ( ! $state['finished'] && 'idle' === $state['phase'] ) {
echo '
' . esc_html__( 'Run the first scan to see results.', 'lynkora' ) . '
';
} else {
$this->render_view( $view );
}
?>
intro( __( 'Published content that no other page, menu, widget or template links to. Visitors and search engines can only find these through search, archives or the sitemap. Each row suggests related pages to link from; "Add link" links the first mention of the title there, or adds a short "See also" line at the end. Pages that are meant to be unlinked, such as landing pages for ads, can be marked as intentional.', 'lynkora' ) );
$this->content_table( $report->orphans(), 'orphans', $report );
break;
case 'deadends':
$this->intro( __( 'Published content that does not link to any other page on the site. Visitors who arrive here have nowhere obvious to go next. Each row suggests related pages to link to, starting with pages nothing links to yet, so one link can fix two problems.', 'lynkora' ) );
$this->content_table( $report->dead_ends(), 'deadends', $report );
break;
case 'broken':
$this->intro( __( 'Links to trashed, unpublished or deleted content, to old addresses, through redirects, or to addresses that do not work. Every change made from here is saved as a revision, so it can be undone from the revision history.', 'lynkora' ) );
$this->broken_table( $report->broken(), $report );
break;
case 'deep':
$this->intro(
sprintf(
/* translators: %d: number of clicks */
__( 'Content more than %d clicks from the homepage, or that cannot be reached by following links at all. Each row names a related page closer to the homepage and how many clicks away the page would be after a link from there. Counts links in content, menus, widgets and templates; on a blog the newest posts count as one click away. Category and tag archives are not followed.', 'lynkora' ),
$settings['depth']
)
);
$this->content_table( $report->deep( $settings['depth'] ), 'deep', $report );
break;
case 'mentions':
$this->intro( __( 'Pages with few or no links pointing to them, and other posts that mention their title in the text without linking. "Link it" turns the first plain-text mention into a link and saves a revision.', 'lynkora' ) );
$this->mentions_table( $report->mentions() );
break;
case 'all':
$this->content_table( $report->content(), 'all', $report );
break;
default:
$this->overview( $report, $settings );
}
}
private function intro( $text ) {
echo '' . esc_html( $text ) . '
';
}
/**
* @param Lynkora_Report $report Report.
* @param array $settings Settings.
*/
private function overview( $report, $settings ) {
$totals = $report->totals( $settings['depth'] );
$history = Lynkora_History::all();
$cards = array(
'orphans' => array( 'orphans', __( 'Orphans', 'lynkora' ), __( 'Nothing links to them', 'lynkora' ) ),
'deadends' => array( 'dead_ends', __( 'Dead ends', 'lynkora' ), __( 'Link to nothing', 'lynkora' ) ),
'broken' => array( 'broken', __( 'Broken links', 'lynkora' ), __( 'Go nowhere or somewhere old', 'lynkora' ) ),
/* translators: %d: number of clicks */
'deep' => array( 'deep', __( 'Hard to reach', 'lynkora' ), sprintf( __( 'More than %d clicks from home', 'lynkora' ), $settings['depth'] ) ),
);
$count = count( $history );
$last = $count ? $history[ $count - 1 ] : null;
$prev = $count > 1 ? $history[ $count - 2 ] : null;
?>
$card ) :
$number = $totals[ $card[0] ];
?>
trend( $history );
}
/**
* @param int $diff Change in a number between the last two scans.
* @return string
*/
private static function change_label( $diff ) {
if ( 0 === $diff ) {
return __( 'No change since the previous scan', 'lynkora' );
}
return sprintf(
/* translators: %s: signed number, for example "+2" or "-3" */
__( '%s since the previous scan', 'lynkora' ),
( $diff > 0 ? '+' : '-' ) . number_format_i18n( abs( $diff ) )
);
}
/**
* Line chart of the last scans, drawn as SVG on the server.
*
* @param array $history From Lynkora_History::all().
*/
private function trend( $history ) {
echo '' . esc_html__( 'Progress over time', 'lynkora' ) . ' ';
if ( count( $history ) < 2 ) {
echo '' . esc_html__( 'The chart appears after the second full scan. A full scan runs in the background every day.', 'lynkora' ) . '
';
return;
}
$history = array_slice( $history, -20 );
$series = array(
'orphans' => array( __( 'Orphans', 'lynkora' ), '#d63638' ),
'dead_ends' => array( __( 'Dead ends', 'lynkora' ), '#dba617' ),
'broken' => array( __( 'Broken links', 'lynkora' ), '#2271b1' ),
'deep' => array( __( 'Hard to reach', 'lynkora' ), '#8c5ac8' ),
);
$width = 640;
$height = 180;
$pad = 28;
$max = 1;
foreach ( $history as $entry ) {
foreach ( array_keys( $series ) as $key ) {
$max = max( $max, isset( $entry[ $key ] ) ? (int) $entry[ $key ] : 0 );
}
}
$step = ( $width - 2 * $pad ) / ( count( $history ) - 1 );
// Scans on the same day are told apart by time.
$format = get_option( 'date_format' );
if ( wp_date( 'Y-m-d', (int) $history[0]['t'] ) === wp_date( 'Y-m-d', (int) $history[ count( $history ) - 1 ]['t'] ) ) {
$format .= ' ' . get_option( 'time_format' );
}
?>
0
$info ) :
$points = array();
foreach ( $history as $i => $entry ) {
$value = isset( $entry[ $key ] ) ? (int) $entry[ $key ] : 0;
$points[] = round( $pad + $i * $step, 1 ) . ',' . round( $height - $pad - ( $value / $max ) * ( $height - 2 * $pad ), 1 );
}
?>
filters( $view, $type, $search );
if ( ! $items ) {
echo '' . esc_html__( 'Nothing here.', 'lynkora' ) . '
';
return;
}
$total = count( $items );
$items = array_slice( $items, ( $paged - 1 ) * self::PER_PAGE, self::PER_PAGE );
$args = array_filter(
array(
'view' => $view,
'type' => $type,
's' => $search,
)
);
$back = self::url( $args + ( $paged > 1 ? array( 'paged' => $paged ) : array() ) );
$suggester = in_array( $view, array( 'orphans', 'deadends', 'deep' ), true ) ? new Lynkora_Suggester( $report ) : null;
$menus = in_array( $view, array( 'orphans', 'deep' ), true ) && current_user_can( 'edit_theme_options' ) ? wp_get_nav_menus() : array();
$settings = Lynkora_Settings::get();
$this->bulk_form(
'content',
$back,
array(
'ignore' => __( 'Mark as intentional', 'lynkora' ),
'draft' => __( 'Move to draft', 'lynkora' ),
'trash' => __( 'Move to trash', 'lynkora' ),
)
);
?>
pagination( $total, $paged, $args );
$this->ignored_note( $view );
if ( 'all' === $view ) {
$this->pages_datalist( $report );
}
}
/**
* Suggested links for one row.
*
* @param Lynkora_Suggester $suggester Suggester.
* @param array $item Content item.
* @param string $view View key.
* @param string $back Return address.
*/
private function suggestions( $suggester, $item, $view, $back ) {
if ( 'deadends' === $view ) {
$list = $suggester->link_to( $item['id'] );
if ( ! $list ) {
echo '' . esc_html__( 'No related pages found. Link to a page visitors usually want next, such as contact details.', 'lynkora' ) . ' ';
return;
}
echo '';
foreach ( $list as $to ) {
echo '';
printf( '%2$s ', esc_url( (string) get_permalink( $to['id'] ) ), esc_html( $to['title'] ) );
if ( $to['is_orphan'] ) {
echo ' ' . esc_html__( '(nothing links to it yet)', 'lynkora' ) . ' ';
}
printf( ' %2$s ', esc_url( self::do_url( 'add_link', $item['id'], $to['id'], $back ) ), esc_html__( 'Add link', 'lynkora' ) );
echo ' ';
}
echo ' ';
return;
}
$deep = 'deep' === $view;
$list = $suggester->link_from( $item['id'], $deep ? 2 : 3, $deep ? 'depth' : 'score' );
if ( ! $list ) {
echo '' . esc_html( $deep ? __( 'No related page is closer to the homepage. Adding it to a menu makes it one click away.', 'lynkora' ) : __( 'No related pages found.', 'lynkora' ) ) . ' ';
return;
}
echo '';
foreach ( $list as $from ) {
echo '';
if ( $deep ) {
echo esc_html(
sprintf(
/* translators: 1: page title, 2: number of clicks */
_n( 'A link from %1$s would bring this to %2$s click.', 'A link from %1$s would bring this to %2$s clicks.', (int) $from['depth_after'], 'lynkora' ),
$from['title'],
number_format_i18n( (int) $from['depth_after'] )
)
);
} else {
printf( '%2$s ', esc_url( (string) get_edit_post_link( $from['id'] ) ), esc_html( $from['title'] ) );
if ( null !== $from['depth_after'] ) {
echo ' ';
echo esc_html(
sprintf(
/* translators: %s: number of clicks */
_n( '(then %s click from home)', '(then %s clicks from home)', (int) $from['depth_after'], 'lynkora' ),
number_format_i18n( (int) $from['depth_after'] )
)
);
echo ' ';
}
}
$label = $from['mentions'] ? __( 'Link the mention', 'lynkora' ) : __( 'Add link', 'lynkora' );
printf( ' %2$s ', esc_url( self::do_url( 'add_link', $from['id'], $item['id'], $back ) ), esc_html( $label ) );
echo ' ';
}
echo ' ';
}
/**
* "Add to menu" form for one row.
*
* @param int $post_id Post ID.
* @param array $menus Menus.
* @param string $back Return address.
*/
private function menu_form( $post_id, $menus, $back ) {
?>
';
foreach ( array_slice( $report->content(), 0, 2000, true ) as $item ) {
printf( ' ', esc_attr( $item['title'] . ' (#' . $item['id'] . ')' ) );
}
echo '';
}
/**
* Bulk action bar. Checkboxes in the table belong to this form through
* their form attribute, so rows can hold forms of their own.
*
* @param string $kind 'content' or 'broken'.
* @param string $back Return address.
* @param array $actions Action => label.
*/
private function bulk_form( $kind, $back, $actions ) {
?>
';
echo esc_html(
sprintf(
/* translators: %s: number of items */
_n( '%s item marked as intentional is hidden here. It is listed under All content.', '%s items marked as intentional are hidden here. They are listed under All content.', $count, 'lynkora' ),
number_format_i18n( $count )
)
);
echo '';
}
}
/**
* @param array $rows From Lynkora_Report::broken().
* @param Lynkora_Report $report Report, for the page list.
*/
private function broken_table( $rows, $report ) {
$this->filters( 'broken', '', '', false );
if ( ! $rows ) {
echo '' . esc_html__( 'No broken internal links found.', 'lynkora' ) . '
';
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only paging.
$paged = isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1;
$total = count( $rows );
$rows = array_slice( $rows, ( $paged - 1 ) * self::PER_PAGE, self::PER_PAGE );
$back = self::url( array( 'view' => 'broken' ) + ( $paged > 1 ? array( 'paged' => $paged ) : array() ) );
$this->bulk_form( 'broken', $back, array( 'unlink' => __( 'Remove links, keep the text', 'lynkora' ) ) );
?>
pagination( $total, $paged, array( 'view' => 'broken' ) );
$this->pages_datalist( $report );
}
/**
* @param array $groups From Lynkora_Report::mentions().
*/
private function mentions_table( $groups ) {
if ( ! $groups ) {
echo '' . esc_html__( 'No unlinked mentions found.', 'lynkora' ) . '
';
return;
}
$back = self::url( array( 'view' => 'mentions' ) );
?>
intro( __( 'WordPress sends visitors from a post\'s old address to its new one, but it does not do this for pages. Lynkora remembers the old address whenever a published page (or a page above it) gets a new address, and sends visitors and old links there. A redirect only happens when the old address would otherwise show "not found", so it never hides a real page.', 'lynkora' ) );
if ( ! $enabled ) {
echo '' . esc_html__( 'Redirects are turned off in Settings. Old addresses are still recorded.', 'lynkora' ) . '
';
}
$rows = Lynkora_Redirects::all();
if ( ! $rows ) {
echo '' . esc_html__( 'No page has changed its address since Lynkora was activated.', 'lynkora' ) . '
';
return;
}
$back = self::url( array( 'view' => 'redirects' ) );
?>
'lynkora_export',
'view' => $view,
),
admin_url( 'admin-post.php' )
);
?>
';
echo wp_kses_post(
paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%', self::url( array_filter( $args ) ) ),
'format' => '',
'current' => $paged,
'total' => $pages,
)
)
);
echo '
';
}
private function render_settings() {
$settings = Lynkora_Settings::get();
$public = get_post_types( array( 'public' => true ), 'objects' );
unset( $public['attachment'] );
$active = Lynkora_Indexer::content_types();
$can_save = current_user_can( 'manage_options' );
?>
start_scan() : $indexer->step();
$state['unchecked'] = Lynkora_DB::count_unchecked();
wp_send_json_success( $state );
}
private static function ignore_url( $post_id, $ignore, $back ) {
return wp_nonce_url(
add_query_arg(
array(
'action' => 'lynkora_ignore',
'post' => (int) $post_id,
'ignore' => $ignore ? 1 : 0,
'back' => rawurlencode( $back ),
),
admin_url( 'admin-post.php' )
),
'lynkora_ignore_' . (int) $post_id
);
}
public function handle_ignore() {
$post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0;
check_admin_referer( 'lynkora_ignore_' . $post_id );
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'lynkora' ), '', array( 'response' => 403 ) );
}
Lynkora_Settings::set_ignored( $post_id, ! empty( $_GET['ignore'] ) );
$back = isset( $_GET['back'] ) ? esc_url_raw( wp_unslash( $_GET['back'] ) ) : self::url();
wp_safe_redirect( $back ? $back : self::url() );
exit;
}
private static function fix_url( $row_id ) {
$url = add_query_arg(
array(
'action' => 'lynkora_fix',
'row' => (int) $row_id,
),
admin_url( 'admin-post.php' )
);
return wp_nonce_url( $url, 'lynkora_fix_' . (int) $row_id );
}
public function handle_fix() {
$row_id = isset( $_GET['row'] ) ? (int) $_GET['row'] : 0;
check_admin_referer( 'lynkora_fix_' . $row_id );
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'lynkora' ), '', array( 'response' => 403 ) );
}
$result = Lynkora_Fixer::fix( $row_id );
if ( is_wp_error( $result ) ) {
self::notice( 'error', $result->get_error_message() );
} else {
self::notice( 'success', __( 'Link updated. The previous version is kept in the revision history.', 'lynkora' ) );
}
wp_safe_redirect( self::url( array( 'view' => 'broken' ) ) );
exit;
}
public function handle_settings() {
check_admin_referer( 'lynkora_settings' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'lynkora' ), '', array( 'response' => 403 ) );
}
$before = Lynkora_Settings::get();
$list = function ( $key ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked above with check_admin_referer().
return isset( $_POST[ $key ] ) && is_array( $_POST[ $key ] ) ? array_map( 'sanitize_key', wp_unslash( $_POST[ $key ] ) ) : array();
};
Lynkora_Settings::save(
array(
'post_types' => $list( 'post_types' ),
'depth' => isset( $_POST['depth'] ) ? (int) $_POST['depth'] : 3,
'redirects' => ! empty( $_POST['redirects'] ),
'related_types' => $list( 'related_types' ),
'related_count' => isset( $_POST['related_count'] ) ? (int) $_POST['related_count'] : 3,
'child_list' => ! empty( $_POST['child_list'] ),
'email' => ! empty( $_POST['email'] ),
'email_to' => isset( $_POST['email_to'] ) ? sanitize_email( wp_unslash( $_POST['email_to'] ) ) : '',
)
);
$after = Lynkora_Settings::get();
// The first weekly email lists only problems that appear from now on.
if ( $after['email'] && ! $before['email'] ) {
Lynkora_History::set_baseline();
}
Lynkora_History::schedule();
$rescan = $before['post_types'] !== $after['post_types'] || $before['related_types'] !== $after['related_types'] || $before['related_count'] !== $after['related_count'] || $before['child_list'] !== $after['child_list'] || $before['redirects'] !== $after['redirects'];
self::notice( 'success', $rescan ? __( 'Settings saved. Run a new scan so the reports include this change.', 'lynkora' ) : __( 'Settings saved.', 'lynkora' ) );
wp_safe_redirect( self::url( array( 'view' => 'settings' ) ) );
exit;
}
/**
* Link for a single fix action.
*
* @param string $action Action key.
* @param int $a First ID.
* @param int $b Second ID.
* @param string $back Return address.
* @return string
*/
private static function do_url( $action, $a, $b, $back ) {
return wp_nonce_url(
add_query_arg(
array(
'action' => 'lynkora_do',
'do' => $action,
'a' => (int) $a,
'b' => (int) $b,
'back' => rawurlencode( $back ),
),
admin_url( 'admin-post.php' )
),
'lynkora_do_' . $action . '_' . (int) $a . '_' . (int) $b
);
}
/**
* Single fix actions: add a link, link a mention, restore or publish a
* linked page, remove a broken link, forget an old address.
*/
public function handle_do() {
$action = isset( $_GET['do'] ) ? sanitize_key( wp_unslash( $_GET['do'] ) ) : '';
$a = isset( $_GET['a'] ) ? (int) $_GET['a'] : 0;
$b = isset( $_GET['b'] ) ? (int) $_GET['b'] : 0;
check_admin_referer( 'lynkora_do_' . $action . '_' . $a . '_' . $b );
$this->require_cap();
switch ( $action ) {
case 'add_link':
$result = Lynkora_Editor::add_link( $a, $b );
if ( ! is_wp_error( $result ) ) {
$result = 'wrapped' === $result
/* translators: 1: page with the new link, 2: linked page */
? sprintf( __( 'The mention of "%2$s" in "%1$s" is now a link. The previous version is kept as a revision.', 'lynkora' ), get_the_title( $a ), get_the_title( $b ) )
/* translators: 1: page with the new link, 2: linked page */
: sprintf( __( 'A "See also" link to "%2$s" was added at the end of "%1$s". The previous version is kept as a revision.', 'lynkora' ), get_the_title( $a ), get_the_title( $b ) );
}
break;
case 'link_mention':
$result = Lynkora_Editor::link_mention( $a, $b );
if ( true === $result ) {
/* translators: 1: page with the new link, 2: linked page */
$result = sprintf( __( 'The mention of "%2$s" in "%1$s" is now a link. The previous version is kept as a revision.', 'lynkora' ), get_the_title( $a ), get_the_title( $b ) );
}
break;
case 'restore':
$result = Lynkora_Editor::restore( $a );
if ( true === $result ) {
/* translators: %s: page title */
$result = sprintf( __( '"%s" was restored from the trash and links to it work again.', 'lynkora' ), get_the_title( $a ) );
}
break;
case 'publish':
$result = Lynkora_Editor::publish( $a );
if ( true === $result ) {
/* translators: %s: page title */
$result = sprintf( __( '"%s" is published and links to it work now.', 'lynkora' ), get_the_title( $a ) );
}
break;
case 'unlink':
$result = self::bulk_message( Lynkora_Editor::unlink( array( $a ) ), 'removed' );
break;
case 'forget':
Lynkora_Redirects::delete( $a );
$result = __( 'The old address is no longer redirected.', 'lynkora' );
break;
default:
$result = new WP_Error( 'lynkora_action', __( 'Unknown action.', 'lynkora' ) );
}
$this->finish( $result );
}
/**
* Bulk actions on content rows and broken link rows.
*/
public function handle_bulk() {
check_admin_referer( 'lynkora_bulk' );
$this->require_cap();
$action = isset( $_POST['bulk_action'] ) ? sanitize_key( wp_unslash( $_POST['bulk_action'] ) ) : '';
$ids = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? array_map( 'intval', wp_unslash( $_POST['ids'] ) ) : array();
$rows = isset( $_POST['rows'] ) && is_array( $_POST['rows'] ) ? array_map( 'intval', wp_unslash( $_POST['rows'] ) ) : array();
if ( ( 'unlink' === $action && ! $rows ) || ( 'unlink' !== $action && ! $ids ) ) {
$this->finish( new WP_Error( 'lynkora_empty', __( 'Select at least one item first.', 'lynkora' ) ) );
}
switch ( $action ) {
case 'ignore':
foreach ( $ids as $id ) {
Lynkora_Settings::set_ignored( $id, true );
}
$result = self::bulk_message(
array(
'done' => count( $ids ),
'skipped' => 0,
),
'ignored'
);
break;
case 'draft':
case 'trash':
$result = self::bulk_message( Lynkora_Editor::bulk_status( $ids, $action ), $action );
break;
case 'unlink':
$result = self::bulk_message( Lynkora_Editor::unlink( $rows ), 'removed' );
break;
default:
$result = new WP_Error( 'lynkora_action', __( 'Choose a bulk action first.', 'lynkora' ) );
}
$this->finish( $result );
}
/**
* Point all links to a page, or all links like a broken one, at another page.
*/
public function handle_replace() {
check_admin_referer( 'lynkora_replace' );
$this->require_cap();
$from = isset( $_POST['from'] ) ? sanitize_key( wp_unslash( $_POST['from'] ) ) : '';
$id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;
$to = self::parse_page( isset( $_POST['to'] ) ? sanitize_text_field( wp_unslash( $_POST['to'] ) ) : '' );
if ( 'row' === $from ) {
$row = Lynkora_DB::row( $id );
$from_id = $row ? (int) $row['target_id'] : 0;
$from_url = $row ? (string) $row['url'] : '';
} else {
$from_id = 'publish' === get_post_status( $id ) ? $id : 0;
$from_url = $from_id ? (string) get_permalink( $from_id ) : '';
}
if ( ! $from_id && '' === $from_url ) {
$this->finish( new WP_Error( 'lynkora_missing', __( 'That link is no longer in the index. Run a new scan.', 'lynkora' ) ) );
}
if ( ! $to ) {
$this->finish( new WP_Error( 'lynkora_target', __( 'No published page matches that title or address. Pick one from the suggestions.', 'lynkora' ) ) );
}
$result = Lynkora_Editor::replace( $from_id, $from_url, $to );
if ( ! is_wp_error( $result ) ) {
$result = sprintf(
/* translators: 1: number of links, 2: page title */
_n( '%1$s link now points to "%2$s".', '%1$s links now point to "%2$s".', $result['changed'], 'lynkora' ),
number_format_i18n( $result['changed'] ),
get_the_title( $to )
) . ( $result['skipped'] ? ' ' . sprintf(
/* translators: %s: number of links */
_n( '%s link (in a widget, or content you cannot edit) needs to be changed by hand.', '%s links (in widgets, or content you cannot edit) need to be changed by hand.', $result['skipped'], 'lynkora' ),
number_format_i18n( $result['skipped'] )
) : '' );
}
$this->finish( $result );
}
/**
* Add a page to a menu.
*/
public function handle_menu_add() {
check_admin_referer( 'lynkora_menu_add' );
$this->require_cap();
$post_id = isset( $_POST['post'] ) ? (int) $_POST['post'] : 0;
$menu_id = isset( $_POST['menu'] ) ? (int) $_POST['menu'] : 0;
$result = Lynkora_Editor::add_to_menu( $post_id, $menu_id );
if ( true === $result ) {
$menu = wp_get_nav_menu_object( $menu_id );
$result = sprintf(
/* translators: 1: page title, 2: menu name */
__( '"%1$s" was added to the end of the menu "%2$s". You can move it under Appearance > Menus.', 'lynkora' ),
get_the_title( $post_id ),
$menu ? $menu->name : ''
);
}
$this->finish( $result );
}
/**
* A post ID from "Title (#ID)", a bare ID, an address or an exact title.
*
* @param string $value Field value.
* @return int Published post ID, or 0.
*/
private static function parse_page( $value ) {
$value = trim( $value );
$id = 0;
if ( preg_match( '/\(#(\d+)\)\s*$/', $value, $m ) || preg_match( '/^#?(\d+)$/', $value, $m ) ) {
$id = (int) $m[1];
} elseif ( preg_match( '#^(https?://|/)#i', $value ) ) {
$resolver = new Lynkora_Resolver();
$absolute = $resolver->absolute_internal( $value );
$id = $absolute ? (int) url_to_postid( $absolute ) : 0;
} elseif ( '' !== $value ) {
$found = get_posts(
array(
'post_type' => Lynkora_Indexer::content_types(),
'post_status' => 'publish',
'title' => $value,
'posts_per_page' => 1,
'fields' => 'ids',
)
);
$id = $found ? (int) $found[0] : 0;
}
return $id && 'publish' === get_post_status( $id ) && in_array( get_post_type( $id ), Lynkora_Indexer::content_types(), true ) ? $id : 0;
}
/**
* @param array $counts { done|removed, skipped }.
* @param string $what Action.
* @return string
*/
private static function bulk_message( $counts, $what ) {
$done = isset( $counts['removed'] ) ? $counts['removed'] : $counts['done'];
switch ( $what ) {
case 'trash':
/* translators: %s: number of items */
$message = _n( '%s item moved to the trash.', '%s items moved to the trash.', $done, 'lynkora' );
break;
case 'draft':
/* translators: %s: number of items */
$message = _n( '%s item moved to draft.', '%s items moved to draft.', $done, 'lynkora' );
break;
case 'ignored':
/* translators: %s: number of items */
$message = _n( '%s item marked as intentional.', '%s items marked as intentional.', $done, 'lynkora' );
break;
default:
/* translators: %s: number of links */
$message = _n( '%s link removed. Its text is kept, and the previous version is saved as a revision.', '%s links removed. Their text is kept, and the previous versions are saved as revisions.', $done, 'lynkora' );
}
$message = sprintf( $message, number_format_i18n( $done ) );
if ( ! empty( $counts['skipped'] ) ) {
$message .= ' ' . sprintf(
/* translators: %s: number of items */
_n( '%s item was skipped because it changed, is in a widget, or you are not allowed to edit it.', '%s items were skipped because they changed, are in widgets, or you are not allowed to edit them.', $counts['skipped'], 'lynkora' ),
number_format_i18n( $counts['skipped'] )
);
}
return $message;
}
private function require_cap() {
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'lynkora' ), '', array( 'response' => 403 ) );
}
}
/**
* Show the result and go back to where the action started.
*
* @param string|WP_Error $result Success message or error.
*/
private function finish( $result ) {
if ( is_wp_error( $result ) ) {
self::notice( 'error', $result->get_error_message() );
} else {
self::notice( 'success', (string) $result );
}
// phpcs:ignore WordPress.Security.NonceVerification -- Nonce checked by the calling handler.
$back = isset( $_REQUEST['back'] ) ? esc_url_raw( wp_unslash( $_REQUEST['back'] ) ) : '';
// Only ever return to a Lynkora screen.
if ( '' === $back || 0 !== strpos( $back, admin_url( 'tools.php?page=' . self::SLUG ) ) ) {
$back = self::url();
}
wp_safe_redirect( $back );
exit;
}
/**
* CSV download of a report.
*/
public function handle_export() {
check_admin_referer( 'lynkora_export' );
if ( ! current_user_can( self::CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'lynkora' ), '', array( 'response' => 403 ) );
}
$view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'all';
$report = new Lynkora_Report();
$settings = Lynkora_Settings::get();
$lines = array();
if ( 'broken' === $view ) {
$lines[] = array( 'Found in', 'Link text', 'Points to', 'Problem', 'Goes to' );
foreach ( $report->broken() as $row ) {
$lines[] = array( $row['source'], $row['anchor'], $row['url'], Lynkora_Report::problem_label( $row['problem'] ), $row['new_url'] );
}
} else {
switch ( $view ) {
case 'orphans':
$items = $report->orphans();
break;
case 'deadends':
$items = $report->dead_ends();
break;
case 'deep':
$items = $report->deep( $settings['depth'] );
break;
default:
$items = $report->content();
$view = 'all';
}
$lines[] = array( 'Title', 'Type', 'URL', 'Links in', 'Links out', 'Clicks from home', 'Intentional' );
foreach ( $items as $item ) {
$lines[] = array( $item['title'], $item['type'], get_permalink( $item['id'] ), $item['inbound'], $item['outbound'], null === $item['depth'] ? 'not reachable' : $item['depth'], $item['ignored'] ? 'yes' : '' );
}
}
$csv = '';
foreach ( $lines as $line ) {
$csv .= implode( ',', array_map( array( __CLASS__, 'csv_cell' ), $line ) ) . "\r\n";
}
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="lynkora-' . $view . '-' . gmdate( 'Y-m-d' ) . '.csv"' );
// CSV file download, not HTML; every cell is quoted by csv_cell().
echo "\xEF\xBB\xBF" . $csv; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
exit;
}
/**
* Quote a CSV cell, and stop spreadsheet apps from treating text as a
* formula.
*
* @param mixed $value Cell value.
* @return string
*/
private static function csv_cell( $value ) {
$value = (string) $value;
if ( '' !== $value && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
$value = "'" . $value;
}
return '"' . str_replace( '"', '""', $value ) . '"';
}
// ------------------------------------------------------------------
// Posts and Pages list column
// ------------------------------------------------------------------
/** @var array|null Post ID => { in, out } for the posts on the current list screen. */
private $column_counts = null;
public function register_columns() {
if ( ! current_user_can( self::CAPABILITY ) || ! Lynkora_Indexer::get_state()['finished'] ) {
return;
}
foreach ( Lynkora_Indexer::content_types() as $type ) {
add_filter( "manage_{$type}_posts_columns", array( $this, 'add_column' ) );
add_action( "manage_{$type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 );
}
}
/**
* @param array $columns Columns.
* @return array
*/
public function add_column( $columns ) {
$columns['lynkora'] = __( 'Links in / out', 'lynkora' );
return $columns;
}
/**
* @param string $column Column key.
* @param int $post_id Post ID.
*/
public function render_column( $column, $post_id ) {
if ( 'lynkora' !== $column ) {
return;
}
if ( null === $this->column_counts ) {
global $wp_query;
$ids = $wp_query && $wp_query->posts ? wp_list_pluck( $wp_query->posts, 'ID' ) : array( $post_id );
$this->column_counts = Lynkora_Report::counts_for( $ids );
}
if ( 'publish' !== get_post_status( $post_id ) ) {
echo '– ';
return;
}
$in = isset( $this->column_counts[ $post_id ] ) ? $this->column_counts[ $post_id ]['in'] : 0;
$out = isset( $this->column_counts[ $post_id ] ) ? $this->column_counts[ $post_id ]['out'] : 0;
printf(
'%2$s / %3$s',
esc_attr( 0 === $in ? 'lynkora-zero' : '' ),
esc_html( number_format_i18n( $in ) ),
esc_html( number_format_i18n( $out ) )
);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private static function type_label( $type ) {
$object = get_post_type_object( $type );
return $object ? $object->labels->singular_name : $type;
}
/**
* @param array $row Broken link.
* @return string
*/
private static function source_edit_url( $row ) {
if ( 'menu' === $row['source_type'] ) {
return admin_url( 'nav-menus.php?action=edit&menu=' . (int) $row['source_id'] );
}
if ( 'widget' === $row['source_type'] ) {
return admin_url( 'widgets.php' );
}
return (string) get_edit_post_link( $row['source_id'], 'raw' );
}
private static function notice( $type, $message ) {
set_transient(
self::NOTICE . get_current_user_id(),
array(
'type' => $type,
'message' => $message,
),
MINUTE_IN_SECONDS
);
}
public function show_notice() {
$key = self::NOTICE . get_current_user_id();
$notice = get_transient( $key );
if ( ! is_array( $notice ) ) {
return;
}
delete_transient( $key );
printf(
'',
esc_attr( 'error' === $notice['type'] ? 'error' : 'success' ),
esc_html( $notice['message'] )
);
}
}