exporter = $exporter; } /** * Records the current state as the baseline. * * @param string $origin URL of the live site this copy was cloned from. * @return array Baseline summary. * @throws \RuntimeException When the working directory is not private or cannot be written. */ public function capture( string $origin = '' ): array { // The baseline lists every object of the site: never in a folder the web may serve. if ( ! Storage::is_private() ) { throw new \RuntimeException( esc_html( Importer::storage_message() ) ); } $index = $this->exporter->snapshot_index(); $meta = array( 'created' => time(), 'home' => home_url(), 'origin' => esc_url_raw( $origin ), 'count' => count( $index ), 'hashes' => Normalizer::VERSION, ); if ( ! Storage::write_json( Storage::base() . 'baseline.json', $index ) ) { throw new \RuntimeException( esc_html__( 'The baseline could not be written to the working directory.', 'wonderful-sync' ) ); } update_option( self::OPTION_BASELINE, $meta, false ); return $meta; } /** * Whether the post behind a key is in the trash (b: and n: keys alike). * * @param Exporter $exporter Exporter. * @param string $key Object key. */ private static function in_trash( Exporter $exporter, string $key ): bool { if ( 0 !== strpos( $key, 'post:' ) ) { return false; } $uid = substr( $key, 5 ); $id = 0 === strpos( $uid, 'b:' ) ? (int) substr( $uid, 2 ) : $exporter->find_post( $uid ); return $id > 0 && 'trash' === get_post_status( $id ); } /** * Baseline summary, or null. */ public static function meta(): ?array { $meta = get_option( self::OPTION_BASELINE ); return is_array( $meta ) ? $meta : null; } /** * Updates the origin URL. * * @param string $origin URL. */ public static function set_origin( string $origin ): void { $meta = self::meta(); if ( $meta ) { $meta['origin'] = esc_url_raw( $origin ); update_option( self::OPTION_BASELINE, $meta, false ); } } /** * Baseline index: key => [h, t, c, k]. * * @return array */ public function baseline(): array { $index = Storage::read_json( Storage::base() . 'baseline.json' ); return is_array( $index ) ? $index : array(); } /** * Computes the changeset. * * Objects created after the baseline receive their permanent "n:" UID here, so * their key is stable from now on. * * @return array */ public function compute(): array { if ( (int) ( self::meta()['hashes'] ?? 0 ) !== Normalizer::VERSION ) { throw new \RuntimeException( esc_html__( 'The baseline was recorded by an older version of Wonderful Sync, which compared content differently. Record the baseline again (right after a fresh clone).', 'wonderful-sync' ) ); } $baseline = $this->baseline(); $current = $this->exporter->snapshot_index(); $changes = array(); foreach ( $current as $key => $row ) { if ( 0 === strpos( $key, 'post:b:' ) && ! isset( $baseline[ $key ] ) ) { $uid = $this->exporter->ensure_post_uid( (int) substr( $key, 7 ) ); $key = 'post:' . $uid; } elseif ( 0 === strpos( $key, 'term:b:' ) && ! isset( $baseline[ $key ] ) ) { $uid = $this->exporter->ensure_term_uid( (int) substr( $key, 7 ) ); $key = 'term:' . $uid; } if ( ! isset( $baseline[ $key ] ) ) { // Created after the clone and already thrown away: nothing to send by default. $status = self::in_trash( $this->exporter, $key ) ? 'trashed' : 'new'; } elseif ( $baseline[ $key ]['h'] !== $row['h'] ) { $status = 'changed'; // A page moved to the trash on staging is a deletion for the user: shown and // treated like one (never preselected); live moves it to the trash as well. if ( self::in_trash( $this->exporter, $key ) ) { $status = 'trashed'; } } else { continue; } $changes[ $key ] = array( 'key' => $key, 'status' => $status, 'kind' => $row['k'], 'subtype' => $row['s'] ?? '', 'class' => $row['c'], 'title' => $row['t'], 'base' => $baseline[ $key ]['h'] ?? '', 'hash' => $row['h'], 'fields' => isset( $baseline[ $key ] ) && in_array( $status, array( 'changed', 'trashed' ), true ) ? Exporter::changed_fields( (array) ( $baseline[ $key ]['f'] ?? array() ), (array) ( $row['f'] ?? array() ) ) : array(), ); } foreach ( $baseline as $key => $row ) { if ( isset( $current[ $key ] ) ) { continue; } // An option that no longer travels (rules changed since the baseline) is not deleted. if ( 'option' === $row['k'] && ! Classifier::is_syncable( $this->exporter->classifier()->option( substr( (string) $key, 7 ) ) ) ) { continue; } $changes[ $key ] = array( 'key' => $key, 'status' => 'deleted', 'kind' => $row['k'], 'subtype' => $row['s'] ?? '', 'class' => $row['c'], 'title' => $row['t'], 'base' => $row['h'], 'hash' => '', 'fields' => array(), ); } uasort( $changes, function ( $a, $b ) { return array( $a['class'], $a['kind'], $a['title'] ) <=> array( $b['class'], $b['kind'], $b['title'] ); } ); return $changes; } /** * Live side of "live → staging": the objects whose state differs from the given * baseline hashes. Objects created here after the clone get their permanent "n:" UID, * the same way compute() does it on the staging copy. * * @param array $hashes Baseline of the staging copy: key => hash. * @return array{rows: array, missing: string[]} rows: key => index row * (plus 'x' for posts in the trash); missing: baseline keys that are gone here. */ public function remote_rows( array $hashes ): array { $rows = array(); $present = array(); foreach ( $this->exporter->snapshot_index() as $key => $row ) { if ( 0 === strpos( $key, 'post:b:' ) && ! isset( $hashes[ $key ] ) ) { $key = 'post:' . $this->exporter->ensure_post_uid( (int) substr( $key, 7 ) ); } elseif ( 0 === strpos( $key, 'term:b:' ) && ! isset( $hashes[ $key ] ) ) { $key = 'term:' . $this->exporter->ensure_term_uid( (int) substr( $key, 7 ) ); } $present[ $key ] = true; if ( isset( $hashes[ $key ] ) && (string) $hashes[ $key ] === (string) $row['h'] ) { continue; } if ( self::in_trash( $this->exporter, $key ) ) { $row['x'] = 1; } $rows[ $key ] = $row; } return array( 'rows' => $rows, 'missing' => array_values( array_diff( array_keys( $hashes ), array_keys( $present ) ) ), ); } /** * Staging side of "live → staging": the changes on the live site since the baseline, * in the same shape as compute(). * * @param array $rows Rows from remote_rows() on the live site. * @param string[] $missing Baseline keys that are gone on the live site. * @param array|null $baseline Baseline rows (default: this site's baseline). * @return array */ public function compare_remote( array $rows, array $missing, ?array $baseline = null ): array { $baseline = null === $baseline ? $this->baseline() : $baseline; $changes = array(); foreach ( $rows as $key => $row ) { $key = (string) $key; if ( ! is_array( $row ) || ! isset( $row['h'], $row['k'], $row['c'] ) || ( isset( $baseline[ $key ] ) && $baseline[ $key ]['h'] === $row['h'] ) ) { continue; } $known = isset( $baseline[ $key ] ); $status = ! empty( $row['x'] ) ? 'trashed' : ( $known ? 'changed' : 'new' ); $changes[ $key ] = array( 'key' => $key, 'status' => $status, 'kind' => (string) $row['k'], 'subtype' => (string) ( $row['s'] ?? '' ), 'class' => (string) $row['c'], 'title' => (string) ( $row['t'] ?? $key ), 'base' => $baseline[ $key ]['h'] ?? '', 'hash' => (string) $row['h'], 'fields' => $known && 'new' !== $status ? Exporter::changed_fields( (array) ( $baseline[ $key ]['f'] ?? array() ), (array) ( $row['f'] ?? array() ) ) : array(), ); } foreach ( $missing as $key ) { if ( ! isset( $baseline[ $key ]['k'], $baseline[ $key ]['c'] ) ) { continue; } $row = $baseline[ $key ]; $changes[ $key ] = array( 'key' => $key, 'status' => 'deleted', 'kind' => $row['k'], 'subtype' => $row['s'] ?? '', 'class' => $row['c'], 'title' => (string) ( $row['t'] ?? $key ), 'base' => (string) $row['h'], 'hash' => '', 'fields' => array(), ); } uasort( $changes, function ( $a, $b ) { return array( $a['class'], $a['kind'], $a['title'] ) <=> array( $b['class'], $b['kind'], $b['title'] ); } ); return $changes; } /** * Baseline hashes only (key => hash), for asking the live site what changed there. * * @return array */ public function baseline_hashes(): array { return array_map( function ( $row ) { return (string) $row['h']; }, $this->baseline() ); } /** * Adds changed objects the selection depends on: media used in content, layouts * used by Theme Builder templates, menus of menu items, parents. * * @param string[] $keys Selected keys. * @param array $changes Full changeset. * @return array{keys: string[], added: array} added: key => reason key. */ public function with_dependencies( array $keys, array $changes ): array { $selected = array_fill_keys( $keys, true ); $added = array(); $queue = $keys; while ( $queue ) { $key = array_shift( $queue ); $object = $this->exporter->export_key( $key ); if ( ! $object ) { continue; } $deps = $this->references( $object ); // A Theme Builder template is only active when the Theme Builder lists it. if ( 'et_template' === Exporter::subtype_of( $object ) ) { foreach ( $changes as $other => $change ) { if ( 'et_theme_builder' === ( $change['subtype'] ?? '' ) ) { $deps[] = $other; } } } foreach ( $deps as $dep ) { if ( isset( $changes[ $dep ] ) && ! in_array( $changes[ $dep ]['status'], array( 'deleted', 'trashed' ), true ) && ! isset( $selected[ $dep ] ) ) { $selected[ $dep ] = true; $added[ $dep ] = $key; $queue[] = $dep; } } } return array( 'keys' => array_keys( $selected ), 'added' => $added, ); } /** * Keys of objects an object points to. * * @param array $object Exported object. * @return string[] */ public function references( array $object ): array { $data = $object['data']; $refs = Exporter::object_refs( $object['kind'], $data ); // Media referenced by URL only. $text = (string) wp_json_encode( 'option' === $object['kind'] ? $data['value'] : ( $data['post']['post_content'] ?? '' ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); foreach ( $this->upload_paths( $text ) as $path ) { $id = $this->attachment_by_path( $path ); if ( $id ) { $refs[] = 'post:' . $this->exporter->post_uid( $id ); } } return array_values( array_unique( $refs ) ); } /** * Baseline rows of some objects (null where an object is not in the baseline). * * @param string[] $keys Object keys. * @return array */ public function baseline_rows( array $keys ): array { $baseline = $this->baseline(); $rows = array(); foreach ( $keys as $key ) { $rows[ (string) $key ] = $baseline[ $key ] ?? null; } return $rows; } /** * Puts baseline rows back (null removes the row: the object counts as new again). * * @param array $rows Rows from baseline_rows(). */ public function restore_rows( array $rows ): void { $baseline = $this->baseline(); foreach ( $rows as $key => $row ) { if ( is_array( $row ) ) { $baseline[ $key ] = $row; } else { unset( $baseline[ $key ] ); } } Storage::write_json( Storage::base() . 'baseline.json', $baseline ); } /** * Marks objects as transferred with exactly the state that was in the bundle. * * Using the bundle hash (not the current state) keeps edits made on staging after * the bundle was built in the changeset. * * @param array $hashes Key => hash from the bundle ('' for deletions). */ public function mark_synced( array $hashes ): void { $baseline = $this->baseline(); foreach ( $hashes as $key => $hash ) { if ( '' === (string) $hash ) { unset( $baseline[ $key ] ); continue; } $object = $this->exporter->export_key( (string) $key ); $same = $object && Normalizer::hash( $object['data'] ) === (string) $hash; $baseline[ $key ] = array( 'h' => (string) $hash, 't' => $object ? $this->exporter->title_of( $object ) : ( $baseline[ $key ]['t'] ?? (string) $key ), 'c' => $object ? $object['class'] : ( $baseline[ $key ]['c'] ?? '' ), 'k' => $object ? $object['kind'] : ( $baseline[ $key ]['k'] ?? '' ), 's' => $object ? Exporter::subtype_of( $object ) : ( $baseline[ $key ]['s'] ?? '' ), // Field hashes only when the current state is exactly what was transferred. 'f' => $same ? Exporter::field_hashes( $object['kind'], $object['data'] ) : array(), ); } Storage::write_json( Storage::base() . 'baseline.json', $baseline ); } /** * Upload paths ("2026/10/foo.jpg") referenced in a text with tokenised URLs. * * @param string $text Text. * @return string[] */ private function upload_paths( string $text ): array { $uploads = wp_upload_dir( null, false ); $rel = trim( (string) wp_parse_url( $uploads['baseurl'], PHP_URL_PATH ), '/' ); $text = str_replace( '\\/', '/', $text ); $pattern = '#\{\{wsync:(?:home|siteurl)(?::e)?\}\}/' . preg_quote( $rel, '#' ) . '/([^"\'\s\)\]\\\\?<>]+)#'; if ( ! preg_match_all( $pattern, $text, $m ) ) { return array(); } return array_values( array_unique( $m[1] ) ); } /** * Attachment ID for an upload path, also for generated sizes ("foo-300x200.jpg") and "-scaled". * * @param string $path Relative upload path. */ private function attachment_by_path( string $path ): int { global $wpdb; static $cache = array(); if ( isset( $cache[ $path ] ) ) { return $cache[ $path ]; } $candidates = array_unique( array( $path, preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $path ), preg_replace( '/(\.[a-z0-9]+)$/i', '-scaled$1', preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', $path ) ), ) ); $id = 0; foreach ( $candidates as $candidate ) { $id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value = %s LIMIT 1", $candidate ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery -- exact lookup. if ( $id ) { break; } } $cache[ $path ] = $id; return $id; } }