Changeset 3730994
- Timestamp:
- 10/06/2026 02:18:23 PM (3 weeks ago)
- Location:
- vibe-ai
- Files:
-
- 14 edited
- 1 copied
-
tags/1.20.3 (copied) (copied from vibe-ai/trunk )
-
tags/1.20.3/includes/class-wpvibe-content-files.php (modified) (3 diffs)
-
tags/1.20.3/includes/class-wpvibe-draft-theme.php (modified) (12 diffs)
-
tags/1.20.3/includes/class-wpvibe-file-ops.php (modified) (2 diffs)
-
tags/1.20.3/includes/class-wpvibe-rest.php (modified) (2 diffs)
-
tags/1.20.3/includes/cli/trait-wpvibe-cli-db.php (modified) (1 diff)
-
tags/1.20.3/readme.txt (modified) (3 diffs)
-
tags/1.20.3/vibe-ai.php (modified) (2 diffs)
-
trunk/includes/class-wpvibe-content-files.php (modified) (3 diffs)
-
trunk/includes/class-wpvibe-draft-theme.php (modified) (12 diffs)
-
trunk/includes/class-wpvibe-file-ops.php (modified) (2 diffs)
-
trunk/includes/class-wpvibe-rest.php (modified) (2 diffs)
-
trunk/includes/cli/trait-wpvibe-cli-db.php (modified) (1 diff)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/vibe-ai.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
vibe-ai/tags/1.20.3/includes/class-wpvibe-content-files.php
r3698878 r3730994 181 181 } 182 182 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 183 192 public function list_files( $pattern = null, $directory = '' ) { 184 193 $full = $this->resolve( $directory, true ); … … 186 195 return $full; 187 196 } 197 $base = trim( (string) $directory, '/' ); 188 198 $stack = array( array( $full, $directory ) ); 189 199 $files = array(); … … 209 219 if ( $is_dir ) { 210 220 $stack[] = array( $item->getPathname(), $path ); 211 } elseif ( ! $pattern || fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD) ) {221 } elseif ( ! $pattern || self::pattern_matches( $pattern, $path, $base ) ) { 212 222 $files[] = array( 'path' => $path, 'type' => 'file', 'size' => $item->getSize(), 'extension' => strtolower( $item->getExtension() ) ); 213 223 } -
vibe-ai/tags/1.20.3/includes/class-wpvibe-draft-theme.php
r3709843 r3730994 20 20 */ 21 21 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 } 22 119 23 120 /** … … 135 232 } 136 233 234 list( $parked, $stuck ) = self::park_draft_rows( $draft_slug, $active_slug ); 235 137 236 WPVibe_Change_Tracker::mark( array( 138 237 'summary' => 'Draft theme created', … … 144 243 $message = sprintf( 145 244 /* 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 isa 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' ), 147 246 $stale 148 247 ) . ' ' . $message; 248 } 249 if ( $parked || $stuck ) { 250 $message .= ' ' . self::parked_note( $parked, $stuck, $active_slug ); 149 251 } 150 252 $response = array( … … 441 543 } 442 544 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 ); 446 549 } ); 447 550 } 448 551 449 private function publish_locked( ) {552 private function publish_locked( $saved = null ) { 450 553 if ( get_option( 'wpvibe_draft_theme' ) && ! WPVibe_Draft_Lock::valid_draft() ) { 451 554 return WPVibe_Draft_Lock::conflict(); … … 473 576 return self::missing_error(); 474 577 } 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 475 586 // Unfiltered: WPVibe_Preview filters 'stylesheet' to the draft slug when a 476 587 // preview token is present, and rolling back onto the draft would … … 608 719 switch_theme( $source_slug ); 609 720 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 610 739 // Publish replaced live templates in place; switch_theme's action only 611 740 // reaches engines that hook it (LiteSpeed, WP Rocket). Flush the rest … … 622 751 $health = WPVibe_PHP_Guard::render_health_check(); 623 752 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 } 625 759 return new WP_Error( 626 760 'publish_rolled_back', … … 637 771 // Cleanup. 638 772 $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(); 641 775 self::record_draft_event( 'published' ); 642 643 // The cookie still points at a now-deleted draft slug; clear it so644 // the next admin request doesn't re-enter a nonexistent preview.645 if ( class_exists( 'WPVibe_Preview' ) ) {646 WPVibe_Preview::clear_cookie();647 }648 776 649 777 WPVibe_Change_Tracker::mark( array( … … 654 782 ) ); 655 783 656 return rest_ensure_response( array( 657 'status' => 'published', 784 $message = $had_live 658 785 /* 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 ) ); 663 1033 } 664 1034 … … 735 1105 $url = add_query_arg( 'wpvibe_preview', $token, home_url( '/' ) ); 736 1106 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 ) ) ); 741 1114 } 742 1115 … … 789 1162 } 790 1163 1164 $source = (string) get_option( 'wpvibe_draft_source' ); 1165 list( $parked, $stuck ) = '' !== $source ? self::park_draft_rows( $draft_slug, $source ) : array( array(), array() ); 791 1166 self::clear_draft_records(); 792 1167 self::record_draft_event( 'deleted' ); … … 797 1172 ) ); 798 1173 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 } 799 1184 return rest_ensure_response( array( 800 1185 '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, 808 1187 ) ); 809 1188 } -
vibe-ai/tags/1.20.3/includes/class-wpvibe-file-ops.php
r3711469 r3730994 662 662 } 663 663 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 664 709 /** 665 710 * List all files in the draft theme, optionally filtered by glob pattern. … … 694 739 695 740 // 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 ) ) { 697 742 continue; 698 743 } -
vibe-ai/tags/1.20.3/includes/class-wpvibe-rest.php
r3719193 r3730994 499 499 'args' => array( 500 500 'expected_source_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ), 501 'saved_customizations' => array( 'type' => 'string', 'enum' => array( 'set_aside', 'keep' ) ), 501 502 ), 502 503 ) ); … … 1502 1503 } 1503 1504 $draft = new WPVibe_Draft_Theme(); 1504 return $draft->publish( );1505 return $draft->publish( $request ? $request->get_param( 'saved_customizations' ) : null ); 1505 1506 } 1506 1507 -
vibe-ai/tags/1.20.3/includes/cli/trait-wpvibe-cli-db.php
r3726341 r3730994 73 73 } 74 74 $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 ) ) { 76 77 return preg_replace( '/\\\\(["\\\\])/', '$1', $m[1] ); 77 78 } -
vibe-ai/tags/1.20.3/readme.txt
r3726341 r3730994 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.20. 27 Stable tag: 1.20.3 8 8 License: GPL-2.0-or-later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 221 221 222 222 == 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. 223 229 224 230 = 1.20.2 = … … 358 364 * 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. 359 365 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 381 366 = Older versions = 382 367 WP.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 3 3 * Plugin Name: WPVibe – Connect Your Site to Claude, ChatGPT & AI Assistants 4 4 * 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. 25 * Version: 1.20.3 6 6 * Author: SeedProd 7 7 * Author URI: https://wpvibe.ai … … 15 15 defined( 'ABSPATH' ) || exit; 16 16 17 define( 'WPVIBE_VERSION', '1.20. 2' );17 define( 'WPVIBE_VERSION', '1.20.3' ); 18 18 define( 'WPVIBE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 19 19 define( 'WPVIBE_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); -
vibe-ai/trunk/includes/class-wpvibe-content-files.php
r3698878 r3730994 181 181 } 182 182 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 183 192 public function list_files( $pattern = null, $directory = '' ) { 184 193 $full = $this->resolve( $directory, true ); … … 186 195 return $full; 187 196 } 197 $base = trim( (string) $directory, '/' ); 188 198 $stack = array( array( $full, $directory ) ); 189 199 $files = array(); … … 209 219 if ( $is_dir ) { 210 220 $stack[] = array( $item->getPathname(), $path ); 211 } elseif ( ! $pattern || fnmatch( $pattern, $path, FNM_PATHNAME | FNM_CASEFOLD) ) {221 } elseif ( ! $pattern || self::pattern_matches( $pattern, $path, $base ) ) { 212 222 $files[] = array( 'path' => $path, 'type' => 'file', 'size' => $item->getSize(), 'extension' => strtolower( $item->getExtension() ) ); 213 223 } -
vibe-ai/trunk/includes/class-wpvibe-draft-theme.php
r3709843 r3730994 20 20 */ 21 21 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 } 22 119 23 120 /** … … 135 232 } 136 233 234 list( $parked, $stuck ) = self::park_draft_rows( $draft_slug, $active_slug ); 235 137 236 WPVibe_Change_Tracker::mark( array( 138 237 'summary' => 'Draft theme created', … … 144 243 $message = sprintf( 145 244 /* 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 isa 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' ), 147 246 $stale 148 247 ) . ' ' . $message; 248 } 249 if ( $parked || $stuck ) { 250 $message .= ' ' . self::parked_note( $parked, $stuck, $active_slug ); 149 251 } 150 252 $response = array( … … 441 543 } 442 544 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 ); 446 549 } ); 447 550 } 448 551 449 private function publish_locked( ) {552 private function publish_locked( $saved = null ) { 450 553 if ( get_option( 'wpvibe_draft_theme' ) && ! WPVibe_Draft_Lock::valid_draft() ) { 451 554 return WPVibe_Draft_Lock::conflict(); … … 473 576 return self::missing_error(); 474 577 } 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 475 586 // Unfiltered: WPVibe_Preview filters 'stylesheet' to the draft slug when a 476 587 // preview token is present, and rolling back onto the draft would … … 608 719 switch_theme( $source_slug ); 609 720 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 610 739 // Publish replaced live templates in place; switch_theme's action only 611 740 // reaches engines that hook it (LiteSpeed, WP Rocket). Flush the rest … … 622 751 $health = WPVibe_PHP_Guard::render_health_check(); 623 752 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 } 625 759 return new WP_Error( 626 760 'publish_rolled_back', … … 637 771 // Cleanup. 638 772 $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(); 641 775 self::record_draft_event( 'published' ); 642 643 // The cookie still points at a now-deleted draft slug; clear it so644 // the next admin request doesn't re-enter a nonexistent preview.645 if ( class_exists( 'WPVibe_Preview' ) ) {646 WPVibe_Preview::clear_cookie();647 }648 776 649 777 WPVibe_Change_Tracker::mark( array( … … 654 782 ) ); 655 783 656 return rest_ensure_response( array( 657 'status' => 'published', 784 $message = $had_live 658 785 /* 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 ) ); 663 1033 } 664 1034 … … 735 1105 $url = add_query_arg( 'wpvibe_preview', $token, home_url( '/' ) ); 736 1106 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 ) ) ); 741 1114 } 742 1115 … … 789 1162 } 790 1163 1164 $source = (string) get_option( 'wpvibe_draft_source' ); 1165 list( $parked, $stuck ) = '' !== $source ? self::park_draft_rows( $draft_slug, $source ) : array( array(), array() ); 791 1166 self::clear_draft_records(); 792 1167 self::record_draft_event( 'deleted' ); … … 797 1172 ) ); 798 1173 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 } 799 1184 return rest_ensure_response( array( 800 1185 '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, 808 1187 ) ); 809 1188 } -
vibe-ai/trunk/includes/class-wpvibe-file-ops.php
r3711469 r3730994 662 662 } 663 663 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 664 709 /** 665 710 * List all files in the draft theme, optionally filtered by glob pattern. … … 694 739 695 740 // 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 ) ) { 697 742 continue; 698 743 } -
vibe-ai/trunk/includes/class-wpvibe-rest.php
r3719193 r3730994 499 499 'args' => array( 500 500 'expected_source_hash' => array( 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$' ), 501 'saved_customizations' => array( 'type' => 'string', 'enum' => array( 'set_aside', 'keep' ) ), 501 502 ), 502 503 ) ); … … 1502 1503 } 1503 1504 $draft = new WPVibe_Draft_Theme(); 1504 return $draft->publish( );1505 return $draft->publish( $request ? $request->get_param( 'saved_customizations' ) : null ); 1505 1506 } 1506 1507 -
vibe-ai/trunk/includes/cli/trait-wpvibe-cli-db.php
r3726341 r3730994 73 73 } 74 74 $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 ) ) { 76 77 return preg_replace( '/\\\\(["\\\\])/', '$1', $m[1] ); 77 78 } -
vibe-ai/trunk/readme.txt
r3726341 r3730994 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1.20. 27 Stable tag: 1.20.3 8 8 License: GPL-2.0-or-later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 221 221 222 222 == 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. 223 229 224 230 = 1.20.2 = … … 358 364 * 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. 359 365 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 381 366 = Older versions = 382 367 WP.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 3 3 * Plugin Name: WPVibe – Connect Your Site to Claude, ChatGPT & AI Assistants 4 4 * 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. 25 * Version: 1.20.3 6 6 * Author: SeedProd 7 7 * Author URI: https://wpvibe.ai … … 15 15 defined( 'ABSPATH' ) || exit; 16 16 17 define( 'WPVIBE_VERSION', '1.20. 2' );17 define( 'WPVIBE_VERSION', '1.20.3' ); 18 18 define( 'WPVIBE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); 19 19 define( 'WPVIBE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)