├── includes ├── classes │ ├── .gitkeep │ ├── BlockCatalogTaxonomy.php │ ├── ToolsPage.php │ ├── PostFinder.php │ ├── RESTSupport.php │ ├── CatalogExporter.php │ ├── CatalogBuilder.php │ └── CatalogCommand.php ├── utility.php └── core.php ├── assets ├── images │ ├── icon-128.png │ ├── icon-192.png │ └── icon-512.png ├── css │ └── admin │ │ └── tools-style.css └── js │ └── admin │ ├── indexer.js │ └── tools.js ├── languages └── block-catalog.pot ├── block-catalog.php ├── autoload.php └── readme.txt /includes/classes/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/block-catalog/HEAD/assets/images/icon-128.png -------------------------------------------------------------------------------- /assets/images/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/block-catalog/HEAD/assets/images/icon-192.png -------------------------------------------------------------------------------- /assets/images/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/10up/block-catalog/HEAD/assets/images/icon-512.png -------------------------------------------------------------------------------- /assets/css/admin/tools-style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * BlockCatalog - Admin only styles 3 | */ 4 | 5 | :root { 6 | --index-progress-bg: hsl(0deg 0% 93.3%); 7 | --index-progress-shadow: hsla(0deg 0% 0% / 25%); 8 | --index-progress-value: hsl(206.9deg 67.8% 41.4%); 9 | } 10 | 11 | .index-progress-bar { 12 | background-color: var(--index-progress-bg); 13 | border-radius: 2px; 14 | box-shadow: 0 2px 5px var(--index-progress-shadow) inset; 15 | width: 300px; 16 | } 17 | 18 | .index-progress-bar::-webkit-progress-value { 19 | background-color: var(--index-progress-value); 20 | } 21 | -------------------------------------------------------------------------------- /languages/block-catalog.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: BlockCatalog\n" 4 | "POT-Creation-Date: 2015-03-03T12:53:58.231Z\n" 5 | "PO-Revision-Date: 2015-03-03T12:53:58.231Z\n" 6 | "Last-Translator: 10up \n" 7 | "Language-Team: \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "X-Poedit-KeywordsList: __;_e;__ngettext:1,2;_n:1,2;__ngettext_noop:1,2;" 12 | "_n_noop:1,2;_x:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_ex:1,2c;" 13 | "esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" 14 | "X-Poedit-Basepath: .\n" 15 | "X-Poedit-SearchPath-0: ..\n" 16 | -------------------------------------------------------------------------------- /block-catalog.php: -------------------------------------------------------------------------------- 1 | disable(); // disconnects the wp action hooks that trigger indexing jobs 53 | } 54 | 55 | if ( ! defined( 'WP_IMPORTING' ) ) { 56 | define( 'WP_IMPORTING', true ); 57 | } 58 | 59 | if ( ! defined( 'DOING_AUTOSAVE' ) ) { 60 | define( 'DOING_AUTOSAVE', true ); 61 | } 62 | } 63 | 64 | /** 65 | * Stop bulk operation global updates 66 | * 67 | * @props VIP 68 | */ 69 | function stop_bulk_operation() { 70 | wp_defer_term_counting( false ); 71 | } 72 | 73 | /** 74 | * Clear object caches to avoid oom errors 75 | * 76 | * @props VIP 77 | */ 78 | function clear_caches() { 79 | global $wpdb; 80 | 81 | $wpdb->queries = array(); 82 | 83 | // Clear runtime cache to prevent out of memory errors during bulk operations 84 | // Use wp_cache_supports() to check for runtime flush capability (WordPress 6.0+) 85 | if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_runtime' ) ) { 86 | wp_cache_flush_runtime(); 87 | } else { 88 | // Fallback to wp_cache_flush() for older WordPress versions or implementations without runtime support 89 | wp_cache_flush(); 90 | } 91 | } 92 | 93 | /** 94 | * Returns the list of post types supported by the BlockCatalog plugin 95 | * 96 | * @return array 97 | */ 98 | function get_supported_post_types() { 99 | $post_types = get_post_types( 100 | [ 101 | 'show_in_rest' => true, 102 | '_builtin' => false, 103 | ] 104 | ); 105 | 106 | /** 107 | * List of other misc post types that don't need indexing. 108 | */ 109 | $excluded_post_types = [ 110 | // Core 111 | 'wp_navigation', 112 | 113 | // Jetpack 114 | 'feedback', 115 | 'jp_pay_order', 116 | 'jp_pay_product', 117 | 118 | // Distributor 119 | 'dt_subscription', 120 | ]; 121 | 122 | $post_types = array_diff( $post_types, $excluded_post_types ); 123 | $post_types = array_merge( $post_types, [ 'post', 'page' ] ); 124 | 125 | /** 126 | * Filters the post types supported by the block catalog plugin. 127 | * 128 | * @param array $options Default post types 129 | * @return array New list of post types 130 | */ 131 | $post_types = apply_filters( 132 | 'block_catalog_post_types', 133 | $post_types, 134 | ); 135 | 136 | return $post_types; 137 | } 138 | 139 | /** 140 | * Returns the capability name required to manage block catalogs 141 | * 142 | * @return string 143 | */ 144 | function get_required_capability() { 145 | /** 146 | * Filters the capability name required to use the block catalog plugin. 147 | * 148 | * @param string $cap The capability name 149 | * @return string The new capability name 150 | */ 151 | return apply_filters( 'block_catalog_capability', 'edit_posts' ); 152 | } 153 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | prefixes[ $prefix ] ) === false ) { 52 | $this->prefixes[ $prefix ] = array(); 53 | } 54 | 55 | // retain the base directory for the namespace prefix 56 | if ( $prepend ) { 57 | array_unshift( $this->prefixes[ $prefix ], $base_dir ); 58 | } else { 59 | array_push( $this->prefixes[ $prefix ], $base_dir ); 60 | } 61 | } 62 | 63 | /** 64 | * Loads the class file for a given class name. 65 | * 66 | * @param string $class The fully-qualified class name. 67 | * @return mixed The mapped file name on success, or boolean false on 68 | * failure. 69 | */ 70 | public function load_class( $class ) { 71 | // the current namespace prefix 72 | $prefix = $class; 73 | 74 | // work backwards through the namespace names of the fully-qualified 75 | // class name to find a mapped file name 76 | while ( false !== $pos = strrpos( $prefix, '\\' ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition 77 | 78 | // retain the trailing namespace separator in the prefix 79 | $prefix = substr( $class, 0, $pos + 1 ); 80 | 81 | // the rest is the relative class name 82 | $relative_class = substr( $class, $pos + 1 ); 83 | 84 | // try to load a mapped file for the prefix and relative class 85 | $mapped_file = $this->load_mapped_file( $prefix, $relative_class ); 86 | if ( $mapped_file ) { 87 | return $mapped_file; 88 | } 89 | 90 | // remove the trailing namespace separator for the next iteration 91 | // of strrpos() 92 | $prefix = rtrim( $prefix, '\\' ); 93 | } 94 | 95 | // never found a mapped file 96 | return false; 97 | } 98 | 99 | /** 100 | * Load the mapped file for a namespace prefix and relative class. 101 | * 102 | * @param string $prefix The namespace prefix. 103 | * @param string $relative_class The relative class name. 104 | * @return mixed Boolean false if no mapped file can be loaded, or the 105 | * name of the mapped file that was loaded. 106 | */ 107 | protected function load_mapped_file( $prefix, $relative_class ) { 108 | // are there any base directories for this namespace prefix? 109 | if ( isset( $this->prefixes[ $prefix ] ) === false ) { 110 | return false; 111 | } 112 | 113 | // look through base directories for this namespace prefix 114 | foreach ( $this->prefixes[ $prefix ] as $base_dir ) { 115 | 116 | // replace the namespace prefix with the base directory, 117 | // replace namespace separators with directory separators 118 | // in the relative class name, append with .php 119 | $file = $base_dir . str_replace( '\\', '/', $relative_class ) . '.php'; 120 | 121 | // if the mapped file exists, require it 122 | if ( $this->require_file( $file ) ) { 123 | // yes, we're done 124 | return $file; 125 | } 126 | } 127 | 128 | // never found it 129 | return false; 130 | } 131 | 132 | /** 133 | * If a file exists, require it from the file system. 134 | * 135 | * @param string $file The file to require. 136 | * @return bool True if the file exists, false if not. 137 | */ 138 | protected function require_file( $file ) { 139 | if ( file_exists( $file ) ) { 140 | require $file; 141 | return true; 142 | } 143 | return false; 144 | } 145 | } 146 | 147 | // instantiate the loader 148 | $block_catalog_loader = new \BlockCatalog\Psr4AutoloaderClass(); 149 | 150 | // register the autoloader 151 | $block_catalog_loader->register(); 152 | 153 | // register the base directories for the namespace prefix 154 | $block_catalog_loader->add_namespace( 'BlockCatalog', __DIR__ . '/includes/classes' ); 155 | -------------------------------------------------------------------------------- /includes/classes/BlockCatalogTaxonomy.php: -------------------------------------------------------------------------------- 1 | get_name() to get the taxonomy's slug. 49 | * @return bool 50 | */ 51 | public function register() { 52 | \register_taxonomy( 53 | $this->get_name(), 54 | \BlockCatalog\Utility\get_supported_post_types(), 55 | $this->get_options() 56 | ); 57 | 58 | /** 59 | * Filters the availability of the block catalog filter on the post listing screen. 60 | * 61 | * @param bool $enabled The enabled state 62 | * @return bool The new enabled state 63 | */ 64 | if ( apply_filters( 'block_catalog_filter_enabled', true ) ) { 65 | add_action( 'restrict_manage_posts', [ $this, 'render_block_catalog_filter' ], 10000 ); 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * Get the options for the taxonomy. 73 | * 74 | * @return array 75 | */ 76 | public function get_options() { 77 | $options = array( 78 | 'labels' => $this->get_labels(), 79 | 'hierarchical' => true, 80 | 'show_ui' => true, 81 | 'show_admin_column' => true, 82 | 'query_var' => true, 83 | 'show_in_rest' => false, 84 | 'public' => false, 85 | ); 86 | 87 | /** 88 | * Filters the options for the block catalog taxonomy. 89 | * 90 | * @param array $options Default taxonomy options. 91 | * @param string $name Taxonomy name. 92 | * @return array The new taxonomy options 93 | */ 94 | $options = apply_filters( 'block_catalog_taxonomy_options', $options, $this->get_name() ); 95 | 96 | return $options; 97 | } 98 | 99 | /** 100 | * Get the labels for the taxonomy. 101 | * 102 | * @return array 103 | */ 104 | public function get_labels() { 105 | $plural_label = $this->get_plural_label(); 106 | $singular_label = $this->get_singular_label(); 107 | 108 | // phpcs:disable 109 | $labels = array( 110 | 'name' => $plural_label, // Already translated via get_plural_label(). 111 | 'singular_name' => $singular_label, // Already translated via get_singular_label(). 112 | 'search_items' => sprintf( __( 'Search %s', 'block-catalog' ), $plural_label ), 113 | 'popular_items' => sprintf( __( 'Popular %s', 'block-catalog' ), $plural_label ), 114 | 'all_items' => sprintf( __( 'All %s', 'block-catalog' ), $plural_label ), 115 | 'edit_item' => sprintf( __( 'Edit %s', 'block-catalog' ), $singular_label ), 116 | 'update_item' => sprintf( __( 'Update %s', 'block-catalog' ), $singular_label ), 117 | 'add_new_item' => sprintf( __( 'Add New %s', 'block-catalog' ), $singular_label ), 118 | 'new_item_name' => sprintf( __( 'New %s Name', 'block-catalog' ), $singular_label ), 119 | 'separate_items_with_commas' => sprintf( __( 'Separate %s with commas', 'block-catalog' ), strtolower( $plural_label ) ), 120 | 'add_or_remove_items' => sprintf( __( 'Add or remove %s', 'block-catalog' ), strtolower( $plural_label ) ), 121 | 'choose_from_most_used' => sprintf( __( 'Choose from the most used %s', 'block-catalog' ), strtolower( $plural_label ) ), 122 | 'not_found' => sprintf( __( 'No %s found.', 'block-catalog' ), strtolower( $plural_label ) ), 123 | 'not_found_in_trash' => sprintf( __( 'No %s found in Trash.', 'block-catalog' ), strtolower( $plural_label ) ), 124 | 'view_item' => sprintf( __( 'View %s', 'block-catalog' ), $singular_label ), 125 | ); 126 | // phpcs:enable 127 | 128 | return $labels; 129 | } 130 | 131 | /** 132 | * Outputs the Block catalog post listing dropdown 133 | */ 134 | public function render_block_catalog_filter() { 135 | global $typenow; 136 | 137 | if ( ! empty( $typenow ) && is_object_in_taxonomy( $typenow, BLOCK_CATALOG_TAXONOMY ) ) { 138 | $selection = isset( $_GET['block-catalog'] ) ? sanitize_text_field( $_GET['block-catalog'] ) : ''; // phpcs:ignore 139 | 140 | $dropdown_args = [ 141 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 142 | 'name' => 'block-catalog', 143 | 'value_field' => 'slug', 144 | 'selected' => $selection, 145 | 'orderby' => 'name', 146 | 'hierarchical' => true, 147 | 'hide_empty' => 0, 148 | 'hide_if_empty' => false, 149 | 'show_option_all' => __( 'All Blocks', 'block-catalog' ), 150 | 'aria_describedby' => 'parent-description', 151 | ]; 152 | 153 | /** 154 | * Filters the block catalog taxonomy filter dropdown options. 155 | * 156 | * @param array $dropdown_args The dropdown filter options 157 | * @return array The new dropdown filter options 158 | */ 159 | $dropdown_args = apply_filters( 'block_catalog_filter_dropdown_args', $dropdown_args ); 160 | 161 | wp_dropdown_categories( $dropdown_args ); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /includes/classes/ToolsPage.php: -------------------------------------------------------------------------------- 1 | slug, 46 | [ $this, 'render' ] 47 | ); 48 | } 49 | 50 | /** 51 | * Outputs the menu page contents. 52 | */ 53 | public function render() { 54 | $post_types = \BlockCatalog\Utility\get_supported_post_types(); 55 | ?> 56 |

57 | 58 | 62 | 63 | 65 | 66 |
67 | 68 |

69 | 70 |

