> */ private array $posts = []; /** @var array> */ private array $terms = []; /** @var array */ private array $trashing = []; public function __construct( private readonly Manager $manager = new Manager(), private readonly Repository $repository = new Repository() ) {} public function boot(): void { add_action( 'pre_post_update', [ $this, 'before_post' ], 10, 1 ); // wp_after_insert_post rather than post_updated: it fires after terms and meta are // saved, and a product's address can depend on its category. add_action( 'wp_after_insert_post', [ $this, 'after_post' ], 10, 1 ); add_action( 'wp_trash_post', [ $this, 'before_trash' ], 10, 1 ); add_action( 'trashed_post', [ $this, 'after_trash' ], 10, 1 ); add_action( 'untrashed_post', [ $this, 'untrashed' ], 10, 1 ); add_action( 'before_delete_post', [ $this, 'deleting' ], 10, 1 ); add_action( 'edit_terms', [ $this, 'before_term' ], 10, 2 ); add_action( 'edited_term', [ $this, 'after_term' ], 10, 3 ); } /* --------------------------------------------------------------------- * Posts * ------------------------------------------------------------------ */ public function before_post( int $post_id ): void { $post = get_post( $post_id ); if ( ! $this->watches( $post ) ) { return; } $this->posts[ $post_id ] = [ $post_id => self::path( (string) get_permalink( $post ) ) ]; if ( is_post_type_hierarchical( $post->post_type ) ) { foreach ( $this->descendants( $post ) as $child ) { $this->posts[ $post_id ][ $child ] = self::path( (string) get_permalink( $child ) ); } } } public function after_post( int $post_id ): void { if ( ! isset( $this->posts[ $post_id ] ) ) { return; } $before = $this->posts[ $post_id ]; unset( $this->posts[ $post_id ] ); $post = get_post( $post_id ); // Unpublishing is not moving. A page taken down has no new address to send anybody // to, and what happens to visitors then is the trash setting's business. if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { return; } foreach ( $before as $id => $old ) { clean_post_cache( $id ); $new = self::path( (string) get_permalink( $id ) ); if ( $old !== $new ) { $this->moved( $old, $new, 'post', $id ); } } } private function watches( mixed $post ): bool { if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) { return false; } if ( ! Settings::get( 'auto_slug' ) || '' === (string) get_option( 'permalink_structure' ) ) { return false; } if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return false; } return in_array( $post->post_type, self::post_types(), true ); } /** @return list */ public static function post_types(): array { $chosen = (array) Settings::get( 'auto_post_types' ); $public = ContentTypes::post_types(); return [] === $chosen ? $public : array_values( array_intersect( $public, $chosen ) ); } /** @return list */ private function descendants( \WP_Post $post ): array { $ids = get_posts( [ 'post_type' => $post->post_type, 'post_status' => 'publish', 'posts_per_page' => self::MAX_CHILDREN, 'fields' => 'ids', 'no_found_rows' => true, 'post_parent' => $post->ID, ] ); $all = array_map( 'intval', $ids ); // Grandchildren too, breadth first, still within the overall limit. foreach ( $all as $child ) { if ( count( $all ) >= self::MAX_CHILDREN ) { break; } foreach ( $this->descendants( get_post( $child ) ) as $grandchild ) { $all[] = $grandchild; } } return array_slice( array_values( array_unique( $all ) ), 0, self::MAX_CHILDREN ); } /* --------------------------------------------------------------------- * Trash * ------------------------------------------------------------------ */ public function before_trash( int $post_id ): void { $post = get_post( $post_id ); // The address has to be read now: once trashed, the slug gains "__trashed" and the // permalink is no longer the one visitors know. if ( $post instanceof \WP_Post && 'publish' === $post->post_status && 'none' !== Settings::get( 'on_trash' ) && in_array( $post->post_type, self::post_types(), true ) && '' !== (string) get_option( 'permalink_structure' ) ) { $this->trashing[ $post_id ] = self::path( (string) get_permalink( $post ) ); } } public function after_trash( int $post_id ): void { $path = $this->trashing[ $post_id ] ?? null; unset( $this->trashing[ $post_id ] ); if ( null === $path || '/' === $path || [] !== $this->repository->by_source( $path ) ) { return; } $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } if ( 'gone' === Settings::get( 'on_trash' ) ) { $this->manager->create( [ 'source' => $path, 'status_code' => 410, 'origin' => 'trash', 'post_id' => $post_id, ] ); return; } $this->manager->create( [ 'source' => $path, 'status_code' => 301, 'origin' => 'trash', 'post_id' => $post_id ] + self::nearest( $post ) ); } /** * Where a deleted page's visitors would most want to go: its parent page, then its * category, then the listing it appeared in, then the homepage. * * @return array{target: string, target_type: string, target_id: int} */ private static function nearest( \WP_Post $post ): array { if ( $post->post_parent > 0 && 'publish' === get_post_status( $post->post_parent ) ) { return [ 'target' => '', 'target_type' => 'post', 'target_id' => $post->post_parent ]; } foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) { if ( ! is_taxonomy_viewable( $taxonomy ) || 'post_format' === $taxonomy ) { continue; } $terms = get_the_terms( $post, $taxonomy ); if ( is_array( $terms ) && [] !== $terms && 'uncategorized' !== $terms[0]->slug ) { return [ 'target' => '', 'target_type' => 'term', 'target_id' => $terms[0]->term_id ]; } } $archive = get_post_type_archive_link( $post->post_type ); if ( is_string( $archive ) && 'page' !== $post->post_type ) { return [ 'target' => self::path( $archive ), 'target_type' => 'url', 'target_id' => 0 ]; } return [ 'target' => '/', 'target_type' => 'url', 'target_id' => 0 ]; } /** Restored from the trash: it is back at its old address, so the stand-in goes. */ public function untrashed( int $post_id ): void { $ids = array_map( static fn( Rule $r ): int => $r->id, $this->repository->for_post( $post_id, 'trash' ) ); if ( [] !== $ids ) { $this->manager->delete( $ids ); } } /** * Permanently deleted. The redirect set from this post's own editor box goes with * it, because it was a property of the post. Redirects that pointed at it stay — * they still send visitors to the address the post last had — and appear under * "Needs attention" so somebody can choose a new destination. */ public function deleting( int $post_id ): void { $ids = array_map( static fn( Rule $r ): int => $r->id, $this->repository->for_post( $post_id, 'post' ) ); if ( [] !== $ids ) { $this->manager->delete( $ids ); } } /* --------------------------------------------------------------------- * Categories, tags and other terms * ------------------------------------------------------------------ */ public function before_term( int $term_id, string $taxonomy ): void { if ( ! Settings::get( 'auto_terms' ) || ! is_taxonomy_viewable( $taxonomy ) || '' === (string) get_option( 'permalink_structure' ) ) { return; } $link = get_term_link( $term_id, $taxonomy ); if ( ! is_string( $link ) ) { return; } $this->terms[ $term_id ] = [ $term_id => self::path( $link ) ]; if ( is_taxonomy_hierarchical( $taxonomy ) ) { $children = get_term_children( $term_id, $taxonomy ); foreach ( is_array( $children ) ? array_slice( $children, 0, self::MAX_CHILDREN ) : [] as $child ) { $child_link = get_term_link( (int) $child, $taxonomy ); if ( is_string( $child_link ) ) { $this->terms[ $term_id ][ (int) $child ] = self::path( $child_link ); } } } } public function after_term( int $term_id, int $tt_id, string $taxonomy ): void { if ( ! isset( $this->terms[ $term_id ] ) ) { return; } $before = $this->terms[ $term_id ]; unset( $this->terms[ $term_id ] ); foreach ( $before as $id => $old ) { clean_term_cache( $id, $taxonomy ); $link = get_term_link( $id, $taxonomy ); if ( is_string( $link ) && self::path( $link ) !== $old ) { $this->moved( $old, self::path( $link ), 'term', $id ); } } } /* --------------------------------------------------------------------- * The redirect itself * ------------------------------------------------------------------ */ /** * Something moved from one address to another. */ private function moved( string $from, string $to, string $type, int $id ): void { if ( '/' === $from ) { return; } // Moved back to an address it once had: the redirect that used to cover that // address now points at the page standing on it, which is a loop. Remove it. foreach ( $this->repository->by_source( $to ) as $existing ) { if ( $existing->target_type === $type && $existing->target_id === $id && 'manual' !== $existing->origin ) { $this->manager->delete( [ $existing->id ] ); } } // A redirect set from this post's own editor box was keyed to the old address. // It follows the post, or it would stop working the moment the post was renamed. if ( 'post' === $type ) { foreach ( $this->repository->for_post( $id, 'post' ) as $own ) { $this->manager->update( $own->id, [ 'source' => $to ] ); } } // Somebody already decided what happens at the old address. Theirs stands. if ( [] !== $this->repository->by_source( $from ) ) { return; } $result = $this->manager->create( [ 'source' => $from, 'target' => '', 'target_type' => $type, 'target_id' => $id, 'status_code' => 301, 'origin' => 'auto', 'post_id' => 'post' === $type ? $id : 0, ] ); /** * Fires after an address change was caught and redirected. * * @param array{rule: Rule, warnings: list>}|\WP_Error $result * @param string $from The address it had. * @param string $to The address it has now. */ do_action( 'codebold_redirect_auto_created', $result, $from, $to ); } private static function path( string $url ): string { return Address::parse( $url )->path; } }