knowledge = $knowledge ?? new Knowledge( $connection ); $this->roles = $roles ?? new Roles( $connection, $this->knowledge ); $this->access = new Access( $connection ); $this->builder = $builder ?? new DocumentBuilder( $connection ); } /** * Hooks the plugin into editing. */ public function register(): void { // wp_after_insert_post, not save_post: it runs after terms and meta are // written, so the document is built from the finished post rather than // from a half-saved one. add_action( 'wp_after_insert_post', array( $this, 'on_post_saved' ), 10, 2 ); add_action( 'before_delete_post', array( $this, 'on_post_deleted' ) ); add_action( 'wp_trash_post', array( $this, 'on_post_deleted' ) ); add_action( 'attachment_updated', array( $this, 'on_post_saved' ) ); } // ------------------------------------------------------------- what is in. /** * Whether a post is something Sharely should hold. * * The type has to be in scope, and the post's access has to be something * that can be written as roles — `Access` is where that is decided, and * it is the same judgement that decides what the item is tagged with. A * post this says yes to is a post `roles_for()` can describe. * * @param \WP_Post $post The post. */ public function in_scope( \WP_Post $post ): bool { if ( ! in_array( $post->post_type, $this->connection->post_types(), true ) ) { return false; } return $this->access->is_expressible( $post ); } /** * Every post id in scope, oldest first. * * @return int[] */ public function in_scope_ids(): array { $types = $this->connection->post_types(); if ( array() === $types ) { return array(); } return array_map( 'intval', get_posts( array( 'post_type' => $types, 'post_status' => $this->access->private_included() ? array( 'publish', 'private' ) : array( 'publish' ), 'has_password' => false, 'numberposts' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', // `suppress_filters` is not set here, and that is not an // oversight: get_posts() already defaults it to true, and // setting it explicitly is an error in Plugin Check. The // inventory is deliberately the site's own content, not // what another plugin has filtered the query down to. 'no_found_rows' => true, ) ) ); } // ------------------------------------------------------------- one post. /** * Sends one post, or removes it if it no longer belongs. * * @param \WP_Post|int $post The post. * * @return array{outcome:string, message:string} * outcome is created|updated|unchanged|removed|skipped|failed. */ public function sync_post( $post ): array { $post = get_post( $post ); if ( ! $post instanceof \WP_Post ) { return $this->outcome( 'skipped' ); } $existing = (string) get_post_meta( $post->ID, self::META_ID, true ); if ( ! $this->in_scope( $post ) ) { if ( '' === $existing ) { return $this->outcome( 'skipped' ); } return $this->remove_post( $post->ID ); } $document = $this->builder->build( $post ); // The role set is part of the fingerprint. Flipping a post between // published and private can leave its text identical, and a hash that // only read the text would call that unchanged — leaving a private // post tagged for anonymous visitors, which is the whole bug this // phase exists to prevent. $hash = $this->builder->hash( $document, $this->access->roles_for( $post ) ); if ( '' !== $existing && (string) get_post_meta( $post->ID, self::META_HASH, true ) === $hash ) { delete_post_meta( $post->ID, self::META_ERROR ); return $this->outcome( 'unchanged' ); } // The roles WordPress would let read this post, as Sharely knows them. // Not "public" any more: a private post carries the roles that hold // the capability, and a published one carries anonymous. $roles = $this->roles->ids_for( $this->access->roles_for( $post ) ); if ( ! $roles['ok'] ) { return $this->fail( $post->ID, $roles['message'] ); } // 1. The new item, created and tagged: searchable before anything is lost. $created = $this->knowledge->create( $document, $roles['ids'] ); if ( ! $created['ok'] ) { return $this->fail( $post->ID, $created['message'] ); } // 2. The old one goes. A failure here is recorded, not raised: the new // item is already live, and leaving the page unsynced over a stale // duplicate would be the worse trade. if ( '' !== $existing ) { $deleted = $this->knowledge->delete( $existing ); if ( ! $deleted['ok'] ) { $this->note_orphan( $post->ID, $existing ); } } // 3. Bookkeeping last. update_post_meta( $post->ID, self::META_ID, $created['id'] ); update_post_meta( $post->ID, self::META_HASH, $hash ); update_post_meta( $post->ID, self::META_SYNCED, time() ); delete_post_meta( $post->ID, self::META_ERROR ); $outcome = '' !== $existing ? 'updated' : 'created'; if ( ! empty( $document['truncated'] ) ) { return $this->outcome( $outcome, sprintf( /* translators: %s: the size limit, already formatted. */ __( 'Sent, but longer than %s and cut to fit.', 'sharelyai' ), size_format( DocumentBuilder::MAX_BYTES ) ) ); } return $this->outcome( $outcome ); } /** * Removes one post's item from the workspace. * * @param int $post_id The post. * * @return array{outcome:string, message:string} */ public function remove_post( int $post_id ): array { $existing = (string) get_post_meta( $post_id, self::META_ID, true ); if ( '' === $existing ) { $this->forget( $post_id ); return $this->outcome( 'skipped' ); } $deleted = $this->knowledge->delete( $existing ); if ( ! $deleted['ok'] ) { return $this->fail( $post_id, $deleted['message'] ); } $this->forget( $post_id ); return $this->outcome( 'removed' ); } // --------------------------------------------------------------- a run. /** * Claims the workspace items this site already put there. * * The bug this exists for: disconnecting clears the local map — every * `_sharelyai_knowledge_id` — but deliberately changes nothing in Sharely, * because the workspace is the customer's. Reconnect, and every post looks * unsent, so the next sync **creates a second copy of everything**. Do it * twice and there are three. Nothing in the old code noticed, because * "have I sent this?" was only ever asked of the local map. * * So before a full sync sends anything, it asks the workspace what it * already holds and matches items back to posts by `sourceUrl` — the * permalink, which is written on every item at creation and is the one * thing both sides agree on. * * Two jobs, and the second matters just as much: * * - **Adopt.** A post with no id whose permalink is already in the * workspace takes that id back, and is updated rather than duplicated. * - **Deduplicate.** Where a permalink appears more than once — damage * from a reconnection before this existed — the extras are deleted. * This is the only thing that cleans up a workspace already doubled. * * The hash is deliberately *not* restored. A post may have been edited * while the site was disconnected, and the workspace's copy is then stale; * leaving the hash unset costs one re-send per adopted post and is right * every time, where guessing would be wrong some of the time. * * Items whose URL is not on this site are left alone: a workspace may * serve more than one site, and this one gets no say over the others. * * @return array{ok:bool, adopted:int, duplicates:int, message:string} */ public function adopt(): array { if ( ! $this->connection->is_connected() ) { return array( 'ok' => false, 'adopted' => 0, 'duplicates' => 0, 'message' => __( 'This site is not connected to Sharely.', 'sharelyai' ), ); } $listed = $this->knowledge->list_all(); if ( ! $listed['ok'] ) { // Not fatal. Without the listing a sync behaves as it did before: // it sends what the local map says is missing. return array( 'ok' => false, 'adopted' => 0, 'duplicates' => 0, 'message' => $listed['message'], ); } // Compared in the normalised form, both sides. Comparing raw URLs is // how a site behind a proxy that changed the scheme decides none of // its own pages are its own — and then sends every one of them again. $base = self::normalise_url( home_url() ); $ours = array(); foreach ( $listed['items'] as $item ) { $id = (string) ( $item['id'] ?? $item['knowledgeId'] ?? '' ); $url = self::normalise_url( (string) ( $item['sourceUrl'] ?? $item['metadata']['sourceUrl'] ?? '' ) ); if ( '' === $id || '' === $url || ! str_starts_with( $url, $base ) ) { continue; } $ours[ $url ][] = $id; } // What this site believes it owns, so an id already claimed by one post // is never handed to another. $claimed = array(); foreach ( $this->synced_post_ids() as $post_id ) { $claimed[ (string) get_post_meta( $post_id, self::META_ID, true ) ] = true; } $adopted = 0; $duplicates = 0; foreach ( $this->in_scope_ids() as $post_id ) { $url = self::normalise_url( (string) get_permalink( $post_id ) ); if ( ! isset( $ours[ $url ] ) ) { continue; } $ids = array_values( array_filter( $ours[ $url ], static fn( $id ) => ! isset( $claimed[ $id ] ) ) ); unset( $ours[ $url ] ); if ( array() === $ids ) { continue; } $keep = array_shift( $ids ); if ( '' === (string) get_post_meta( $post_id, self::META_ID, true ) ) { update_post_meta( $post_id, self::META_ID, $keep ); delete_post_meta( $post_id, self::META_HASH ); delete_post_meta( $post_id, self::META_ERROR ); $claimed[ $keep ] = true; ++$adopted; } // Whatever else answers to this URL is a copy of the same page. foreach ( $ids as $extra ) { if ( $this->knowledge->delete( $extra )['ok'] ) { ++$duplicates; } } } return array( 'ok' => true, 'adopted' => $adopted, 'duplicates' => $duplicates, 'message' => '', ); } /** * A permalink in the one form both sides can be compared in. * * The platform stores what was sent; WordPress may since have changed the * scheme behind a proxy, or gained a trailing slash. Neither is a different * page, and treating them as different is how a sync duplicates. * * The host is lower-cased and the path is **not**. Hosts are * case-insensitive and paths are not: `/About` and `/about` can be two * pages, and this comparison decides what gets deleted. * * @param string $url The URL to normalise. */ private static function normalise_url( string $url ): string { $url = (string) strtok( $url, '#' ); $url = (string) preg_replace( '#^https?://#i', '', $url ); $slash = strpos( $url, '/' ); if ( false === $slash ) { return strtolower( untrailingslashit( $url ) ); } $host = strtolower( substr( $url, 0, $slash ) ); $path = untrailingslashit( substr( $url, $slash ) ); return $host . $path; } /** * Mirrors the site's roles into the workspace. * * Here rather than only on `Roles` so the queue runner has one object to * talk to, and so a test can run the real queue against a workspace that * is not the network. * * @return array{ok:bool, mirrored:int, total:int, skipped:int, message:string} */ public function mirror_roles(): array { return $this->roles->mirror(); } /** * Starts a full sync: counts the work, then queues it. * * @return array{ok:bool, total:int, message:string} */ public function start_full(): array { if ( ! $this->connection->is_connected() ) { return array( 'ok' => false, 'total' => 0, 'message' => __( 'This site is not connected to Sharely.', 'sharelyai' ), ); } $ids = $this->in_scope_ids(); update_option( self::RUN_OPTION, array( 'started' => time(), 'finished' => 0, 'total' => count( $ids ), 'done' => 0, 'created' => 0, 'updated' => 0, 'unchanged' => 0, 'removed' => 0, 'failed' => 0, ), false ); update_option( self::LOG_OPTION, array(), false ); $queue = new Queue(); $queue->cancel_all(); // Roles first, as the Drupal module does — but queued, not inline. The // entries about to be sent are tagged with a role id, and a visitor's // token names the roles they hold, so the roles should exist by the // time the batches run. Doing it here in the request would put six API // calls inside the OAuth callback, where a timeout loses the lot. $queue->add_roles(); // Then: claim what the workspace already holds. Without this a sync // after a reconnection creates a second copy of every page, because // the local map was cleared and the workspace's copies were not. $queue->add_adopt(); foreach ( array_chunk( $ids, self::BATCH ) as $batch ) { $queue->add_batch( $batch ); } if ( array() === $ids ) { $this->finish_run(); } return array( 'ok' => true, 'total' => count( $ids ), 'message' => '', ); } /** * Works through one queued batch. * * @param int[] $post_ids The batch. */ public function process_batch( array $post_ids ): void { foreach ( $post_ids as $post_id ) { $result = $this->sync_post( (int) $post_id ); $this->record( (int) $post_id, $result ); } if ( ! ( new Queue() )->has_pending() ) { $this->finish_run(); } } /** * Compares the workspace with this site and fixes the differences. * * Two kinds of drift: a post pointing at an item the workspace no longer has * (re-sent), and an item from this site that no post claims (deleted). Only * items whose source URL is on this site are touched — a workspace may hold * knowledge from elsewhere and deleting that would be vandalism. * * @return array{ok:bool, resent:int, orphans:int, message:string} */ public function reconcile(): array { if ( ! $this->connection->is_connected() ) { return array( 'ok' => false, 'resent' => 0, 'orphans' => 0, 'message' => __( 'This site is not connected to Sharely.', 'sharelyai' ), ); } $listed = $this->knowledge->list_all(); if ( ! $listed['ok'] ) { return array( 'ok' => false, 'resent' => 0, 'orphans' => 0, 'message' => $listed['message'], ); } $remote = array(); foreach ( $listed['items'] as $item ) { $id = (string) ( $item['id'] ?? $item['knowledgeId'] ?? '' ); if ( '' !== $id ) { $remote[ $id ] = $item; } } $resent = 0; foreach ( $this->synced_post_ids() as $post_id ) { $id = (string) get_post_meta( $post_id, self::META_ID, true ); if ( isset( $remote[ $id ] ) ) { unset( $remote[ $id ] ); continue; } // The workspace does not have it: forget the id so sync_post() creates // rather than trying to replace something that is gone. $this->forget( $post_id ); $result = $this->sync_post( $post_id ); if ( in_array( $result['outcome'], array( 'created', 'updated' ), true ) ) { ++$resent; } } $base = untrailingslashit( home_url() ); $orphans = 0; foreach ( $remote as $id => $item ) { $url = (string) ( $item['sourceUrl'] ?? $item['metadata']['sourceUrl'] ?? '' ); if ( '' === $url || ! str_starts_with( $url, $base ) ) { continue; } if ( $this->knowledge->delete( $id )['ok'] ) { ++$orphans; } } return array( 'ok' => true, 'resent' => $resent, 'orphans' => $orphans, 'message' => '', ); } // ------------------------------------------------------------- reporting. /** * Where the current or last run got to. * * @return array */ public function run(): array { $run = get_option( self::RUN_OPTION, array() ); return is_array( $run ) ? $run : array(); } /** * Whether a sync is in progress. */ public function is_running(): bool { $run = $this->run(); if ( array() === $run || ! empty( $run['finished'] ) ) { return false; } return ( new Queue() )->has_pending(); } /** * Counts for the status screen. * * @return array{in_scope:int, synced:int, queued:int, failed:int, last_synced:int} */ public function stats(): array { return array( 'in_scope' => count( $this->in_scope_ids() ), 'synced' => count( $this->synced_post_ids() ), 'queued' => ( new Queue() )->pending_count(), 'failed' => count( $this->failed_post_ids() ), 'last_synced' => (int) ( $this->run()['finished'] ?? 0 ), ); } /** * The most recent events, newest first. * * @return array */ public function log(): array { $log = get_option( self::LOG_OPTION, array() ); return is_array( $log ) ? $log : array(); } /** * Posts whose last sync failed, with the reason. * * @return array */ public function failures(): array { $out = array(); foreach ( $this->failed_post_ids() as $post_id ) { $out[] = array( 'post_id' => $post_id, 'title' => (string) get_the_title( $post_id ), 'message' => (string) get_post_meta( $post_id, self::META_ERROR, true ), ); } return $out; } /** * Post ids that have an item in the workspace. * * @return int[] */ public function synced_post_ids(): array { return $this->ids_with_meta( self::META_ID ); } /** * Post ids whose last sync failed. * * @return int[] */ public function failed_post_ids(): array { return $this->ids_with_meta( self::META_ERROR ); } /** * Forgets everything this site recorded, without touching the workspace. * * For disconnect: the credentials are going, so the ids are meaningless, but * the content stays in Sharely because the workspace is the customer's. */ public function forget_all(): void { foreach ( array_unique( array_merge( $this->synced_post_ids(), $this->failed_post_ids() ) ) as $post_id ) { $this->forget( $post_id ); } delete_option( self::RUN_OPTION ); delete_option( self::LOG_OPTION ); } // ----------------------------------------------------------------- hooks. /** * A post was saved. * * @param int $post_id The post. * @param \WP_Post|null $post The post object, when WordPress passes one. */ public function on_post_saved( $post_id, $post = null ): void { $post_id = (int) $post_id; if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $post = $post instanceof \WP_Post ? $post : get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } // An auto-draft is a post WordPress made for an editor who has not typed // anything yet, and queueing it would be work for nothing. if ( 'auto-draft' === $post->post_status ) { return; } // Nothing to do for a post that is neither in scope nor already sent. if ( ! $this->in_scope( $post ) && '' === (string) get_post_meta( $post_id, self::META_ID, true ) ) { return; } ( new Queue() )->add_post( $post_id ); } /** * A post is being deleted or trashed. * * Removal is immediate rather than queued: by the time a queued action ran, * the post would be gone and there would be no way to find its item. * * @param int $post_id The post. */ public function on_post_deleted( $post_id ): void { $post_id = (int) $post_id; if ( '' === (string) get_post_meta( $post_id, self::META_ID, true ) ) { return; } $this->record( $post_id, $this->remove_post( $post_id ) ); } // --------------------------------------------------------------- private. /** * Post ids carrying a meta key. * * @param string $key The meta key. * * @return int[] */ private function ids_with_meta( string $key ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a meta_query through WP_Query cannot ask this without also loading every post. $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", $key ) ); return array_map( 'intval', (array) $ids ); } /** * Clears a post's bookkeeping. * * @param int $post_id The post. */ private function forget( int $post_id ): void { foreach ( array( self::META_ID, self::META_HASH, self::META_SYNCED, self::META_ERROR ) as $key ) { delete_post_meta( $post_id, $key ); } } /** * Records an item this site could not delete, for reconcile to find. * * @param int $post_id The post it belonged to. * @param string $knowledge_id The item left behind. */ private function note_orphan( int $post_id, string $knowledge_id ): void { $orphans = (array) get_post_meta( $post_id, self::META_ORPHANS, true ); $orphans[] = $knowledge_id; update_post_meta( $post_id, self::META_ORPHANS, array_values( array_unique( $orphans ) ) ); } /** * Records a failure against the post and returns it as an outcome. * * @param int $post_id The post. * @param string $message What went wrong. * * @return array{outcome:string, message:string} */ private function fail( int $post_id, string $message ): array { update_post_meta( $post_id, self::META_ERROR, $message ); return $this->outcome( 'failed', $message ); } /** * An outcome. * * @param string $outcome One of the outcome names. * @param string $message Anything worth saying about it. * * @return array{outcome:string, message:string} */ private function outcome( string $outcome, string $message = '' ): array { return array( 'outcome' => $outcome, 'message' => $message, ); } /** * Adds one post's result to the run counters and to the visible log. * * @param int $post_id The post. * @param array{outcome:string, message:string} $result What happened. */ private function record( int $post_id, array $result ): void { $run = $this->run(); if ( array() !== $run && empty( $run['finished'] ) ) { $run['done'] = (int) ( $run['done'] ?? 0 ) + 1; if ( isset( $run[ $result['outcome'] ] ) ) { $run[ $result['outcome'] ] = (int) $run[ $result['outcome'] ] + 1; } update_option( self::RUN_OPTION, $run, false ); } $log = $this->log(); array_unshift( $log, array( 'time' => time(), 'post_id' => $post_id, 'title' => (string) get_the_title( $post_id ), 'outcome' => $result['outcome'], 'message' => $result['message'], ) ); update_option( self::LOG_OPTION, array_slice( $log, 0, self::LOG_LENGTH ), false ); } /** * Marks the current run as over. */ private function finish_run(): void { $run = $this->run(); if ( array() === $run ) { return; } $run['finished'] = time(); update_option( self::RUN_OPTION, $run, false ); } }