plugin = $plugin;
}
/**
* Registers hooks.
*
* @return void
*/
public function hooks() {
add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) );
add_action( 'admin_post_linktender_action', array( $this, 'handle_action' ) );
add_action( 'admin_post_linktender_edit_url', array( $this, 'handle_edit_url' ) );
add_action( 'admin_post_linktender_tool', array( $this, 'handle_tool' ) );
add_action( 'admin_notices', array( $this, 'notices' ) );
add_action( 'update_option_' . LinkTender_Settings::OPTION, array( $this, 'settings_changed' ), 10, 2 );
add_filter( 'set-screen-option', array( $this, 'set_screen_option' ), 10, 3 );
add_filter( 'plugin_action_links_' . plugin_basename( LINKTENDER_FILE ), array( $this, 'plugin_links' ) );
}
/**
* Menu entries.
*
* @return void
*/
public function menu() {
$cap = LinkTender_Plugin::capability();
// The menu title stays plain so the screen IDs are stable; the count bubble is added below.
$this->links_hook = add_menu_page(
__( 'Broken Links', 'linktender' ),
__( 'LinkTender', 'linktender' ),
$cap,
'linktender',
array( $this, 'render_links_page' ),
'dashicons-admin-links',
81
);
$this->hooks = array( $this->links_hook );
$this->hooks[] = add_submenu_page( 'linktender', __( 'Broken Links', 'linktender' ), __( 'Links', 'linktender' ), $cap, 'linktender', array( $this, 'render_links_page' ) );
$this->hooks[] = add_submenu_page( 'linktender', __( 'LinkTender Settings', 'linktender' ), __( 'Settings', 'linktender' ), 'manage_options', 'linktender-settings', array( $this, 'render_settings_page' ) );
$this->hooks[] = add_submenu_page( 'linktender', __( 'LinkTender Health & Tools', 'linktender' ), __( 'Health & Tools', 'linktender' ), 'manage_options', 'linktender-tools', array( $this, 'render_tools_page' ) );
add_action( 'load-' . $this->links_hook, array( $this, 'load_links_page' ) );
/**
* Fires after the plugin's admin menu is registered. Add-ons add their pages here.
*/
do_action( 'linktender_admin_menu' );
// Pages that add-ons registered under the LinkTender menu count as LinkTender screens too.
global $submenu;
if ( isset( $submenu['linktender'] ) && is_array( $submenu['linktender'] ) ) {
foreach ( $submenu['linktender'] as $item ) {
if ( isset( $item[2] ) ) {
$this->hooks[] = get_plugin_page_hookname( $item[2], 'linktender' );
}
}
}
$this->hooks = array_values( array_unique( array_filter( $this->hooks ) ) );
$this->add_count_bubble();
}
/**
* Adds the broken-link count to the top-level menu item.
*
* @return void
*/
private function add_count_bubble() {
global $menu;
if ( ! is_array( $menu ) || ! LinkTender_Install::checking_started() ) {
return;
}
$broken = get_transient( 'linktender_broken_count' );
if ( false === $broken ) {
$counts = $this->plugin->links->status_counts();
$broken = (int) $counts['broken'];
set_transient( 'linktender_broken_count', $broken, 5 * MINUTE_IN_SECONDS );
}
$broken = (int) $broken;
if ( $broken < 1 ) {
return;
}
foreach ( $menu as $position => $item ) {
if ( isset( $item[2] ) && 'linktender' === $item[2] ) {
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Appending a count bubble to our own menu item, as core does for comments and updates.
$menu[ $position ][0] .= sprintf( ' %2$s ', $broken, esc_html( number_format_i18n( $broken ) ) );
break;
}
}
}
/**
* Whether a hook suffix or screen ID belongs to a LinkTender admin screen.
*
* @param string $hook Hook suffix (same as the screen ID for these pages).
* @return bool
*/
public function is_own_screen( $hook ) {
/**
* Filters the hook suffixes of the LinkTender admin screens, where its CSS and JS load.
*
* @param string[] $hooks Hook suffixes.
*/
$hooks = (array) apply_filters( 'linktender_admin_screens', $this->hooks );
return '' !== (string) $hook && in_array( (string) $hook, $hooks, true );
}
/**
* Settings link on the Plugins screen.
*
* @param array $links Links.
* @return array
*/
public function plugin_links( $links ) {
$label = LinkTender_Install::checking_started() ? __( 'Broken links', 'linktender' ) : __( 'Start checking', 'linktender' );
array_unshift( $links, '' . esc_html( $label ) . ' ' );
return $links;
}
/**
* Saves the per-page screen option.
*
* @param mixed $status Status.
* @param string $option Option.
* @param mixed $value Value.
* @return mixed
*/
public function set_screen_option( $status, $option, $value ) {
return 'linktender_per_page' === $option ? max( 5, min( 200, (int) $value ) ) : $status;
}
/**
* Styles and scripts on our pages only.
*
* @param string $hook Hook suffix.
* @return void
*/
public function assets( $hook ) {
if ( ! $this->is_own_screen( $hook ) ) {
return;
}
wp_enqueue_style( 'linktender-admin', LINKTENDER_URL . 'assets/css/admin.css', array(), LINKTENDER_VERSION );
wp_enqueue_script( 'linktender-admin', LINKTENDER_URL . 'assets/js/admin.js', array(), LINKTENDER_VERSION, true );
}
/**
* Registers the settings option.
*
* @return void
*/
public function register_settings() {
register_setting(
'linktender_settings',
LinkTender_Settings::OPTION,
array(
'type' => 'array',
'sanitize_callback' => array( 'LinkTender_Settings', 'sanitize' ),
'default' => array(),
)
);
}
/**
* Starts a rescan when scan-related settings change.
*
* @param mixed $old_value Old.
* @param mixed $new_value New.
* @return void
*/
public function settings_changed( $old_value, $new_value ) {
$keys = array( 'post_types', 'post_statuses', 'scan_menus', 'scan_widgets', 'scan_custom_fields', 'scan_excerpts', 'scan_comments', 'scan_comment_urls', 'check_images', 'check_media', 'check_internal', 'exclude_patterns' );
$old = is_array( $old_value ) ? $old_value : array();
$new = is_array( $new_value ) ? $new_value : array();
foreach ( $keys as $key ) {
$a = isset( $old[ $key ] ) ? $old[ $key ] : null;
$b = isset( $new[ $key ] ) ? $new[ $key ] : null;
if ( $a !== $b ) {
LinkTender_Scanner::request_full_scan();
return;
}
}
}
/**
* Labels for source types.
*
* @return array
*/
public static function source_labels() {
/**
* Filters labels for source types (add-ons may register their own).
*
* @param array $labels Labels.
*/
return apply_filters(
'linktender_source_labels',
array(
'post' => __( 'Posts, pages & custom post types', 'linktender' ),
'menu' => __( 'Menus', 'linktender' ),
'widget' => __( 'Widgets', 'linktender' ),
'comment' => __( 'Comments', 'linktender' ),
)
);
}
/**
* Labels for element types.
*
* @return array
*/
public static function element_labels() {
return array(
'a' => __( 'Links', 'linktender' ),
'img' => __( 'Images', 'linktender' ),
'iframe' => __( 'Iframes', 'linktender' ),
'video' => __( 'Video', 'linktender' ),
'audio' => __( 'Audio', 'linktender' ),
'source' => __( 'Media sources', 'linktender' ),
'embed' => __( 'Embeds', 'linktender' ),
'block' => __( 'Block settings', 'linktender' ),
'field' => __( 'Custom field URLs', 'linktender' ),
'menu' => __( 'Menu items', 'linktender' ),
'shortcode' => __( 'Shortcode settings', 'linktender' ),
'author' => __( 'Commenter website', 'linktender' ),
);
}
/**
* URL of the details screen.
*
* @param int $link_id Link ID.
* @return string
*/
public static function details_url( $link_id ) {
return add_query_arg(
array(
'page' => 'linktender',
'link' => (int) $link_id,
),
admin_url( 'admin.php' )
);
}
/**
* Nonce-protected single action URL.
*
* @param string $action Action.
* @param int $link_id Link ID.
* @return string
*/
public static function action_url( $action, $link_id ) {
return wp_nonce_url(
add_query_arg(
array(
'action' => 'linktender_action',
'do' => $action,
'link_id' => (int) $link_id,
),
admin_url( 'admin-post.php' )
),
'linktender_' . $action . '_' . (int) $link_id
);
}
/**
* Status badge HTML.
*
* @param object $item Link row.
* @return string
*/
public static function status_badge( $item ) {
$status = (int) $item->dismissed ? 'dismissed' : $item->status;
$labels = array(
'pending' => __( 'Not checked yet', 'linktender' ),
'ok' => __( 'OK', 'linktender' ),
'redirect' => __( 'Redirected', 'linktender' ),
'broken' => __( 'Broken', 'linktender' ),
'unverified' => __( 'Unverified', 'linktender' ),
'dismissed' => __( 'Marked as fine', 'linktender' ),
);
$label = isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
$out = '' . esc_html( $label ) . ' ';
$detail = '';
if ( $item->http_code ) {
$detail = (string) (int) $item->http_code;
if ( 'redirect' === $item->status && $item->redirect_count ) {
/* translators: %d: number of redirects. */
$detail = sprintf( _n( '%d redirect', '%d redirects', (int) $item->redirect_count, 'linktender' ), (int) $item->redirect_count ) . ' → ' . (int) $item->http_code;
}
}
$explain = self::explain( $item );
if ( 'unverified' === $item->status && (int) $item->fail_count > 0 && ! (int) $item->dismissed ) {
$retries = count( LinkTender_Queue::retry_intervals() );
/* translators: 1: retry number, 2: total retries. */
$explain .= ' ' . sprintf( __( 'Retry %1$d of %2$d scheduled. Only reported as broken if it keeps failing for 24 hours.', 'linktender' ), min( (int) $item->fail_count, $retries ), $retries );
}
if ( '' !== $detail || '' !== $explain ) {
$out .= '
' . wp_kses( $detail, array() ) . ( '' !== $detail && '' !== $explain ? ' · ' : '' ) . esc_html( $explain ) . '
';
}
return $out;
}
/**
* Formats a GMT MySQL datetime in the site's timezone and formats.
*
* @param string $gmt GMT datetime.
* @return string
*/
public static function format_gmt( $gmt ) {
return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $gmt . ' UTC' ) );
}
/**
* Plain-language explanation of a result.
*
* @param object $item Link row.
* @return string
*/
public static function explain( $item ) {
switch ( $item->error_code ) {
case 'bot_protection':
return __( 'Blocked by bot protection. Probably fine; open it in your browser to confirm.', 'linktender' );
case 'access_denied':
if ( 999 === (int) $item->http_code ) {
return __( 'The site blocks automated checks with code 999 (typical of LinkedIn). Not counted as broken.', 'linktender' );
}
return __( 'The site refused automated checks (login or firewall). Not counted as broken.', 'linktender' );
case 'rate_limited':
return __( 'The site asked us to slow down (rate limit). Will retry later.', 'linktender' );
case 'client_error':
if ( 400 === (int) $item->http_code ) {
return __( 'Bad request (400). Some sites, such as Facebook, answer automated checks this way. Check it in a browser. Not counted as broken.', 'linktender' );
}
return __( 'The server rejected the check request. Not counted as broken.', 'linktender' );
case 'loopback':
return __( 'Your server could not connect to its own site (some hosts block this). Check the link in a browser. Not counted as broken.', 'linktender' );
case 'offline':
return __( 'Your server could not reach the internet when this was checked. Not counted as broken; it will be checked again.', 'linktender' );
case 'dns':
return __( 'Domain not found (DNS).', 'linktender' );
case 'timeout':
return __( 'Timed out.', 'linktender' );
case 'refused':
return __( 'Connection refused.', 'linktender' );
case 'ssl':
return __( 'SSL/TLS certificate problem.', 'linktender' );
case 'server_error':
return __( 'Server error.', 'linktender' );
case 'redirect_loop':
return __( 'Redirect loop.', 'linktender' );
case 'too_many_redirects':
return __( 'Too many redirects.', 'linktender' );
}
if ( 'broken' === $item->status && in_array( (int) $item->http_code, array( 404, 410 ), true ) ) {
return 410 === (int) $item->http_code ? __( 'Gone', 'linktender' ) : __( 'Not found', 'linktender' );
}
return '';
}
/**
* Describes where an instance lives, with an edit link.
*
* @param object $instance Instance row.
* @return string HTML.
*/
public static function instance_label( $instance ) {
$anchor = '' !== $instance->anchor ? ' “' . esc_html( wp_html_excerpt( $instance->anchor, 60, '…' ) ) . '” ' : '';
$kind = self::element_labels();
$kind = isset( $kind[ $instance->element ] ) ? $kind[ $instance->element ] : $instance->element;
$kind = '' . esc_html( $kind ) . ' ';
switch ( $instance->source_type ) {
case 'post':
$post = get_post( (int) $instance->source_id );
if ( ! $post ) {
return esc_html__( '(deleted post)', 'linktender' );
}
$title = '' !== $post->post_title ? $post->post_title : __( '(no title)', 'linktender' );
$type = get_post_type_object( $post->post_type );
$field = '';
if ( '_elementor_data' === substr( $instance->source_field, 5 ) ) {
$field = __( 'Elementor', 'linktender' );
} elseif ( 0 === strpos( $instance->source_field, 'meta:' ) ) {
/* translators: %s: custom field name. */
$field = sprintf( __( 'field %s', 'linktender' ), substr( $instance->source_field, 5 ) );
} elseif ( 'shortcode_output' === $instance->source_field ) {
$field = __( 'shortcode output', 'linktender' );
} elseif ( 'post_excerpt' === $instance->source_field ) {
$field = __( 'excerpt', 'linktender' );
}
$edit = get_edit_post_link( $post->ID );
$name = $edit ? '' . esc_html( $title ) . ' ' : esc_html( $title );
return $kind . ' ' . $name . ' (' . esc_html( $type ? $type->labels->singular_name : $post->post_type ) . ( '' !== $field ? ', ' . esc_html( $field ) : '' ) . ') ' . $anchor;
case 'menu':
$menus = wp_get_object_terms( (int) $instance->source_id, 'nav_menu' );
$menu = ( ! is_wp_error( $menus ) && $menus ) ? $menus[0] : null;
$url = $menu ? admin_url( 'nav-menus.php?action=edit&menu=' . (int) $menu->term_id ) : admin_url( 'nav-menus.php' );
/* translators: %s: menu name. */
$name = $menu ? sprintf( __( 'Menu: %s', 'linktender' ), $menu->name ) : __( 'Menu', 'linktender' );
return $kind . ' ' . esc_html( $name ) . ' ' . $anchor;
case 'comment':
$comment = get_comment( (int) $instance->source_id );
if ( ! $comment ) {
return esc_html__( '(deleted comment)', 'linktender' );
}
$post_title = get_the_title( (int) $comment->comment_post_ID );
/* translators: 1: comment author, 2: post title. */
$name = sprintf( __( 'Comment by %1$s on %2$s', 'linktender' ), $comment->comment_author, '' !== $post_title ? $post_title : __( '(no title)', 'linktender' ) );
$edit = admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID );
return $kind . ' ' . esc_html( $name ) . ' ' . $anchor;
case 'widget':
/* translators: %s: widget ID. */
return $kind . ' ' . esc_html( sprintf( __( 'Widget %s', 'linktender' ), substr( $instance->source_field, 7 ) ) ) . ' ' . $anchor;
}
/**
* Describes add-on source types.
*
* @param string $html HTML.
* @param object $instance Instance.
*/
return (string) apply_filters( 'linktender_instance_label', esc_html( $instance->source_type ), $instance );
}
/**
* Prepares the list table and handles bulk actions.
*
* @return void
*/
public function load_links_page() {
add_screen_option(
'per_page',
array(
'default' => 30,
'option' => 'linktender_per_page',
)
);
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only used to decide which screen to build.
if ( isset( $_GET['link'] ) ) {
return;
}
$this->table = new LinkTender_List_Table();
$action = $this->table->current_action();
if ( $action ) {
if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'bulk-links' ) ) {
$this->deny( 'expired' );
}
$ids = isset( $_REQUEST['link_ids'] ) ? array_map( 'absint', (array) wp_unslash( $_REQUEST['link_ids'] ) ) : array();
if ( $ids ) {
$message = $this->run_action( $action, $ids );
$this->notice( $message );
}
wp_safe_redirect( remove_query_arg( array( 'action', 'action2', 'link_ids', '_wpnonce', '_wp_http_referer' ), wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=linktender' ) ) );
exit;
}
$this->table->prepare_items();
}
/**
* Runs one action on links. Returns a message.
*
* @param string $action Action.
* @param int[] $ids Link IDs.
* @return string
*/
private function run_action( $action, array $ids ) {
delete_transient( 'linktender_broken_count' );
$links = $this->plugin->links;
$editor = $this->plugin->editor;
$count = count( $ids );
switch ( $action ) {
case 'recheck':
$links->recheck( $ids );
/* translators: %d: number of links. */
return sprintf( _n( '%d link will be rechecked in the next batch.', '%d links will be rechecked in the next batch.', $count, 'linktender' ), $count );
case 'dismiss':
$links->set_dismissed( $ids, true );
/* translators: %d: number of links. */
return sprintf( _n( '%d link marked as fine.', '%d links marked as fine.', $count, 'linktender' ), $count );
case 'undismiss':
$links->set_dismissed( $ids, false );
$links->recheck( $ids );
/* translators: %d: number of links. */
return sprintf( _n( '%d link restored.', '%d links restored.', $count, 'linktender' ), $count );
case 'unlink':
$changed = 0;
foreach ( $ids as $id ) {
$result = $editor->unlink( $id );
$changed += $result['changed'];
}
$this->record_fix( $changed );
/* translators: %d: number of places changed. */
return sprintf( _n( 'Link removed in %d place.', 'Link removed in %d places.', $changed, 'linktender' ), $changed );
case 'use_target':
$changed = 0;
foreach ( $ids as $id ) {
$link = $links->get( $id );
if ( $link && LinkTender_Links::final_url_is_usable( $link ) ) {
$result = $editor->replace_url( $id, $link->final_url );
$changed += $result['changed'];
}
}
$this->record_fix( $changed );
/* translators: %d: number of places changed. */
return sprintf( _n( 'Updated %d place to the final URL.', 'Updated %d places to the final URL.', $changed, 'linktender' ), $changed );
case 'ignore_host':
$hosts = array();
foreach ( $ids as $id ) {
$link = $links->get( $id );
if ( $link && '' !== $link->host ) {
$hosts[] = $link->host;
}
}
$all = array_unique( array_merge( (array) LinkTender_Settings::get( 'ignored_hosts', array() ), $hosts ) );
LinkTender_Settings::update( array( 'ignored_hosts' => $all ) );
$links->set_dismissed( $this->ids_on_hosts( $hosts ), true );
/* translators: %s: domain list. */
return sprintf( __( 'Ignoring links on: %s', 'linktender' ), implode( ', ', $hosts ) );
}
/**
* Handles custom bulk/row actions registered by add-ons.
*
* @param string $message Message to show (empty if not handled).
* @param string $action Action.
* @param int[] $ids Link IDs.
*/
return (string) apply_filters( 'linktender_bulk_action', '', $action, $ids );
}
/**
* IDs of links on the given hosts.
*
* @param string[] $hosts Hosts.
* @return int[]
*/
private function ids_on_hosts( array $hosts ) {
$ids = array();
foreach ( $hosts as $host ) {
$result = $this->plugin->links->query(
array(
'view' => 'all',
'host' => $host,
'per_page' => 5000,
)
);
$ids = array_merge( $ids, wp_list_pluck( $result['items'], 'id' ) );
}
return array_map( 'intval', $ids );
}
/**
* Single-link action (row action links).
*
* @return void
*/
public function handle_action() {
$action = isset( $_GET['do'] ) ? sanitize_key( wp_unslash( $_GET['do'] ) ) : '';
$link_id = isset( $_GET['link_id'] ) ? absint( $_GET['link_id'] ) : 0;
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'linktender_' . $action . '_' . $link_id ) ) {
$this->deny( 'expired' );
}
if ( ! current_user_can( LinkTender_Plugin::capability() ) ) {
$this->deny( 'capability' );
}
$this->notice( $this->run_action( $action, array( $link_id ) ) );
$back = wp_get_referer();
if ( ! $back || ( in_array( $action, array( 'unlink', 'use_target' ), true ) && false !== strpos( $back, 'link=' ) ) ) {
$back = admin_url( 'admin.php?page=linktender' );
}
wp_safe_redirect( $back );
exit;
}
/**
* Replace-URL form handler.
*
* @return void
*/
public function handle_edit_url() {
$link_id = isset( $_POST['link_id'] ) ? absint( $_POST['link_id'] ) : 0;
if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'linktender_edit_url_' . $link_id ) ) {
$this->deny( 'expired' );
}
if ( ! current_user_can( LinkTender_Plugin::capability() ) ) {
$this->deny( 'capability' );
}
$new_url = isset( $_POST['new_url'] ) ? trim( esc_url_raw( wp_unslash( $_POST['new_url'] ) ) ) : '';
$link = $this->plugin->links->get( $link_id );
if ( ! $link || '' === $new_url ) {
$this->notice( __( 'Please enter a valid URL.', 'linktender' ), 'error' );
} elseif ( $new_url === $link->url ) {
$this->notice( __( 'The URL is unchanged.', 'linktender' ), 'warning' );
} else {
$result = $this->plugin->editor->replace_url( $link_id, $new_url );
$this->record_fix( $result['changed'] );
if ( $result['changed'] ) {
/* translators: 1: number of places, 2: new URL. */
$this->notice( sprintf( _n( 'Replaced in %1$d place. The new URL %2$s will be checked shortly.', 'Replaced in %1$d places. The new URL %2$s will be checked shortly.', $result['changed'], 'linktender' ), $result['changed'], $new_url ) );
} else {
$this->notice( __( 'Nothing was changed. The link may be generated by a theme or plugin rather than stored in content.', 'linktender' ), 'warning' );
}
}
wp_safe_redirect( admin_url( 'admin.php?page=linktender' ) );
exit;
}
/**
* Tools (run now, rescan, pause, import).
*
* @return void
*/
public function handle_tool() {
if ( ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'linktender_tool' ) ) {
$this->deny( 'expired' );
}
if ( ! current_user_can( 'manage_options' ) ) {
$this->deny( 'capability' );
}
$tool = isset( $_POST['tool'] ) ? sanitize_key( wp_unslash( $_POST['tool'] ) ) : '';
switch ( $tool ) {
case 'start':
LinkTender_Install::start_checking();
$this->notice( __( 'Link checking started. Your content is scanned and links are checked in the background, in small batches.', 'linktender' ) );
break;
case 'run':
if ( ! LinkTender_Install::checking_started() ) {
$this->notice( __( 'Link checking has not been started yet.', 'linktender' ), 'warning' );
break;
}
$summary = $this->plugin->queue->run( true );
if ( $summary['locked'] ) {
$this->notice( __( 'A batch is already running. Try again in a minute.', 'linktender' ), 'warning' );
} else {
/* translators: %d: number of links checked. */
$this->notice( sprintf( _n( 'Batch finished: %d link checked.', 'Batch finished: %d links checked.', $summary['checked'], 'linktender' ), $summary['checked'] ) );
}
break;
case 'rescan':
LinkTender_Scanner::request_full_scan();
$this->notice( __( 'A full content scan was started in the background.', 'linktender' ) );
break;
case 'recheck_all':
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk update of the plugin's own table.
$wpdb->query( $wpdb->prepare( "UPDATE %i SET next_check = '2000-01-01 00:00:00', fail_count = 0, first_failure = NULL WHERE dismissed = 0", LinkTender_Install::links_table() ) );
$this->notice( __( 'All links are queued for a recheck.', 'linktender' ) );
break;
case 'pause':
LinkTender_Settings::update( array( 'paused' => true ) );
$this->notice( __( 'Checking is paused.', 'linktender' ) );
break;
case 'resume':
LinkTender_Settings::update( array( 'paused' => false ) );
$this->notice( __( 'Checking resumed.', 'linktender' ) );
break;
case 'import_blc':
$importer = new LinkTender_Importer();
$result = $importer->import();
/* translators: 1: number of settings, 2: number of dismissed links. */
$this->notice( sprintf( __( 'Imported %1$d settings and %2$d links marked as fine from Broken Link Checker.', 'linktender' ), count( $result['settings'] ), $result['dismissed'] ) );
LinkTender_Scanner::request_full_scan();
break;
default:
/**
* Handles add-on tools posted to the tools form.
*
* @param string $tool Tool key.
*/
do_action( 'linktender_tool', $tool );
}
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=linktender-tools' ) );
exit;
}
/**
* Remembers the first successful fix, which shows a one-time review prompt.
*
* @param int $changed Number of places changed.
* @return void
*/
private function record_fix( $changed ) {
if ( $changed < 1 ) {
return;
}
$review = get_option( 'linktender_review', array() );
$review = is_array( $review ) ? $review : array();
if ( empty( $review['fixed_at'] ) ) {
$review['fixed_at'] = time();
update_option( 'linktender_review', $review, false );
}
}
/**
* Prints the review prompt once, right after the first successful fix.
*
* @return void
*/
private function maybe_review_prompt() {
$review = get_option( 'linktender_review', array() );
if ( ! is_array( $review ) || empty( $review['fixed_at'] ) || ! empty( $review['shown'] ) ) {
return;
}
$review['shown'] = time();
update_option( 'linktender_review', $review, false );
printf(
'',
esc_html__( 'Glad that worked. If LinkTender saved you some time, a short review on WordPress.org helps other site owners find it. We will not ask again.', 'linktender' ),
esc_url( 'https://wordpress.org/support/plugin/linktender/reviews/#new-post' ),
esc_html__( 'Leave a review', 'linktender' )
);
}
/**
* Refuses a request whose nonce or capability check failed. Nothing is changed; the user is
* sent back with an explanation instead of an error page.
*
* @param string $reason "expired" (nonce) or "capability".
* @return void
*/
private function deny( $reason ) {
if ( 'capability' === $reason ) {
$this->notice( __( 'You are not allowed to do this, so nothing was changed. Ask an administrator of this site.', 'linktender' ), 'error' );
} else {
$this->notice( __( 'This link or form has expired, so nothing was changed. Please try again.', 'linktender' ), 'error' );
}
wp_safe_redirect( current_user_can( LinkTender_Plugin::capability() ) ? admin_url( 'admin.php?page=linktender' ) : admin_url() );
exit;
}
/**
* Queues a notice for the current user's next page view.
*
* @param string $message Message.
* @param string $type success|warning|error|info.
* @return void
*/
private function notice( $message, $type = 'success' ) {
if ( '' === $message ) {
return;
}
set_transient(
'linktender_notice_' . get_current_user_id(),
array(
'message' => $message,
'type' => $type,
),
MINUTE_IN_SECONDS
);
}
/**
* Shows queued notices on our screens.
*
* @return void
*/
public function notices() {
$screen = get_current_screen();
if ( ! $screen || ! $this->is_own_screen( $screen->id ) ) {
return;
}
$key = 'linktender_notice_' . get_current_user_id();
$notice = get_transient( $key );
if ( $notice ) {
delete_transient( $key );
printf( '', esc_attr( $notice['type'] ), esc_html( $notice['message'] ) );
if ( 'success' === $notice['type'] ) {
$this->maybe_review_prompt();
}
}
if ( ! wp_http_supports() ) {
printf( '', esc_html__( 'This server cannot send HTTP requests (PHP has neither the cURL extension nor working stream sockets), so links cannot be checked. Ask your host to enable cURL. Scanning your content still works.', 'linktender' ) );
}
if ( ! LinkTender_Install::checking_started() ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only: which screen is shown.
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
if ( 'linktender' !== $page ) {
printf(
'',
esc_html__( 'Link checking has not been started yet.', 'linktender' ),
esc_url( admin_url( 'admin.php?page=linktender' ) ),
esc_html__( 'Start it on the Links screen.', 'linktender' )
);
}
} elseif ( LinkTender_Settings::get( 'paused' ) ) {
printf( '', esc_html__( 'Link checking is paused. Resume it under LinkTender → Health & Tools.', 'linktender' ) );
}
}
/**
* Explains what checking does and asks an administrator to start it. Shown on the Links
* screen until checking was started. Nothing is requested from other servers before that.
*
* @return void
*/
private function render_start_panel() {
echo '';
echo '
' . esc_html__( 'Start checking your links', 'linktender' ) . ' ';
echo '
' . esc_html__( 'LinkTender has not contacted any other server yet. When you start it, it scans your content on this server and then checks each link it found by sending a request (HEAD, or GET when needed) to that link\'s address. This is how broken links are found. Requests run in the background in small batches, with a pause between requests to the same domain.', 'linktender' ) . '
';
echo '
' . esc_html__( 'No data about your site or its visitors is sent to LinkTender or anyone else. You can pause checking at any time under Health & Tools.', 'linktender' ) . '
';
if ( current_user_can( 'manage_options' ) ) {
echo '
';
} else {
echo '
' . esc_html__( 'An administrator needs to start link checking.', 'linktender' ) . '
';
}
echo '
';
}
/**
* Summary cards above the list.
*
* @return void
*/
private function render_summary() {
$counts = $this->plugin->links->status_counts();
$state = LinkTender_Scanner::state();
$last = get_option( LinkTender_Queue::LAST_RUN, array() );
$cards = array(
'broken' => __( 'Broken', 'linktender' ),
'redirect' => __( 'Redirected', 'linktender' ),
'unverified' => __( 'Unverified', 'linktender' ),
'ok' => __( 'OK', 'linktender' ),
'pending' => __( 'Waiting', 'linktender' ),
);
echo '';
foreach ( $cards as $key => $label ) {
printf(
'
%3$s %4$s ',
esc_attr( $key ),
esc_url( admin_url( 'admin.php?page=linktender&view=' . $key ) ),
esc_html( number_format_i18n( $counts[ $key ] ) ),
esc_html( $label )
);
}
echo '
';
if ( LinkTender_Scanner::is_running() ) {
/* translators: %s: number of items. */
echo esc_html( sprintf( __( 'Scanning content in the background (%s items so far).', 'linktender' ), number_format_i18n( (int) $state['parsed'] ) ) ) . ' ';
}
if ( ! empty( $last['time'] ) ) {
/* translators: 1: time ago, 2: number of links. */
echo esc_html( sprintf( __( 'Last batch %1$s ago checked %2$d links.', 'linktender' ), human_time_diff( (int) $last['time'] ), (int) $last['checked'] ) ) . ' ';
}
echo esc_html__( 'All checks run on this server. No account, no quota.', 'linktender' );
echo '
';
if ( current_user_can( 'manage_options' ) ) {
echo '';
}
}
/**
* Links page (list or details).
*
* @return void
*/
public function render_links_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only view switch.
$link_id = isset( $_GET['link'] ) ? absint( $_GET['link'] ) : 0;
if ( $link_id ) {
$this->render_details( $link_id );
return;
}
echo '';
echo '
' . esc_html__( 'Broken Links', 'linktender' ) . ' ';
echo '';
if ( ! LinkTender_Install::checking_started() ) {
$this->render_start_panel();
echo '';
return;
}
$this->render_summary();
$this->table->views();
echo '';
}
/**
* Details screen for one link.
*
* @param int $link_id Link ID.
* @return void
*/
private function render_details( $link_id ) {
$link = $this->plugin->links->get( $link_id );
echo '';
return;
}
$instances = $this->plugin->links->instances( $link_id );
echo '' . esc_html( $link->url ) . ' ';
echo '';
echo '' . esc_html__( 'Status', 'linktender' ) . ' ' . wp_kses_post( self::status_badge( $link ) ) . ' ';
if ( $link->final_url ) {
echo '' . esc_html__( 'Ends at', 'linktender' ) . ' ' . esc_html( $link->final_url ) . ' ';
}
if ( $link->message ) {
echo '' . esc_html__( 'Server said', 'linktender' ) . ' ' . esc_html( $link->message ) . ' ';
}
echo '' . esc_html__( 'Last checked', 'linktender' ) . ' ' . esc_html( $link->last_checked ? self::format_gmt( $link->last_checked ) : '—' ) . ' ';
echo '' . esc_html__( 'Next check', 'linktender' ) . ' ' . esc_html( self::format_gmt( $link->next_check ) ) . ' ';
echo '' . esc_html__( 'Checks / failures in a row', 'linktender' ) . ' ' . (int) $link->check_count . ' / ' . (int) $link->fail_count . ' ';
echo '
';
echo '' . esc_html__( 'Replace this URL everywhere', 'linktender' ) . ' ';
echo '';
echo '';
echo '' . esc_html__( 'Recheck', 'linktender' ) . ' ';
echo '' . esc_html__( 'Unlink everywhere', 'linktender' ) . ' ';
if ( (int) $link->dismissed ) {
echo '' . esc_html__( 'Undo "mark as fine"', 'linktender' ) . ' ';
} else {
echo '' . esc_html__( 'Mark as fine', 'linktender' ) . ' ';
}
echo '
';
/* translators: %d: number of places. */
echo '' . esc_html( sprintf( _n( 'Found in %d place', 'Found in %d places', count( $instances ), 'linktender' ), count( $instances ) ) ) . ' ';
echo '';
foreach ( $instances as $instance ) {
echo '' . wp_kses_post( self::instance_label( $instance ) ) . ' ';
}
echo ' ';
/**
* Fires at the end of the link details screen.
*
* @param object $link Link row.
*/
do_action( 'linktender_link_details', $link );
echo '';
}
/**
* Settings page.
*
* @return void
*/
public function render_settings_page() {
$s = LinkTender_Settings::all();
$name = LinkTender_Settings::OPTION;
$types = LinkTender_Settings::available_post_types();
?>
plugin->links->table_stats();
$state = LinkTender_Scanner::state();
$last = get_option( LinkTender_Queue::LAST_RUN, array() );
$paused = LinkTender_Settings::get( 'paused' );
$importer = new LinkTender_Importer();
$runner = LinkTender_Queue::use_action_scheduler() ? __( 'Action Scheduler', 'linktender' ) : __( 'WP-Cron', 'linktender' );
$next = wp_next_scheduled( LinkTender_Queue::HOOK );
?>
plugin->links->count_due() ) ); ?>
$info ) : ?>
available() ) : ?>