*/ private $old_meta = array(); /** * Address of each post about to be trashed (the trash renames the slug). * * @var array */ private $pre_trash = array(); /** * Constructor. * * @param Settings $settings Settings. * @param TypeRegistry $types Type registry. * @param Repository $redirects Redirects. * @param ChangeLog $log Change log. * @param Impact $impact Impact. */ public function __construct( Settings $settings, TypeRegistry $types, Repository $redirects, ChangeLog $log, Impact $impact ) { $this->settings = $settings; $this->types = $types; $this->redirects = $redirects; $this->log = $log; $this->impact = $impact; } /** * Stops recording (for rollbacks, imports and other changes Orbitstem makes itself). */ public static function suspend() { ++self::$suspended; } /** * Resumes recording. */ public static function resume() { self::$suspended = max( 0, self::$suspended - 1 ); } /** * Whether recording is active. * * @return bool */ public static function active() { return 0 === self::$suspended && Tables::v3_ready(); } /** * Registers hooks. */ public function register() { add_action( 'post_updated', array( $this, 'on_post_updated' ), 10, 3 ); add_action( 'transition_post_status', array( $this, 'on_publish' ), 10, 3 ); add_action( 'wp_trash_post', array( $this, 'before_trash' ) ); add_action( 'trashed_post', array( $this, 'on_trashed' ) ); add_action( 'update_option_blog_public', array( $this, 'on_visibility' ), 10, 2 ); add_action( 'update_option_' . Settings::OPTION, array( $this, 'on_settings' ), 10, 2 ); add_filter( 'update_post_metadata', array( $this, 'capture_old_meta' ), 10, 3 ); add_filter( 'delete_post_metadata', array( $this, 'capture_old_meta' ), 10, 3 ); add_action( 'deleted_post_meta', array( $this, 'on_meta_deleted' ), 10, 3 ); add_action( 'added_post_meta', array( $this, 'on_meta' ), 10, 4 ); add_action( 'updated_post_meta', array( $this, 'on_meta' ), 10, 4 ); add_action( 'admin_notices', array( $this, 'print_notices' ) ); } /** * A published item's address changed: redirect the old one and record it. * * @param int $post_id Post ID. * @param WP_Post $after Post after the update. * @param WP_Post $before Post before the update. */ public function on_post_updated( $post_id, $after, $before ) { if ( ! self::active() || 'publish' !== $before->post_status || 'publish' !== $after->post_status || ! $this->types->supports_post_type( $after->post_type ) ) { return; } $old_url = Url::normalize( (string) get_permalink( $before ), home_url( '/' ) ); $new_url = Url::normalize( (string) get_permalink( $after ), home_url( '/' ) ); if ( '' === $old_url || $old_url === $new_url ) { return; } $redirect_id = 0; $replaced = null; $removed = null; $repointed = array(); $children = array(); // A rule from the new address would hijack the page itself; remove it. $hijack = $this->find_rule( Repository::normalize_path( $new_url ) ); if ( $hijack ) { $removed = $hijack; self::suspend(); $this->redirects->delete( $hijack['id'], false ); self::resume(); } if ( $this->settings->get( 'guardian_auto_redirect' ) ) { self::suspend(); $existing = $this->find_rule( Repository::normalize_path( $old_url ) ); if ( $existing ) { $replaced = $existing; $this->redirects->update( $existing['id'], array( 'source' => $existing['source'], 'target' => $new_url, 'code' => 301, 'note' => $existing['note'], ), false ); $redirect_id = (int) $existing['id']; } else { $created = $this->redirects->create( array( 'source' => $old_url, 'target' => $new_url, 'code' => 301, 'note' => __( 'Added by Orbitstem SEO Guardian after an address change', 'orbitstem-seo' ), ), false ); $redirect_id = is_wp_error( $created ) ? 0 : (int) $created; } if ( $redirect_id ) { $repointed = $this->repoint( $old_url, $new_url, $redirect_id ); $children = $this->redirect_children( $after, $old_url, $new_url, $repointed ); } self::resume(); } $this->log->record( self::LOG_ID, 'post', $post_id, $redirect_id /* translators: 1: title, 2: old URL, 3: new URL */ ? sprintf( __( 'Address of "%1$s" changed from %2$s to %3$s; old address now redirects', 'orbitstem-seo' ), $after->post_title, $old_url, $new_url ) /* translators: 1: title, 2: old URL, 3: new URL */ : sprintf( __( 'Address of "%1$s" changed from %2$s to %3$s', 'orbitstem-seo' ), $after->post_title, $old_url, $new_url ), array( 'kind' => 'slug', 'url' => $old_url, 'post_name' => $before->post_name, 'post_parent' => (int) $before->post_parent, 'replaced' => $replaced, 'removed' => $removed, 'repointed' => $repointed, ), array( 'kind' => 'slug', 'url' => $new_url, 'post_name' => $after->post_name, 'post_parent' => (int) $after->post_parent, 'redirect_id' => $redirect_id, 'children' => $children, ) ); $this->add_pending( $post_id ); $inbound = $this->impact->for_post( $after )['inbound']; $message = $redirect_id ? __( 'The page address changed. Visitors and search engines using the old address are now redirected to the new one.', 'orbitstem-seo' ) : __( 'The page address changed. Links to the old address will no longer work.', 'orbitstem-seo' ); if ( $inbound ) { /* translators: %d: number of posts */ $message .= ' ' . sprintf( _n( '%d post links to this page; update its links to the new address in the Repair Center.', '%d posts link to this page; update their links to the new address in the Repair Center.', $inbound, 'orbitstem-seo' ), $inbound ); } $this->notify( $message, $inbound ? admin_url( 'admin.php?page=orbitstem-seo-repair&fixer=outdated_links' ) : '' ); } /** * Older rules that pointed at the old address now point at the new one, so * renaming A to B to C gives /a -> /c and /b -> /c, not a chain. * * @param string $old_url Old address. * @param string $new_url New address. * @param int $redirect_id The rule just added for the old address. * @return array Rules changed, with their previous target. */ private function repoint( $old_url, $new_url, $redirect_id ) { $old_path = Repository::normalize_path( $old_url ); $repointed = array(); foreach ( $this->redirects->all_exact() as $rule ) { if ( (int) $rule['id'] === (int) $redirect_id || '' === (string) $rule['target'] || Repository::normalize_path( $rule['target'] ) !== $old_path ) { continue; } $full = $this->redirects->get( $rule['id'] ); $updated = $this->redirects->update( $rule['id'], array( 'source' => $full['source'], 'target' => $new_url, 'code' => $full['code'], 'note' => $full['note'], ), false ); if ( ! is_wp_error( $updated ) ) { $repointed[] = array( 'id' => (int) $rule['id'], 'target' => (string) $rule['target'], 'to' => $new_url, ); } } return $repointed; } /** * Renaming or moving a parent page changes the address of every page below * it; each published descendant's old address gets its own redirect. * * Older rules that pointed at a descendant's old address are re-pointed too, * so renaming a parent twice never leaves /a/kid -> /b/kid -> /c/kid. * * @param WP_Post $post The renamed post. * @param string $old_url Its old address. * @param string $new_url Its new address. * @param array $repointed Rules re-pointed so far; descendants' are added (for undo). * @return int[] Rule IDs added. */ private function redirect_children( WP_Post $post, $old_url, $new_url, array &$repointed ) { if ( ! is_post_type_hierarchical( $post->post_type ) ) { return array(); } // No "number" limit: get_pages() applies it before picking descendants, // which could drop grandchildren. The cap below keeps one save bounded. $descendants = array_slice( (array) get_pages( array( 'child_of' => $post->ID, 'post_type' => $post->post_type, 'post_status' => 'publish', ) ), 0, 500 ); $added = array(); foreach ( $descendants as $child ) { $child_new = Url::normalize( (string) get_permalink( $child ), home_url( '/' ) ); if ( 0 !== strpos( $child_new, $new_url ) ) { continue; } $child_old = $old_url . substr( $child_new, strlen( $new_url ) ); if ( $this->find_rule( Repository::normalize_path( $child_old ) ) ) { continue; // Keep a rule someone already made for that address. } $created = $this->redirects->create( array( 'source' => $child_old, 'target' => $child_new, 'code' => 301, 'note' => __( 'Added by Orbitstem SEO Guardian after a parent page\'s address changed', 'orbitstem-seo' ), ), false ); if ( ! is_wp_error( $created ) ) { $added[] = (int) $created; foreach ( $this->repoint( $child_old, $child_new, (int) $created ) as $moved ) { $repointed[] = $moved; } } } return $added; } /** * Something was published: an old redirect from its address would make it * unreachable, so an exact rule is removed (recorded, undoable). Pattern rules * never apply to existing pages. Noindex or a canonical set while it was * a draft is recorded now that it takes effect. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public function on_publish( $new_status, $old_status, $post ) { if ( 'publish' !== $new_status || 'publish' === $old_status || ! self::active() || ! $post instanceof WP_Post || ! $this->types->supports_post_type( $post->post_type ) ) { return; } $url = Url::normalize( (string) get_permalink( $post ), home_url( '/' ) ); $rule = $this->find_rule( Repository::normalize_path( $url ) ); if ( $rule ) { self::suspend(); $this->redirects->delete( $rule['id'], false ); self::resume(); $this->log->record( self::LOG_ID, 'post', $post->ID, /* translators: 1: title, 2: address, 3: old redirect target */ sprintf( __( 'Published "%1$s" at %2$s; removed the redirect from that address to %3$s', 'orbitstem-seo' ), $post->post_title, $url, $rule['target'] ), array( 'kind' => 'freed', 'rule' => $rule, ), array( 'kind' => 'freed', 'url' => $url, ) ); /* translators: %s: old redirect target */ $this->notify( sprintf( __( 'A redirect from this address to %s was removed so the new page can be reached. You can undo this from Orbitstem SEO > Guardian.', 'orbitstem-seo' ), $rule['target'] ), admin_url( 'admin.php?page=orbitstem-seo-guardian' ), __( 'Review', 'orbitstem-seo' ) ); } foreach ( array( 'noindex', 'canonical' ) as $field ) { if ( ! MetaRepository::has_value( 'post', $post->ID, $field ) ) { continue; } $value = get_post_meta( $post->ID, MetaRepository::PREFIX . $field, true ); $this->log->record( self::LOG_ID, 'post', $post->ID, 'noindex' === $field /* translators: %s: title */ ? sprintf( __( '"%s" published with noindex (hidden from search results)', 'orbitstem-seo' ), $post->post_title ) /* translators: 1: title, 2: URL */ : sprintf( __( '"%1$s" published with its canonical set to %2$s', 'orbitstem-seo' ), $post->post_title, (string) $value ), array( 'kind' => 'post_' . $field, 'value' => 'noindex' === $field ? false : '', ), array( 'kind' => 'post_' . $field, 'value' => 'noindex' === $field ? true : (string) $value, ) ); } } /** * Remembers the address of a post before the trash renames it. * * @param int $post_id Post ID. */ public function before_trash( $post_id ) { $post = get_post( $post_id ); if ( $post && 'publish' === $post->post_status ) { $this->pre_trash[ (int) $post_id ] = Url::normalize( (string) get_permalink( $post ), home_url( '/' ) ); } } /** * A published item was trashed (the ordinary way, not through the Guardian screen). * * @param int $post_id Post ID. */ public function on_trashed( $post_id ) { if ( ! self::active() || ! isset( $this->pre_trash[ $post_id ] ) ) { return; } $post = get_post( $post_id ); if ( ! $post || ! $this->types->supports_post_type( $post->post_type ) ) { return; } $url = $this->pre_trash[ $post_id ]; $change_id = $this->record_trash( $post, $url, 0 ); /* translators: %s: title */ $message = sprintf( __( '"%s" was moved to the trash and its address no longer works.', 'orbitstem-seo' ), $post->post_title ); $this->notify( $message, admin_url( 'admin.php?page=orbitstem-seo-guardian&change=' . $change_id ), __( 'Review impact and add a redirect', 'orbitstem-seo' ) ); } /** * Records a trash change. * * @param WP_Post $post Trashed post. * @param string $url Address before the trash. * @param int $redirect_id Redirect added for it, or 0. * @return int Change ID. */ public function record_trash( WP_Post $post, $url, $redirect_id ) { return $this->log->record( self::LOG_ID, 'post', $post->ID, $redirect_id /* translators: 1: title, 2: URL */ ? sprintf( __( 'Moved "%1$s" to the trash; %2$s now redirects', 'orbitstem-seo' ), $post->post_title, $url ) /* translators: 1: title, 2: URL */ : sprintf( __( 'Moved "%1$s" to the trash (%2$s)', 'orbitstem-seo' ), $post->post_title, $url ), array( 'kind' => 'trash', 'status' => 'publish', 'url' => $url, ), array( 'kind' => 'trash', 'status' => 'trash', 'redirect_id' => (int) $redirect_id, ) ); } /** * "Discourage search engines" switched on. * * @param mixed $old Old value. * @param mixed $value New value. */ public function on_visibility( $old, $value ) { if ( ! self::active() || '1' !== (string) $old || '0' !== (string) $value ) { return; } $this->log->record( self::LOG_ID, 'site', 0, __( 'Search engines discouraged from indexing the whole site (Settings > Reading)', 'orbitstem-seo' ), array( 'kind' => 'visibility', 'value' => 1, ), array( 'kind' => 'visibility', 'value' => 0, ) ); $this->notify( __( 'Search engines are now asked not to index any page of this site. If this was not intended, undo it from Orbitstem SEO > Guardian.', 'orbitstem-seo' ), admin_url( 'admin.php?page=orbitstem-seo-guardian' ), __( 'Review', 'orbitstem-seo' ) ); } /** * Indexability-related settings changed toward less visibility. * * @param mixed $old Old settings. * @param mixed $value New settings. */ public function on_settings( $old, $value ) { if ( ! self::active() || ! is_array( $old ) || ! is_array( $value ) ) { return; } $this->record_robots( $old, $value ); $before = self::indexability( $old ); $after = self::indexability( $value ); $worse = array(); foreach ( $after as $key => $value ) { $was = isset( $before[ $key ] ) ? $before[ $key ] : null; if ( null === $was ) { continue; } $reduces = ( false !== strpos( $key, 'noindex' ) && $value && ! $was ) || ( ( false !== strpos( $key, 'sitemap' ) ) && ! $value && $was ); if ( $reduces ) { $worse[] = $key; } } if ( ! $worse ) { return; } $this->log->record( self::LOG_ID, 'site', 0, /* translators: %s: setting keys */ sprintf( __( 'Settings reduced search visibility: %s', 'orbitstem-seo' ), implode( ', ', $worse ) ), array( 'kind' => 'settings', 'values' => array_intersect_key( $before, array_flip( $worse ) ), ), array( 'kind' => 'settings', 'values' => array_intersect_key( $after, array_flip( $worse ) ), ) ); $this->notify( __( 'Your settings hide some content from search engines or the sitemap. The change is recorded and can be undone from Orbitstem SEO > Guardian.', 'orbitstem-seo' ), admin_url( 'admin.php?page=orbitstem-seo-guardian' ), __( 'Review', 'orbitstem-seo' ) ); } /** * Records changes to the robots.txt rules or blocked AI crawlers (undoable). * * @param array $old Settings before. * @param array $value Settings after. */ private function record_robots( array $old, array $value ) { $keys = array( 'robots_extra', 'ai_bots_block' ); $before = array_intersect_key( array_merge( Settings::defaults(), $old ), array_flip( $keys ) ); $after = array_intersect_key( array_merge( Settings::defaults(), $value ), array_flip( $keys ) ); if ( $before === $after ) { return; } $this->log->record( self::LOG_ID, 'site', 0, __( 'robots.txt rules or blocked AI crawlers changed', 'orbitstem-seo' ), array( 'kind' => 'robots', 'values' => $before, ), array( 'kind' => 'robots', 'values' => $after, ) ); } /** * Flattened indexability switches: "post_types.post.noindex" => bool, etc. * * @param array $settings Settings array (stored form). * @return array */ public static function indexability( array $settings ) { $merged = array_merge( Settings::defaults(), $settings ); $out = array( 'sitemap_enabled' => (bool) $merged['sitemap_enabled'] ); // Every public type, with defaults filled in, so a first-time change (no stored value yet) is seen. $names = array( 'post_types' => array_unique( array_merge( array_keys( (array) $merged['post_types'] ), array_keys( get_post_types( array( 'public' => true ) ) ) ) ), 'taxonomies' => array_unique( array_merge( array_keys( (array) $merged['taxonomies'] ), array_keys( get_taxonomies( array( 'public' => true ) ) ) ) ), ); foreach ( $names as $group => $list ) { foreach ( $list as $name ) { $stored = isset( $merged[ $group ][ $name ] ) && is_array( $merged[ $group ][ $name ] ) ? $merged[ $group ][ $name ] : array(); $values = array_merge( 'post_types' === $group ? Settings::post_type_defaults( $name ) : Settings::taxonomy_defaults( $name ), $stored ); foreach ( array( 'noindex', 'sitemap' ) as $field ) { $out[ "{$group}.{$name}.{$field}" ] = (bool) $values[ $field ]; } } } foreach ( array( 'author_archive', 'date_archive' ) as $group ) { if ( isset( $merged[ $group ]['noindex'] ) ) { $out[ "{$group}.noindex" ] = (bool) $merged[ $group ]['noindex']; } } return $out; } /** * Keeps the previous value of SEO meta about to be updated. * * @param mixed $check Short-circuit value (returned unchanged). * @param int $post_id Post ID. * @param string $meta_key Key. * @return mixed */ public function capture_old_meta( $check, $post_id, $meta_key ) { if ( MetaRepository::PREFIX . 'canonical' === $meta_key ) { $this->old_meta[ $post_id . ':' . $meta_key ] = (string) get_post_meta( $post_id, $meta_key, true ); } return $check; } /** * Records noindex being set or a canonical changing on a published item. * * @param int $meta_id Meta ID. * @param int $post_id Post ID. * @param string $meta_key Key. * @param mixed $value New value. */ public function on_meta( $meta_id, $post_id, $meta_key, $value ) { if ( ! self::active() || ! in_array( $meta_key, array( MetaRepository::PREFIX . 'noindex', MetaRepository::PREFIX . 'canonical' ), true ) ) { return; } $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status || ! $this->types->supports_post_type( $post->post_type ) ) { return; } if ( MetaRepository::PREFIX . 'noindex' === $meta_key ) { if ( ! $value ) { return; } $this->log->record( self::LOG_ID, 'post', $post_id, /* translators: %s: title */ sprintf( __( '"%s" set to noindex (hidden from search results)', 'orbitstem-seo' ), $post->post_title ), array( 'kind' => 'post_noindex', 'value' => false, ), array( 'kind' => 'post_noindex', 'value' => true, ) ); return; } $old = isset( $this->old_meta[ $post_id . ':' . $meta_key ] ) ? $this->old_meta[ $post_id . ':' . $meta_key ] : ''; unset( $this->old_meta[ $post_id . ':' . $meta_key ] ); if ( (string) $old === (string) $value ) { return; } $this->log->record( self::LOG_ID, 'post', $post_id, /* translators: 1: title, 2: URL */ sprintf( __( 'Canonical of "%1$s" set to %2$s', 'orbitstem-seo' ), $post->post_title, (string) $value ), array( 'kind' => 'post_canonical', 'value' => (string) $old, ), array( 'kind' => 'post_canonical', 'value' => (string) $value, ) ); } /** * Records a canonical being cleared (an empty value deletes the field). * * @param int[] $meta_ids Deleted meta IDs. * @param int $post_id Post ID. * @param string $meta_key Key. */ public function on_meta_deleted( $meta_ids, $post_id, $meta_key ) { if ( MetaRepository::PREFIX . 'canonical' !== $meta_key ) { return; } $old = isset( $this->old_meta[ $post_id . ':' . $meta_key ] ) ? $this->old_meta[ $post_id . ':' . $meta_key ] : ''; unset( $this->old_meta[ $post_id . ':' . $meta_key ] ); $post = get_post( $post_id ); if ( '' === $old || ! self::active() || ! $post || 'publish' !== $post->post_status || ! $this->types->supports_post_type( $post->post_type ) ) { return; } $this->log->record( self::LOG_ID, 'post', $post_id, /* translators: 1: title, 2: previous URL */ sprintf( __( 'Canonical of "%1$s" removed (was %2$s); the page\'s own address is used again', 'orbitstem-seo' ), $post->post_title, $old ), array( 'kind' => 'post_canonical', 'value' => $old, ), array( 'kind' => 'post_canonical', 'value' => '', ) ); } /** * Posts whose address changed recently (for the outdated-links repair). * * @return int[] */ public static function pending() { return array_map( 'intval', (array) get_option( self::PENDING_OPTION, array() ) ); } /** * Sets the pending list. * * @param int[] $ids Post IDs. */ public static function set_pending( array $ids ) { update_option( self::PENDING_OPTION, array_values( array_unique( array_map( 'intval', $ids ) ) ), false ); } /** * Adds a post to the pending list (newest kept, max 500). * * @param int $post_id Post ID. */ private function add_pending( $post_id ) { $ids = self::pending(); $ids[] = (int) $post_id; self::set_pending( array_slice( array_unique( $ids ), -500 ) ); } /** * Exact redirect rule for a source path. * * @param string $path Normalized path. * @return array|null */ private function find_rule( $path ) { global $wpdb; if ( '' === $path ) { return null; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE source_hash = %s', Tables::redirects(), Repository::hash( $path ) ), ARRAY_A ); return $row ? $row : null; } /** * Queues a notice for the current user's next admin screen. * * @param string $message Message. * @param string $link Optional action link. * @param string $label Link text. */ private function notify( $message, $link = '', $label = '' ) { $user = get_current_user_id(); if ( ! $user ) { return; } $notices = get_transient( self::NOTICE_TRANSIENT . $user ); $notices = is_array( $notices ) ? $notices : array(); $notices[] = array( 'message' => $message, 'link' => $link, 'label' => '' !== $label ? $label : __( 'Open Repair Center', 'orbitstem-seo' ), ); set_transient( self::NOTICE_TRANSIENT . $user, array_slice( $notices, -5 ), HOUR_IN_SECONDS ); } /** * Prints and clears queued notices for the current user. */ public function print_notices() { $key = self::NOTICE_TRANSIENT . get_current_user_id(); $notices = get_transient( $key ); if ( ! is_array( $notices ) || ! $notices ) { return; } delete_transient( $key ); foreach ( $notices as $notice ) { printf( '

%1$s %2$s %3$s

', esc_html__( 'Orbitstem SEO Guardian:', 'orbitstem-seo' ), esc_html( $notice['message'] ), '' !== $notice['link'] ? '' . esc_html( $notice['label'] ) . '' : '' ); } } }