The short version
- SureCart has no bulk “add to collection” button today. The only bulk actions on the Products screen are Archive, Un-archive and Delete.
- A product list CAN show several collections at once. In the shortcode you separate the ids with a comma. In the Product List block you pick several under Filters.
- A product list CANNOT exclude a collection. That part is not built in.
The two snippets below add the missing pieces, so you only have to tag ONE of your two groups, and you can tag it in a few clicks instead of opening 100 products.
See it working
- BEFORE: one shop with everything (23 products, parts mixed in)
- Parts shop (only the 5 products tagged “Parts”)
- Main shop, everything except Parts (18 products, shortcode version)
- Main shop, everything except Parts (same thing built with the Shop block and sidebar filters)
- Two collections at once (Parts and Servicing in one list)
Only the Parts products were ever tagged. Nothing else in the catalogue was touched.
Snippet 1. Bulk Collection Editor
Adds a screen at SureCart > Bulk Collections. Tick as many products as you like, pick a collection, press Apply. It adds the collection, it does not replace the collections a product already has. There is a Remove option too. It works in batches of 5 so a slow site does not time out. In our test 12 products took about 20 seconds, so 100 products is roughly 3 minutes.
<?php
/**
* SureCart - Bulk Collection Editor
* Adds "SureCart > Bulk Collections": tick many products, then add them to (or
* remove them from) a collection in one go.
*
* Writes go to the SureCart platform through the official Product model, so the
* change is real (not just a local WordPress term) and the product page/shop
* are re-synced automatically.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! defined( 'SCBC_PAGE' ) ) {
define( 'SCBC_PAGE', 'sc-bulk-collections' );
define( 'SCBC_BATCH', 5 ); // products written per page load.
define( 'SCBC_PAUSE', 300000 ); // microseconds between writes (be kind to the API).
}
if ( ! function_exists( 'scbc_menu' ) ) {
/**
* Add the admin screen under the SureCart menu.
*/
function scbc_menu() {
add_submenu_page(
'sc-dashboard',
'Bulk Collections',
'↳ Bulk Collections',
'edit_sc_products',
SCBC_PAGE,
'scbc_render'
);
}
add_action( 'admin_menu', 'scbc_menu', 100 );
/**
* All SureCart collections as term_id => [name, sc_id].
*/
function scbc_collections() {
$terms = get_terms(
array(
'taxonomy' => 'sc_collection',
'hide_empty' => false,
'orderby' => 'name',
)
);
$out = array();
if ( is_wp_error( $terms ) ) {
return $out;
}
foreach ( $terms as $term ) {
$sc_id = get_term_meta( $term->term_id, 'sc_id', true );
if ( ! empty( $sc_id ) ) {
$out[ $sc_id ] = $term->name;
}
}
return $out;
}
/**
* The platform collection ids a product currently belongs to (read locally, no API call).
*/
function scbc_current_collection_ids( $post_id ) {
$ids = array();
$terms = wp_get_post_terms( $post_id, 'sc_collection' );
if ( is_wp_error( $terms ) ) {
return $ids;
}
foreach ( $terms as $term ) {
$sc_id = get_term_meta( $term->term_id, 'sc_id', true );
if ( ! empty( $sc_id ) ) {
$ids[] = $sc_id;
}
}
return array_values( array_unique( $ids ) );
}
/**
* Transient key for the running job.
*/
function scbc_job_key() {
return 'scbc_job_' . get_current_user_id();
}
/**
* Process up to SCBC_BATCH products of the queued job.
*/
function scbc_run_batch() {
$job = get_transient( scbc_job_key() );
if ( empty( $job ) || empty( $job['queue'] ) ) {
return $job;
}
$slice = array_splice( $job['queue'], 0, SCBC_BATCH );
foreach ( $slice as $post_id ) {
$post_id = (int) $post_id;
$sc_id = get_post_meta( $post_id, 'sc_id', true );
$title = get_the_title( $post_id );
if ( empty( $sc_id ) ) {
$job['log'][] = array( 'skip', $title, 'no SureCart id on this product' );
continue;
}
$current = scbc_current_collection_ids( $post_id );
if ( 'add' === $job['mode'] ) {
$new = array_values( array_unique( array_merge( $current, array( $job['collection'] ) ) ) );
} else {
$new = array_values( array_diff( $current, array( $job['collection'] ) ) );
}
sort( $current );
$compare = $new;
sort( $compare );
if ( $current === $compare ) {
$job['log'][] = array( 'skip', $title, 'already correct' );
continue;
}
$product = new \SureCart\Models\Product( $sc_id );
$result = $product->update( array( 'product_collection_ids' => $new ) );
if ( is_wp_error( $result ) ) {
$job['log'][] = array( 'error', $title, $result->get_error_message() );
} else {
$job['log'][] = array( 'ok', $title, implode( ', ', $new ) );
}
usleep( SCBC_PAUSE );
}
set_transient( scbc_job_key(), $job, HOUR_IN_SECONDS );
return $job;
}
/**
* Queue the job before any output, so the redirect works.
*/
function scbc_maybe_start_job() {
if ( ! isset( $_POST['scbc_nonce'] ) ) {
return;
}
if ( ! current_user_can( 'edit_sc_products' ) ) {
return;
}
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['scbc_nonce'] ) ), 'scbc_go' ) ) {
return;
}
$base_url = admin_url( 'admin.php?page=' . SCBC_PAGE );
$ids = isset( $_POST['products'] ) ? array_map( 'intval', (array) $_POST['products'] ) : array();
$collection = isset( $_POST['collection'] ) ? sanitize_text_field( wp_unslash( $_POST['collection'] ) ) : '';
$mode = ( isset( $_POST['mode'] ) && 'remove' === $_POST['mode'] ) ? 'remove' : 'add';
$known = scbc_collections();
if ( empty( $ids ) || empty( $collection ) || ! isset( $known[ $collection ] ) ) {
wp_safe_redirect( $base_url . '&error=1' );
exit;
}
set_transient(
scbc_job_key(),
array(
'queue' => $ids,
'total' => count( $ids ),
'collection' => $collection,
'mode' => $mode,
'log' => array(),
),
HOUR_IN_SECONDS
);
wp_safe_redirect( $base_url . '&run=1' );
exit;
}
add_action( 'admin_init', 'scbc_maybe_start_job' );
/**
* Render the screen.
*/
function scbc_render() {
if ( ! current_user_can( 'edit_sc_products' ) ) {
wp_die( 'Not allowed.' );
}
$collections = scbc_collections();
$base_url = admin_url( 'admin.php?page=' . SCBC_PAGE );
if ( isset( $_GET['error'] ) ) {
echo '<div class="notice notice-error"><p>Pick a collection and at least one product.</p></div>';
}
// Running a job.
if ( isset( $_GET['run'] ) ) {
$job = scbc_run_batch();
echo '<div class="wrap"><h1>Bulk Collections</h1>';
if ( empty( $job ) ) {
echo '<p>Nothing to do. <a href="' . esc_url( $base_url ) . '">Back</a></p></div>';
return;
}
$done = $job['total'] - count( $job['queue'] );
printf(
'<p><strong>%s</strong> %d of %d products processed.</p>',
'remove' === $job['mode'] ? 'Removing from' : 'Adding to',
(int) $done,
(int) $job['total']
);
echo '<table class="widefat striped"><tbody>';
foreach ( array_slice( $job['log'], -40 ) as $line ) {
printf(
'<tr><td style="width:80px">%s</td><td>%s</td><td>%s</td></tr>',
esc_html( strtoupper( $line[0] ) ),
esc_html( $line[1] ),
esc_html( $line[2] )
);
}
echo '</tbody></table>';
if ( ! empty( $job['queue'] ) ) {
echo '<p>Working, please leave this page open...</p>';
echo '<meta http-equiv="refresh" content="1;url=' . esc_url( $base_url . '&run=1' ) . '">';
} else {
delete_transient( scbc_job_key() );
echo '<p><strong>Finished.</strong> <a class="button button-primary" href="' . esc_url( $base_url ) . '">Back to the list</a></p>';
}
echo '</div>';
return;
}
// The list.
$products = get_posts(
array(
'post_type' => 'sc_product',
'post_status' => array( 'publish', 'draft' ),
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
'suppress_filters' => false,
)
);
$filter = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
?>
<div class="wrap">
<h1>Bulk Collections</h1>
<p>Tick the products you want, choose a collection, then Apply. Nothing else about the product is changed.</p>
<form method="get" style="margin-bottom:12px">
<input type="hidden" name="page" value="<?php echo esc_attr( SCBC_PAGE ); ?>">
<input type="search" name="s" value="<?php echo esc_attr( $filter ); ?>" placeholder="Filter by product name">
<button class="button">Filter</button>
<?php if ( $filter ) : ?>
<a class="button" href="<?php echo esc_url( $base_url ); ?>">Clear</a>
<?php endif; ?>
</form>
<form method="post">
<?php wp_nonce_field( 'scbc_go', 'scbc_nonce' ); ?>
<p>
<label>Collection
<select name="collection">
<?php foreach ( $collections as $sc_id => $name ) : ?>
<option value="<?php echo esc_attr( $sc_id ); ?>"><?php echo esc_html( $name ); ?></option>
<?php endforeach; ?>
</select>
</label>
<label style="margin-left:14px"><input type="radio" name="mode" value="add" checked> Add to collection</label>
<label style="margin-left:8px"><input type="radio" name="mode" value="remove"> Remove from collection</label>
<button class="button button-primary" style="margin-left:14px">Apply</button>
</p>
<table class="widefat striped">
<thead>
<tr>
<td style="width:28px"><input type="checkbox" onclick="document.querySelectorAll('input[name=\'products[]\']').forEach(function(c){c.checked=this.checked}.bind(this))"></td>
<th>Product</th>
<th>Status</th>
<th>Collections</th>
</tr>
</thead>
<tbody>
<?php
foreach ( $products as $product ) {
if ( $filter && false === stripos( $product->post_title, $filter ) ) {
continue;
}
$terms = wp_get_post_terms( $product->ID, 'sc_collection' );
$names = ( ! is_wp_error( $terms ) && $terms ) ? wp_list_pluck( $terms, 'name' ) : array();
printf(
'<tr><td><input type="checkbox" name="products[]" value="%d"></td><td>%s</td><td>%s</td><td>%s</td></tr>',
(int) $product->ID,
esc_html( $product->post_title ),
esc_html( $product->post_status ),
esc_html( $names ? implode( ', ', $names ) : '-' )
);
}
?>
</tbody>
</table>
<p><button class="button button-primary">Apply</button></p>
</form>
</div>
<?php
}
}
Snippet 2. Product list that excludes a collection
Two ways to use it. Either the shortcode [sc_product_list_not_in collection="parts" columns="4" limit="12"], or add your shop page and the collection to the list at the top of the snippet if you build the page with the Product List block or a page builder. The excluded collection also disappears from the Collections filter, so nobody can filter their way back into it.
<?php
/**
* SureCart - show every product EXCEPT one or more collections.
*
* A SureCart product list can be limited to one or several collections, but it
* has no "exclude" option. This adds one, so you only ever have to tag ONE of
* your two groups.
*
* Two ways to use it:
*
* 1) Shortcode, anywhere a shortcode works:
* [sc_product_list_not_in collection="parts" columns="4" limit="12"]
* Every
-
-
-
-
-
attribute still works, plus collection="...".
* Several collections: collection="parts,clearance"
*
* 2) Page rule, for the Product List BLOCK, the Shop pattern, Elementor, etc.
* Add the page slug (or ID) and the collection slug(s) in scx_page_rules().
*
* The excluded collection is also taken out of the "Collections" filter on
* those pages, so shoppers cannot filter their way back into it.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! function_exists( 'scx_page_rules' ) ) {
/**
* 'page slug or id' => 'collection slug, another collection slug'
*/
function scx_page_rules() {
return array(
'main-shop-block-no-parts-1540800' => 'parts-1540800',
);
}
/**
* Turn collection slugs into WordPress term ids.
*/
function scx_terms_for_slugs( $slugs ) {
$ids = array();
foreach ( array_filter( array_map( 'trim', explode( ',', (string) $slugs ) ) ) as $slug ) {
$term = get_term_by( 'slug', sanitize_title( $slug ), 'sc_collection' );
if ( $term && ! is_wp_error( $term ) ) {
$ids[] = (int) $term->term_id;
}
}
return $ids;
}
/**
* Resolve the page rule ONCE per request, before anything is rendered.
*/
function scx_resolve_page_rule() {
$GLOBALS['scx_page_exclude_terms'] = array();
if ( is_admin() ) {
return;
}
foreach ( scx_page_rules() as $page => $slugs ) {
if ( is_page( $page ) ) {
$GLOBALS['scx_page_exclude_terms'] = scx_terms_for_slugs( $slugs );
return;
}
}
}
add_action( 'template_redirect', 'scx_resolve_page_rule', 1 );
/**
* Which collections are excluded right now. No database lookups here.
*/
function scx_active_excluded_terms() {
if ( is_admin() ) {
return array();
}
if ( ! empty( $GLOBALS['scx_exclude_terms'] ) ) {
return (array) $GLOBALS['scx_exclude_terms'];
}
if ( ! empty( $GLOBALS['scx_page_exclude_terms'] ) ) {
return (array) $GLOBALS['scx_page_exclude_terms'];
}
return array();
}
/**
* The shortcode wrapper.
*/
function scx_product_list_not_in( $atts, $content = '' ) {
$atts = (array) $atts;
$exclude = isset( $atts['collection'] ) ? $atts['collection'] : '';
unset( $atts['collection'] );
$GLOBALS['scx_exclude_terms'] = scx_terms_for_slugs( $exclude );
$inner = '
';
$output = do_shortcode( $inner );
$GLOBALS['scx_exclude_terms'] = array();
return $output;
}
add_shortcode( 'sc_product_list_not_in', 'scx_product_list_not_in' );
/**
* Keep those products out of the list, whatever is in the URL.
*/
function scx_filter_product_query( $query ) {
$post_type = $query->get( 'post_type' );
$is_list = ( 'sc_product' === $post_type ) || ( is_array( $post_type ) && in_array( 'sc_product', $post_type, true ) );
if ( ! $is_list || $query->is_singular() ) {
return;
}
$terms = scx_active_excluded_terms();
if ( empty( $terms ) ) {
return;
}
$tax_query = (array) $query->get( 'tax_query' );
$tax_query[] = array(
'taxonomy' => 'sc_collection',
'field' => 'term_id',
'terms' => $terms,
'operator' => 'NOT IN',
'include_children' => false,
);
$query->set( 'tax_query', $tax_query );
}
add_action( 'pre_get_posts', 'scx_filter_product_query' );
/**
* Take the excluded collections out of the Collections filter on those pages.
*/
function scx_filter_collection_terms( $args, $taxonomies ) {
if ( ! in_array( 'sc_collection', (array) $taxonomies, true ) ) {
return $args;
}
$terms = scx_active_excluded_terms();
if ( empty( $terms ) ) {
return $args;
}
$args['exclude'] = array_values( array_unique( array_merge( (array) ( $args['exclude'] ?? array() ), $terms ) ) );
return $args;
}
add_filter( 'get_terms_args', 'scx_filter_collection_terms', 20, 2 );
}
How to install
- Install a code snippets plugin (WPCode, Code Snippets, WPCodeBox, or your child theme functions.php).
- Paste each snippet as a new PHP snippet and activate it.
- Create ONE collection, for example “Parts”, at SureCart > Products > Collections.
- Go to SureCart > Bulk Collections, tick your parts products, pick “Parts”, press Apply.
- Parts page: a Product List limited to the Parts collection, or just use the collection page SureCart already creates at /collections/parts/.
- Everything else page: the exclude shortcode, or the page rule.