Plugin Directory

Changeset 3730994


Ignore:
Timestamp:
10/06/2026 02:18:23 PM (3 weeks ago)
Author:
seedprod
Message:

1.20.3: long double-quoted db writes, list_files patterns at any depth, publish sets aside overriding Site Editor rows, WordPress.org update warning

Location:
vibe-ai
Files:
14 edited
1 copied

Legend:

Unmodified
Added
Removed
  • vibe-ai/tags/1.20.3/includes/class-wpvibe-content-files.php

    r3698878 r3730994  
    181181        }
    182182
     183        /** Relative to the listed directory; a pattern that spells out the directory itself keeps the older full-path form. */
     184        private static function pattern_matches( $pattern, $path, $base ) {
     185                if ( '' === $base || 0 !== strpos( $path, $base . '/' ) ) {
     186                        return WPVibe_File_Ops::path_matches( $pattern, $path );
     187                }
     188                $full = 0 === stripos( ltrim( $pattern, './' ), $base . '/' );
     189                return WPVibe_File_Ops::path_matches( $pattern, $full ? $path : substr( $path, strlen( $base ) + 1 ) );
     190        }
     191
    183192        public function list_files( $pattern = null, $directory = '' ) {
    184193                $full = $this->resolve( $directory, true );
    … …  
    186195                        return $full;
    187196                }
     197                $base  = trim( (string) $directory, '/' );
    188198                $stack = array( array( $full, $directory ) );
    189199                $files = array();
    … …  
    209219                                        if ( $is_dir ) {
    210220                                                $stack[] = array( $item->getPathname(), $path );
    211                                         } elseif ( ! $pattern || fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD ) ) {
     221                                        } elseif ( ! $pattern || self::pattern_matches( $pattern, $path, $base ) ) {
    212222                                                $files[] = array( 'path' => $path, 'type' => 'file', 'size' => $item->getSize(), 'extension' => strtolower( $item->getExtension() ) );
    213223                                        }
  • vibe-ai/tags/1.20.3/includes/class-wpvibe-draft-theme.php

    r3709843 r3730994  
    2020         */
    2121        const BUILDER_THEMES = array( 'divi', 'extra', 'avada', 'enfold', 'flatsome', 'betheme', 'bridge', 'salient', 'the7', 'x' );
     22
     23        // Saved Site Editor rows are keyed to a theme slug, not to its files, so a publish leaves them behind.
     24        const SITE_EDITOR_TYPES = array( 'wp_template', 'wp_template_part', 'wp_global_styles' );
     25
     26        /** @return array[] Published Site Editor rows saved for a theme slug. */
     27        public static function saved_customizations( $slug ) {
     28                $posts = get_posts( array(
     29                        'post_type'      => self::SITE_EDITOR_TYPES,
     30                        'post_status'    => 'publish',
     31                        'posts_per_page' => -1,
     32                        'no_found_rows'  => true,
     33                        'tax_query'      => array( array( 'taxonomy' => 'wp_theme', 'field' => 'name', 'terms' => $slug ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a handful of rows per theme.
     34                ) );
     35                return array_map( function ( $p ) {
     36                        return array( 'id' => (int) $p->ID, 'type' => $p->post_type, 'name' => $p->post_name, 'title' => $p->post_title );
     37                }, $posts );
     38        }
     39
     40        public static function set_aside_slug( $slug ) {
     41                return $slug . '-wpvibe-set-aside';
     42        }
     43
     44        /** Live rows that override what the draft provides (a file or a row saved during the preview), so the preview shows something else. */
     45        public static function clashing_customizations( $live_slug, $draft_slug ) {
     46                $draft_rows = self::saved_customizations( $draft_slug );
     47                $dirs       = self::template_dirs( $draft_slug );
     48                $shown      = self::shown_styles_row( $live_slug );
     49                return array_values( array_filter( self::saved_customizations( $live_slug ), function ( $row ) use ( $draft_rows, $dirs, $shown ) {
     50                        // An older styles row shows nothing, so only the one core shows can override the draft.
     51                        if ( 'wp_global_styles' === $row['type'] ) {
     52                                return $row['id'] === $shown && self::has_styles( $row['id'] );
     53                        }
     54                        foreach ( $dirs[ $row['type'] ] as $dir ) {
     55                                if ( is_file( $dir . '/' . $row['name'] . '.html' ) ) {
     56                                        return true;
     57                                }
     58                        }
     59                        foreach ( $draft_rows as $draft_row ) {
     60                                if ( $draft_row['type'] === $row['type'] && $draft_row['name'] === $row['name'] ) {
     61                                        return true;
     62                                }
     63                        }
     64                        return false;
     65                } ) );
     66        }
     67
     68        /** Core creates an empty published global-styles row whenever the editor or the active-theme REST query runs; only real styles clash. */
     69        private static function has_styles( $id ) {
     70                $post = get_post( $id );
     71                $data = $post ? json_decode( (string) $post->post_content, true ) : null;
     72                return is_array( $data ) && ( ! empty( $data['styles'] ) || ! empty( $data['settings'] ) );
     73        }
     74
     75        /** Template folders of the draft and, for a child draft, of its parent; old folder names included. */
     76        private static function template_dirs( $draft_slug ) {
     77                $roots  = array( get_theme_root() . '/' . $draft_slug );
     78                $header = is_readable( $roots[0] . '/style.css' ) ? get_file_data( $roots[0] . '/style.css', array( 'Template' => 'Template' ) ) : array();
     79                $parent = trim( (string) ( $header['Template'] ?? '' ) );
     80                if ( '' !== $parent && $parent === basename( $parent ) && $parent !== $draft_slug ) {
     81                        $roots[] = get_theme_root( $parent ) . '/' . $parent;
     82                }
     83                $dirs = array( 'wp_template' => array(), 'wp_template_part' => array() );
     84                foreach ( $roots as $root ) {
     85                        array_push( $dirs['wp_template'], $root . '/templates', $root . '/block-templates' );
     86                        array_push( $dirs['wp_template_part'], $root . '/parts', $root . '/block-template-parts' );
     87                }
     88                return $dirs;
     89        }
     90
     91        private static function describe_rows( array $rows ) {
     92                $labels = array( 'wp_template' => 'template', 'wp_template_part' => 'template part' );
     93                $names  = array_map( function ( $row ) use ( $labels ) {
     94                        return ( 'wp_global_styles' === $row['type'] ? 'global styles' : $row['name'] . ' ' . $labels[ $row['type'] ] ) . ' #' . $row['id'];
     95                }, array_slice( $rows, 0, 10 ) );
     96                return implode( ', ', $names ) . ( count( $rows ) > 10 ? sprintf( ', and %d more', count( $rows ) - 10 ) : '' );
     97        }
     98
     99        /** WordPress.org offers its release to any folder of the same name unless Update URI points elsewhere. */
     100        public static function wporg_update_warning( $slug, $dir, $published = false ) {
     101                // No refresh when the transient is missing (a publish deletes it): that is a blocking request to api.wordpress.org.
     102                $updates = get_site_transient( 'update_themes' );
     103                if ( ! is_object( $updates ) || ! ( isset( $updates->response[ $slug ] ) || isset( $updates->no_update[ $slug ] ) ) ) {
     104                        return null;
     105                }
     106                $header = is_readable( $dir . '/style.css' ) ? get_file_data( $dir . '/style.css', array( 'UpdateURI' => 'Update URI' ) ) : array();
     107                $uri    = trim( (string) ( $header['UpdateURI'] ?? '' ) );
     108                if ( '' !== $uri && ! preg_match( '#^(?:https?://)?(?:www\.)?(?:wordpress\.org|w\.org)(?:/|$)#i', $uri ) ) {
     109                        return null;
     110                }
     111                return sprintf(
     112                        /* translators: %s: theme folder name */
     113                        $published
     114                                ? __( 'The folder name \'%s\' matches a theme that WordPress.org updates, so a WordPress.org update for that theme can replace these files. If this is now your own design, add the line "Update URI: false" to the style.css header in a new draft and publish it.', 'vibe-ai' )
     115                                : __( 'The folder name \'%s\' matches a theme that WordPress.org updates, so after publish a WordPress.org update for that theme can replace these files. If this is now your own design, add the line "Update URI: false" to the draft\'s style.css header before you publish.', 'vibe-ai' ),
     116                        $slug
     117                );
     118        }
    22119
    23120        /**
    … …  
    135232                }
    136233
     234                list( $parked, $stuck ) = self::park_draft_rows( $draft_slug, $active_slug );
     235
    137236                WPVibe_Change_Tracker::mark( array(
    138237                        'summary'      => 'Draft theme created',
    … …  
    144243                        $message = sprintf(
    145244                                /* translators: %s: theme directory name */
    146                                 __( 'A stale draft record for \'%s\' was cleared first: its folder was already gone, so no files were deleted and earlier unpublished draft edits are not recoverable. The new draft is a fresh copy of the live theme.', 'vibe-ai' ),
     245                                __( 'A stale draft record for \'%s\' was cleared first: its folder was already gone, so no files were deleted and earlier unpublished draft file edits are not recoverable. The new draft\'s files are a fresh copy of the live theme.', 'vibe-ai' ),
    147246                                $stale
    148247                        ) . ' ' . $message;
     248                }
     249                if ( $parked || $stuck ) {
     250                        $message .= ' ' . self::parked_note( $parked, $stuck, $active_slug );
    149251                }
    150252                $response = array(
    … …  
    441543        }
    442544
    443         public function publish() {
    444                 return WPVibe_Draft_Lock::run( function () {
    445                         return $this->publish_locked();
     545        /** @param string|null $saved "keep" leaves clashing live Site Editor rows in place; anything else sets them aside. */
     546        public function publish( $saved = null ) {
     547                return WPVibe_Draft_Lock::run( function () use ( $saved ) {
     548                        return $this->publish_locked( $saved );
    446549                } );
    447550        }
    448551
    449         private function publish_locked() {
     552        private function publish_locked( $saved = null ) {
    450553                if ( get_option( 'wpvibe_draft_theme' ) && ! WPVibe_Draft_Lock::valid_draft() ) {
    451554                        return WPVibe_Draft_Lock::conflict();
    … …  
    473576                        return self::missing_error();
    474577                }
     578
     579                // The approved preview rendered without these rows, so setting them aside is the default; "keep" is an explicit choice.
     580                $clashing = self::clashing_customizations( $source_slug, $draft_slug );
     581                $saved    = 'keep' === $saved ? 'keep' : 'set_aside';
     582
     583                // Read now: wp_clean_themes_cache() below deletes the update_themes transient this needs.
     584                $update_warning = self::wporg_update_warning( $source_slug, $draft_dir, true );
     585
    475586                // Unfiltered: WPVibe_Preview filters 'stylesheet' to the draft slug when a
    476587                // preview token is present, and rolling back onto the draft would
    … …  
    608719                switch_theme( $source_slug );
    609720
     721                // Before the purge and the health check, so both see the final rows and a failure can undo them.
     722                $this->settle_log = array();
     723                try {
     724                        $settled = $this->settle_saved_customizations( $source_slug, $draft_slug, $clashing, $saved );
     725                } catch ( \Throwable $e ) {
     726                        $unmoved = $this->unsettle();
     727                        $rolled  = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     728                        return new WP_Error(
     729                                'publish_rolled_back',
     730                                $unmoved
     731                                        /* translators: 1: error message, 2: post IDs, 3: rollback outcome */
     732                                        ? sprintf( __( 'Published, then moving the saved Site Editor customizations failed (%1$s). WPVibe could not put back these rows: %2$s. The publish was rolled back: %3$s', 'vibe-ai' ), $e->getMessage(), '#' . implode( ', #', $unmoved ), $rolled )
     733                                        /* translators: 1: error message, 2: rollback outcome */
     734                                        : sprintf( __( 'Published, then moving the saved Site Editor customizations failed (%1$s), so they were put back and the publish was rolled back: %2$s', 'vibe-ai' ), $e->getMessage(), $rolled ),
     735                                WPVibe_Error_Contract::data( 'wp_core', true, array( 'status' => 500 ) )
     736                        );
     737                }
     738
    610739                // Publish replaced live templates in place; switch_theme's action only
    611740                // reaches engines that hook it (LiteSpeed, WP Rocket). Flush the rest
    … …  
    622751                $health = WPVibe_PHP_Guard::render_health_check();
    623752                if ( is_wp_error( $health ) ) {
    624                         $rolled = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     753                        $unmoved = $this->unsettle();
     754                        $rolled  = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     755                        if ( $unmoved ) {
     756                                /* translators: %s: post IDs */
     757                                $rolled .= ' ' . sprintf( __( 'WPVibe could not put back these saved Site Editor rows: %s.', 'vibe-ai' ), '#' . implode( ', #', $unmoved ) );
     758                        }
    625759                        return new WP_Error(
    626760                                'publish_rolled_back',
    … …  
    637771                // Cleanup.
    638772                $this->delete_directory( $draft_dir );
    639                 delete_option( 'wpvibe_draft_theme' );
    640                 delete_option( 'wpvibe_draft_source' );
     773                // The preview token too: an old preview link must not show the next draft before its leftover rows are parked.
     774                self::clear_draft_records();
    641775                self::record_draft_event( 'published' );
    642 
    643                 // The cookie still points at a now-deleted draft slug; clear it so
    644                 // the next admin request doesn't re-enter a nonexistent preview.
    645                 if ( class_exists( 'WPVibe_Preview' ) ) {
    646                         WPVibe_Preview::clear_cookie();
    647                 }
    648776
    649777                WPVibe_Change_Tracker::mark( array(
    … …  
    654782                ) );
    655783
    656                 return rest_ensure_response( array(
    657                         'status'  => 'published',
     784                $message = $had_live
    658785                        /* translators: 1: theme slug, 2: backup slug */
    659                         'message' => $had_live
    660                                 ? sprintf( __( 'Draft published to \'%1$s\'. Backup saved as \'%2$s\'.', 'vibe-ai' ), $source_slug, $source_slug . '-wpvibe-backup' )
    661                                 : sprintf( __( 'New theme \'%s\' published. No existing theme directory was replaced; no theme backup was created.', 'vibe-ai' ), $source_slug ),
    662                 ) );
     786                        ? sprintf( __( 'Draft published to \'%1$s\'. Backup saved as \'%2$s\'.', 'vibe-ai' ), $source_slug, $source_slug . '-wpvibe-backup' )
     787                        /* translators: %s: theme slug */
     788                        : sprintf( __( 'New theme \'%s\' published. No existing theme directory was replaced; no theme backup was created.', 'vibe-ai' ), $source_slug );
     789                if ( $settled['set_aside'] ) {
     790                        $message .= ' ' . sprintf(
     791                                /* translators: 1: list of rows, 2: parking theme slug, 3: post id, 4: live theme slug */
     792                                __( 'Set aside the live theme\'s saved Site Editor customizations that would override this draft (saved global styles, and templates or template parts the draft also has), so they no longer override the published theme: %1$s. They are kept under the theme name \'%2$s\', which no theme uses. To bring one back, run the WP-CLI command "post term set <id> wp_theme %4$s" (for example "post term set %3$d wp_theme %4$s"). If a customization carried over from the preview has the same name, first set that one aside with "post term set <its id> wp_theme %2$s".', 'vibe-ai' ),
     793                                self::describe_rows( $settled['set_aside'] ),
     794                                self::set_aside_slug( $source_slug ),
     795                                $settled['set_aside'][0]['id'],
     796                                $source_slug
     797                        );
     798                }
     799                if ( $settled['kept'] ) {
     800                        /* translators: %s: list of rows */
     801                        $message .= ' ' . sprintf( __( 'Kept the live theme\'s saved Site Editor customizations, which override the published files: %s.', 'vibe-ai' ), self::describe_rows( $settled['kept'] ) );
     802                }
     803                if ( $settled['moved'] ) {
     804                        /* translators: %s: list of rows */
     805                        $message .= ' ' . sprintf( __( 'Carried the customizations saved during the preview over to the live theme: %s.', 'vibe-ai' ), self::describe_rows( $settled['moved'] ) );
     806                }
     807                if ( $settled['left'] ) {
     808                        /* translators: %s: list of rows */
     809                        $message .= ' ' . sprintf(
     810                                /* translators: 1: list of rows, 2: parking theme slug */
     811                                __( 'Set aside these customizations saved during the preview, because the live theme keeps its own saved version: %1$s. They are kept under the theme name \'%2$s\'.', 'vibe-ai' ),
     812                                self::describe_rows( $settled['left'] ),
     813                                self::set_aside_slug( $source_slug )
     814                        );
     815                }
     816                if ( $update_warning ) {
     817                        $message .= ' ' . $update_warning;
     818                }
     819
     820                return rest_ensure_response( array_filter( array(
     821                        'status'               => 'published',
     822                        'message'              => $message,
     823                        'saved_customizations' => array_filter( $settled ),
     824                        'update_warning'       => $update_warning,
     825                ) ) );
     826        }
     827
     828        /** Rows saved during the preview go live; clashing live rows are parked or kept, as asked. */
     829        private function settle_saved_customizations( $live_slug, $draft_slug, array $clashing, $mode ) {
     830                $out    = array( 'set_aside' => array(), 'kept' => array(), 'moved' => array(), 'left' => array() );
     831                $park   = self::set_aside_slug( $live_slug );
     832                $styles = function ( $row, $slug ) {
     833                        return 'wp_global_styles' === $row['type'] ? array( 'post_name' => 'wp-global-styles-' . rawurlencode( $slug ) ) : array();
     834                };
     835                $key    = function ( $row ) {
     836                        return 'wp_global_styles' === $row['type'] ? $row['type'] : $row['type'] . '/' . $row['name'];
     837                };
     838                $taken  = array();
     839                $clash_ids = array_column( $clashing, 'id' );
     840                // Core reads the newest global-styles row for a slug, so an empty live one could hide the styles the preview showed.
     841                $empty_styles = array_filter( self::saved_customizations( $live_slug ), function ( $row ) use ( $clash_ids ) {
     842                        return 'wp_global_styles' === $row['type'] && ! in_array( $row['id'], $clash_ids, true );
     843                } );
     844                // The other live styles rows show nothing now, but one of them becomes core's pick once the shown row leaves.
     845                $park_live_styles = function ( $styled_only ) use ( &$empty_styles, &$out, $park, $live_slug, $styles ) {
     846                        foreach ( $empty_styles as $i => $other ) {
     847                                $styled = self::has_styles( $other['id'] );
     848                                if ( $styled_only && ! $styled ) {
     849                                        continue;
     850                                }
     851                                $this->retag( $other, $park, $live_slug, $styles( $other, $park ) );
     852                                if ( $styled ) {
     853                                        $out['set_aside'][] = $other;
     854                                }
     855                                unset( $empty_styles[ $i ] );
     856                        }
     857                };
     858                foreach ( $clashing as $row ) {
     859                        if ( 'set_aside' === $mode ) {
     860                                // Not the trash: wp-admin has no trash screen for these types, and trashing renames the slug.
     861                                $this->retag( $row, $park, $live_slug, $styles( $row, $park ) );
     862                                $out['set_aside'][] = $row;
     863                                if ( 'wp_global_styles' === $row['type'] ) {
     864                                        $park_live_styles( true );
     865                                }
     866                        } else {
     867                                $out['kept'][]         = $row;
     868                                $taken[ $key( $row ) ] = true;
     869                        }
     870                }
     871                $draft_rows = self::saved_customizations( $draft_slug );
     872                $shown      = self::shown_styles_row( $draft_slug );
     873                foreach ( $draft_rows as $row ) {
     874                        // Only the styles row core shows for the draft is what the preview showed; an empty one would also hide the real live row.
     875                        if ( 'wp_global_styles' === $row['type'] && ( $row['id'] !== $shown || ! self::has_styles( $row['id'] ) ) ) {
     876                                $this->retag( $row, $park, $draft_slug, $styles( $row, $park ) );
     877                                continue;
     878                        }
     879                        if ( isset( $taken[ $key( $row ) ] ) ) {
     880                                // Parked as well, or the next draft under this slug would carry it over unseen.
     881                                $this->retag( $row, $park, $draft_slug, $styles( $row, $park ) + self::live_part_refs( $row['id'], $draft_slug, $live_slug ) );
     882                                $out['left'][] = $row;
     883                                continue;
     884                        }
     885                        if ( 'wp_global_styles' === $row['type'] ) {
     886                                $park_live_styles( false );
     887                        }
     888                        $this->retag( $row, $live_slug, $draft_slug, $styles( $row, $live_slug ) + self::live_part_refs( $row['id'], $draft_slug, $live_slug ) );
     889                        $out['moved'][] = $row;
     890                }
     891                return $out;
     892        }
     893
     894        // The styles row core itself shows for a theme, in core's own order, so a tie resolves the way the site does.
     895        private static function shown_styles_row( $slug ) {
     896                $data = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme( $slug ) );
     897                return (int) ( $data['ID'] ?? 0 );
     898        }
     899
     900        private $settle_log = array();
     901
     902        // Fields first: core's template slug filter renames a row whose new theme already has that name.
     903        private function retag( array $row, $slug, $from, array $fields ) {
     904                $undo = array( 'id' => $row['id'], 'slug' => $from, 'fields' => array() );
     905                if ( $fields ) {
     906                        $post = get_post( $row['id'] );
     907                        foreach ( array_keys( $fields ) as $field ) {
     908                                $undo['fields'][ $field ] = $post ? $post->$field : '';
     909                        }
     910                        $updated = wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $fields ), true );
     911                        // Logged only once something changed, so a rollback never names a row that did not move.
     912                        if ( is_wp_error( $updated ) ) {
     913                                throw new RuntimeException( sprintf( 'post #%d: %s', $row['id'], $updated->get_error_message() ) );
     914                        }
     915                }
     916                $this->settle_log[] = $undo;
     917                if ( ! self::set_theme_term( $row['id'], $slug ) ) {
     918                        self::set_theme_term( $row['id'], $from );
     919                        throw new RuntimeException( sprintf( 'post #%d: could not move it to theme %s (its themes now: %s)', $row['id'], $slug, implode( ', ', self::theme_terms( $row['id'] ) ) ) );
     920                }
     921        }
     922
     923        // Add, confirm, then drop the others: core does not check its relationship INSERT, so a failed write must leave the row where it was.
     924        private static function set_theme_term( $post_id, $slug ) {
     925                $added = wp_set_object_terms( $post_id, $slug, 'wp_theme', true );
     926                if ( is_wp_error( $added ) || ! in_array( $slug, self::theme_terms( $post_id ), true ) ) {
     927                        return false;
     928                }
     929                $others = array_values( array_diff( self::theme_terms( $post_id ), array( $slug ) ) );
     930                if ( $others ) {
     931                        $removed = wp_remove_object_terms( $post_id, $others, 'wp_theme' );
     932                        if ( is_wp_error( $removed ) || ! $removed ) {
     933                                return false;
     934                        }
     935                }
     936                return array( $slug ) === self::theme_terms( $post_id );
     937        }
     938
     939        // Rows saved under a draft slug outlive the draft; parked, so the next draft under that slug does not show them unseen.
     940        private static function park_draft_rows( $draft_slug, $live_slug ) {
     941                $park  = self::set_aside_slug( $live_slug );
     942                $moved = array();
     943                $stuck = array();
     944                foreach ( self::saved_customizations( $draft_slug ) as $row ) {
     945                        $post   = get_post( $row['id'] );
     946                        $fields = self::live_part_refs( $row['id'], $draft_slug, $live_slug );
     947                        if ( 'wp_global_styles' === $row['type'] ) {
     948                                $fields['post_name'] = 'wp-global-styles-' . rawurlencode( $park );
     949                        }
     950                        $old = array();
     951                        foreach ( array_keys( $fields ) as $field ) {
     952                                $old[ $field ] = $post ? $post->$field : '';
     953                        }
     954                        // Core's empty styles row is no customization worth naming.
     955                        $named = 'wp_global_styles' !== $row['type'] || self::has_styles( $row['id'] );
     956                        if ( $fields && is_wp_error( wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $fields ), true ) ) ) {
     957                                if ( $named ) {
     958                                        $stuck[] = $row;
     959                                }
     960                                continue;
     961                        }
     962                        if ( ! self::set_theme_term( $row['id'], $park ) ) {
     963                                self::set_theme_term( $row['id'], $draft_slug );
     964                                if ( $old ) {
     965                                        wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $old ) );
     966                                }
     967                                if ( $named ) {
     968                                        $stuck[] = $row;
     969                                }
     970                                continue;
     971                        }
     972                        if ( $named ) {
     973                                $moved[] = $row;
     974                        }
     975                }
     976                return array( $moved, $stuck );
     977        }
     978
     979        // Template-part blocks saved in the preview name the draft slug and render nothing once it is gone.
     980        private static function live_part_refs( $post_id, $draft_slug, $live_slug ) {
     981                $post    = get_post( $post_id );
     982                $content = $post ? (string) $post->post_content : '';
     983                // serialize_block_attributes() writes "--" as \u002d\u002d.
     984                $enc     = function ( $slug ) {
     985                        return str_replace( '--', '\\u002d\\u002d', $slug );
     986                };
     987                $renamed = str_replace(
     988                        array( '"theme":"' . $draft_slug . '"', '"theme":"' . $enc( $draft_slug ) . '"' ),
     989                        array( '"theme":"' . $live_slug . '"', '"theme":"' . $enc( $live_slug ) . '"' ),
     990                        $content
     991                );
     992                return $renamed !== $content ? array( 'post_content' => $renamed ) : array();
     993        }
     994
     995        private static function parked_note( array $parked, array $stuck, $live_slug ) {
     996                $note = ! $parked ? '' : sprintf(
     997                        /* translators: 1: list of rows, 2: parking theme slug, 3: live theme slug */
     998                        __( 'Set aside the customizations saved during a draft preview: %1$s. They are kept under the theme name \'%2$s\', which no theme uses. To bring back a template or template part, run the WP-CLI command "post term set <id> wp_theme %3$s".', 'vibe-ai' ),
     999                        self::describe_rows( $parked ),
     1000                        self::set_aside_slug( $live_slug ),
     1001                        $live_slug
     1002                );
     1003                if ( in_array( 'wp_global_styles', array_column( $parked, 'type' ), true ) ) {
     1004                        $note .= ' ' . __( 'A global styles row comes back the same way, and it replaces the live styles when it is newer than the live global styles row, because WordPress uses the newest one.', 'vibe-ai' );
     1005                }
     1006                if ( $stuck ) {
     1007                        /* translators: %s: list of rows */
     1008                        $note .= ' ' . sprintf( __( 'Could not set aside these customizations, which stay under the draft theme name, so a preview of a draft under that name shows them: %s.', 'vibe-ai' ), self::describe_rows( $stuck ) );
     1009                }
     1010                return trim( $note );
     1011        }
     1012
     1013        private static function theme_terms( $post_id ) {
     1014                $names = wp_get_object_terms( $post_id, 'wp_theme', array( 'fields' => 'names' ) );
     1015                return is_array( $names ) ? array_values( array_map( 'strval', $names ) ) : array();
     1016        }
     1017
     1018        /** @return int[] Rows that could not be put back. */
     1019        private function unsettle() {
     1020                $unmoved = array();
     1021                foreach ( array_reverse( $this->settle_log ) as $undo ) {
     1022                        // Term back first, so the fields are written where the row's name is unique, as in retag().
     1023                        $ok = self::set_theme_term( $undo['id'], $undo['slug'] );
     1024                        if ( $undo['fields'] && is_wp_error( wp_update_post( wp_slash( array( 'ID' => $undo['id'] ) + $undo['fields'] ), true ) ) ) {
     1025                                $ok = false;
     1026                        }
     1027                        if ( ! $ok ) {
     1028                                $unmoved[] = (int) $undo['id'];
     1029                        }
     1030                }
     1031                $this->settle_log = array();
     1032                return array_values( array_unique( $unmoved ) );
    6631033        }
    6641034
    … …  
    7351105                $url = add_query_arg( 'wpvibe_preview', $token, home_url( '/' ) );
    7361106
    737                 return rest_ensure_response( array(
    738                         'preview_url' => $url,
    739                         'draft_slug'  => $draft_slug,
    740                 ) );
     1107                $source = (string) get_option( 'wpvibe_draft_source' );
     1108                return rest_ensure_response( array_filter( array(
     1109                        'preview_url'         => $url,
     1110                        'draft_slug'          => $draft_slug,
     1111                        'live_customizations' => '' !== $source ? self::clashing_customizations( $source, $draft_slug ) : array(),
     1112                        'update_warning'      => '' !== $source ? self::wporg_update_warning( $source, get_theme_root() . '/' . $draft_slug ) : null,
     1113                ) ) );
    7411114        }
    7421115
    … …  
    7891162                }
    7901163
     1164                $source = (string) get_option( 'wpvibe_draft_source' );
     1165                list( $parked, $stuck ) = '' !== $source ? self::park_draft_rows( $draft_slug, $source ) : array( array(), array() );
    7911166                self::clear_draft_records();
    7921167                self::record_draft_event( 'deleted' );
    … …  
    7971172                ) );
    7981173
     1174                $message = $had_dir
     1175                        ? __( 'Draft theme removed.', 'vibe-ai' )
     1176                        : sprintf(
     1177                                /* translators: %s: theme directory name */
     1178                                __( 'Draft record cleared. Its folder \'%s\' was already gone, so no files were deleted.', 'vibe-ai' ),
     1179                                $draft_slug
     1180                        );
     1181                if ( $parked || $stuck ) {
     1182                        $message .= ' ' . self::parked_note( $parked, $stuck, $source );
     1183                }
    7991184                return rest_ensure_response( array(
    8001185                        'status'  => 'deleted',
    801                         'message' => $had_dir
    802                                 ? __( 'Draft theme removed.', 'vibe-ai' )
    803                                 : sprintf(
    804                                         /* translators: %s: theme directory name */
    805                                         __( 'Draft record cleared. Its folder \'%s\' was already gone, so no files were deleted.', 'vibe-ai' ),
    806                                         $draft_slug
    807                                 ),
     1186                        'message' => $message,
    8081187                ) );
    8091188        }
  • vibe-ai/tags/1.20.3/includes/class-wpvibe-file-ops.php

    r3711469 r3730994  
    662662        }
    663663
     664        /** Glob match for listings: no "/" matches the file name at any depth (as in .gitignore), and "**" spans zero or more folders. */
     665        public static function path_matches( $pattern, $path ) {
     666                $pattern = ltrim( 0 === strpos( $pattern, './' ) ? substr( $pattern, 2 ) : $pattern, '/' );
     667                if ( '/' === substr( $pattern, -1 ) ) {
     668                        $pattern .= '**';
     669                }
     670                if ( false === strpos( $pattern, '/' ) ) {
     671                        return fnmatch( $pattern, basename( $path ), FNM_PATHNAME | FNM_CASEFOLD );
     672                }
     673                if ( false === strpos( $pattern, '**' ) ) {
     674                        return fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD );
     675                }
     676                return 1 === preg_match( self::globstar_regex( $pattern ), $path );
     677        }
     678
     679        /** fnmatch has no "**"; a single "*" here still stops at "/". */
     680        private static function globstar_regex( $pattern ) {
     681                $re  = '';
     682                $len = strlen( $pattern );
     683                for ( $i = 0; $i < $len; $i++ ) {
     684                        $c = $pattern[ $i ];
     685                        if ( '*' === $c && '*' === ( $pattern[ $i + 1 ] ?? '' ) ) {
     686                                $i++;
     687                                if ( '/' === ( $pattern[ $i + 1 ] ?? '' ) ) {
     688                                        $i++;
     689                                        $re .= '(?:.*/)?';
     690                                } else {
     691                                        $re .= '.*';
     692                                }
     693                        } elseif ( '*' === $c ) {
     694                                $re .= '[^/]*';
     695                        } elseif ( '?' === $c ) {
     696                                $re .= '[^/]';
     697                        } elseif ( '[' === $c && false !== ( $end = strpos( $pattern, ']', $i + 2 ) ) ) {
     698                                $class = substr( $pattern, $i + 1, $end - $i - 1 );
     699                                $neg   = '!' === $class[0] || '^' === $class[0];
     700                                $re   .= '[' . ( $neg ? '^' : '' ) . str_replace( array( '\\', '#' ), array( '\\\\', '\\#' ), $neg ? substr( $class, 1 ) : $class ) . ']';
     701                                $i     = $end;
     702                        } else {
     703                                $re .= preg_quote( $c, '#' );
     704                        }
     705                }
     706                return '#^' . $re . '$#i';
     707        }
     708
    664709        /**
    665710         * List all files in the draft theme, optionally filtered by glob pattern.
    … …  
    694739
    695740                        // Apply glob pattern filter if provided.
    696                         if ( $pattern && ! $item->isDir() && ! fnmatch( $pattern, $relative, FNM_PATHNAME | FNM_CASEFOLD ) ) {
     741                        if ( $pattern && ! $item->isDir() && ! self::path_matches( $pattern, $relative ) ) {
    697742                                continue;
    698743                        }
  • vibe-ai/tags/1.20.3/includes/class-wpvibe-rest.php

    r3719193 r3730994  
    499499                        'args'                => array(
    500500                                'expected_source_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ),
     501                                'saved_customizations' => array( 'type' => 'string', 'enum' => array( 'set_aside', 'keep' ) ),
    501502                        ),
    502503                ) );
    … …  
    15021503                }
    15031504                $draft = new WPVibe_Draft_Theme();
    1504                 return $draft->publish();
     1505                return $draft->publish( $request ? $request->get_param( 'saved_customizations' ) : null );
    15051506        }
    15061507
  • vibe-ai/tags/1.20.3/includes/cli/trait-wpvibe-cli-db.php

    r3726341 r3730994  
    7373                }
    7474                $q = $rest[0];
    75                 if ( '"' === $q && preg_match( '/^"((?:[^"\\\\]|\\\\.)*)"$/s', $rest, $m ) ) {
     75                // Possessive: the backtracking form exhausts PCRE's JIT stack past ~8 KB and silently keeps the quotes.
     76                if ( '"' === $q && preg_match( '/^"((?:[^"\\\\]++|\\\\.)*+)"$/s', $rest, $m ) ) {
    7677                        return preg_replace( '/\\\\(["\\\\])/', '$1', $m[1] );
    7778                }
  • vibe-ai/tags/1.20.3/readme.txt

    r3726341 r3730994  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.20.2
     7Stable tag: 1.20.3
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    221221
    222222== Changelog ==
     223
     224= 1.20.3 =
     225* Fix: Approved database writes that are long (over about 8 KB) and wrapped in double quotes no longer fail with a syntax error on some hosts.
     226* Fix: File listings with a name pattern such as *.php now find matching files in every folder, and a pattern used with a folder now matches inside that folder. Before, *.php found only top-level files, and with a folder it found nothing.
     227* Fix: Saved Site Editor changes on the live theme no longer override a published draft theme by default. Publishing sets aside the live theme's saved global styles, and its saved templates and template parts that the draft also has. Set-aside changes are kept and can be brought back, and you can ask to keep them instead. Changes saved in the Site Editor during the preview now go live with the theme, unless you keep a live change with the same name. Deleting a draft sets its saved changes aside too.
     228* Feature: The preview and the publish result warn when the theme's folder name belongs to a theme on WordPress.org, because a WordPress.org update could replace a custom theme. Adding "Update URI: false" to style.css stops this.
    223229
    224230= 1.20.2 =
    … …  
    358364* Fix: `comment create` now applies WordPress's comment filtering for authors who lack the unfiltered_html capability (non-super-admins on multisite, sites with DISALLOW_UNFILTERED_HTML), matching what core does for those users.
    359365
    360 = 1.16.0 =
    361 * Feature: WPVibe can now update itself through your AI assistant. `plugin update vibe-ai` schedules the update to run out-of-band (the same model WordPress core uses), so the connection serving the request is never the one replacing the plugin's files. Where available, WordPress's automatic updater runs it, with its post-update fatal check and rollback for active plugins. Progress and the outcome are recorded in a status option your AI can read back.
    362 * Feature: `plugin auto-updates enable|disable|status` commands, matching real WP-CLI behavior, so your AI can enroll any plugin (including WPVibe) in WordPress auto-updates. Also fixes auto-update enrollment on multisite, where the setting lives in a network option.
    363 * Feature: `theme update --all` with `--exclude` and `--dry-run`, and multiple theme slugs in one command, matching the plugin update family.
    364 * Feature: `--expect-version` on `plugin update` and `theme update` (a WPVibe extension): the update refuses if the available version is not the one you named, closing the race where a newer release lands between review and execution.
    365 * Fix: publishing a draft theme whose functions.php fatals now rolls back to the theme the site was actually running, and only ever activates a theme that is really installed, instead of leaving the site pointed at a directory that no longer exists.
    366 * Fix: raw SQL that writes to a protected identity table (users, usermeta, blocked options), including through a JOIN from another table, is now refused when submitted instead of after a human approves it, and the refusal names the protected table.
    367 * Fix: `theme install --version=<version>` now installs the version you asked for. The themes API ignores a version argument, so the flag was silently dropped and the latest release was installed instead; an unavailable version is refused by name.
    368 * Feature: comment moderation from your AI assistant: `comment create` (replies via `--comment_parent`), `comment approve`, `unapprove`, `spam`, `unspam`, `trash`, `untrash`, and `comment delete`. Deleting without `--force` moves comments to the trash; `--force` permanently deletes and pauses for approval. `comment list` gains `--format=ids|count`, `--orderby`, `--order`, `--offset`, and `--comment__in`.
    369 * Feature: approved long-running commands (a live `search-replace`, mutating `db query`) can run in the background: the request answers immediately, a one-shot WP-Cron job (or a token-authenticated loopback when cron is disabled) runs the command as the approving user, and the outcome is recorded in the operation receipt your AI polls. No more connection timeouts on big search-replace runs.
    370 * Feature: `post list` and `user list` accept `--paged=<n>` / `--offset=<n>` so large sites can be enumerated page by page; the truncation notice names the next page.
    371 * Security: the approval-only routes (`cli/run-approved`, `code-snippet`) now verify a per-operation proof signed by WPVibe with a key provisioned per site, so an approved operation can only be executed by the approval flow that showed it to you, never by a request that merely holds the application password. Sites without a key keep working as before until WPVibe provisions one.
    372 * Fix: operation receipts now accept the fleet runner's operation ids, so background jobs can recover the outcome of a call that timed out mid-flight instead of re-probing.
    373 * Feature: `core update` and `core update-db`. Updating WordPress core pauses for browser approval and shows the exact version change; the approval is re-verified against the site at execution, and a leftover .maintenance file is cleaned up so a failed update cannot strand the site.
    374 * Fix: when a security plugin filters WordPress core update data, `core check-update` and `core update` now say so instead of reporting "no update available" as a certainty.
    375 * Fix: Elementor pages saved through WPVibe are verified after the save. If Elementor stored the layout empty, WPVibe writes the requested structure directly, removes the empty autosave the editor would otherwise open, and never reports success on a page that is still empty.
    376 * Fix: a content edit whose old text differs from the stored value only by whitespace now applies when it is the one unambiguous match, and a miss says what was tried instead of a bare "not found".
    377 * Hardening: PHP file writes are refused when they would redeclare a function or class the running site already has, and publishing a draft theme renders the front page afterwards and rolls back to the backup on a fatal, keeping the draft for editing.
    378 * Fix: the Approve click on the connection screen now creates the application password through WPVibe's own route with your logged-in session, so hosts that block the core users endpoint as "user enumeration" no longer stop the one-click connect.
    379 * Fix: when the connection check fails, the connected page now says what actually came back (a firewall page, a redirect, a server error) and what to do about it, instead of one generic message.
    380 
    381366= Older versions =
    382367WP.org caps the changelog at 5,000 words. For the full release history back to 1.0.0, see https://wpvibe.ai/changelog/
  • vibe-ai/tags/1.20.3/vibe-ai.php

    r3726341 r3730994  
    33 * Plugin Name: WPVibe – Connect Your Site to Claude, ChatGPT & AI Assistants
    44 * Description: Connect any AI assistant to your WordPress site. Manage content, edit themes, and automate site tasks with Claude, ChatGPT, Cursor & more via MCP.
    5  * Version: 1.20.2
     5 * Version: 1.20.3
    66 * Author: SeedProd
    77 * Author URI: https://wpvibe.ai
    … …  
    1515defined( 'ABSPATH' ) || exit;
    1616
    17 define( 'WPVIBE_VERSION', '1.20.2' );
     17define( 'WPVIBE_VERSION', '1.20.3' );
    1818define( 'WPVIBE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    1919define( 'WPVIBE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
  • vibe-ai/trunk/includes/class-wpvibe-content-files.php

    r3698878 r3730994  
    181181        }
    182182
     183        /** Relative to the listed directory; a pattern that spells out the directory itself keeps the older full-path form. */
     184        private static function pattern_matches( $pattern, $path, $base ) {
     185                if ( '' === $base || 0 !== strpos( $path, $base . '/' ) ) {
     186                        return WPVibe_File_Ops::path_matches( $pattern, $path );
     187                }
     188                $full = 0 === stripos( ltrim( $pattern, './' ), $base . '/' );
     189                return WPVibe_File_Ops::path_matches( $pattern, $full ? $path : substr( $path, strlen( $base ) + 1 ) );
     190        }
     191
    183192        public function list_files( $pattern = null, $directory = '' ) {
    184193                $full = $this->resolve( $directory, true );
    … …  
    186195                        return $full;
    187196                }
     197                $base  = trim( (string) $directory, '/' );
    188198                $stack = array( array( $full, $directory ) );
    189199                $files = array();
    … …  
    209219                                        if ( $is_dir ) {
    210220                                                $stack[] = array( $item->getPathname(), $path );
    211                                         } elseif ( ! $pattern || fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD ) ) {
     221                                        } elseif ( ! $pattern || self::pattern_matches( $pattern, $path, $base ) ) {
    212222                                                $files[] = array( 'path' => $path, 'type' => 'file', 'size' => $item->getSize(), 'extension' => strtolower( $item->getExtension() ) );
    213223                                        }
  • vibe-ai/trunk/includes/class-wpvibe-draft-theme.php

    r3709843 r3730994  
    2020         */
    2121        const BUILDER_THEMES = array( 'divi', 'extra', 'avada', 'enfold', 'flatsome', 'betheme', 'bridge', 'salient', 'the7', 'x' );
     22
     23        // Saved Site Editor rows are keyed to a theme slug, not to its files, so a publish leaves them behind.
     24        const SITE_EDITOR_TYPES = array( 'wp_template', 'wp_template_part', 'wp_global_styles' );
     25
     26        /** @return array[] Published Site Editor rows saved for a theme slug. */
     27        public static function saved_customizations( $slug ) {
     28                $posts = get_posts( array(
     29                        'post_type'      => self::SITE_EDITOR_TYPES,
     30                        'post_status'    => 'publish',
     31                        'posts_per_page' => -1,
     32                        'no_found_rows'  => true,
     33                        'tax_query'      => array( array( 'taxonomy' => 'wp_theme', 'field' => 'name', 'terms' => $slug ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- a handful of rows per theme.
     34                ) );
     35                return array_map( function ( $p ) {
     36                        return array( 'id' => (int) $p->ID, 'type' => $p->post_type, 'name' => $p->post_name, 'title' => $p->post_title );
     37                }, $posts );
     38        }
     39
     40        public static function set_aside_slug( $slug ) {
     41                return $slug . '-wpvibe-set-aside';
     42        }
     43
     44        /** Live rows that override what the draft provides (a file or a row saved during the preview), so the preview shows something else. */
     45        public static function clashing_customizations( $live_slug, $draft_slug ) {
     46                $draft_rows = self::saved_customizations( $draft_slug );
     47                $dirs       = self::template_dirs( $draft_slug );
     48                $shown      = self::shown_styles_row( $live_slug );
     49                return array_values( array_filter( self::saved_customizations( $live_slug ), function ( $row ) use ( $draft_rows, $dirs, $shown ) {
     50                        // An older styles row shows nothing, so only the one core shows can override the draft.
     51                        if ( 'wp_global_styles' === $row['type'] ) {
     52                                return $row['id'] === $shown && self::has_styles( $row['id'] );
     53                        }
     54                        foreach ( $dirs[ $row['type'] ] as $dir ) {
     55                                if ( is_file( $dir . '/' . $row['name'] . '.html' ) ) {
     56                                        return true;
     57                                }
     58                        }
     59                        foreach ( $draft_rows as $draft_row ) {
     60                                if ( $draft_row['type'] === $row['type'] && $draft_row['name'] === $row['name'] ) {
     61                                        return true;
     62                                }
     63                        }
     64                        return false;
     65                } ) );
     66        }
     67
     68        /** Core creates an empty published global-styles row whenever the editor or the active-theme REST query runs; only real styles clash. */
     69        private static function has_styles( $id ) {
     70                $post = get_post( $id );
     71                $data = $post ? json_decode( (string) $post->post_content, true ) : null;
     72                return is_array( $data ) && ( ! empty( $data['styles'] ) || ! empty( $data['settings'] ) );
     73        }
     74
     75        /** Template folders of the draft and, for a child draft, of its parent; old folder names included. */
     76        private static function template_dirs( $draft_slug ) {
     77                $roots  = array( get_theme_root() . '/' . $draft_slug );
     78                $header = is_readable( $roots[0] . '/style.css' ) ? get_file_data( $roots[0] . '/style.css', array( 'Template' => 'Template' ) ) : array();
     79                $parent = trim( (string) ( $header['Template'] ?? '' ) );
     80                if ( '' !== $parent && $parent === basename( $parent ) && $parent !== $draft_slug ) {
     81                        $roots[] = get_theme_root( $parent ) . '/' . $parent;
     82                }
     83                $dirs = array( 'wp_template' => array(), 'wp_template_part' => array() );
     84                foreach ( $roots as $root ) {
     85                        array_push( $dirs['wp_template'], $root . '/templates', $root . '/block-templates' );
     86                        array_push( $dirs['wp_template_part'], $root . '/parts', $root . '/block-template-parts' );
     87                }
     88                return $dirs;
     89        }
     90
     91        private static function describe_rows( array $rows ) {
     92                $labels = array( 'wp_template' => 'template', 'wp_template_part' => 'template part' );
     93                $names  = array_map( function ( $row ) use ( $labels ) {
     94                        return ( 'wp_global_styles' === $row['type'] ? 'global styles' : $row['name'] . ' ' . $labels[ $row['type'] ] ) . ' #' . $row['id'];
     95                }, array_slice( $rows, 0, 10 ) );
     96                return implode( ', ', $names ) . ( count( $rows ) > 10 ? sprintf( ', and %d more', count( $rows ) - 10 ) : '' );
     97        }
     98
     99        /** WordPress.org offers its release to any folder of the same name unless Update URI points elsewhere. */
     100        public static function wporg_update_warning( $slug, $dir, $published = false ) {
     101                // No refresh when the transient is missing (a publish deletes it): that is a blocking request to api.wordpress.org.
     102                $updates = get_site_transient( 'update_themes' );
     103                if ( ! is_object( $updates ) || ! ( isset( $updates->response[ $slug ] ) || isset( $updates->no_update[ $slug ] ) ) ) {
     104                        return null;
     105                }
     106                $header = is_readable( $dir . '/style.css' ) ? get_file_data( $dir . '/style.css', array( 'UpdateURI' => 'Update URI' ) ) : array();
     107                $uri    = trim( (string) ( $header['UpdateURI'] ?? '' ) );
     108                if ( '' !== $uri && ! preg_match( '#^(?:https?://)?(?:www\.)?(?:wordpress\.org|w\.org)(?:/|$)#i', $uri ) ) {
     109                        return null;
     110                }
     111                return sprintf(
     112                        /* translators: %s: theme folder name */
     113                        $published
     114                                ? __( 'The folder name \'%s\' matches a theme that WordPress.org updates, so a WordPress.org update for that theme can replace these files. If this is now your own design, add the line "Update URI: false" to the style.css header in a new draft and publish it.', 'vibe-ai' )
     115                                : __( 'The folder name \'%s\' matches a theme that WordPress.org updates, so after publish a WordPress.org update for that theme can replace these files. If this is now your own design, add the line "Update URI: false" to the draft\'s style.css header before you publish.', 'vibe-ai' ),
     116                        $slug
     117                );
     118        }
    22119
    23120        /**
    … …  
    135232                }
    136233
     234                list( $parked, $stuck ) = self::park_draft_rows( $draft_slug, $active_slug );
     235
    137236                WPVibe_Change_Tracker::mark( array(
    138237                        'summary'      => 'Draft theme created',
    … …  
    144243                        $message = sprintf(
    145244                                /* translators: %s: theme directory name */
    146                                 __( 'A stale draft record for \'%s\' was cleared first: its folder was already gone, so no files were deleted and earlier unpublished draft edits are not recoverable. The new draft is a fresh copy of the live theme.', 'vibe-ai' ),
     245                                __( 'A stale draft record for \'%s\' was cleared first: its folder was already gone, so no files were deleted and earlier unpublished draft file edits are not recoverable. The new draft\'s files are a fresh copy of the live theme.', 'vibe-ai' ),
    147246                                $stale
    148247                        ) . ' ' . $message;
     248                }
     249                if ( $parked || $stuck ) {
     250                        $message .= ' ' . self::parked_note( $parked, $stuck, $active_slug );
    149251                }
    150252                $response = array(
    … …  
    441543        }
    442544
    443         public function publish() {
    444                 return WPVibe_Draft_Lock::run( function () {
    445                         return $this->publish_locked();
     545        /** @param string|null $saved "keep" leaves clashing live Site Editor rows in place; anything else sets them aside. */
     546        public function publish( $saved = null ) {
     547                return WPVibe_Draft_Lock::run( function () use ( $saved ) {
     548                        return $this->publish_locked( $saved );
    446549                } );
    447550        }
    448551
    449         private function publish_locked() {
     552        private function publish_locked( $saved = null ) {
    450553                if ( get_option( 'wpvibe_draft_theme' ) && ! WPVibe_Draft_Lock::valid_draft() ) {
    451554                        return WPVibe_Draft_Lock::conflict();
    … …  
    473576                        return self::missing_error();
    474577                }
     578
     579                // The approved preview rendered without these rows, so setting them aside is the default; "keep" is an explicit choice.
     580                $clashing = self::clashing_customizations( $source_slug, $draft_slug );
     581                $saved    = 'keep' === $saved ? 'keep' : 'set_aside';
     582
     583                // Read now: wp_clean_themes_cache() below deletes the update_themes transient this needs.
     584                $update_warning = self::wporg_update_warning( $source_slug, $draft_dir, true );
     585
    475586                // Unfiltered: WPVibe_Preview filters 'stylesheet' to the draft slug when a
    476587                // preview token is present, and rolling back onto the draft would
    … …  
    608719                switch_theme( $source_slug );
    609720
     721                // Before the purge and the health check, so both see the final rows and a failure can undo them.
     722                $this->settle_log = array();
     723                try {
     724                        $settled = $this->settle_saved_customizations( $source_slug, $draft_slug, $clashing, $saved );
     725                } catch ( \Throwable $e ) {
     726                        $unmoved = $this->unsettle();
     727                        $rolled  = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     728                        return new WP_Error(
     729                                'publish_rolled_back',
     730                                $unmoved
     731                                        /* translators: 1: error message, 2: post IDs, 3: rollback outcome */
     732                                        ? sprintf( __( 'Published, then moving the saved Site Editor customizations failed (%1$s). WPVibe could not put back these rows: %2$s. The publish was rolled back: %3$s', 'vibe-ai' ), $e->getMessage(), '#' . implode( ', #', $unmoved ), $rolled )
     733                                        /* translators: 1: error message, 2: rollback outcome */
     734                                        : sprintf( __( 'Published, then moving the saved Site Editor customizations failed (%1$s), so they were put back and the publish was rolled back: %2$s', 'vibe-ai' ), $e->getMessage(), $rolled ),
     735                                WPVibe_Error_Contract::data( 'wp_core', true, array( 'status' => 500 ) )
     736                        );
     737                }
     738
    610739                // Publish replaced live templates in place; switch_theme's action only
    611740                // reaches engines that hook it (LiteSpeed, WP Rocket). Flush the rest
    … …  
    622751                $health = WPVibe_PHP_Guard::render_health_check();
    623752                if ( is_wp_error( $health ) ) {
    624                         $rolled = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     753                        $unmoved = $this->unsettle();
     754                        $rolled  = $this->rollback_publish( $live_dir, $backup_dir, $draft_dir, $swapped_by_rename, $source_slug, $had_live, $previous_active );
     755                        if ( $unmoved ) {
     756                                /* translators: %s: post IDs */
     757                                $rolled .= ' ' . sprintf( __( 'WPVibe could not put back these saved Site Editor rows: %s.', 'vibe-ai' ), '#' . implode( ', #', $unmoved ) );
     758                        }
    625759                        return new WP_Error(
    626760                                'publish_rolled_back',
    … …  
    637771                // Cleanup.
    638772                $this->delete_directory( $draft_dir );
    639                 delete_option( 'wpvibe_draft_theme' );
    640                 delete_option( 'wpvibe_draft_source' );
     773                // The preview token too: an old preview link must not show the next draft before its leftover rows are parked.
     774                self::clear_draft_records();
    641775                self::record_draft_event( 'published' );
    642 
    643                 // The cookie still points at a now-deleted draft slug; clear it so
    644                 // the next admin request doesn't re-enter a nonexistent preview.
    645                 if ( class_exists( 'WPVibe_Preview' ) ) {
    646                         WPVibe_Preview::clear_cookie();
    647                 }
    648776
    649777                WPVibe_Change_Tracker::mark( array(
    … …  
    654782                ) );
    655783
    656                 return rest_ensure_response( array(
    657                         'status'  => 'published',
     784                $message = $had_live
    658785                        /* translators: 1: theme slug, 2: backup slug */
    659                         'message' => $had_live
    660                                 ? sprintf( __( 'Draft published to \'%1$s\'. Backup saved as \'%2$s\'.', 'vibe-ai' ), $source_slug, $source_slug . '-wpvibe-backup' )
    661                                 : sprintf( __( 'New theme \'%s\' published. No existing theme directory was replaced; no theme backup was created.', 'vibe-ai' ), $source_slug ),
    662                 ) );
     786                        ? sprintf( __( 'Draft published to \'%1$s\'. Backup saved as \'%2$s\'.', 'vibe-ai' ), $source_slug, $source_slug . '-wpvibe-backup' )
     787                        /* translators: %s: theme slug */
     788                        : sprintf( __( 'New theme \'%s\' published. No existing theme directory was replaced; no theme backup was created.', 'vibe-ai' ), $source_slug );
     789                if ( $settled['set_aside'] ) {
     790                        $message .= ' ' . sprintf(
     791                                /* translators: 1: list of rows, 2: parking theme slug, 3: post id, 4: live theme slug */
     792                                __( 'Set aside the live theme\'s saved Site Editor customizations that would override this draft (saved global styles, and templates or template parts the draft also has), so they no longer override the published theme: %1$s. They are kept under the theme name \'%2$s\', which no theme uses. To bring one back, run the WP-CLI command "post term set <id> wp_theme %4$s" (for example "post term set %3$d wp_theme %4$s"). If a customization carried over from the preview has the same name, first set that one aside with "post term set <its id> wp_theme %2$s".', 'vibe-ai' ),
     793                                self::describe_rows( $settled['set_aside'] ),
     794                                self::set_aside_slug( $source_slug ),
     795                                $settled['set_aside'][0]['id'],
     796                                $source_slug
     797                        );
     798                }
     799                if ( $settled['kept'] ) {
     800                        /* translators: %s: list of rows */
     801                        $message .= ' ' . sprintf( __( 'Kept the live theme\'s saved Site Editor customizations, which override the published files: %s.', 'vibe-ai' ), self::describe_rows( $settled['kept'] ) );
     802                }
     803                if ( $settled['moved'] ) {
     804                        /* translators: %s: list of rows */
     805                        $message .= ' ' . sprintf( __( 'Carried the customizations saved during the preview over to the live theme: %s.', 'vibe-ai' ), self::describe_rows( $settled['moved'] ) );
     806                }
     807                if ( $settled['left'] ) {
     808                        /* translators: %s: list of rows */
     809                        $message .= ' ' . sprintf(
     810                                /* translators: 1: list of rows, 2: parking theme slug */
     811                                __( 'Set aside these customizations saved during the preview, because the live theme keeps its own saved version: %1$s. They are kept under the theme name \'%2$s\'.', 'vibe-ai' ),
     812                                self::describe_rows( $settled['left'] ),
     813                                self::set_aside_slug( $source_slug )
     814                        );
     815                }
     816                if ( $update_warning ) {
     817                        $message .= ' ' . $update_warning;
     818                }
     819
     820                return rest_ensure_response( array_filter( array(
     821                        'status'               => 'published',
     822                        'message'              => $message,
     823                        'saved_customizations' => array_filter( $settled ),
     824                        'update_warning'       => $update_warning,
     825                ) ) );
     826        }
     827
     828        /** Rows saved during the preview go live; clashing live rows are parked or kept, as asked. */
     829        private function settle_saved_customizations( $live_slug, $draft_slug, array $clashing, $mode ) {
     830                $out    = array( 'set_aside' => array(), 'kept' => array(), 'moved' => array(), 'left' => array() );
     831                $park   = self::set_aside_slug( $live_slug );
     832                $styles = function ( $row, $slug ) {
     833                        return 'wp_global_styles' === $row['type'] ? array( 'post_name' => 'wp-global-styles-' . rawurlencode( $slug ) ) : array();
     834                };
     835                $key    = function ( $row ) {
     836                        return 'wp_global_styles' === $row['type'] ? $row['type'] : $row['type'] . '/' . $row['name'];
     837                };
     838                $taken  = array();
     839                $clash_ids = array_column( $clashing, 'id' );
     840                // Core reads the newest global-styles row for a slug, so an empty live one could hide the styles the preview showed.
     841                $empty_styles = array_filter( self::saved_customizations( $live_slug ), function ( $row ) use ( $clash_ids ) {
     842                        return 'wp_global_styles' === $row['type'] && ! in_array( $row['id'], $clash_ids, true );
     843                } );
     844                // The other live styles rows show nothing now, but one of them becomes core's pick once the shown row leaves.
     845                $park_live_styles = function ( $styled_only ) use ( &$empty_styles, &$out, $park, $live_slug, $styles ) {
     846                        foreach ( $empty_styles as $i => $other ) {
     847                                $styled = self::has_styles( $other['id'] );
     848                                if ( $styled_only && ! $styled ) {
     849                                        continue;
     850                                }
     851                                $this->retag( $other, $park, $live_slug, $styles( $other, $park ) );
     852                                if ( $styled ) {
     853                                        $out['set_aside'][] = $other;
     854                                }
     855                                unset( $empty_styles[ $i ] );
     856                        }
     857                };
     858                foreach ( $clashing as $row ) {
     859                        if ( 'set_aside' === $mode ) {
     860                                // Not the trash: wp-admin has no trash screen for these types, and trashing renames the slug.
     861                                $this->retag( $row, $park, $live_slug, $styles( $row, $park ) );
     862                                $out['set_aside'][] = $row;
     863                                if ( 'wp_global_styles' === $row['type'] ) {
     864                                        $park_live_styles( true );
     865                                }
     866                        } else {
     867                                $out['kept'][]         = $row;
     868                                $taken[ $key( $row ) ] = true;
     869                        }
     870                }
     871                $draft_rows = self::saved_customizations( $draft_slug );
     872                $shown      = self::shown_styles_row( $draft_slug );
     873                foreach ( $draft_rows as $row ) {
     874                        // Only the styles row core shows for the draft is what the preview showed; an empty one would also hide the real live row.
     875                        if ( 'wp_global_styles' === $row['type'] && ( $row['id'] !== $shown || ! self::has_styles( $row['id'] ) ) ) {
     876                                $this->retag( $row, $park, $draft_slug, $styles( $row, $park ) );
     877                                continue;
     878                        }
     879                        if ( isset( $taken[ $key( $row ) ] ) ) {
     880                                // Parked as well, or the next draft under this slug would carry it over unseen.
     881                                $this->retag( $row, $park, $draft_slug, $styles( $row, $park ) + self::live_part_refs( $row['id'], $draft_slug, $live_slug ) );
     882                                $out['left'][] = $row;
     883                                continue;
     884                        }
     885                        if ( 'wp_global_styles' === $row['type'] ) {
     886                                $park_live_styles( false );
     887                        }
     888                        $this->retag( $row, $live_slug, $draft_slug, $styles( $row, $live_slug ) + self::live_part_refs( $row['id'], $draft_slug, $live_slug ) );
     889                        $out['moved'][] = $row;
     890                }
     891                return $out;
     892        }
     893
     894        // The styles row core itself shows for a theme, in core's own order, so a tie resolves the way the site does.
     895        private static function shown_styles_row( $slug ) {
     896                $data = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme( $slug ) );
     897                return (int) ( $data['ID'] ?? 0 );
     898        }
     899
     900        private $settle_log = array();
     901
     902        // Fields first: core's template slug filter renames a row whose new theme already has that name.
     903        private function retag( array $row, $slug, $from, array $fields ) {
     904                $undo = array( 'id' => $row['id'], 'slug' => $from, 'fields' => array() );
     905                if ( $fields ) {
     906                        $post = get_post( $row['id'] );
     907                        foreach ( array_keys( $fields ) as $field ) {
     908                                $undo['fields'][ $field ] = $post ? $post->$field : '';
     909                        }
     910                        $updated = wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $fields ), true );
     911                        // Logged only once something changed, so a rollback never names a row that did not move.
     912                        if ( is_wp_error( $updated ) ) {
     913                                throw new RuntimeException( sprintf( 'post #%d: %s', $row['id'], $updated->get_error_message() ) );
     914                        }
     915                }
     916                $this->settle_log[] = $undo;
     917                if ( ! self::set_theme_term( $row['id'], $slug ) ) {
     918                        self::set_theme_term( $row['id'], $from );
     919                        throw new RuntimeException( sprintf( 'post #%d: could not move it to theme %s (its themes now: %s)', $row['id'], $slug, implode( ', ', self::theme_terms( $row['id'] ) ) ) );
     920                }
     921        }
     922
     923        // Add, confirm, then drop the others: core does not check its relationship INSERT, so a failed write must leave the row where it was.
     924        private static function set_theme_term( $post_id, $slug ) {
     925                $added = wp_set_object_terms( $post_id, $slug, 'wp_theme', true );
     926                if ( is_wp_error( $added ) || ! in_array( $slug, self::theme_terms( $post_id ), true ) ) {
     927                        return false;
     928                }
     929                $others = array_values( array_diff( self::theme_terms( $post_id ), array( $slug ) ) );
     930                if ( $others ) {
     931                        $removed = wp_remove_object_terms( $post_id, $others, 'wp_theme' );
     932                        if ( is_wp_error( $removed ) || ! $removed ) {
     933                                return false;
     934                        }
     935                }
     936                return array( $slug ) === self::theme_terms( $post_id );
     937        }
     938
     939        // Rows saved under a draft slug outlive the draft; parked, so the next draft under that slug does not show them unseen.
     940        private static function park_draft_rows( $draft_slug, $live_slug ) {
     941                $park  = self::set_aside_slug( $live_slug );
     942                $moved = array();
     943                $stuck = array();
     944                foreach ( self::saved_customizations( $draft_slug ) as $row ) {
     945                        $post   = get_post( $row['id'] );
     946                        $fields = self::live_part_refs( $row['id'], $draft_slug, $live_slug );
     947                        if ( 'wp_global_styles' === $row['type'] ) {
     948                                $fields['post_name'] = 'wp-global-styles-' . rawurlencode( $park );
     949                        }
     950                        $old = array();
     951                        foreach ( array_keys( $fields ) as $field ) {
     952                                $old[ $field ] = $post ? $post->$field : '';
     953                        }
     954                        // Core's empty styles row is no customization worth naming.
     955                        $named = 'wp_global_styles' !== $row['type'] || self::has_styles( $row['id'] );
     956                        if ( $fields && is_wp_error( wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $fields ), true ) ) ) {
     957                                if ( $named ) {
     958                                        $stuck[] = $row;
     959                                }
     960                                continue;
     961                        }
     962                        if ( ! self::set_theme_term( $row['id'], $park ) ) {
     963                                self::set_theme_term( $row['id'], $draft_slug );
     964                                if ( $old ) {
     965                                        wp_update_post( wp_slash( array( 'ID' => $row['id'] ) + $old ) );
     966                                }
     967                                if ( $named ) {
     968                                        $stuck[] = $row;
     969                                }
     970                                continue;
     971                        }
     972                        if ( $named ) {
     973                                $moved[] = $row;
     974                        }
     975                }
     976                return array( $moved, $stuck );
     977        }
     978
     979        // Template-part blocks saved in the preview name the draft slug and render nothing once it is gone.
     980        private static function live_part_refs( $post_id, $draft_slug, $live_slug ) {
     981                $post    = get_post( $post_id );
     982                $content = $post ? (string) $post->post_content : '';
     983                // serialize_block_attributes() writes "--" as \u002d\u002d.
     984                $enc     = function ( $slug ) {
     985                        return str_replace( '--', '\\u002d\\u002d', $slug );
     986                };
     987                $renamed = str_replace(
     988                        array( '"theme":"' . $draft_slug . '"', '"theme":"' . $enc( $draft_slug ) . '"' ),
     989                        array( '"theme":"' . $live_slug . '"', '"theme":"' . $enc( $live_slug ) . '"' ),
     990                        $content
     991                );
     992                return $renamed !== $content ? array( 'post_content' => $renamed ) : array();
     993        }
     994
     995        private static function parked_note( array $parked, array $stuck, $live_slug ) {
     996                $note = ! $parked ? '' : sprintf(
     997                        /* translators: 1: list of rows, 2: parking theme slug, 3: live theme slug */
     998                        __( 'Set aside the customizations saved during a draft preview: %1$s. They are kept under the theme name \'%2$s\', which no theme uses. To bring back a template or template part, run the WP-CLI command "post term set <id> wp_theme %3$s".', 'vibe-ai' ),
     999                        self::describe_rows( $parked ),
     1000                        self::set_aside_slug( $live_slug ),
     1001                        $live_slug
     1002                );
     1003                if ( in_array( 'wp_global_styles', array_column( $parked, 'type' ), true ) ) {
     1004                        $note .= ' ' . __( 'A global styles row comes back the same way, and it replaces the live styles when it is newer than the live global styles row, because WordPress uses the newest one.', 'vibe-ai' );
     1005                }
     1006                if ( $stuck ) {
     1007                        /* translators: %s: list of rows */
     1008                        $note .= ' ' . sprintf( __( 'Could not set aside these customizations, which stay under the draft theme name, so a preview of a draft under that name shows them: %s.', 'vibe-ai' ), self::describe_rows( $stuck ) );
     1009                }
     1010                return trim( $note );
     1011        }
     1012
     1013        private static function theme_terms( $post_id ) {
     1014                $names = wp_get_object_terms( $post_id, 'wp_theme', array( 'fields' => 'names' ) );
     1015                return is_array( $names ) ? array_values( array_map( 'strval', $names ) ) : array();
     1016        }
     1017
     1018        /** @return int[] Rows that could not be put back. */
     1019        private function unsettle() {
     1020                $unmoved = array();
     1021                foreach ( array_reverse( $this->settle_log ) as $undo ) {
     1022                        // Term back first, so the fields are written where the row's name is unique, as in retag().
     1023                        $ok = self::set_theme_term( $undo['id'], $undo['slug'] );
     1024                        if ( $undo['fields'] && is_wp_error( wp_update_post( wp_slash( array( 'ID' => $undo['id'] ) + $undo['fields'] ), true ) ) ) {
     1025                                $ok = false;
     1026                        }
     1027                        if ( ! $ok ) {
     1028                                $unmoved[] = (int) $undo['id'];
     1029                        }
     1030                }
     1031                $this->settle_log = array();
     1032                return array_values( array_unique( $unmoved ) );
    6631033        }
    6641034
    … …  
    7351105                $url = add_query_arg( 'wpvibe_preview', $token, home_url( '/' ) );
    7361106
    737                 return rest_ensure_response( array(
    738                         'preview_url' => $url,
    739                         'draft_slug'  => $draft_slug,
    740                 ) );
     1107                $source = (string) get_option( 'wpvibe_draft_source' );
     1108                return rest_ensure_response( array_filter( array(
     1109                        'preview_url'         => $url,
     1110                        'draft_slug'          => $draft_slug,
     1111                        'live_customizations' => '' !== $source ? self::clashing_customizations( $source, $draft_slug ) : array(),
     1112                        'update_warning'      => '' !== $source ? self::wporg_update_warning( $source, get_theme_root() . '/' . $draft_slug ) : null,
     1113                ) ) );
    7411114        }
    7421115
    … …  
    7891162                }
    7901163
     1164                $source = (string) get_option( 'wpvibe_draft_source' );
     1165                list( $parked, $stuck ) = '' !== $source ? self::park_draft_rows( $draft_slug, $source ) : array( array(), array() );
    7911166                self::clear_draft_records();
    7921167                self::record_draft_event( 'deleted' );
    … …  
    7971172                ) );
    7981173
     1174                $message = $had_dir
     1175                        ? __( 'Draft theme removed.', 'vibe-ai' )
     1176                        : sprintf(
     1177                                /* translators: %s: theme directory name */
     1178                                __( 'Draft record cleared. Its folder \'%s\' was already gone, so no files were deleted.', 'vibe-ai' ),
     1179                                $draft_slug
     1180                        );
     1181                if ( $parked || $stuck ) {
     1182                        $message .= ' ' . self::parked_note( $parked, $stuck, $source );
     1183                }
    7991184                return rest_ensure_response( array(
    8001185                        'status'  => 'deleted',
    801                         'message' => $had_dir
    802                                 ? __( 'Draft theme removed.', 'vibe-ai' )
    803                                 : sprintf(
    804                                         /* translators: %s: theme directory name */
    805                                         __( 'Draft record cleared. Its folder \'%s\' was already gone, so no files were deleted.', 'vibe-ai' ),
    806                                         $draft_slug
    807                                 ),
     1186                        'message' => $message,
    8081187                ) );
    8091188        }
  • vibe-ai/trunk/includes/class-wpvibe-file-ops.php

    r3711469 r3730994  
    662662        }
    663663
     664        /** Glob match for listings: no "/" matches the file name at any depth (as in .gitignore), and "**" spans zero or more folders. */
     665        public static function path_matches( $pattern, $path ) {
     666                $pattern = ltrim( 0 === strpos( $pattern, './' ) ? substr( $pattern, 2 ) : $pattern, '/' );
     667                if ( '/' === substr( $pattern, -1 ) ) {
     668                        $pattern .= '**';
     669                }
     670                if ( false === strpos( $pattern, '/' ) ) {
     671                        return fnmatch( $pattern, basename( $path ), FNM_PATHNAME | FNM_CASEFOLD );
     672                }
     673                if ( false === strpos( $pattern, '**' ) ) {
     674                        return fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD );
     675                }
     676                return 1 === preg_match( self::globstar_regex( $pattern ), $path );
     677        }
     678
     679        /** fnmatch has no "**"; a single "*" here still stops at "/". */
     680        private static function globstar_regex( $pattern ) {
     681                $re  = '';
     682                $len = strlen( $pattern );
     683                for ( $i = 0; $i < $len; $i++ ) {
     684                        $c = $pattern[ $i ];
     685                        if ( '*' === $c && '*' === ( $pattern[ $i + 1 ] ?? '' ) ) {
     686                                $i++;
     687                                if ( '/' === ( $pattern[ $i + 1 ] ?? '' ) ) {
     688                                        $i++;
     689                                        $re .= '(?:.*/)?';
     690                                } else {
     691                                        $re .= '.*';
     692                                }
     693                        } elseif ( '*' === $c ) {
     694                                $re .= '[^/]*';
     695                        } elseif ( '?' === $c ) {
     696                                $re .= '[^/]';
     697                        } elseif ( '[' === $c && false !== ( $end = strpos( $pattern, ']', $i + 2 ) ) ) {
     698                                $class = substr( $pattern, $i + 1, $end - $i - 1 );
     699                                $neg   = '!' === $class[0] || '^' === $class[0];
     700                                $re   .= '[' . ( $neg ? '^' : '' ) . str_replace( array( '\\', '#' ), array( '\\\\', '\\#' ), $neg ? substr( $class, 1 ) : $class ) . ']';
     701                                $i     = $end;
     702                        } else {
     703                                $re .= preg_quote( $c, '#' );
     704                        }
     705                }
     706                return '#^' . $re . '$#i';
     707        }
     708
    664709        /**
    665710         * List all files in the draft theme, optionally filtered by glob pattern.
    … …  
    694739
    695740                        // Apply glob pattern filter if provided.
    696                         if ( $pattern && ! $item->isDir() && ! fnmatch( $pattern, $relative, FNM_PATHNAME | FNM_CASEFOLD ) ) {
     741                        if ( $pattern && ! $item->isDir() && ! self::path_matches( $pattern, $relative ) ) {
    697742                                continue;
    698743                        }
  • vibe-ai/trunk/includes/class-wpvibe-rest.php

    r3719193 r3730994  
    499499                        'args'                => array(
    500500                                'expected_source_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ),
     501                                'saved_customizations' => array( 'type' => 'string', 'enum' => array( 'set_aside', 'keep' ) ),
    501502                        ),
    502503                ) );
    … …  
    15021503                }
    15031504                $draft = new WPVibe_Draft_Theme();
    1504                 return $draft->publish();
     1505                return $draft->publish( $request ? $request->get_param( 'saved_customizations' ) : null );
    15051506        }
    15061507
  • vibe-ai/trunk/includes/cli/trait-wpvibe-cli-db.php

    r3726341 r3730994  
    7373                }
    7474                $q = $rest[0];
    75                 if ( '"' === $q && preg_match( '/^"((?:[^"\\\\]|\\\\.)*)"$/s', $rest, $m ) ) {
     75                // Possessive: the backtracking form exhausts PCRE's JIT stack past ~8 KB and silently keeps the quotes.
     76                if ( '"' === $q && preg_match( '/^"((?:[^"\\\\]++|\\\\.)*+)"$/s', $rest, $m ) ) {
    7677                        return preg_replace( '/\\\\(["\\\\])/', '$1', $m[1] );
    7778                }
  • vibe-ai/trunk/readme.txt

    r3726341 r3730994  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.20.2
     7Stable tag: 1.20.3
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    221221
    222222== Changelog ==
     223
     224= 1.20.3 =
     225* Fix: Approved database writes that are long (over about 8 KB) and wrapped in double quotes no longer fail with a syntax error on some hosts.
     226* Fix: File listings with a name pattern such as *.php now find matching files in every folder, and a pattern used with a folder now matches inside that folder. Before, *.php found only top-level files, and with a folder it found nothing.
     227* Fix: Saved Site Editor changes on the live theme no longer override a published draft theme by default. Publishing sets aside the live theme's saved global styles, and its saved templates and template parts that the draft also has. Set-aside changes are kept and can be brought back, and you can ask to keep them instead. Changes saved in the Site Editor during the preview now go live with the theme, unless you keep a live change with the same name. Deleting a draft sets its saved changes aside too.
     228* Feature: The preview and the publish result warn when the theme's folder name belongs to a theme on WordPress.org, because a WordPress.org update could replace a custom theme. Adding "Update URI: false" to style.css stops this.
    223229
    224230= 1.20.2 =
    … …  
    358364* Fix: `comment create` now applies WordPress's comment filtering for authors who lack the unfiltered_html capability (non-super-admins on multisite, sites with DISALLOW_UNFILTERED_HTML), matching what core does for those users.
    359365
    360 = 1.16.0 =
    361 * Feature: WPVibe can now update itself through your AI assistant. `plugin update vibe-ai` schedules the update to run out-of-band (the same model WordPress core uses), so the connection serving the request is never the one replacing the plugin's files. Where available, WordPress's automatic updater runs it, with its post-update fatal check and rollback for active plugins. Progress and the outcome are recorded in a status option your AI can read back.
    362 * Feature: `plugin auto-updates enable|disable|status` commands, matching real WP-CLI behavior, so your AI can enroll any plugin (including WPVibe) in WordPress auto-updates. Also fixes auto-update enrollment on multisite, where the setting lives in a network option.
    363 * Feature: `theme update --all` with `--exclude` and `--dry-run`, and multiple theme slugs in one command, matching the plugin update family.
    364 * Feature: `--expect-version` on `plugin update` and `theme update` (a WPVibe extension): the update refuses if the available version is not the one you named, closing the race where a newer release lands between review and execution.
    365 * Fix: publishing a draft theme whose functions.php fatals now rolls back to the theme the site was actually running, and only ever activates a theme that is really installed, instead of leaving the site pointed at a directory that no longer exists.
    366 * Fix: raw SQL that writes to a protected identity table (users, usermeta, blocked options), including through a JOIN from another table, is now refused when submitted instead of after a human approves it, and the refusal names the protected table.
    367 * Fix: `theme install --version=<version>` now installs the version you asked for. The themes API ignores a version argument, so the flag was silently dropped and the latest release was installed instead; an unavailable version is refused by name.
    368 * Feature: comment moderation from your AI assistant: `comment create` (replies via `--comment_parent`), `comment approve`, `unapprove`, `spam`, `unspam`, `trash`, `untrash`, and `comment delete`. Deleting without `--force` moves comments to the trash; `--force` permanently deletes and pauses for approval. `comment list` gains `--format=ids|count`, `--orderby`, `--order`, `--offset`, and `--comment__in`.
    369 * Feature: approved long-running commands (a live `search-replace`, mutating `db query`) can run in the background: the request answers immediately, a one-shot WP-Cron job (or a token-authenticated loopback when cron is disabled) runs the command as the approving user, and the outcome is recorded in the operation receipt your AI polls. No more connection timeouts on big search-replace runs.
    370 * Feature: `post list` and `user list` accept `--paged=<n>` / `--offset=<n>` so large sites can be enumerated page by page; the truncation notice names the next page.
    371 * Security: the approval-only routes (`cli/run-approved`, `code-snippet`) now verify a per-operation proof signed by WPVibe with a key provisioned per site, so an approved operation can only be executed by the approval flow that showed it to you, never by a request that merely holds the application password. Sites without a key keep working as before until WPVibe provisions one.
    372 * Fix: operation receipts now accept the fleet runner's operation ids, so background jobs can recover the outcome of a call that timed out mid-flight instead of re-probing.
    373 * Feature: `core update` and `core update-db`. Updating WordPress core pauses for browser approval and shows the exact version change; the approval is re-verified against the site at execution, and a leftover .maintenance file is cleaned up so a failed update cannot strand the site.
    374 * Fix: when a security plugin filters WordPress core update data, `core check-update` and `core update` now say so instead of reporting "no update available" as a certainty.
    375 * Fix: Elementor pages saved through WPVibe are verified after the save. If Elementor stored the layout empty, WPVibe writes the requested structure directly, removes the empty autosave the editor would otherwise open, and never reports success on a page that is still empty.
    376 * Fix: a content edit whose old text differs from the stored value only by whitespace now applies when it is the one unambiguous match, and a miss says what was tried instead of a bare "not found".
    377 * Hardening: PHP file writes are refused when they would redeclare a function or class the running site already has, and publishing a draft theme renders the front page afterwards and rolls back to the backup on a fatal, keeping the draft for editing.
    378 * Fix: the Approve click on the connection screen now creates the application password through WPVibe's own route with your logged-in session, so hosts that block the core users endpoint as "user enumeration" no longer stop the one-click connect.
    379 * Fix: when the connection check fails, the connected page now says what actually came back (a firewall page, a redirect, a server error) and what to do about it, instead of one generic message.
    380 
    381366= Older versions =
    382367WP.org caps the changelog at 5,000 words. For the full release history back to 1.0.0, see https://wpvibe.ai/changelog/
  • vibe-ai/trunk/vibe-ai.php

    r3726341 r3730994  
    33 * Plugin Name: WPVibe – Connect Your Site to Claude, ChatGPT & AI Assistants
    44 * Description: Connect any AI assistant to your WordPress site. Manage content, edit themes, and automate site tasks with Claude, ChatGPT, Cursor & more via MCP.
    5  * Version: 1.20.2
     5 * Version: 1.20.3
    66 * Author: SeedProd
    77 * Author URI: https://wpvibe.ai
    … …  
    1515defined( 'ABSPATH' ) || exit;
    1616
    17 define( 'WPVIBE_VERSION', '1.20.2' );
     17define( 'WPVIBE_VERSION', '1.20.3' );
    1818define( 'WPVIBE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    1919define( 'WPVIBE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.