71 | 72 |
73 | 74 | labels->singular_name; 83 | ?> 84 |

85 | 89 |

90 | 91 | 92 |

93 | 94 | 95 |

96 |
97 | 98 | 106 | 107 | 115 | 116 | 121 | 122 | 123 | 124 | get_settings() ); 135 | } 136 | 137 | /** 138 | * Returns the settings to send to the tools page. 139 | * 140 | * @return array 141 | */ 142 | public function get_settings() { 143 | return [ 144 | 'settings' => [ 145 | /** 146 | * Filters the number of posts indexed by the block catalog plugin per REST request. 147 | * 148 | * @param int $batch_size The batch size 149 | * @return int The new batch size 150 | */ 151 | 'index_batch_size' => apply_filters( 'block_catalog_index_batch_size', 50 ), 152 | /** 153 | * The number of terms deleted by the block catalog plugin per REST request. 154 | * 155 | * @param int $batch_size The batch size 156 | * @return int The new batch size 157 | */ 158 | 'delete_index_batch_size' => apply_filters( 'block_catalog_delete_index_batch_size', 2 ), 159 | 'posts_endpoint' => rest_url( 'block-catalog/v1/posts' ), 160 | 'index_endpoint' => rest_url( 'block-catalog/v1/index' ), 161 | 'terms_endpoint' => rest_url( 'block-catalog/v1/terms' ), 162 | 'delete_index_endpoint' => rest_url( 'block-catalog/v1/delete-index' ), 163 | 'catalog_page' => admin_url( 'edit-tags.php?taxonomy=' . BLOCK_CATALOG_TAXONOMY ), 164 | ], 165 | ]; 166 | } 167 | 168 | /** 169 | * Add the action links to the plugin page. 170 | * 171 | * @param array $links The Action links for the plugin. 172 | * @return array Modified action links to include custom link. 173 | */ 174 | public function filter_plugin_action_links( $links ) { 175 | 176 | if ( ! is_array( $links ) ) { 177 | return $links; 178 | } 179 | 180 | return array_merge( 181 | array( 182 | 'index-posts' => sprintf( 183 | ' %s ', 184 | esc_url( admin_url( 'tools.php?page=block-catalog-tools' ) ), 185 | esc_html__( 'Index Posts', 'block-catalog' ) 186 | ), 187 | ), 188 | $links 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /includes/classes/PostFinder.php: -------------------------------------------------------------------------------- 1 | is_indexed() ) { 30 | return new \WP_Error( 31 | 'not-indexed', 32 | __( 'Block Catalog index is empty, please index the site first.', 'block-catalog' ) 33 | ); 34 | } 35 | 36 | $slugs = $this->get_tax_query_terms( $blocks ); 37 | 38 | if ( empty( $slugs ) ) { 39 | return []; 40 | } 41 | 42 | $query_params = [ 43 | 'post_type' => ! empty( $opts['post_type'] ) ? $opts['post_type'] : \BlockCatalog\Utility\get_supported_post_types(), 44 | 'post_status' => ! empty( $opts['post_status'] ) ? $opts['post_status'] : 'any', 45 | 'posts_per_page' => ! empty( $opts['posts_per_page'] ) ? $opts['posts_per_page'] : 10, 46 | 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query 47 | [ 48 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 49 | 'field' => 'slug', 50 | 'terms' => $slugs, 51 | 'operator' => ! empty( $opts['operator'] ) ? $opts['operator'] : 'IN', 52 | ], 53 | ], 54 | ]; 55 | 56 | $query = new \WP_Query( $query_params ); 57 | $posts = $query->posts; 58 | 59 | return $posts; 60 | } 61 | 62 | /** 63 | * Find posts that have a specific block on a multisite network. 64 | * 65 | * @param array $sites Sites to search. 66 | * @param array $blocks Blocks to search for. 67 | * @param array $opts Options for the search. 68 | * @return array 69 | */ 70 | public function find_on_network( $sites = [], $blocks = [], $opts = [] ) { 71 | $found_posts = []; 72 | 73 | foreach ( $sites as $blog_id ) { 74 | switch_to_blog( $blog_id ); 75 | 76 | $found_on_site = $this->find( $blocks, $opts ); 77 | 78 | $result = [ 79 | 'blog_id' => $blog_id, 80 | 'blog_url' => get_site_url( $blog_id ), 81 | 'posts' => ! is_wp_error( $found_on_site ) ? $found_on_site : [], 82 | ]; 83 | 84 | if ( is_wp_error( $found_on_site ) ) { 85 | $result['error'] = $found_on_site; 86 | } 87 | 88 | $found_posts[] = $result; 89 | 90 | restore_current_blog(); 91 | } 92 | 93 | return $found_posts; 94 | } 95 | 96 | /** 97 | * Count posts that have a specific block. 98 | * 99 | * @param array $blocks Blocks to search for. 100 | * @param array $opts Options for the search. 101 | * @return int Total number of posts. 102 | */ 103 | public function count( $blocks = [], $opts = [] ) { 104 | $slugs = $this->get_tax_query_terms( $blocks ); 105 | 106 | if ( empty( $slugs ) ) { 107 | return 0; 108 | } 109 | 110 | $query_params = [ 111 | 'post_type' => ! empty( $opts['post_type'] ) ? $opts['post_type'] : \BlockCatalog\Utility\get_supported_post_types(), 112 | 'post_status' => ! empty( $opts['post_status'] ) ? $opts['post_status'] : 'any', 113 | 'tax_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query 114 | [ 115 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 116 | 'field' => 'slug', 117 | 'terms' => $slugs, 118 | 'operator' => ! empty( $opts['operator'] ) ? $opts['operator'] : 'IN', 119 | ], 120 | ], 121 | ]; 122 | 123 | $query = new \WP_Query( $query_params ); 124 | 125 | return $query->found_posts; 126 | } 127 | 128 | /** 129 | * Count posts that have a specific block on a multisite network. 130 | * 131 | * @param array $sites Sites to search. 132 | * @param array $blocks Blocks to search for. 133 | * @param array $opts Options for the search. 134 | * @return int Total number of posts. 135 | */ 136 | public function count_on_network( $sites = [], $blocks = [], $opts = [] ) { 137 | $found_posts = []; 138 | 139 | foreach ( $sites as $blog_id ) { 140 | switch_to_blog( $blog_id ); 141 | 142 | $found_on_site = $this->count( $blocks, $opts ); 143 | $found_posts[] = [ 144 | 'blog_id' => $blog_id, 145 | 'blog_url' => get_site_url( $blog_id ), 146 | 'count' => $found_on_site, 147 | ]; 148 | 149 | restore_current_blog(); 150 | } 151 | 152 | return $found_posts; 153 | } 154 | 155 | /** 156 | * Converts the search query terms like 'core/block' into slugs like 'core-block'. 157 | * 158 | * @param array $args Query terms. 159 | * @return array 160 | */ 161 | public function get_tax_query_terms( $args = [] ) { 162 | $slugs = []; 163 | 164 | foreach ( $args as $index => $arg ) { 165 | $slug = sanitize_title( $arg ); 166 | $slug_term = get_term_by( 'slug', $slug, BLOCK_CATALOG_TAXONOMY ); 167 | 168 | /** 169 | * Filters the slug term for a block query. 170 | * 171 | * @param string|false $slug The slug for the block. 172 | * @param string $arg The original argument. 173 | * @param WP_Term|false $slug_term The term for the slug. 174 | * @return string|false 175 | */ 176 | $slug_term = apply_filters( 'block_catalog_block_query_slug', $slug_term, $arg ); 177 | 178 | if ( false !== $slug_term ) { 179 | $slugs[] = $slug; 180 | } 181 | } 182 | 183 | $slugs = array_values( $slugs ); 184 | $slugs = array_unique( $slugs ); 185 | 186 | return $slugs; 187 | } 188 | 189 | /** 190 | * Checks if the Block Catalog taxonomy is indexed. 191 | * 192 | * @return bool 193 | */ 194 | public function is_indexed() { 195 | $catalog_terms = wp_count_terms( 196 | [ 197 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 198 | 'hide_empty' => false, 199 | ] 200 | ); 201 | 202 | return ! empty( $catalog_terms ); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /assets/js/admin/indexer.js: -------------------------------------------------------------------------------- 1 | class Indexer extends EventTarget { 2 | load(opts) { 3 | this.progress = 0; 4 | this.total = 0; 5 | this.triggerEvent('loadStart'); 6 | 7 | const fetchOpts = { 8 | url: opts.endpoint, 9 | method: 'POST', 10 | data: { 11 | post_types: opts.postTypes || [], 12 | }, 13 | ...opts, 14 | }; 15 | 16 | return this.apiFetch(fetchOpts) 17 | .then((res) => { 18 | if (res.errors) { 19 | this.triggerEvent('loadError', res); 20 | } else if (!res.success && res.data) { 21 | this.triggerEvent('loadError', res); 22 | } else if (res?.posts === undefined || res?.posts.length === 0) { 23 | this.triggerEvent('loadError', { 24 | code: 'invalid_response', 25 | message: 'Server returned empty posts.', 26 | }); 27 | } else { 28 | this.triggerEvent('loadComplete', res); 29 | } 30 | 31 | return res; 32 | }) 33 | .catch((err) => { 34 | this.triggerEvent('loadError', err); 35 | }); 36 | } 37 | 38 | async index(ids, opts) { 39 | this.progress = 0; 40 | this.completed = 0; 41 | this.failures = 0; 42 | this.total = ids.length; 43 | this.triggerEvent('indexStart', { progress: 0, total: this.total }); 44 | 45 | const chunks = this.toChunks(ids, opts.batchSize || 50); 46 | const n = chunks.length; 47 | 48 | for (let i = 0; i < n; i++) { 49 | const batch = chunks[i]; 50 | try { 51 | await this.indexBatch(batch, opts); // eslint-disable-line no-await-in-loop 52 | } catch (err) { 53 | this.failures += batch.length; 54 | this.progress += batch.length; 55 | 56 | this.triggerEvent('indexProgress', { 57 | progress: this.progress, 58 | total: this.total, 59 | }); 60 | 61 | this.triggerEvent('indexError', err); 62 | } 63 | } 64 | 65 | this.triggerEvent('indexComplete', { 66 | progress: this.progress, 67 | total: this.total, 68 | completed: this.completed, 69 | failures: this.failures, 70 | }); 71 | } 72 | 73 | async indexBatch(batch, opts = {}) { 74 | const fetchOpts = { 75 | url: opts.endpoint, 76 | method: 'POST', 77 | data: { 78 | post_ids: batch, 79 | }, 80 | ...opts, 81 | }; 82 | 83 | const promise = this.apiFetch(fetchOpts); 84 | 85 | promise.then((res) => { 86 | if (res.errors) { 87 | this.failures += batch.length; 88 | this.triggerEvent('indexError', res); 89 | } else if (!res.success && res.data) { 90 | this.failures += batch.length; 91 | this.triggerEvent('indexError', res); 92 | } else if (res?.updated === undefined) { 93 | this.failures += batch.length; 94 | this.triggerEvent('indexError', { 95 | code: 'invalid_response', 96 | message: 'Failed to index some posts', 97 | }); 98 | } else { 99 | this.completed += batch.length; 100 | } 101 | 102 | this.progress += batch.length; 103 | this.triggerEvent('indexProgress', { 104 | progress: this.progress, 105 | total: this.total, 106 | ...res, 107 | }); 108 | }); 109 | 110 | return promise; 111 | } 112 | 113 | cancel() { 114 | this.cancelPending(); 115 | this.triggerEvent('indexCancel', { 116 | progress: this.progress, 117 | total: this.total, 118 | }); 119 | } 120 | 121 | loadTerms(opts) { 122 | this.progress = 0; 123 | this.total = 0; 124 | this.triggerEvent('loadTermsStart'); 125 | 126 | const fetchOpts = { 127 | url: opts.endpoint, 128 | method: 'POST', 129 | data: {}, 130 | ...opts, 131 | }; 132 | 133 | return this.apiFetch(fetchOpts) 134 | .then((res) => { 135 | if (res.errors) { 136 | this.triggerEvent('loadTermsError', res); 137 | } else if (!res.success && res.data) { 138 | this.triggerEvent('loadTermsError', res); 139 | } else if (res?.terms === undefined || res?.terms.length === 0) { 140 | this.triggerEvent('loadTermsError', { 141 | code: 'invalid_response', 142 | message: 'Server returned empty terms.', 143 | ...res, 144 | }); 145 | } else { 146 | this.triggerEvent('loadTermsComplete', res); 147 | } 148 | 149 | return res; 150 | }) 151 | .catch((err) => { 152 | this.triggerEvent('loadTermsError', err); 153 | }); 154 | } 155 | 156 | async deleteIndex(opts) { 157 | this.progress = 0; 158 | this.completed = 0; 159 | this.failures = 0; 160 | this.total = 0; 161 | this.triggerEvent('deleteIndexStart'); 162 | 163 | const fetchOpts = { 164 | url: opts.endpoint, 165 | method: 'POST', 166 | }; 167 | 168 | const promise = this.apiFetch(fetchOpts) 169 | .then((res) => { 170 | if (res.errors) { 171 | this.triggerEvent('deleteIndexError', res); 172 | } else if (!res.success && res.data) { 173 | this.triggerEvent('deleteIndexError', res); 174 | } else { 175 | this.triggerEvent('deleteIndexComplete', res); 176 | } 177 | 178 | return res; 179 | }) 180 | .catch((err) => { 181 | this.triggerEvent('deleteIndexError', err); 182 | }); 183 | 184 | return promise; 185 | } 186 | 187 | cancelDelete() { 188 | this.cancelPending(); 189 | this.triggerEvent('deleteIndexCancel'); 190 | } 191 | 192 | triggerEvent(eventName, data = {}) { 193 | const event = new CustomEvent(eventName, { detail: data }); 194 | this.dispatchEvent(event); 195 | } 196 | 197 | toChunks(list, chunkSize = 50) { 198 | const first = list.shift(); 199 | const output = []; 200 | 201 | for (let i = 0; i < list.length; i += chunkSize) { 202 | output.push(list.slice(i, i + chunkSize)); 203 | } 204 | 205 | output.unshift([first]); 206 | 207 | return output; 208 | } 209 | 210 | cancelPending() { 211 | if (this.pending?.length) { 212 | for (let i = 0; i < this.pending.length; i++) { 213 | const promise = this.pending[i]; 214 | promise.cancelled = true; 215 | } 216 | } 217 | 218 | this.pending = []; 219 | } 220 | 221 | apiFetch(opts) { 222 | if (!this.pending) { 223 | this.pending = []; 224 | } 225 | 226 | this.cancelPending(); 227 | 228 | const promise = this.apiFetchWithCancel(opts); 229 | this.pending.push(promise); 230 | 231 | return promise; 232 | } 233 | 234 | apiFetchWithCancel(opts) { 235 | const request = wp.apiFetch(opts); 236 | const wrapper = new Promise((resolve, reject) => { 237 | request 238 | .then((res) => { 239 | if (wrapper?.cancelled) { 240 | return; 241 | } 242 | 243 | resolve(res); 244 | }) 245 | .catch((err) => { 246 | if (wrapper?.cancelled) { 247 | return; 248 | } 249 | 250 | reject(err); 251 | }); 252 | }); 253 | 254 | wrapper.request = request; 255 | 256 | return wrapper; 257 | } 258 | } 259 | 260 | export default Indexer; 261 | -------------------------------------------------------------------------------- /includes/core.php: -------------------------------------------------------------------------------- 1 | register(); 40 | 41 | $rest_support = new RESTSupport(); 42 | $rest_support->register(); 43 | 44 | do_action( 'block_catalog_plugin_loaded' ); 45 | } 46 | 47 | /** 48 | * Registers the default textdomain. 49 | * 50 | * @return void 51 | */ 52 | function i18n() { 53 | /** 54 | * Filters the plugin locale 55 | * 56 | * @param string $locale The plugin locale 57 | * @param string $slug The plugin slug 58 | */ 59 | $locale = apply_filters( 'plugin_locale', get_locale(), 'block-catalog' ); 60 | load_textdomain( 'block-catalog', WP_LANG_DIR . '/block-catalog/block-catalog-' . $locale . '.mo' ); 61 | load_plugin_textdomain( 'block-catalog', false, plugin_basename( BLOCK_CATALOG_PLUGIN_PATH ) . '/languages/' ); 62 | } 63 | 64 | /** 65 | * Initializes the plugin and fires an action other plugins can hook into. 66 | * 67 | * @return void 68 | */ 69 | function init() { 70 | do_action( 'block_catalog_plugin_init' ); 71 | 72 | $block_catalog_taxonomy = new BlockCatalogTaxonomy(); 73 | $block_catalog_taxonomy->register(); 74 | } 75 | 76 | /** 77 | * Updates the block catalog for the specified post. 78 | * 79 | * @param int $post_id The post id 80 | */ 81 | function update_post_block_catalog( $post_id ) { 82 | $supported = \BlockCatalog\Utility\get_supported_post_types(); 83 | $post_type = get_post_type( $post_id ); 84 | 85 | if ( ! in_array( $post_type, $supported, true ) ) { 86 | return; 87 | } 88 | 89 | if ( wp_is_post_revision( $post_id ) ) { 90 | return; 91 | } 92 | 93 | $builder = new CatalogBuilder(); 94 | $builder->catalog( $post_id ); 95 | } 96 | 97 | /** 98 | * Activate the plugin 99 | * 100 | * @return void 101 | */ 102 | function activate() { 103 | // First load the init scripts in case any rewrite functionality is being loaded 104 | init(); 105 | flush_rewrite_rules(); 106 | } 107 | 108 | /** 109 | * Displays a notice message if not indexed. 110 | */ 111 | function render_index_notice() { 112 | $notice_shown = filter_var( get_option( 'block_catalog_notice_shown' ), FILTER_VALIDATE_BOOLEAN ); 113 | 114 | if ( $notice_shown ) { 115 | return; 116 | } 117 | 118 | update_option( 'block_catalog_notice_shown', 1 ); 119 | 120 | ?> 121 |
122 |

123 | 124 | Index Now', 'block-catalog' ), esc_url( admin_url( 'tools.php?page=block-catalog-tools' ) ) ) ); ?> 125 |

126 |
127 | get_data( $handle, 'script_execution' ); 224 | 225 | if ( ! $script_execution ) { 226 | return $tag; 227 | } 228 | 229 | if ( 'async' !== $script_execution && 'defer' !== $script_execution ) { 230 | return $tag; 231 | } 232 | 233 | // Abort adding async/defer for scripts that have this script as a dependency. _doing_it_wrong()? 234 | foreach ( wp_scripts()->registered as $script ) { 235 | if ( in_array( $handle, $script->deps, true ) ) { 236 | return $tag; 237 | } 238 | } 239 | 240 | // Add the attribute if it hasn't already been added. 241 | if ( ! preg_match( ":\s$script_execution(=|>|\s):", $tag ) ) { 242 | $tag = preg_replace( ':(?=>):', " $script_execution", $tag, 1 ); 243 | } 244 | 245 | return $tag; 246 | } 247 | -------------------------------------------------------------------------------- /includes/classes/RESTSupport.php: -------------------------------------------------------------------------------- 1 | 'POST', 40 | 'callback' => [ $this, 'get_posts' ], 41 | 'permission_callback' => function () { 42 | return current_user_can( \BlockCatalog\Utility\get_required_capability() ); 43 | }, 44 | 'args' => [ 45 | 'post_types' => [ 46 | 'required' => false, 47 | 'type' => 'array', 48 | 'validate_callback' => [ $this, 'validate_post_types' ], 49 | ], 50 | ], 51 | ] 52 | ); 53 | 54 | register_rest_route( 55 | 'block-catalog/v1', 56 | '/index/', 57 | [ 58 | 'methods' => 'POST', 59 | 'callback' => [ $this, 'index' ], 60 | 'permission_callback' => function () { 61 | return current_user_can( \BlockCatalog\Utility\get_required_capability() ); 62 | }, 63 | 'args' => [ 64 | 'post_ids' => [ 65 | 'required' => true, 66 | 'type' => 'array', 67 | 'validate_callback' => [ $this, 'validate_post_ids' ], 68 | ], 69 | ], 70 | ] 71 | ); 72 | 73 | register_rest_route( 74 | 'block-catalog/v1', 75 | '/terms/', 76 | [ 77 | 'methods' => 'POST', 78 | 'callback' => [ $this, 'get_terms' ], 79 | 'permission_callback' => function () { 80 | return current_user_can( \BlockCatalog\Utility\get_required_capability() ); 81 | }, 82 | ] 83 | ); 84 | 85 | register_rest_route( 86 | 'block-catalog/v1', 87 | '/delete-index/', 88 | [ 89 | 'methods' => 'POST', 90 | 'callback' => [ $this, 'delete_index' ], 91 | 'permission_callback' => function () { 92 | return current_user_can( \BlockCatalog\Utility\get_required_capability() ); 93 | }, 94 | ] 95 | ); 96 | } 97 | 98 | /** 99 | * Returns the list of block catalog terms 100 | * 101 | * @return array 102 | */ 103 | public function get_terms() { 104 | $term_opts = [ 105 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 106 | 'hide_empty' => false, 107 | ]; 108 | 109 | $terms = get_terms( $term_opts ); 110 | 111 | if ( empty( $terms ) ) { 112 | return [ 'terms' => [] ]; 113 | } 114 | 115 | $output = []; 116 | 117 | foreach ( $terms as $term ) { 118 | $output[] = [ 119 | 'id' => intval( $term->term_id ), 120 | 'slug' => $term->slug, 121 | 'name' => $term->name, 122 | 'count' => $term->count, 123 | ]; 124 | } 125 | 126 | return [ 'terms' => $output ]; 127 | } 128 | 129 | /** 130 | * Deletes the Block catalog index. 131 | * 132 | * @return array 133 | */ 134 | public function delete_index() { 135 | \BlockCatalog\Utility\start_bulk_operation(); 136 | 137 | $updated = 0; 138 | $errors = 0; 139 | $builder = new CatalogBuilder(); 140 | 141 | $result = $builder->delete_index( [ 'bulk' => true ] ); 142 | $updated = $result['removed']; 143 | $errors = $result['errors']; 144 | 145 | \BlockCatalog\Utility\stop_bulk_operation(); 146 | 147 | return [ 148 | 'removed' => $updated, 149 | 'errors' => $errors, 150 | ]; 151 | } 152 | 153 | /** 154 | * Returns the list of post ids to be indexed. 155 | * 156 | * @param \WP_REST_Request $request The request object 157 | * @return array 158 | */ 159 | public function get_posts( $request ) { 160 | \BlockCatalog\Utility\start_bulk_operation(); 161 | 162 | $query_params = $this->get_posts_to_index_query( $request ); 163 | $query_params['posts_per_page'] = 1; 164 | 165 | $count_query = new \WP_Query( $query_params ); 166 | $total = $count_query->found_posts; 167 | 168 | /** 169 | * Filters the number of posts fetched in the paginated ids query. 170 | * 171 | * @param int $page_size The page size 172 | * @return int The new page size 173 | */ 174 | $page_size = apply_filters( 'block_catalog_posts_to_index_page_size', 500 ); 175 | $total_pages = ceil( $total / $page_size ); 176 | 177 | $query_params['posts_per_page'] = $page_size; // phpcs:ignore 178 | 179 | if ( empty( $total ) ) { 180 | return [ 'posts' => [] ]; 181 | } 182 | 183 | $results = []; 184 | 185 | for ( $i = 0; $i < $total_pages; $i++ ) { 186 | $query_params['paged'] = $i + 1; 187 | 188 | $query = new \WP_Query( $query_params ); 189 | $posts = $query->posts; 190 | 191 | if ( ! empty( $posts ) ) { 192 | $results = array_merge( $results, $posts ); 193 | } 194 | 195 | \BlockCatalog\Utility\clear_caches(); 196 | } 197 | 198 | \BlockCatalog\Utility\stop_bulk_operation(); 199 | 200 | return [ 'posts' => $results ]; 201 | } 202 | 203 | /** 204 | * Returns the WP Query params used to fetch the posts to index. 205 | * 206 | * @param \WP_REST_Request $request The request object 207 | * @return array 208 | */ 209 | public function get_posts_to_index_query( $request ) { 210 | $post_types = $request->get_param( 'post_types' ); 211 | 212 | if ( empty( $post_types ) ) { 213 | $post_types = \BlockCatalog\Utility\get_supported_post_types(); 214 | } 215 | 216 | $query_params = [ 217 | 'post_type' => $post_types, 218 | 'post_status' => 'any', 219 | 'fields' => 'ids', 220 | ]; 221 | 222 | /** 223 | * Filters the query params used to lookup the posts to index. 224 | * 225 | * @param array $query_params The query params 226 | * @param \WP_REST_Request $request The rest request object 227 | * @return array The new query params 228 | */ 229 | return apply_filters( 'block_catalog_posts_to_index_query_params', $query_params, $request ); 230 | } 231 | 232 | /** 233 | * Indexes the blocks in the specified posts. 234 | * 235 | * @param \WP_REST_Request $request The request object 236 | * @return bool 237 | */ 238 | public function index( $request ) { 239 | \BlockCatalog\Utility\start_bulk_operation(); 240 | 241 | $post_ids = $request->get_param( 'post_ids' ); 242 | $updated = 0; 243 | $errors = 0; 244 | $builder = new CatalogBuilder(); 245 | 246 | foreach ( $post_ids as $post_id ) { 247 | $result = $builder->catalog( $post_id ); 248 | 249 | \BlockCatalog\Utility\clear_caches(); 250 | 251 | if ( is_wp_error( $result ) ) { 252 | ++$errors; 253 | } else { 254 | $updated += count( $result ); 255 | } 256 | } 257 | 258 | \BlockCatalog\Utility\stop_bulk_operation(); 259 | 260 | return [ 261 | 'updated' => $updated, 262 | 'errors' => $errors, 263 | ]; 264 | } 265 | 266 | /** 267 | * Verifies the post type argument matches the supported list. 268 | * 269 | * @param array $post_types The post types list 270 | * @return bool 271 | */ 272 | public function validate_post_types( $post_types ) { 273 | $supported = \BlockCatalog\Utility\get_supported_post_types(); 274 | 275 | if ( empty( $post_types ) ) { 276 | return true; 277 | } 278 | 279 | foreach ( $post_types as $post_type ) { 280 | if ( ! in_array( $post_type, $supported, true ) ) { 281 | return false; 282 | } 283 | } 284 | 285 | return true; 286 | } 287 | 288 | /** 289 | * Validates the specified post ids. 290 | * 291 | * @param array $post_ids The post ids to validate 292 | * @return bool 293 | */ 294 | public function validate_post_ids( $post_ids ) { 295 | if ( empty( $post_ids ) ) { 296 | return true; 297 | } 298 | 299 | $post_ids = array_map( 'intval', $post_ids ); 300 | $post_ids = array_filter( $post_ids ); 301 | 302 | return ! empty( $post_ids ); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /includes/classes/CatalogExporter.php: -------------------------------------------------------------------------------- 1 | is_output_writable( dirname( $output ) ) ) { 31 | return new \WP_Error( 'output_not_writable', __( 'The output path is not writable', 'block-catalog' ) ); 32 | } 33 | 34 | $terms = $this->get_block_catalog_terms(); 35 | 36 | // check for WP_Error 37 | if ( is_wp_error( $terms ) ) { 38 | return [ 39 | 'success' => false, 40 | 'message' => $terms->get_error_message(), 41 | ]; 42 | } 43 | 44 | if ( empty( $terms ) ) { 45 | return array( 46 | 'success' => false, 47 | 'message' => __( 'No terms found', 'block-catalog' ), 48 | ); 49 | } 50 | 51 | $total_posts = $this->get_total_posts( $terms, $opts ); 52 | 53 | $this->put_csv( array( 'block_name', 'block_slug', 'post_id', 'post_type', 'post_title', 'permalink', 'status' ) ); 54 | 55 | // when running in WP CLI mode, there is a progress bar 56 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 57 | $progress = \WP_CLI\Utils\make_progress_bar( "Exporting catalog usage for $total_posts posts ...", $total_posts ); 58 | $opts['progress'] = $progress; 59 | } 60 | 61 | foreach ( $terms as $term ) { 62 | if ( $this->can_export_term( $term, $opts ) ) { 63 | $this->export_term( $term, $opts ); 64 | } 65 | } 66 | 67 | $result = $this->flush_csv( $output ); 68 | 69 | if ( ! $result ) { 70 | return array( 71 | 'success' => false, 72 | 'message' => __( 'Failed to write to output file', 'block-catalog' ), 73 | ); 74 | } 75 | 76 | return array( 77 | 'success' => true, 78 | 'message' => __( 'Exported successfully', 'block-catalog' ), 79 | 'total_posts' => $total_posts, 80 | ); 81 | } 82 | 83 | /** 84 | * Retrieves all terms associated with the 'block_catalog' taxonomy. 85 | * 86 | * @return array List of WP_Term objects. 87 | */ 88 | private function get_block_catalog_terms() { 89 | return get_terms( 90 | array( 91 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 92 | 'hide_empty' => false, 93 | ) 94 | ); 95 | } 96 | 97 | /** 98 | * Gets the total number of posts associated with the given terms. 99 | * 100 | * @param array $terms List of WP_Term objects. 101 | * @param array $opts Options for the query. 102 | * @return int Total post count. 103 | */ 104 | private function get_total_posts( $terms, $opts ) { 105 | $total = 0; 106 | 107 | foreach ( $terms as $term ) { 108 | $query_args = $this->get_query_args( $term->slug, $opts ); 109 | $query_args['fields'] = 'ids'; // Only retrieve post IDs 110 | $query = new \WP_Query( $query_args ); 111 | $total += $query->post_count; 112 | } 113 | return $total; 114 | } 115 | 116 | /** 117 | * Exports the posts associated with a specific term to the CSV file. 118 | * 119 | * @param WP_Term $term The term to export. 120 | * @param array $opts Options for the export. 121 | */ 122 | private function export_term( $term, $opts ) { 123 | $query_args = $this->get_query_args( $term->slug, $opts ); 124 | $query = new \WP_Query( $query_args ); 125 | $posts = $query->posts; 126 | $total = count( $posts ); 127 | 128 | for ( $i = 0; $i < $total; $i++ ) { 129 | $post = $posts[ $i ]; 130 | 131 | $this->put_csv( 132 | [ 133 | $term->name, 134 | $term->slug, 135 | $post->ID, 136 | $post->post_type, 137 | $post->post_title, 138 | get_permalink( $post ), 139 | $post->post_status, 140 | ] 141 | ); 142 | 143 | if ( 0 === $i % 100 ) { 144 | \BlockCatalog\Utility\clear_caches(); 145 | } 146 | 147 | if ( ! empty( $opts['progress'] ) ) { 148 | // tick the progress bar if it exists. 149 | $opts['progress']->tick(); 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * Constructs the query arguments for retrieving posts associated with a term slug. 156 | * 157 | * @param string $term_slug The slug of the term. 158 | * @param array $opts Options for the query. 159 | * @return array Query arguments. 160 | */ 161 | private function get_query_args( $term_slug, $opts ) { 162 | return array( 163 | 'post_type' => isset( $opts['post_type'] ) ? $opts['post_type'] : get_post_types( array( 'public' => true ) ), 164 | 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query 165 | array( 166 | 'taxonomy' => BLOCK_CATALOG_TAXONOMY, 167 | 'field' => 'slug', 168 | 'terms' => $term_slug, 169 | ), 170 | ), 171 | 'posts_per_page' => isset( $opts['posts_per_block'] ) ? intval( $opts['posts_per_block'] ) : -1, 172 | ); 173 | } 174 | 175 | /** 176 | * Lazy initializes the wp filesystem. 177 | * 178 | * @return \WP_Filesystem_Base The filesystem object. 179 | */ 180 | private function get_wp_filesystem() { 181 | global $wp_filesystem; 182 | 183 | if ( ! $wp_filesystem ) { 184 | require_once ABSPATH . 'wp-admin/includes/file.php'; 185 | WP_Filesystem(); 186 | } 187 | 188 | return $wp_filesystem; 189 | } 190 | 191 | /** 192 | * Checks if the output path is writable. 193 | * 194 | * @param string $output The path to the output file. 195 | * @return bool True if the path is writable, false otherwise. 196 | */ 197 | private function is_output_writable( $output ) { 198 | $filesystem = $this->get_wp_filesystem(); 199 | return $filesystem->is_writable( $output ); 200 | } 201 | 202 | /** 203 | * Add a row to the CSV file buffer. 204 | * 205 | * @param array $row The row to write. 206 | */ 207 | private function put_csv( $row ) { 208 | $this->csv_buffer[] = $row; 209 | } 210 | 211 | /** 212 | * Flush the CSV buffer to the output file. 213 | * 214 | * @param string $output_path The path to the output file. 215 | * @return bool True if the buffer was flushed successfully, false otherwise. 216 | */ 217 | private function flush_csv( $output_path ) { 218 | $filesystem = $this->get_wp_filesystem(); 219 | $output = ''; 220 | 221 | foreach ( $this->csv_buffer as $row ) { 222 | $row = array_map( [ $this, 'esc_csv' ], $row ); 223 | $output .= implode( ',', $row ) . "\n"; 224 | } 225 | 226 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 227 | \WP_CLI::log( "Writing to $output_path ..." ); 228 | } 229 | 230 | return $filesystem->put_contents( $output_path, $output ); 231 | } 232 | 233 | /** 234 | * Escapes a string for inclusion in a CSV file. 235 | * 236 | * This function adds quotes only if necessary (if the string contains commas, quotes, or newlines). 237 | * It escapes double quotes by doubling them. 238 | * 239 | * @param string $data The input string to be escaped. 240 | * @return string The escaped string. 241 | */ 242 | private function esc_csv( $data ) { 243 | $has_quotes = false !== strpos( $data, '"' ); 244 | $has_commas = false !== strpos( $data, ',' ); 245 | 246 | if ( $has_quotes || $has_commas ) { 247 | $data = str_replace( '"', '""', $data ); 248 | $data = '"' . $data . '"'; 249 | } 250 | 251 | return $data; 252 | } 253 | 254 | /** 255 | * Checks if a term can be exported. Ignores top-level terms by default. 256 | * 257 | * @param WP_Term $term The term to check. 258 | * @param array $opts Options for the export. 259 | * @return bool True if the term can be exported, false otherwise. 260 | */ 261 | private function can_export_term( $term, $opts ) { 262 | $ignore_parent = isset( $opts['ignore_parent'] ) ? $opts['ignore_parent'] : true; 263 | $ignore_parent = filter_var( $ignore_parent, FILTER_VALIDATE_BOOLEAN ); 264 | 265 | // if don't ignore top level terms, no need to check further 266 | if ( ! $ignore_parent ) { 267 | return true; 268 | } 269 | 270 | // if the term is a top-level term, ignore it 271 | if ( 0 === $term->parent ) { 272 | return false; 273 | } 274 | 275 | // if the term is not a top-level term, export it 276 | return true; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Block Catalog === 2 | Contributors: 10up, dsawardekar, dkotter, jeffpaul 3 | Tags: gutenberg, developer, blocks, custom blocks 4 | Tested up to: 6.8 5 | Stable tag: 1.6.2 6 | License: GPL-2.0-or-later 7 | License URI: https://spdx.org/licenses/GPL-2.0-or-later.html 8 | 9 | Keep track of which Gutenberg Blocks are used across your site. 10 | 11 | == Description == 12 | 13 | * Find which blocks are used across your site. 14 | * Fully Integrated with the WordPress Admin. 15 | * Use filters to see Posts that use a specific block. 16 | * Find Posts that use Reusable Blocks. 17 | * Use the WP CLI to quickly find blocks from the command line. 18 | * Use custom WordPress filters to extend the Block Catalog. 19 | 20 | [Fork on GitHub](https://github.com/10up/block-catalog) 21 | 22 | == Screenshots == 23 | 24 | 1. The Block Catalog indexing page. You need to index your content first. 25 | 2. The Blocks found by the plugin on your site. 26 | 3. The Blocks for each post can be seen on the post listing page. 27 | 4. You can filter the post listing to a specific Block using this dropdown. 28 | 29 | == Getting Started == 30 | 31 | 1. On activation, the plugin will prompt you to index your content. You need to do this first before you will be able to see the various blocks used on your site. You can also go to *WP-Admin > Tools > Block Catalog* to do this yourself. Alternately, you can run the WP CLI command `wp block-catalog index` to index your content from the command line. 32 | 33 | 2. Once indexed, you will be able to see the different blocks used on your site in the Block Catalog Taxonomy. 34 | 35 | 3. Navigating to any Block Editor post type will also show you the list of blocks present in a post. 36 | 37 | 4. You can also filter the listing to only show Posts that have a specific block. 38 | 39 | == Frequently Asked Questions == 40 | 41 | = 1) Why does the Plugin require indexing? = 42 | 43 | Block Catalog uses a taxonomy to store the data about blocks used across a site. The plugin can build this index via the Tools > Block Catalog screen or via the WP CLI `wp block-catalog index`. After the initial index, the data is automatically kept in sync after any content updates. 44 | 45 | = 2) Why does the name displayed in the plugin use the blockName attribute instead of the title? = 46 | 47 | If your blocks are registered on the Backend with the old [register_block_type](https://developer.wordpress.org/reference/functions/register_block_type/) API, you may be missing the `title` attribute. The newer [register_block_type_from_metadata](https://developer.wordpress.org/reference/functions/register_block_type_from_metadata/) uses the same `block.json` on the FE and BE which includes the Block title. 48 | 49 | When the plugin detects such a missing `title`, it uses the `blockName` suffix instead. eg:- xyz/custom-block will display as Custom Block. 50 | 51 | To address this you need to update your custom block registration. If this is outside your control, you can also use the `block_catalog_block_title` filter hook to [override the title as seen here](https://gist.github.com/dsawardekar/676d0d4c5d7f688351e199fdc54484d6). 52 | 53 | == Changelog == 54 | 55 | = 1.6.2 - 2025-02-03 = 56 | * **Changed:** Bump WordPress "tested up to" version 6.7 (props [@thrijith](https://github.com/thrijith), [@jeffpaul](https://github.com/jeffpaul), [@Sidsector9](https://github.com/Sidsector9) via [#74](https://github.com/10up/block-catalog/pull/74), [#75](https://github.com/10up/block-catalog/pull/75)). 57 | * **Security:** Bump `webpack` from 5.91.0 to 5.94.0 (props [@dependabot](https://github.com/apps/dependabot), [@peterwilsoncc](https://github.com/peterwilsoncc) via [#68](https://github.com/10up/block-catalog/pull/68)). 58 | * **Security:** Bump `serve-static` from 1.15.0 to 1.16.2 and `express` from 4.19.2 to 4.21.1 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#70](https://github.com/10up/block-catalog/pull/70)). 59 | * **Security:** Bump `cookie` from 0.6.0 to 0.7.1 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#76](https://github.com/10up/block-catalog/pull/76)). 60 | 61 | = 1.6.1 - 2024-07-09 = 62 | * **Changed:** Update [Support Level](https://github.com/10up/block-catalog/blob/develop/README.md#support-level) from `Beta` to `Stable` (props [@jeffpaul](https://github.com/jeffpaul), [@dkotter](https://github.com/dkotter) via [#56](https://github.com/10up/block-catalog/pull/56)). 63 | * **Changed:** Bump WordPress "tested up to" version 6.6 (props [@sudip-md](https://github.com/sudip-md), [@jeffpaul](https://github.com/jeffpaul) via [#60](https://github.com/10up/block-catalog/pull/60)). 64 | * **Changed:** Bump WordPress minimum supported version to 6.4 (props [@sudip-md](https://github.com/sudip-md), [@jeffpaul](https://github.com/jeffpaul) via [#60](https://github.com/10up/block-catalog/pull/60)). 65 | * **Security:** Bump `braces` from 3.0.2 to 3.0.3 (props [@dependabot](https://github.com/apps/dependabot), [@faisal-alvi](https://github.com/faisal-alvi) via [#58](https://github.com/10up/block-catalog/pull/58)). 66 | * **Security:** Bump `ws` from 7.5.9 to 7.5.10 (props [@dependabot](https://github.com/apps/dependabot), [@faisal-alvi](https://github.com/faisal-alvi) via [#58](https://github.com/10up/block-catalog/pull/58)). 67 | 68 | = 1.6.0 - 2024-05-13 = 69 | * **Added:** WP-CLI command, `export`, to generate a CSV of the block catalog (props [@dsawardekar](https://github.com/dsawardekar), [@psorensen](https://github.com/psorensen), [@Sidsector9](https://github.com/Sidsector9) via [#52](https://github.com/10up/block-catalog/pull/52)). 70 | * **Added:** Classic Editor block detection (props [@dsawardekar](https://github.com/dsawardekar), [@Sidsector9](https://github.com/Sidsector9) via [#53](https://github.com/10up/block-catalog/pull/53)). 71 | * **Changed:** Bump WordPress "tested up to" version 6.5 (props [@sudip-md](https://github.com/sudip-md), [@jeffpaul](https://github.com/jeffpaul) via [#51](https://github.com/10up/block-catalog/pull/51)). 72 | * **Changed:** Bump WordPress minimum from 5.7 to 6.3 (props [@sudip-md](https://github.com/sudip-md), [@jeffpaul](https://github.com/jeffpaul) via [#51](https://github.com/10up/block-catalog/pull/51)). 73 | * **Changed:** Replaced [lee-dohm/no-response](https://github.com/lee-dohm/no-response) with [actions/stale](https://github.com/actions/stale) to help with closing no-response/stale issues (props [@jeffpaul](https://github.com/jeffpaul) via [#48](https://github.com/10up/block-catalog/pull/48)). 74 | * **Security:** Bump `express` from 4.18.2 to 4.19.2 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#50](https://github.com/10up/block-catalog/pull/50)). 75 | * **Security:** Bump `follow-redirects` from 1.15.5 to 1.15.6 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#50](https://github.com/10up/block-catalog/pull/50)). 76 | * **Security:** Bump `postcss` from 7.0.39 to 8.4.33 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#50](https://github.com/10up/block-catalog/pull/50)). 77 | * **Security:** Bump `10up-toolkit` from 5.2.3 to 6.0.1 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#50](https://github.com/10up/block-catalog/pull/50)). 78 | * **Security:** Bump `webpack-dev-middleware` from 5.3.3 to 5.3.4 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#50](https://github.com/10up/block-catalog/pull/50)). 79 | 80 | = 1.5.4 - 2024-02-29 = 81 | * **Added:** Support for the WordPress.org plugin preview (props [@dkotter](https://github.com/dkotter), [@jeffpaul](https://github.com/jeffpaul) via [#38](https://github.com/10up/block-catalog/pull/38)). 82 | * **Changed:** Significantly improved performance of block catalog reset on larger WordPress installations (props [@dsawardekar](https://github.com/dsawardekar), [@Sidsector9](https://github.com/Sidsector9) via [#41](https://github.com/10up/block-catalog/pull/41)). 83 | * **Changed:** Clean up NPM dependencies and update the minimum node version to 20 (props [@Sidsector9](https://github.com/Sidsector9), [@dsawardekar](https://github.com/dsawardekar) via [#43](https://github.com/10up/block-catalog/pull/43)). 84 | * **Security:** Bump `tj-actions/changed-files` from 39 to 41 (props [@dependabot](https://github.com/apps/dependabot), [@peterwilsoncc](https://github.com/peterwilsoncc) via [#39](https://github.com/10up/block-catalog/pull/39)). 85 | * **Security:** Bump `follow-redirects` from 1.15.2 to 1.15.4 (props [@dependabot](https://github.com/apps/dependabot), [@Sidsector9](https://github.com/Sidsector9) via [#40](https://github.com/10up/block-catalog/pull/40)). 86 | 87 | = 1.5.3 - 2023-11-23 = 88 | * **Fixed:** PHP 8.2 deprecation warnings (props [@dsawardekar](https://github.com/dsawardekar), [@ravinderk](https://github.com/ravinderk) via [#34](https://github.com/10up/block-catalog/pull/34)). 89 | * **Added:** PHPUnit 9.x support (props [@dsawardekar](https://github.com/dsawardekar), [@ravinderk](https://github.com/ravinderk) via [#34](https://github.com/10up/block-catalog/pull/34)). 90 | * **Security:** Bump `sharp` from 0.32.3 to 0.32.6 (props [@dependabot](https://github.com/apps/dependabot), [@faisal-alvi](https://github.com/faisal-alvi) via [#32](https://github.com/10up/block-catalog/pull/32)). 91 | 92 | = 1.5.2 - 2023-11-16 = 93 | * **Changed:** Bump WordPress "tested up to" version to 6.4 (props [@qasumitbagthariya](https://github.com/qasumitbagthariya), [@jeffpaul](https://github.com/jeffpaul) via [#28](https://github.com/10up/block-catalog/pull/28), [#29](https://github.com/10up/block-catalog/pull/29)). 94 | 95 | = 1.5.1 - 2023-10-24 = 96 | **Note that this release changes the name of the base plugin file. As such, you'll probably need to reactivate the plugin after updating.** 97 | 98 | * **Added:** Add our standard GitHub Action automations (props [@jeffpaul](https://github.com/jeffpaul), [@dsawardekar](https://github.com/dsawardekar), [@dkotter](https://github.com/dkotter) via [#10](https://github.com/10up/block-catalog/pull/10), [#20](https://github.com/10up/block-catalog/pull/20), [#22](https://github.com/10up/block-catalog/pull/22), [#23](https://github.com/10up/block-catalog/pull/23), [#24](https://github.com/10up/block-catalog/pull/24), [#25](https://github.com/10up/block-catalog/pull/25)). 99 | * **Changed:** Update our plugin image assets (props [Brooke Campbell](https://www.linkedin.com/in/brookecampbelldesign/), [@jeffpaul](https://github.com/jeffpaul), [@dsawardekar](https://github.com/dsawardekar), [@faisal-alvi](https://github.com/faisal-alvi) via [#11](https://github.com/10up/block-catalog/pull/11), [#17](https://github.com/10up/block-catalog/pull/17)). 100 | * **Changed:** Updated the main plugin file name (props [@dkotter](https://github.com/dkotter), [@peterwilsoncc](https://github.com/peterwilsoncc), [@dsawardekar](https://github.com/dsawardekar) via [#18](https://github.com/10up/block-catalog/pull/18)). 101 | * **Security:** Bump `@babel/traverse` from 7.22.8 to 7.23.2 (props [@dependabot](https://github.com/apps/dependabot), [@dkotter](https://github.com/dkotter) via [#21](https://github.com/10up/block-catalog/pull/21)). 102 | 103 | = 1.5.0 - 2023-08-11 = 104 | * **Added:** `Beta` Support Level (props [@jeffpaul](https://github.com/jeffpaul), [@dsawardekar](https://github.com/dsawardekar) via [#3](https://github.com/10up/block-catalog/pull/3)). 105 | * **Added:** Adds support for multisite via WP CLI (props [@dsawardekar](https://github.com/dsawardekar), [@Sidsector9](https://github.com/Sidsector9) via [#9](https://github.com/10up/block-catalog/pull/9)). 106 | * **Fixed:** Missing name in the `block_catalog_taxonomy_options` hook (props [@dsawardekar](https://github.com/dsawardekar), [@fabiankaegy](https://github.com/fabiankaegy) via [#6](https://github.com/10up/block-catalog/pull/6)). 107 | 108 | [View historical changelog details here](https://github.com/10up/block-catalog/blob/develop/CHANGELOG.md). 109 | 110 | == Upgrade Notice == 111 | 112 | = 1.6.1 = 113 | Updates the [Support Level](https://github.com/10up/block-catalog/blob/develop/README.md#support-level) from `Beta` to `Stable`. 114 | 115 | = 1.5.1 = 116 | * Note that this release changes the name of the base plugin file. As such, you'll probably need to reactivate the plugin after updating 117 | 118 | -------------------------------------------------------------------------------- /assets/js/admin/tools.js: -------------------------------------------------------------------------------- 1 | import '../../css/admin/tools-style.css'; 2 | import Indexer from './indexer'; 3 | 4 | const { __, sprintf } = wp.i18n; 5 | 6 | class ToolsApp { 7 | constructor(settings) { 8 | this.settings = settings; 9 | } 10 | 11 | enable() { 12 | this.indexer = new Indexer(); 13 | this.state = { status: 'settings', message: '' }; 14 | 15 | this.onIndex('loadStart', 'didLoadStart'); 16 | this.onIndex('loadComplete', 'didLoadComplete'); 17 | this.onIndex('loadError', 'didLoadError'); 18 | 19 | this.onIndex('indexStart', 'didIndexStart'); 20 | this.onIndex('indexProgress', 'didIndexProgress'); 21 | this.onIndex('indexComplete', 'didIndexComplete'); 22 | this.onIndex('indexCancel', 'didIndexCancel'); 23 | this.onIndex('indexError', 'didIndexError'); 24 | 25 | this.onIndex('deleteIndexStart', 'didDeleteIndexStart'); 26 | this.onIndex('deleteIndexProgress', 'didDeleteIndexProgress'); 27 | this.onIndex('deleteIndexComplete', 'didDeleteIndexComplete'); 28 | this.onIndex('deleteIndexError', 'didDeleteIndexError'); 29 | this.onIndex('deleteIndexCancel', 'didDeleteIndexCancel'); 30 | 31 | this.on('.block-catalog-post-type', 'change', 'didPostTypesChange'); 32 | 33 | this.on('#submit', 'click', 'didSubmitClick'); 34 | this.on('#cancel', 'click', 'didCancelClick'); 35 | 36 | this.on('#delete-index', 'click', 'didDeleteIndexClick'); 37 | this.on('#cancel-delete', 'click', 'didDeleteCancelClick'); 38 | } 39 | 40 | setState(state) { 41 | this.prevState = this.state; 42 | this.state = state; 43 | 44 | switch (state.status) { 45 | case 'loading': 46 | this.hide('#index-settings'); 47 | this.show('#index-status'); 48 | this.updateProgress(); 49 | break; 50 | 51 | case 'loaded': 52 | this.hide('#index-settings'); 53 | this.show('#index-status'); 54 | break; 55 | 56 | case 'load-error': 57 | this.show('#index-settings'); 58 | this.hide('#index-status'); 59 | break; 60 | 61 | case 'settings': 62 | this.show('#index-settings'); 63 | this.hide('#index-status'); 64 | break; 65 | 66 | case 'indexing': 67 | this.hide('#index-settings'); 68 | this.show('#index-status'); 69 | this.updateProgress(); 70 | break; 71 | 72 | case 'indexed': 73 | this.hide('#index-settings'); 74 | this.show('#index-status'); 75 | this.updateProgress(); 76 | break; 77 | 78 | case 'cancelled': 79 | this.show('#index-settings'); 80 | this.hide('#index-status'); 81 | break; 82 | 83 | case 'loading-terms': 84 | this.hide('#index-settings'); 85 | this.show('#delete-status'); 86 | this.updateDeleteProgress(); 87 | break; 88 | 89 | case 'loaded-terms': 90 | this.hide('#index-settings'); 91 | this.show('#delete-status'); 92 | break; 93 | 94 | case 'load-terms-error': 95 | this.show('#index-settings'); 96 | this.hide('#delete-status'); 97 | break; 98 | 99 | case 'deleting': 100 | this.hide('#index-settings'); 101 | this.show('#delete-status'); 102 | this.updateDeleteProgress(); 103 | break; 104 | 105 | case 'deleted': 106 | this.show('#index-settings'); 107 | this.hide('#delete-status'); 108 | break; 109 | 110 | case 'delete-error': 111 | this.show('#index-settings'); 112 | this.hide('#delete-status'); 113 | break; 114 | 115 | case 'delete-cancel': 116 | this.show('#index-settings'); 117 | this.hide('#delete-status'); 118 | break; 119 | 120 | default: 121 | break; 122 | } 123 | 124 | this.setMessage(this.state.message || ''); 125 | } 126 | 127 | didLoadStart() { 128 | const message = __('Loading posts to index ...', 'block-catalog'); 129 | 130 | this.setState({ status: 'loading', message }); 131 | this.hideErrors(); 132 | this.setNotice(''); 133 | 134 | window.scrollTo(0, 0); 135 | } 136 | 137 | didLoadComplete(event) { 138 | const message = __('Loaded posts, starting ...', 'block-catalog'); 139 | this.setState({ status: 'loaded', message, ...event.detail }); 140 | 141 | const opts = { 142 | batchSize: this.settings?.index_batch_size, 143 | endpoint: this.settings?.index_endpoint, 144 | }; 145 | 146 | this.indexer.index(this.state.posts, opts); 147 | } 148 | 149 | didLoadError(event) { 150 | const err = event.detail || {}; 151 | 152 | let message = __('Failed to load posts to index.', 'block-catalog'); 153 | 154 | if (err?.message) { 155 | message += ` (${err?.code} - ${err.message})`; 156 | } 157 | 158 | if (err?.data?.message) { 159 | message += ` (${err.data.message})`; 160 | } else if (typeof err?.data === 'string') { 161 | message += ` (${err.data})`; 162 | } 163 | 164 | this.setState({ status: 'load-error', message: '', error: err }); 165 | this.setNotice(message, 'error'); 166 | } 167 | 168 | didIndexStart(event) { 169 | const message = sprintf( 170 | __('Indexing %d / %d Posts ...', 'block-catalog'), 171 | event.detail.progress, 172 | event.detail.total, 173 | ); 174 | 175 | this.setState({ status: 'indexing', message, ...event.detail }); 176 | } 177 | 178 | didIndexProgress(event) { 179 | const message = sprintf( 180 | 'Indexing %d / %d Posts ...', 181 | event.detail.progress, 182 | event.detail.total, 183 | ); 184 | this.setState({ status: 'indexing', message, ...event.detail }); 185 | } 186 | 187 | didIndexComplete(event) { 188 | let message; 189 | let type; 190 | 191 | if (event.detail.failures === 0) { 192 | message = sprintf( 193 | __( 194 | 'Indexed %d / %d Posts Successfully. View Block Catalog', 195 | 'block-catalog', 196 | ), 197 | event.detail.completed, 198 | event.detail.total, 199 | this.settings.catalog_page, 200 | ); 201 | type = 'success'; 202 | } else if (event.detail.failures > 0 && event.detail.completed > 0) { 203 | message = sprintf( 204 | __( 205 | 'Indexed %d Posts successfully with %d Errors. View Block Catalog', 206 | 'block-catalog', 207 | ), 208 | event.detail.completed, 209 | event.detail.failures, 210 | ); 211 | type = 'error'; 212 | } else { 213 | message = sprintf(__('Failed to index %d Posts.', 'block-catalog'), event.detail.total); 214 | type = 'error'; 215 | } 216 | 217 | this.setState({ status: 'settings', message: '', ...event.detail }); 218 | this.setNotice(message, type); 219 | } 220 | 221 | didIndexCancel(event) { 222 | const message = __('Index cancelled.', 'block-catalog'); 223 | this.setState({ status: 'cancelled', message: '', ...event.detail }); 224 | this.setNotice(message, 'error'); 225 | } 226 | 227 | didIndexError(event) { 228 | const err = event.detail || {}; 229 | 230 | let message = __('Failed to index posts', 'block-catalog'); 231 | 232 | if (err?.message) { 233 | message += ` (${err?.code} - ${err.message})`; 234 | } 235 | 236 | if (err?.data?.message) { 237 | message += ` (${err.data.message})`; 238 | } else if (typeof err?.data === 'string') { 239 | message += ` (${err.data})`; 240 | } 241 | 242 | this.addErrorLine(message); 243 | this.updateProgress(); 244 | } 245 | 246 | didDeleteIndexStart(event) { 247 | const message = __('Deleting Index ...', 'block-catalog'); 248 | this.setState({ status: 'deleting', message, ...event.detail }); 249 | 250 | window.scrollTo(0, 0); 251 | } 252 | 253 | didDeleteIndexProgress(event) { 254 | const message = sprintf( 255 | 'Deleting %d / %d Block Catalog Terms ...', 256 | event.detail.progress, 257 | event.detail.total, 258 | ); 259 | this.setState({ status: 'deleting', message, ...event.detail }); 260 | } 261 | 262 | didDeleteIndexComplete(event) { 263 | let message; 264 | 265 | if (event.detail?.failures) { 266 | message = sprintf( 267 | __('Failed to delete %d catalog term(s).', 'block-catalog'), 268 | event.detail?.failures, 269 | ); 270 | 271 | this.setNotice(message, 'error'); 272 | } else if (event.detail?.removed) { 273 | message = sprintf( 274 | __('Deleted %d block catalog term(s) successfully.', 'block-catalog'), 275 | event.detail?.removed, 276 | ); 277 | 278 | this.setNotice(message, 'success'); 279 | } else { 280 | message = __('Nothing to delete, block catalog index is empty.', 'block-catalog'); 281 | this.setNotice(message, 'error'); 282 | } 283 | 284 | this.setState({ status: 'deleted', message: '', ...event.detail }); 285 | } 286 | 287 | didDeleteIndexError(event) { 288 | const err = event.detail || {}; 289 | 290 | let message = __('Failed to delete block catalog terms. ', 'block-catalog'); 291 | 292 | if (err?.message) { 293 | message += ` (${err?.code} - ${err.message})`; 294 | } 295 | 296 | if (err?.data?.message) { 297 | message += ` (${err.data.message})`; 298 | } else if (typeof err?.data === 'string') { 299 | message += ` (${err.data})`; 300 | } 301 | 302 | this.addErrorLine(message); 303 | } 304 | 305 | didDeleteIndexCancel(event) { 306 | const message = __('Deleting Index Cancelled.', 'block-catalog'); 307 | this.setState({ status: 'delete-cancel', message: '', ...event.detail }); 308 | this.setNotice(message, 'error'); 309 | } 310 | 311 | didSubmitClick() { 312 | const opts = { 313 | postTypes: this.getSelectedPostTypes(), 314 | endpoint: this.settings.posts_endpoint, 315 | }; 316 | 317 | this.indexer.load(opts); 318 | return false; 319 | } 320 | 321 | didCancelClick() { 322 | this.indexer.cancel(); 323 | return false; 324 | } 325 | 326 | didDeleteIndexClick() { 327 | const message = __( 328 | 'This will delete all terms in the Block Catalog index. Are you sure?', 329 | 'block-catalog', 330 | ); 331 | const res = confirm(message); // eslint-disable-line 332 | 333 | if (!res) { 334 | return false; 335 | } 336 | 337 | const opts = { 338 | endpoint: this.settings.delete_index_endpoint, 339 | }; 340 | 341 | this.indexer.deleteIndex(opts); 342 | return false; 343 | } 344 | 345 | didDeleteCancelClick() { 346 | this.indexer.cancelDelete(); 347 | return false; 348 | } 349 | 350 | didPostTypesChange() { 351 | this.updateSubmitButton(); 352 | } 353 | 354 | updateSubmitButton() { 355 | const postTypes = this.getSelectedPostTypes(); 356 | const submitButton = document.querySelector('#submit'); 357 | 358 | if (submitButton) { 359 | submitButton.disabled = postTypes.length === 0; 360 | } 361 | } 362 | 363 | getSelectedPostTypes() { 364 | const postTypes = document.querySelectorAll('.block-catalog-post-type:checked'); 365 | 366 | if (!postTypes) { 367 | return []; 368 | } 369 | 370 | return [...postTypes].map((postType) => postType.value); 371 | } 372 | 373 | on(selector, event, handler) { 374 | const elements = document.querySelectorAll(selector); 375 | 376 | if (elements && elements.length) { 377 | elements.forEach((element) => { 378 | element.addEventListener(event, this[handler].bind(this)); 379 | }); 380 | } 381 | } 382 | 383 | onIndex(event, handler) { 384 | this.indexer.addEventListener(event, this[handler].bind(this)); 385 | } 386 | 387 | show(selector) { 388 | const element = document.querySelector(selector); 389 | 390 | if (element) { 391 | element.style.display = 'block'; 392 | } 393 | } 394 | 395 | hide(selector) { 396 | const element = document.querySelector(selector); 397 | 398 | if (element) { 399 | element.style.display = 'none'; 400 | } 401 | } 402 | 403 | setMessage(message) { 404 | const element = document.querySelector('#index-message'); 405 | 406 | if (element) { 407 | element.style.display = message !== '' ? 'block' : 'none'; 408 | element.innerHTML = message; 409 | } 410 | } 411 | 412 | setNotice(message, type = 'success') { 413 | const container = document.querySelector('#index-notice'); 414 | const element = document.querySelector('#index-notice-body'); 415 | 416 | if (container) { 417 | container.style.display = message !== '' ? 'block' : 'none'; 418 | container.className = `notice notice-${type}`; 419 | } 420 | 421 | if (element) { 422 | element.innerHTML = message; 423 | } 424 | } 425 | 426 | updateProgress() { 427 | const percent = this.indexer.total ? (this.indexer.progress / this.indexer.total) * 100 : 0; 428 | 429 | const element = document.querySelector('#index-progress'); 430 | 431 | if (element) { 432 | element.value = percent; 433 | } 434 | } 435 | 436 | updateDeleteProgress() { 437 | const percent = this.indexer.total ? (this.indexer.progress / this.indexer.total) * 100 : 0; 438 | 439 | const element = document.querySelector('#delete-progress'); 440 | 441 | if (element) { 442 | element.value = percent; 443 | } 444 | } 445 | 446 | hideErrors() { 447 | const list = document.querySelector('#index-errors-list'); 448 | 449 | if (list) { 450 | list.innerHTML = ''; 451 | } 452 | 453 | const container = document.querySelector('#index-errors'); 454 | 455 | if (container) { 456 | container.style.display = 'none'; 457 | } 458 | } 459 | 460 | addErrorLine(line) { 461 | const container = document.querySelector('#index-errors'); 462 | const list = document.querySelector('#index-errors-list'); 463 | 464 | if (container) { 465 | container.style.display = 'block'; 466 | } 467 | 468 | if (!list) { 469 | return; 470 | } 471 | 472 | const item = document.createElement('li'); 473 | 474 | item.innerHTML = line; 475 | list.appendChild(item); 476 | } 477 | } 478 | 479 | document.addEventListener('DOMContentLoaded', () => { 480 | const settings = window.block_catalog?.settings || {}; 481 | const app = new ToolsApp(settings); 482 | 483 | app.enable(); 484 | }); 485 | -------------------------------------------------------------------------------- /includes/classes/CatalogBuilder.php: -------------------------------------------------------------------------------- 1 | get_post_block_terms( $post_id, $opts ); 31 | $result = $this->set_post_block_terms( $post_id, $output ); 32 | 33 | return $output; 34 | } catch ( Exception $e ) { 35 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 36 | // translators: %1$d is post_id, %2$s is error message 37 | \WP_CLI::warning( sprintf( __( 'Failed to catalog %1$d - %2$s', 'block-catalog' ), $post_id, $e->getMessage() ) ); 38 | } 39 | 40 | return new \WP_Error( 'catalog_failed', $e->getMessage() ); 41 | } 42 | } 43 | 44 | /** 45 | * Bulk deletes all block catalog terms and their relationships via Direct DB query. 46 | * This is a faster alternative to wp_delete_term() which is slow for large catalogs. 47 | * 48 | * @param array $opts Optional args 49 | * @return array 50 | */ 51 | public function delete_index( $opts = [] ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found 52 | global $wpdb; 53 | 54 | $errors = 0; 55 | $removed = 0; 56 | 57 | // Delete term relationships 58 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery 59 | $term_relationships = $wpdb->query( 60 | $wpdb->prepare( 61 | "DELETE FROM {$wpdb->term_relationships} 62 | WHERE term_taxonomy_id IN ( 63 | SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s 64 | )", 65 | BLOCK_CATALOG_TAXONOMY 66 | ) 67 | ); 68 | 69 | if ( false === $term_relationships ) { 70 | ++$errors; 71 | } 72 | 73 | // Delete term taxonomy 74 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery 75 | $term_taxonomy = $wpdb->query( 76 | $wpdb->prepare( 77 | "DELETE FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s", 78 | BLOCK_CATALOG_TAXONOMY 79 | ) 80 | ); 81 | 82 | if ( false === $term_taxonomy ) { 83 | ++$errors; 84 | } else { 85 | $removed = $term_taxonomy; 86 | } 87 | 88 | // Delete terms 89 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery 90 | $terms = $wpdb->query( 91 | $wpdb->prepare( 92 | "DELETE FROM {$wpdb->terms} 93 | WHERE term_id IN ( 94 | SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s 95 | )", 96 | BLOCK_CATALOG_TAXONOMY 97 | ) 98 | ); 99 | 100 | if ( false === $terms ) { 101 | ++$errors; 102 | } 103 | 104 | // update block catalog term counts = 0 105 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery 106 | $wpdb->query( 107 | $wpdb->prepare( 108 | "UPDATE {$wpdb->term_taxonomy} SET count = 0 WHERE taxonomy = %s", 109 | BLOCK_CATALOG_TAXONOMY 110 | ) 111 | ); 112 | 113 | clean_term_cache( [], BLOCK_CATALOG_TAXONOMY, true ); 114 | 115 | $is_cli = defined( 'WP_CLI' ) && WP_CLI; 116 | 117 | if ( $is_cli ) { 118 | if ( ! empty( $removed ) ) { 119 | /* translators: %d is number of catalog terms removed */ 120 | \WP_CLI::success( sprintf( __( 'Removed %d block catalog term(s).', 'block-catalog' ), $removed ) ); 121 | } else { 122 | \WP_CLI::warning( __( 'No block catalog terms to remove.', 'block-catalog' ) ); 123 | } 124 | 125 | if ( ! empty( $errors ) ) { 126 | // translators: %d is number of catalog terms removed 127 | \WP_CLI::warning( sprintf( 'Failed to remove %d block catalog terms(s).', 'block-catalog' ), $errors ); 128 | } 129 | } 130 | 131 | return [ 132 | 'removed' => $removed, 133 | 'errors' => $errors, 134 | ]; 135 | } 136 | 137 | /** 138 | * Deletes the specified term id and its associations. 139 | * 140 | * @param int $term_id The term id to delete. 141 | */ 142 | public function delete_term_index( $term_id ) { 143 | return wp_delete_term( $term_id, BLOCK_CATALOG_TAXONOMY ); 144 | } 145 | 146 | /** 147 | * Sets the blocks terms of the post. Creates the terms if absent. 148 | * 149 | * @param int $post_id The post id 150 | * @param array $output The block terms & variations 151 | * @return array|WP_Error 152 | */ 153 | public function set_post_block_terms( $post_id, $output ) { 154 | if ( empty( $output['terms'] ) ) { 155 | return wp_set_object_terms( $post_id, [], BLOCK_CATALOG_TAXONOMY ); 156 | } 157 | 158 | $term_ids = []; 159 | 160 | foreach ( $output['terms'] ?? [] as $slug => $label ) { 161 | if ( ! term_exists( $slug, BLOCK_CATALOG_TAXONOMY ) ) { 162 | $term_args = [ 163 | 'slug' => $slug, 164 | ]; 165 | 166 | $parent_id = $this->get_block_parent_term( $slug ); 167 | 168 | if ( ! empty( $parent_id ) ) { 169 | $term_args['parent'] = $parent_id; 170 | $term_ids[] = $parent_id; 171 | } 172 | 173 | $result = wp_insert_term( $label, BLOCK_CATALOG_TAXONOMY, $term_args ); 174 | 175 | if ( ! is_wp_error( $result ) ) { 176 | $term_ids[] = intval( $result['term_id'] ); 177 | } 178 | } else { 179 | $result = get_term_by( 'slug', $slug, BLOCK_CATALOG_TAXONOMY ); 180 | 181 | if ( ! empty( $result ) ) { 182 | $term_ids[] = intval( $result->term_id ); 183 | } 184 | 185 | if ( ! empty( $result->parent ) ) { 186 | $term_ids[] = $result->parent; 187 | } 188 | } 189 | } 190 | 191 | foreach ( $output['variations'] ?? [] as $variation ) { 192 | $block_name = $variation['blockName']; 193 | $terms = $variation['terms'] ?? []; 194 | 195 | if ( empty( $block_name ) || empty( $terms ) ) { 196 | continue; 197 | } 198 | 199 | foreach ( $terms as $label ) { 200 | $slug = $block_name . '-' . $label; 201 | 202 | if ( ! term_exists( $slug, BLOCK_CATALOG_TAXONOMY ) ) { 203 | $term_args = [ 204 | 'slug' => $slug, 205 | ]; 206 | 207 | $parent_id = $this->get_variation_parent_term( $block_name ); 208 | 209 | if ( ! empty( $parent_id ) ) { 210 | $term_args['parent'] = $parent_id; 211 | $term_ids[] = $parent_id; 212 | } 213 | 214 | $result = wp_insert_term( $label, BLOCK_CATALOG_TAXONOMY, $term_args ); 215 | 216 | if ( ! is_wp_error( $result ) ) { 217 | $term_ids[] = intval( $result['term_id'] ); 218 | } 219 | } else { 220 | $result = get_term_by( 'slug', $slug, BLOCK_CATALOG_TAXONOMY ); 221 | 222 | if ( ! empty( $result ) ) { 223 | $term_ids[] = intval( $result->term_id ); 224 | } 225 | 226 | if ( ! empty( $result->parent ) ) { 227 | $term_ids[] = $result->parent; 228 | } 229 | } 230 | } 231 | } 232 | 233 | $term_ids = array_filter( $term_ids ); 234 | $term_ids = array_unique( $term_ids ); 235 | 236 | return wp_set_object_terms( $post_id, $term_ids, BLOCK_CATALOG_TAXONOMY, false ); 237 | } 238 | 239 | /** 240 | * Builds a list of Block Term names for a given post. 241 | * 242 | * @param int $post_id The post id. 243 | * @param array $opts The options. 244 | * @return array 245 | */ 246 | public function get_post_block_terms( $post_id, $opts = [] ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed 247 | if ( empty( $post_id ) ) { 248 | return []; 249 | } 250 | 251 | $content = get_post_field( 'post_content', $post_id ); 252 | 253 | if ( empty( $content ) ) { 254 | return []; 255 | } 256 | 257 | $blocks = parse_blocks( $content ); 258 | 259 | if ( empty( $blocks ) ) { 260 | return []; 261 | } 262 | 263 | $blocks = $this->to_block_list( $blocks ); 264 | 265 | if ( empty( $blocks ) ) { 266 | return []; 267 | } 268 | 269 | $post_terms = []; 270 | $variations = []; 271 | 272 | foreach ( $blocks as $block ) { 273 | $block_terms = $this->block_to_terms( $block ); 274 | 275 | if ( ! empty( $block_terms['terms'] ) ) { 276 | $post_terms = array_replace( $post_terms, $block_terms['terms'] ); 277 | } 278 | 279 | if ( ! empty( $block_terms['variations'] ) ) { 280 | $variations[] = array_merge( 281 | $variations, 282 | [ 283 | 'blockName' => $block['blockName'], 284 | 'block' => $block, 285 | 'terms' => $block_terms['variations'], 286 | ] 287 | ); 288 | } 289 | } 290 | 291 | /** 292 | * Filters the computed list of block terms for a post. 293 | * 294 | * @param array $terms The computed block terms list 295 | * @param int $post_id The post id 296 | * @return int The new list of block terms 297 | */ 298 | $post_terms = apply_filters( 'block_catalog_post_block_terms', $post_terms, $post_id ); 299 | 300 | return [ 301 | 'terms' => $post_terms, 302 | 'variations' => $variations, 303 | ]; 304 | } 305 | 306 | /** 307 | * Flattens the list of blocks into a single array. 308 | * 309 | * @param array $blocks The list of blocks 310 | * @return array 311 | */ 312 | public function to_block_list( $blocks ) { 313 | if ( empty( $blocks ) ) { 314 | return []; 315 | } 316 | 317 | $output = []; 318 | 319 | foreach ( $blocks as $block ) { 320 | // change null blocks to classic editor blocks if matched 321 | if ( $this->is_classic_editor_block( $block ) ) { 322 | $block['blockName'] = 'core/classic'; 323 | } 324 | 325 | // ignore empty blocks 326 | if ( empty( $block['blockName'] ) ) { 327 | continue; 328 | } 329 | 330 | // add current block to output 331 | $output[] = $block; 332 | 333 | if ( ! empty( $block['innerBlocks'] ) ) { 334 | // recursively add all inner blocks to output 335 | $output = array_merge( $output, $this->to_block_list( $block['innerBlocks'] ) ); 336 | } 337 | } 338 | 339 | return $output; 340 | } 341 | 342 | /** 343 | * Converts a block to a list of term names. 344 | * 345 | * @param array $block The block. 346 | * @return array 347 | */ 348 | public function block_to_terms( $block ) { 349 | if ( empty( $block ) || empty( $block['blockName'] ) ) { 350 | return [ 351 | 'terms' => [], 352 | 'variations' => [], 353 | ]; 354 | } 355 | 356 | $terms = []; 357 | $label = $this->get_block_label( $block ); 358 | 359 | /** 360 | * Filters the term label corresponding to the block in the catalog. 361 | * 362 | * @param array $terms The term names corresponding to the block in the catalog 363 | * @param array $block The block data 364 | * @return string 365 | */ 366 | $label = apply_filters( 'block_catalog_block_term_label', $label, $block ); 367 | 368 | if ( 'core/block' === $block['blockName'] && ! empty( $block['attrs']['ref'] ) ) { 369 | $reusable_slug = 're-' . intval( $block['attrs']['ref'] ); 370 | $terms[ $reusable_slug ] = get_the_title( $block['attrs']['ref'] ); 371 | } else { 372 | $terms[ $block['blockName'] ] = $label; 373 | } 374 | 375 | /** 376 | * Filters the term labels corresponding to the block in the catalog. This 377 | * is useful to build multiple terms from a single block. 378 | * 379 | * eg:- embed & special-type-of-embed 380 | * 381 | * @param array $terms The term names corresponding to the block in the catalog 382 | * @param array $block The block data 383 | * @return array The new list of terms 384 | */ 385 | $terms = apply_filters( 'block_catalog_block_terms', $terms, $block ); 386 | 387 | /** 388 | * Filters the term variations for a given block. 389 | * 390 | * @param array $block The block data 391 | */ 392 | $variations = apply_filters( 'block_catalog_block_variations', [], $block ); 393 | 394 | return [ 395 | 'terms' => $terms, 396 | 'variations' => $variations, 397 | ]; 398 | } 399 | 400 | /** 401 | * Finds the label of the block term from its blockName. 402 | * 403 | * @param string $block The block data 404 | * @return string 405 | */ 406 | public function get_block_label( $block ) { 407 | $name = $block['blockName'] ?? ''; 408 | $registered = \WP_Block_Type_Registry::get_instance()->get_registered( $name ); 409 | $title = ! empty( $registered->title ) ? $registered->title : $block['blockName']; 410 | 411 | $parts = explode( '/', $name ); 412 | $namespace = $parts[0] ?? ''; 413 | $short_title = $parts[1] ?? ( $namespace ?? __( 'Untitled', 'block-catalog' ) ); 414 | 415 | // if we got here, the block is incorrectly registered, try to guess at the name 416 | if ( $title === $name ) { 417 | $title = $this->get_display_title( $short_title ); 418 | } 419 | 420 | /** 421 | * Filters the block title for the specified block. 422 | * 423 | * @param string $title The block title 424 | * @param string $name The block name 425 | * @param array $block The block data 426 | * @return string The new block title 427 | */ 428 | $title = apply_filters( 'block_catalog_block_title', $title, $name, $block ); 429 | 430 | return $title; 431 | } 432 | 433 | /** 434 | * Converts phrase to display label. 435 | * 436 | * @param string $title The title string 437 | * @return string 438 | */ 439 | public function get_display_title( $title ) { 440 | $title = str_replace( '-', ' ', $title ); 441 | $title = str_replace( '_', ' ', $title ); 442 | $title = ucwords( $title ); 443 | 444 | return $title; 445 | } 446 | 447 | /** 448 | * Returns the name of the parent term from the full block name. 449 | * 450 | * @param string $name The full block name 451 | * @return string 452 | */ 453 | public function get_block_parent_name( $name ) { 454 | if ( 0 === stripos( $name, 're-' ) ) { 455 | return __( 'Reusable block', 'block-catalog' ); 456 | } 457 | 458 | $parts = explode( '/', $name ); 459 | $namespace = count( $parts ) > 1 ? $parts[0] : ''; 460 | 461 | if ( empty( $namespace ) ) { 462 | return ''; 463 | } 464 | 465 | /** 466 | * Filters the namespace label shown on the parent block term. 467 | * 468 | * eg:- core/embed => Core 469 | * 470 | * @param string $label The block namespace label 471 | * @param string $name The full block name 472 | * @return string 473 | */ 474 | return apply_filters( 'block_catalog_namespace_label', $namespace, $name ); 475 | } 476 | 477 | /** 478 | * Returns the parent term id of the specified term. 479 | * 480 | * @param string $name The full block name 481 | * @return int|false 482 | */ 483 | public function get_block_parent_term( $name ) { 484 | $name = $this->get_block_parent_name( $name ); 485 | 486 | if ( empty( $name ) ) { 487 | return false; 488 | } 489 | 490 | $name = $this->get_display_title( $name ); 491 | $result = get_term_by( 'name', $name, BLOCK_CATALOG_TAXONOMY ); 492 | 493 | if ( ! empty( $result ) ) { 494 | return intval( $result->term_id ); 495 | } 496 | 497 | $result = wp_insert_term( $name, BLOCK_CATALOG_TAXONOMY, [] ); 498 | 499 | if ( ! is_wp_error( $result ) ) { 500 | return intval( $result['term_id'] ); 501 | } 502 | 503 | return false; 504 | } 505 | 506 | /** 507 | * Returns the parent variation term or false if absent. 508 | * 509 | * @param string $name The parent block name 510 | * @return int|false 511 | */ 512 | public function get_variation_parent_term( $name ) { 513 | if ( empty( $name ) ) { 514 | return false; 515 | } 516 | 517 | $result = get_term_by( 'slug', sanitize_title( $name ), BLOCK_CATALOG_TAXONOMY ); 518 | 519 | if ( empty( $result ) || empty( $result->term_id ) ) { 520 | return false; 521 | } 522 | 523 | return intval( $result->term_id ); 524 | } 525 | 526 | /** 527 | * Checks if block is a classic editor block 528 | * 529 | * @param array $block The block data 530 | * @return boolean 531 | */ 532 | public function is_classic_editor_block( $block ) { 533 | if ( empty( $block ) ) { 534 | return false; 535 | } 536 | 537 | // if block has a name, it's not a classic editor block 538 | if ( ! is_null( $block['blockName'] ) ) { 539 | return false; 540 | } 541 | 542 | $allowed_tags = array_keys( wp_kses_allowed_html( 'post' ) ); 543 | 544 | $inner_html = $block['innerHTML'] ?? ''; 545 | $inner_html = trim( $inner_html ); 546 | 547 | if ( empty( $inner_html ) ) { 548 | return false; 549 | } 550 | 551 | // if inner_html has any of the allowed tags, it's a classic editor block 552 | foreach ( $allowed_tags as $tag ) { 553 | if ( strpos( $inner_html, "<{$tag}" ) !== false ) { 554 | return true; 555 | } 556 | } 557 | 558 | return false; 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /includes/classes/CatalogCommand.php: -------------------------------------------------------------------------------- 1 | ] 28 | * : Limits the command to the specified comma delimited post ids. 29 | * 30 | * [--reset] 31 | * : Deletes the previous index before indexing. Default false. 32 | * 33 | * [--network] 34 | * : Runs the command for all sites on a multisite install. Defaults to all 35 | * public sites. Also accepts a comma delimited list of site ids. 36 | * 37 | * [--dry-run] 38 | * : Runs catalog without saving changes to the DB. 39 | * 40 | * @param array $args Command args 41 | * @param array $opts Command opts 42 | */ 43 | public function index( $args = [], $opts = [] ) { 44 | $this->check_network_option( $opts ); 45 | 46 | \BlockCatalog\Utility\start_bulk_operation(); 47 | 48 | $dry_run = ! empty( $opts['dry-run'] ); 49 | $network = $this->get_network_option( $opts ); 50 | 51 | if ( empty( $network ) ) { 52 | $opts['show_dry_run_warning'] = true; 53 | $this->index_site( $args, $opts ); 54 | } else { 55 | $blog_ids = $network; 56 | $opts['show_dry_run_warning'] = false; 57 | 58 | if ( $dry_run ) { 59 | \WP_CLI::warning( __( 'Running in Dry Run Mode, changes will not be saved ...', 'block-catalog' ) ); 60 | } 61 | 62 | if ( ! empty( $blog_ids ) ) { 63 | foreach ( $blog_ids as $blog_id ) { 64 | $site = get_blog_details( $blog_id ); 65 | switch_to_blog( $blog_id ); 66 | 67 | \WP_CLI::log( "Indexing Block Catalog for site[{$site->blog_id}]: " . $site->domain . $site->path ); 68 | $this->index_site( $args, $opts ); 69 | 70 | restore_current_blog(); 71 | \WP_CLI::line(); 72 | } 73 | } 74 | } 75 | 76 | \BlockCatalog\Utility\stop_bulk_operation(); 77 | } 78 | 79 | /** 80 | * Resets the Block Catalog by removing all catalog terms. 81 | * 82 | * ## OPTIONS 83 | * [--network] 84 | * : Deletes the catalog for all sites on a multisite install. 85 | * 86 | * @subcommand delete-index 87 | * @param array $args Command args 88 | * @param array $opts Command opts 89 | */ 90 | public function delete_index( $args = [], $opts = [] ) { 91 | $this->check_network_option( $opts ); 92 | 93 | $builder = new CatalogBuilder(); 94 | $network = $this->get_network_option( $opts ); 95 | 96 | if ( ! empty( $network ) ) { 97 | foreach ( $network as $blog_id ) { 98 | $site = get_blog_details( $blog_id ); 99 | switch_to_blog( $blog_id ); 100 | 101 | \WP_CLI::log( "Deleting Block Catalog for site[{$site->blog_id}]: " . $site->domain . $site->path ); 102 | $builder->delete_index(); 103 | 104 | restore_current_blog(); 105 | \WP_CLI::line(); 106 | } 107 | } else { 108 | $builder->delete_index( $opts ); 109 | } 110 | } 111 | 112 | /** 113 | * Finds the list of posts having the specified block(s). 114 | * 115 | * ## OPTIONS 116 | * 117 | * ... 118 | * : The block names to search for, eg:- core/embed 119 | * 120 | * [--index] 121 | * : Whether to re-index before searching. 122 | * 123 | * [--fields=] 124 | * : List of post fields to display. Comma delimited. 125 | * 126 | * [--format=] 127 | * : Output format, default table. 128 | * 129 | * [--post_type=] 130 | * : Limit search to specified post types. Comma delimited. 131 | * 132 | * [--posts_per_page] 133 | * : Number of posts to find per page, default 20. 134 | * 135 | * [--post_status] 136 | * : Post status of posts to search, default 'publish'. 137 | * 138 | * [--count] 139 | * : Prints total found posts, default false. 140 | * 141 | * [--operator=] 142 | * : The query operator to be used in the search clause. Default IN. 143 | * 144 | * [--network] 145 | * : Runs the command for all sites on a multisite install. Defaults to all 146 | * public sites. Also accepts a comma delimited list of site ids. 147 | * 148 | * @param array $args Command args 149 | * @param array $opts Command opts 150 | */ 151 | public function find( $args = [], $opts = [] ) { 152 | $this->check_network_option( $opts ); 153 | 154 | if ( empty( $args ) ) { 155 | \WP_CLI::error( __( 'Please enter atleast one block name.', 'block-catalog' ) ); 156 | } 157 | 158 | $network = $this->get_network_option( $opts ); 159 | $count = $this->get_count_option( $opts ); 160 | 161 | if ( ! empty( $count ) ) { 162 | if ( ! empty( $network ) ) { 163 | $this->count_on_network( $network, $args, $opts ); 164 | } else { 165 | $this->count_on_site( $args, $opts ); 166 | } 167 | } elseif ( ! empty( $network ) ) { 168 | $this->find_on_network( $network, $args, $opts ); 169 | } else { 170 | $this->find_on_site( $args, $opts ); 171 | } 172 | } 173 | 174 | /** 175 | * Prints the list of blocks in the specified post. 176 | * 177 | * ## OPTIONS 178 | * 179 | * 180 | * : The post id to lookup blocks for. 181 | * 182 | * @subcommand post-blocks 183 | * @param array $args Command args 184 | * @param array $opts Command opts 185 | */ 186 | public function list_post_blocks( $args = [], $opts = [] ) { 187 | if ( empty( $args ) ) { 188 | \WP_CLI::error( __( 'Please enter a valid post_id', 'block-catalog' ) ); 189 | } 190 | 191 | $post_id = intval( $args[0] ); 192 | 193 | $builder = new CatalogBuilder(); 194 | $builder->catalog( $post_id ); 195 | 196 | $blocks = wp_get_object_terms( $post_id, BLOCK_CATALOG_TAXONOMY ); 197 | 198 | if ( empty( $blocks ) ) { 199 | \WP_CLI::error( __( 'No blocks found.', 'block-catalog' ) ); 200 | } 201 | 202 | $block_items = array_map( 203 | function ( $term ) { 204 | return [ 205 | 'Block' => $term->name, 206 | 'ID' => $term->term_id, 207 | ]; 208 | }, 209 | $blocks 210 | ); 211 | 212 | \WP_CLI\Utils\format_items( 'table', $block_items, [ 'ID', 'Block' ] ); 213 | } 214 | 215 | /** 216 | * Exports the posts associated with the 'block_catalog' taxonomy to a CSV file. 217 | * 218 | * ## OPTIONS 219 | * 220 | * [--output=] 221 | * : Path to the CSV file. Defaults to /tmp/block-catalog.csv 222 | * 223 | * [--post_type=] 224 | * : Comma-delimited list of post types. Optional. 225 | * 226 | * [--posts_per_block=] 227 | * : Number of posts per block, default to -1 (all). Optional. 228 | * 229 | * [--ignore_parent=] 230 | * : Ignore top level blocks. Optional. Default true 231 | * 232 | * ## EXAMPLES 233 | * 234 | * wp block-catalog export --output=path/to/csv 235 | * 236 | * @when after_wp_load 237 | * 238 | * @param array $args Positional arguments. 239 | * @param array $opts Optional arguments. 240 | */ 241 | public function export( $args = [], $opts = [] ) { 242 | $output = isset( $opts['output'] ) ? $opts['output'] : '/tmp/block-catalog.csv'; 243 | $post_types = isset( $opts['post_type'] ) ? explode( ',', $opts['post_type'] ) : array(); 244 | $posts_per_block = isset( $opts['posts_per_block'] ) ? intval( $opts['posts_per_block'] ) : -1; 245 | 246 | $opts['output'] = $output; 247 | $opts['post_type'] = $post_types; 248 | $opts['posts_per_block'] = $posts_per_block; 249 | 250 | $exporter = new \BlockCatalog\CatalogExporter(); 251 | $result = $exporter->export( $output, $opts ); 252 | 253 | if ( is_wp_error( $result ) ) { 254 | \WP_CLI::error( $result->get_error_message() ); 255 | } elseif ( ! $result['success'] ) { 256 | \WP_CLI::error( $result['message'] ); 257 | } else { 258 | \WP_CLI::success( $result['message'] ); 259 | } 260 | } 261 | 262 | /** 263 | * Returns the list of post ids to migrate. 264 | * 265 | * @param array $opts Optional opts 266 | * @return array 267 | */ 268 | private function get_posts_to_catalog( $opts = [] ) { 269 | if ( isset( $opts['only'] ) ) { 270 | $only = explode( ',', $opts['only'] ); 271 | $only = array_map( 'intval', $only ); 272 | $only = array_filter( $only ); 273 | 274 | return $only; 275 | } 276 | 277 | $query_params = [ 278 | 'post_type' => \BlockCatalog\Utility\get_supported_post_types(), 279 | 'post_status' => 'any', 280 | 'fields' => 'ids', 281 | 'posts_per_page' => -1, // phpcs:ignore WordPress.WP.PostsPerPageNoUnlimited.posts_per_page_posts_per_page 282 | ]; 283 | 284 | $query = new \WP_Query( $query_params ); 285 | $posts = $query->posts; 286 | 287 | if ( empty( $posts ) ) { 288 | \WP_CLI::warning( __( 'No posts to catalog.', 'block-catalog' ) ); 289 | } 290 | 291 | return $posts; 292 | } 293 | 294 | /** 295 | * Returns the --network option, and a default value if not set. 296 | * 297 | * @param array $opts Optional opts 298 | * @return string|array 299 | */ 300 | private function get_network_option( $opts ) { 301 | if ( ! is_multisite() ) { 302 | return ''; 303 | } 304 | 305 | if ( ! empty( $this->network ) ) { 306 | return $this->network; 307 | } 308 | 309 | if ( ! isset( $opts['network'] ) ) { 310 | return ''; 311 | } 312 | 313 | if ( ! is_plugin_active_for_network( BLOCK_CATALOG_PLUGIN_FILE ) ) { 314 | \WP_CLI::error( __( 'The --network option can only be used when the Block Catalog plugin is network activated.', 'block-catalog' ) ); 315 | } 316 | 317 | $network = \WP_CLI\Utils\get_flag_value( $opts, 'network', 'public' ); 318 | 319 | // assume networks with commas are ids 320 | if ( is_string( $network ) && false !== strpos( $network, ',' ) ) { 321 | $network = explode( ',', $network ); 322 | } 323 | 324 | $this->network = $this->get_site_ids_from_network( $network ); 325 | 326 | return $this->network; 327 | } 328 | 329 | /** 330 | * Validates if the --network option can be used on the current install, and 331 | * throws an error if not. 332 | * 333 | * @param array $opts Optional opts 334 | * @return bool 335 | */ 336 | private function check_network_option( $opts ) { 337 | if ( ! is_multisite() && isset( $opts['network'] ) ) { 338 | \WP_CLI::error( __( 'The --network option can only be used on multisite installs.', 'block-catalog' ) ); 339 | return false; 340 | } 341 | 342 | return true; 343 | } 344 | 345 | /** 346 | * Returns the site ids from the --network option. 347 | * 348 | * @param string|array $network The network option value. 349 | * @return array 350 | */ 351 | private function get_site_ids_from_network( $network ) { 352 | $query_params = [ 353 | 'fields' => 'ids', 354 | ]; 355 | 356 | $accepted = [ 'public', 'archived', 'spam', 'deleted' ]; 357 | 358 | if ( is_string( $network ) && in_array( $network, $accepted, true ) ) { 359 | $query_params[ $network ] = 1; 360 | } elseif ( is_array( $network ) && ! empty( $network ) && is_numeric( $network[0] ) ) { 361 | // list of site ids 362 | $query_params['site__in'] = $network; 363 | } else { 364 | $query_params['site__in'] = []; 365 | } 366 | 367 | $query = new \WP_Site_Query( $query_params ); 368 | $sites = $query->get_sites(); 369 | 370 | return $sites; 371 | } 372 | 373 | /** 374 | * Indexes the block catalog for the current site. 375 | * 376 | * @param array $args Command args 377 | * @param array $opts Command opts 378 | */ 379 | private function index_site( $args = [], $opts = [] ) { 380 | $dry_run = ! empty( $opts['dry-run'] ); 381 | $reset = ! empty( $opts['reset'] ); 382 | 383 | if ( $dry_run && $opts['show_dry_run_warning'] ) { 384 | \WP_CLI::warning( __( 'Running in Dry Run Mode, changes will not be saved ...', 'block-catalog' ) ); 385 | } 386 | 387 | if ( ! $dry_run && $reset ) { 388 | $this->delete_index(); 389 | } 390 | 391 | $post_ids = $this->get_posts_to_catalog( $opts ); 392 | 393 | $total = count( $post_ids ); 394 | 395 | // translators: %d is number of posts found 396 | $message = sprintf( __( 'Cataloging %d Posts ...', 'block-catalog' ), $total ); 397 | $progress_bar = \WP_CLI\Utils\make_progress_bar( $message, $total ); 398 | $updated = 0; 399 | $errors = 0; 400 | 401 | $builder = new CatalogBuilder(); 402 | 403 | foreach ( $post_ids as $post_id ) { 404 | $progress_bar->tick(); 405 | 406 | if ( ! $dry_run ) { 407 | $result = $builder->catalog( $post_id, $opts ); 408 | 409 | \BlockCatalog\Utility\clear_caches(); 410 | } else { 411 | $result = $builder->get_post_block_terms( $post_id ); 412 | } 413 | 414 | if ( is_wp_error( $result ) ) { 415 | ++$errors; 416 | } else { 417 | $updated += count( $result['terms'] ?? [] ); 418 | } 419 | } 420 | 421 | $progress_bar->finish(); 422 | 423 | if ( ! empty( $updated ) ) { 424 | // translators: %1$d is the number of blocks updated, %2$d is the total posts 425 | \WP_CLI::success( sprintf( __( 'Block Catalog updated for %1$d block(s) across %2$d post(s).', 'block-catalog' ), $updated, $total ) ); 426 | } else { 427 | // translators: %d is the total posts 428 | \WP_CLI::warning( sprintf( __( 'No updates were made across %d post(s).', 'block-catalog' ), $total ) ); 429 | } 430 | 431 | if ( ! empty( $errors ) ) { 432 | // translators: %d is the total posts 433 | \WP_CLI::warning( sprintf( __( 'Failed to catalog %d post(s).', 'block-catalog' ), $errors ) ); 434 | } 435 | } 436 | 437 | /** 438 | * Returns a bool depending on if the --count option is set. 439 | * 440 | * @param array $opts Command opts 441 | * @return bool 442 | */ 443 | private function get_count_option( $opts ) { 444 | return isset( $opts['count'] ); 445 | } 446 | 447 | /** 448 | * Counts the number of posts across the network that match the queried terms using the 449 | * PostFinder object. 450 | * 451 | * @param array $sites Sites to query. 452 | * @param array $args Blocks to query. 453 | * @param array $opts Optional arguments. 454 | */ 455 | private function count_on_network( $sites = [], $args = [], $opts = [] ) { 456 | if ( ! empty( $opts['index'] ) ) { 457 | $this->index( $args, $opts ); 458 | } 459 | 460 | $finder = new PostFinder(); 461 | 462 | $result = $finder->count_on_network( $sites, $args, $opts ); 463 | $fields = ! empty( $opts['fields'] ) ? explode( ',', $opts['fields'] ) : [ 'blog_id', 'blog_url', 'count' ]; 464 | $format = ! empty( $opts['format'] ) ? $opts['format'] : 'table'; 465 | 466 | \WP_CLI\Utils\format_items( $format, $result, $fields ); 467 | } 468 | 469 | /** 470 | * Counts the number of posts that match the queried terms using the 471 | * PostFinder object. 472 | * 473 | * @param array $args Blocks to query. 474 | * @param array $opts Optional arguments. 475 | */ 476 | private function count_on_site( $args = [], $opts = [] ) { 477 | if ( ! empty( $opts['index'] ) ) { 478 | $this->index( $args, $opts ); 479 | } 480 | 481 | $finder = new PostFinder(); 482 | $result = $finder->count( $args, $opts ); 483 | 484 | if ( is_wp_error( $result ) ) { 485 | \WP_CLI::error( $result->get_error_message() ); 486 | } 487 | 488 | if ( ! empty( $result ) ) { 489 | // translators: %d is the number of posts found 490 | \WP_CLI::success( sprintf( __( 'Found %d post(s)', 'block-catalog' ), $result ) ); 491 | } else { 492 | \WP_CLI::warning( __( 'No posts found.', 'block-catalog' ) ); 493 | } 494 | } 495 | 496 | /** 497 | * Find posts across the network that match the queried terms using the PostFinder. 498 | * 499 | * @param array $sites Sites to query. 500 | * @param array $args Blocks to query. 501 | * @param array $opts Optional arguments. 502 | */ 503 | private function find_on_network( $sites = [], $args = [], $opts = [] ) { 504 | if ( ! empty( $opts['index'] ) ) { 505 | $this->index( $args, $opts ); 506 | } 507 | 508 | $finder = new PostFinder(); 509 | 510 | $result = $finder->find_on_network( $sites, $args, $opts ); 511 | $fields = ! empty( $opts['fields'] ) ? explode( ',', $opts['fields'] ) : [ 'blog_id', 'blog_url', 'ID', 'post_type', 'post_title' ]; 512 | $format = ! empty( $opts['format'] ) ? $opts['format'] : 'table'; 513 | $output = []; 514 | 515 | foreach ( $result as $result_item ) { 516 | $error = $result_item['error'] ?? false; 517 | $blog_id = $result_item['blog_id']; 518 | $blog_url = $result_item['blog_url']; 519 | 520 | if ( is_wp_error( $error ) ) { 521 | $output[] = [ 522 | 'blog_id' => $blog_id, 523 | 'blog_url' => $blog_url, 524 | 'ID' => 0, 525 | 'post_type' => '', 526 | 'post_title' => $error->get_error_message(), 527 | ]; 528 | } else { 529 | $posts = $result_item['posts']; 530 | 531 | // don't output sites with no posts 532 | if ( empty( $posts ) ) { 533 | continue; 534 | } 535 | 536 | foreach ( $posts as $post ) { 537 | $row = [ 538 | 'blog_id' => $blog_id, 539 | 'blog_url' => $blog_url, 540 | ]; 541 | 542 | foreach ( $fields as $field ) { 543 | if ( empty( $row[ $field ] ) ) { 544 | $row[ $field ] = $post->$field; 545 | } 546 | } 547 | 548 | $output[] = $row; 549 | } 550 | } 551 | } 552 | 553 | if ( ! empty( $output ) ) { 554 | \WP_CLI\Utils\format_items( $format, $output, $fields ); 555 | } else { 556 | \WP_CLI::warning( __( 'No posts found.', 'block-catalog' ) ); 557 | } 558 | } 559 | 560 | /** 561 | * Find posts that match the queried terms using the PostFinder on the current site. 562 | * 563 | * @param array $args Blocks to query. 564 | * @param array $opts Optional arguments. 565 | */ 566 | private function find_on_site( $args = [], $opts = [] ) { 567 | if ( ! empty( $opts['index'] ) ) { 568 | $this->index( $args, $opts ); 569 | } 570 | 571 | $finder = new PostFinder(); 572 | $result = $finder->find( $args, $opts ); 573 | $fields = ! empty( $opts['fields'] ) ? explode( ',', $opts['fields'] ) : [ 'ID', 'post_type', 'post_title' ]; 574 | $format = ! empty( $opts['format'] ) ? $opts['format'] : 'table'; 575 | 576 | if ( is_wp_error( $result ) ) { 577 | \WP_CLI::error( $result->get_error_message() ); 578 | } 579 | 580 | if ( ! empty( $result ) ) { 581 | \WP_CLI\Utils\format_items( $format, $result, $fields ); 582 | } else { 583 | \WP_CLI::warning( __( 'No posts found.', 'block-catalog' ) ); 584 | } 585 | } 586 | } 587 | --------------------------------------------------------------------------------