connection = $connection; $this->stand = $stand; } /** * Hooks. */ public function register(): void { add_action( 'post_updated', array( $this, 'post_updated' ), 10, 3 ); add_action( 'transition_post_status', array( $this, 'status_changed' ), 10, 3 ); add_action( 'before_delete_post', array( $this, 'deleted' ) ); add_action( self::HOOK, array( $this, 'read_again' ) ); add_action( Knowledge::HOOK_REMOVALS, array( $this, 'retry_removals' ) ); } /** * Schedules Read again for an updated picked page, and notes a changed address. * * @param int $post_id Post ID. * @param WP_Post $after Post after the update. * @param WP_Post $before Post before the update. */ public function post_updated( $post_id, $after, $before ): void { if ( ! $after instanceof WP_Post || ! $before instanceof WP_Post || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( 'publish' === $before->post_status && 'publish' === $after->post_status ) { $old_link = (string) get_permalink( $before ); $new_link = (string) get_permalink( $after ); if ( $old_link !== $new_link ) { $this->note_path_change( (int) $post_id, wp_make_link_relative( $old_link ) ); $this->descendants_moved( $after, $old_link, $new_link ); } } if ( '' === (string) get_post_meta( $post_id, Knowledge::META_SOURCE, true ) || 'publish' !== $after->post_status ) { return; } if ( '' !== $after->post_password ) { // A password keeps the page published but not public: stop reading // it, as when it's unpublished, whether or not it's read again. wp_clear_scheduled_hook( self::HOOK, array( (int) $post_id ) ); $this->remove( (int) $post_id ); return; } if ( Settings::get( 'read_on_publish' ) && $this->connection->is_connected() ) { // Also while WordPress needs reconnecting: read_again() waits for it. wp_schedule_single_event( time() + 2 * MINUTE_IN_SECONDS, self::HOOK, array( (int) $post_id ) ); } } /** * Removes a picked page from Stand when it's unpublished or trashed. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public function status_changed( $new_status, $old_status, $post ): void { if ( ! $post instanceof WP_Post || 'publish' !== $old_status || 'publish' === $new_status ) { return; } $this->remove( (int) $post->ID ); } /** * Removes a deleted picked page from Stand. * * @param int $post_id Post ID. */ public function deleted( $post_id ): void { $this->remove( (int) $post_id ); } /** * Cron: reads one page again. * * @param int $post_id Post ID. */ public function read_again( $post_id ): void { if ( ! $this->connection->is_usable() ) { if ( $this->connection->is_connected() ) { // Needs reconnecting: tries again in an hour, so the edit isn't missed. wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::HOOK, array( (int) $post_id ) ); } return; } $knowledge = new Knowledge( $this->stand ); $read = $this->on_one_connection( static function () use ( $knowledge, $post_id ): array { return $knowledge->read_again( array( (int) $post_id ) ); } ); if ( is_wp_error( $read ) || in_array( (int) $post_id, $knowledge->retry(), true ) ) { // Stand turned it away only for now, or WordPress was being // connected: try again in an hour. wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::HOOK, array( (int) $post_id ) ); } } /** * Cron: removes the sources WordPress couldn't remove from Stand before. */ public function retry_removals(): void { $knowledge = new Knowledge( $this->stand ); $retried = $this->connection->is_usable() ? $this->on_one_connection( static function () use ( $knowledge ): int { return $knowledge->retry_removals(); } ) : null; if ( ! is_int( $retried ) ) { Knowledge::schedule_removals(); } } /** * Makes a change this request set off, rather than a page, on the * connection the request began on, as a page's changes are: nobody * connects or disconnects meanwhile, so what's read for one Site isn't * written back for another. * * @param callable $change The change. * @return mixed|WP_Error What the change returns, or why it wasn't made. */ private function on_one_connection( callable $change ) { return $this->stand->on_loaded_connection( null, $change ); } /** * Unpicks a page in Stand. When Stand can't remove its source now, such as * while WordPress needs reconnecting, the source is queued for later. * * @param int $post_id Post ID. */ private function remove( int $post_id ): void { $source = (string) get_post_meta( $post_id, Knowledge::META_SOURCE, true ); if ( '' === $source ) { return; } $knowledge = new Knowledge( $this->stand ); $errors = $this->connection->is_usable() ? $this->on_one_connection( static function () use ( $knowledge, $post_id ): array { return $knowledge->remove( array( $post_id ) ); } ) : null; if ( ! is_array( $errors ) || ! empty( $errors ) ) { Knowledge::queue_removal( $source ); Knowledge::forget( $post_id ); } } /** * Notes that a page's address changed, for the Chat widget screen to offer * updating Behavior rules that used the old address. * * @param int $post_id Post ID. * @param string $old_path Its path before. */ private function note_path_change( int $post_id, string $old_path ): void { $change = array( 'postId' => $post_id, 'oldPath' => $old_path, 'newPath' => Behavior_Rules::path_for_post( $post_id ), 'at' => time(), ); // Added to what's stored now, so another page's move noted meanwhile stays. State::change( 'path_changes', static function ( $changes ) use ( $post_id, $change ): array { $changes = is_array( $changes ) ? $changes : array(); $changes[ $post_id ] = $change; // The last 100: a page that moves takes its descendants with it. return array_slice( $changes, -100, null, true ); } ); } /** * A moved page's descendants moved with it, though WordPress reports only * the page itself: notes their new addresses, and reads the picked ones * again, as for the page. * * @param WP_Post $page The page that moved. * @param string $old_link Its address before. * @param string $new_link Its address now. */ private function descendants_moved( WP_Post $page, string $old_link, string $new_link ): void { if ( ! is_post_type_hierarchical( $page->post_type ) ) { return; } $old_base = wp_make_link_relative( $old_link ); $new_base = wp_make_link_relative( $new_link ); $reread = Settings::get( 'read_on_publish' ) && $this->connection->is_connected(); $children = get_pages( array( 'child_of' => $page->ID, 'post_type' => $page->post_type, 'post_status' => 'publish', ) ); foreach ( is_array( $children ) ? $children : array() as $child ) { $new_path = wp_make_link_relative( (string) get_permalink( $child ) ); if ( 0 !== strpos( $new_path, $new_base ) ) { // Plain permalinks: its address doesn't follow the page's. continue; } $this->note_path_change( (int) $child->ID, $old_base . substr( $new_path, strlen( $new_base ) ) ); if ( $reread && '' === $child->post_password && '' !== (string) get_post_meta( $child->ID, Knowledge::META_SOURCE, true ) ) { wp_schedule_single_event( time() + 2 * MINUTE_IN_SECONDS, self::HOOK, array( (int) $child->ID ) ); } } } }