prefix . 'lgbf_links'; } /** * MD5 hash used to group identical URLs. * * @param string $url URL. * @return string */ public static function hash( $url ) { return md5( $url ); } /** * Synchronizes the stored link set for a post with a freshly extracted one. * * Existing rows for URLs still present keep their status (including * 'dismissed'); rows for URLs no longer in the content are removed; new * URLs are inserted as 'pending'. * * @param int $post_id Post ID. * @param array[] $links Items from LGBF_Link_Extractor::extract(). */ public static function sync_post_links( $post_id, $links ) { global $wpdb; $table = self::table(); $post_id = (int) $post_id; $existing = $wpdb->get_results( $wpdb->prepare( "SELECT id, url_hash, link_type, anchor_text FROM {$table} WHERE post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $post_id ), ARRAY_A ); $existing_by_key = array(); foreach ( (array) $existing as $row ) { $existing_by_key[ $row['link_type'] . '|' . $row['url_hash'] ] = $row; } $seen_keys = array(); foreach ( $links as $link ) { $hash = self::hash( $link['url'] ); $key = $link['type'] . '|' . $hash; $seen_keys[ $key ] = true; if ( isset( $existing_by_key[ $key ] ) ) { if ( $existing_by_key[ $key ]['anchor_text'] !== $link['anchor'] ) { $wpdb->update( $table, array( 'anchor_text' => $link['anchor'] ), array( 'id' => (int) $existing_by_key[ $key ]['id'] ), array( '%s' ), array( '%d' ) ); } continue; } $wpdb->insert( $table, array( 'post_id' => $post_id, 'url' => $link['url'], 'url_hash' => $hash, 'anchor_text' => $link['anchor'], 'link_type' => $link['type'], 'status' => 'pending', 'created_at' => current_time( 'mysql', true ), ), array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) ); } // Remove rows whose URL disappeared from the content. foreach ( $existing_by_key as $key => $row ) { if ( ! isset( $seen_keys[ $key ] ) ) { $wpdb->delete( $table, array( 'id' => (int) $row['id'] ), array( '%d' ) ); } } } /** * Removes all rows for a post (post deleted / excluded). * * @param int $post_id Post ID. */ public static function delete_post_links( $post_id ) { global $wpdb; $wpdb->delete( self::table(), array( 'post_id' => (int) $post_id ), array( '%d' ) ); } /** * Marks every non-dismissed row pending so a new cycle rechecks all URLs. */ public static function reset_for_new_cycle() { global $wpdb; $table = self::table(); $wpdb->query( "UPDATE {$table} SET status = 'pending' WHERE status != 'dismissed'" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * Returns distinct pending URLs to check, oldest first. * * @param int $limit Max URLs. * @return array[] Rows with url and url_hash. */ public static function get_pending_urls( $limit ) { global $wpdb; $table = self::table(); return $wpdb->get_results( $wpdb->prepare( "SELECT url, url_hash FROM {$table} WHERE status = 'pending' GROUP BY url_hash, url ORDER BY MIN(id) ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared max( 1, (int) $limit ) ), ARRAY_A ); } /** * Applies a check result to every row sharing the URL hash. * * @param string $url_hash URL hash. * @param array $result Result from LGBF_Checker::check(). */ public static function apply_check_result( $url_hash, $result ) { global $wpdb; $table = self::table(); $is_broken = in_array( $result['status'], array( 'broken', 'timeout' ), true ); $now = current_time( 'mysql', true ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $is_broken ) { $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, http_status = %d, redirect_count = %d, final_url = %s, last_checked = %s, check_count = check_count + 1, first_detected = COALESCE(first_detected, %s) WHERE url_hash = %s AND status != 'dismissed'", $result['status'], (int) $result['http_status'], (int) $result['redirect_count'], (string) $result['final_url'], $now, $now, $url_hash ) ); } else { $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = %s, http_status = %d, redirect_count = %d, final_url = %s, last_checked = %s, check_count = check_count + 1, first_detected = NULL WHERE url_hash = %s AND status != 'dismissed'", $result['status'], (int) $result['http_status'], (int) $result['redirect_count'], (string) $result['final_url'], $now, $url_hash ) ); } // phpcs:enable } /** * Dashboard counts by status. * * @return array */ public static function get_counts() { global $wpdb; $table = self::table(); $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS total FROM {$table} GROUP BY status", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $counts = array( 'pending' => 0, 'ok' => 0, 'broken' => 0, 'timeout' => 0, 'redirect' => 0, 'dismissed' => 0, ); foreach ( (array) $rows as $row ) { $counts[ $row['status'] ] = (int) $row['total']; } $counts['problems'] = $counts['broken'] + $counts['timeout'] + $counts['redirect']; return $counts; } /** * Issue counts broken down by status and link type (for the issues card). * * @return array e.g. array( 'broken' => array( 'link' => 3, 'image' => 1 ) ). */ public static function get_issue_breakdown() { global $wpdb; $table = self::table(); $rows = $wpdb->get_results( "SELECT status, link_type, COUNT(*) AS total FROM {$table} GROUP BY status, link_type", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $breakdown = array(); foreach ( (array) $rows as $row ) { $breakdown[ $row['status'] ][ $row['link_type'] ] = (int) $row['total']; } return $breakdown; } /** * Broken links first detected after a timestamp (for the email digest). * * @param string $since_gmt MySQL datetime (GMT). * @param int $limit Max rows. * @return array[] */ public static function get_new_broken_since( $since_gmt, $limit = 100 ) { global $wpdb; $table = self::table(); return $wpdb->get_results( $wpdb->prepare( "SELECT id, post_id, url, anchor_text, link_type, status, http_status, first_detected FROM {$table} WHERE status IN ('broken', 'timeout') AND first_detected >= %s ORDER BY first_detected DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $since_gmt, max( 1, (int) $limit ) ), ARRAY_A ); } /** * Loads a single row. * * @param int $id Row ID. * @return array|null */ public static function get( $id ) { global $wpdb; $table = self::table(); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); return $row ? $row : null; } }