Language

Get in touch

WordPress plugin · since 2019 · aka WPBE

WOLF

A spreadsheet-style editor for bulk editing, filtering, exporting and rolling back posts, pages and any custom post type.

First release
Active installs
4,000+
Rating
4.7 / 5 (27)Read reviews
  • PHP
  • WP_Query (tax_query, meta_query)
  • posts_where filters
  • $wpdb + dbDelta
  • Transients
  • admin-ajax
  • jQuery
  • DataTables (server-side mode)
  • DOMDocument (WXR export)
  • fputcsv
View on wordpress.org wordpress.org

About

WOLF (formerly WPBE) is the posts counterpart of the BEAR WooCommerce bulk editor. It shows posts of any registered post type in an editable table, lets the user filter them by core fields, taxonomies and meta, and then change, delete or export the whole filtered set in one operation.

The hard part is that nothing is fixed. Every post type brings its own taxonomies and meta keys, meta values may be serialized arrays, image ID lists or flags with custom values, and the target set can be thousands of posts on shared hosting with short PHP time limits.

The plugin answers this with a field registry rebuilt per post type, a filter that is stored on the server and compiled into one WP_Query, bulk runs driven by the browser in fixed-size batches, and a history log that records the previous value of every field before it is written, so a bulk run can be reverted in batches too.

The premium build and the free build on wordpress.org share one code base; a single flag switches the free limits on.

WOLF · posts bulk editor

what was solved

LIVEsince 2019

One editor for any post type

shipped · 2019

What it givesStaff edit any kind of content in one table that stays fast on large sites, with columns set up per person and per language.

Problem
Posts, pages and custom post types have different taxonomies, meta keys and applicable core fields. One table must show any of them and stay fast on sites with many records.
Solution

A field registry is rebuilt for the active post type and the table runs in DataTables server-side mode over WP_Query, so each request reads one page of posts.

Stack
  • PHP
  • WP_Query
  • DataTables
  • admin-ajax
Tasks · 5
Each post type brings its own set of taxonomiesTaxonomy columns are built from get_object_taxonomies() for the active post type

WPBE_SETTINGS::get_fields() merges the static field list from data/fields.php with one column per taxonomy registered for the current post type, then applies the user's saved column order and titles. New fields that appear later (a new taxonomy, a new meta key) are appended instead of being lost.

Field definitions carry allow_post_types and prohibit_post_types. wrap_field_val() returns a 'restricted' cell when a field does not apply, for example status and comment fields for attachments, mime type only for attachments, sticky only for posts. Attachments are queried with the extra 'inherit' status.

Large sites cannot load every post into the browserServer-side DataTables: each page, sort and search is one paged WP_Query

The table is configured with serverSide: true and calls wpbe_get_posts with DataTables paging parameters. WPBE_POSTS::gets() converts start/length into paged/posts_per_page and switches to meta_value_num ordering when the sort column is a numeric or date meta field.

Rows per page are capped at 100 with the code comment 'to avoid 500 error on weak servers'. The free build caps it at 10.

The editor code must not slow down the rest of wp-adminExtensions and models load only on the editor page and on wpbe_* AJAX actions

init() runs a role check first and then is_should_init(), which returns true only for page=wpbe or an AJAX action whose name contains 'wpbe_'. On other admin screens only the menu entry, the admin bar button and notices are registered.

Extensions follow a naming convention (ext/<slug>/<slug>.php, class WPBE_<SLUG>) and receive storage, settings and the posts model through init_vars(). The same loader also picks up third-party extensions from wp-content/wpbe_ext.

Each user needs own columns, per post type if requiredOptions stored per user ID, optionally split per post type (premium setting)

Settings live in the option wpbe_options_<user_id>. When the 'separate settings' switch is on, the post type is appended to the key, and the registered meta fields list is split per post type the same way. The active post type is remembered per user in the plugin storage.

The switch is disabled in the free build.

On WPML sites the editor must work in the current admin languageCurrent WPML language passed into every query; sticky list kept per language

When SitePress is present, the wpbe_current_language filter returns ICL_LANGUAGE_CODE, gets() calls $sitepress->switch_lang() before building WP_Query, and the sticky posts option is read and written under sticky_posts_<lang>.

Batched bulk edit, delete and export

shipped · 2019

What it givesChanges to thousands of posts finish reliably on ordinary hosting, with visible progress and the option to stop midway.

Problem
Editing thousands of posts in one PHP request runs into time and memory limits, and the user needs progress and a way to stop the run.
Solution

The browser drives the run in fixed-size batches of post IDs; the operation itself is stored once on the server under a random bulk key.

Stack
  • PHP
  • jQuery
  • Transients
  • WordPress term API
  • fputcsv
  • DOMDocument
Tasks · 5
One PHP request cannot edit thousands of postsIDs resolved once, then sent in batches of 50 to a stateless worker endpoint

wpbe_bulk_posts_count stores the bulk form under 'wpbe_bulk_<key>' (16 random characters generated in the browser) and returns the IDs matching the current filter, using fields=ids and no_found_rows. The browser slices that list into batches of 50 and posts them one after another to wpbe_bulk_posts, updating a progress bar and an admin-bar indicator. Cancel aborts the pending XHR; wpbe_bulk_finish returns the counted total.

Bulk delete uses the same pattern with batches of 10 and wp_trash_post(), so deleted posts stay recoverable from the trash.

After a run the browser redraws only the touched rows when four or fewer posts were changed and redraws the current page otherwise.

Text fields need edits relative to the current valueAppend, prepend, replace (with or without case), set new; per-post macros

__process_text_data() reads the current value of each post and applies the chosen behavior. Values can contain {TITLE}, {ID}, {MENU_ORDER} and {meta:key}, resolved per post, and case macros such as {DO_STRING_UP} or {DO_STRING_TITLE} that use the mb_* functions, so non-Latin titles are handled.

Numeric meta needs relative changes and price-like roundingBy value or percent, formula with another meta key, random range, rounding to .05, .10 or .x9

__process_number_data() supports increase/decrease by value or percent, then optionally combines the result with another meta field of the same post (+, -, *, /, with division by zero replaced by 1) and with a random number from a range scaled by a decimal factor.

Rounding of the fraction to steps of .05 or .10, or to an ending such as .99, happens in update_page_field() in the configured number of decimals; cents are converted through an integer step because float modulo gives wrong results.

Hierarchical and flat taxonomies need different term inputTerm IDs for hierarchical taxonomies, term slugs one by one for flat ones

WordPress expects IDs for hierarchical taxonomies and names or slugs for tag-like ones. The bulk worker checks is_taxonomy_hierarchical(); for flat taxonomies it converts each ID to a slug, and for 'replace' it writes the first term with append=false and the rest with append=true. 'Remove' uses wp_remove_object_terms().

The history hook is fired manually before term changes, because this path bypasses update_page_field().

Exports of large selections hit the same limitsCSV appended per batch of 10 via fputcsv; XML written in WordPress WXR format

The count step writes the header row; each batch appends rows with fputcsv() on a file opened in a+ mode. The code notes that WP_Filesystem has no line-by-line append, which is why raw file functions are used here.

XML export builds an RSS document with the WXR 1.2 namespaces (wp:, content:, excerpt:), terms as <category domain nicename> and meta as wp:postmeta with CDATA, so the file follows the format of the WordPress exporter. File names carry the export date and minute, and files older than two days are deleted by a check throttled with a transient.

Filtering by fields, terms and meta

shipped · 2019

What it givesStaff select exactly the posts they want by fields, categories, custom values or pasted ID lists, and edits and exports apply to that same selection.

Problem
Bulk edit and export apply to whatever the filter matches, so the filter must turn many field conditions into one correct WP_Query and stay identical across separate AJAX requests and browser tabs.
Solution

Filter state is saved on the server under a per-tab random key and compiled into tax_query, meta_query and posts_where clauses in one hook used by every query.

Stack
  • WP_Query
  • tax_query
  • meta_query
  • posts_where
  • MySQL REGEXP
Tasks · 6
Table, bulk and export requests must see the same filterFilter saved server-side under a 16-character key generated per browser tab

The browser generates wpbe_filter_current_key when a filter is applied and sends it with every request; the source comment says this allows bulk by filter in different tabs. wpbe_apply_query_filter_data() reads the stored filter and is applied inside WPBE_POSTS::gets(), so the bulk ID list is built by the same code as the visible table.

Matching all of several terms needs one tax_query clause per termAND split into one tax_query clause per term; EXISTS and NOT EXISTS added

For the AND operator each selected term becomes its own clause joined by the outer relation AND, which is the workaround referenced in the code comment. Other operators (IN, NOT IN) pass the term list as is. include_children is controlled by a filter instead of the WordPress default.

Numeric meta ranges need a DECIMAL cast with an explicit scaleBETWEEN with DECIMAL(30,20) cast; one-sided and equal bounds handled

The code comment gives the case: with DECIMAL without precision, a search from 100 to 150 also returned 99.xx. The filter uses DECIMAL(30,20), switches to >= when only 'from' is set and to = when both bounds are equal. Date meta goes through normalize_calendar_date() first.

An empty meta value may be '' or a missing row'Empty' compiles to value = '' OR NOT EXISTS; 'not empty' to value != ''

Posts created before a meta key was introduced have no row at all, so an equality check alone misses them. The 'empty' behavior emits a nested meta_query with relation OR.

Text search needs OR, AND and NOT without raw SQLSmall syntax (^ for OR, ~ for AND, ! for NOT) compiled to prepared REGEXP

posts_txt_where() lowercases the input, splits on ^, and builds LOWER(column) REGEXP / NOT REGEXP conditions with begin, end, exact and not modes. Every value is bound through $wpdb->prepare() and the column must be in an allowlist of five wp_posts columns.

Users paste ID lists and rangesInput such as '12,40-55' expanded into post__in

Comma-separated items are taken as IDs; an item with a dash is expanded into the full range regardless of order. Date and number ranges on wp_posts columns (menu_order, post_parent, post dates) go through a separate posts_where filter with a column allowlist.

History and rollback

shipped · 2019

What it givesA wrong bulk edit can be undone as one step instead of fixing thousands of posts by hand.

Problem
A wrong bulk edit on thousands of posts is hard to undo: WordPress revisions do not cover meta, terms or most core fields, and the rollback itself can be as large as the edit.
Solution

Every field write is preceded by a log row with the previous value; rows of one bulk run share its key and are reverted in batches.

Stack
  • $wpdb
  • dbDelta
  • MySQL
  • jQuery
Tasks · 5
Capture the old value of any field type before it changesHook before each write stores the previous value in wp_wpbe_history

write() is attached to wpbe_before_update_page_field, which update_page_field() fires before every write; the bulk taxonomy path, which writes terms directly, fires it by hand. Taxonomy values are stored as a JSON list of term IDs, array meta as JSON (with JSON_HEX_QUOT and JSON_HEX_TAG for the serialized-meta editor), scalars as is. Each row has user_id and post_type, and the bulk key when it is part of a run.

Thousands of changes must form one undoable operationHead table wpbe_history_bulk per run: keys, post count and state

start_bulk() inserts the head row with the list of edited fields as JSON; each batch adds its size through UPDATE posts_count = posts_count + %d; finish_bulk() marks the run completed. A run cancelled by the user keeps the default state 'terminated', so the list shows it as partial. Both tables are created with dbDelta and the site charset, indexed on bulk_key and user_id, and dropped on uninstall.

Rolling back a large run hits the same time limitsRevert in portions of 10 rows per request; head row removed at the end

The browser asks for the row count of the bulk key, then calls wpbe_history_revert_bulk_portion repeatedly with limit 10 and a progress bar. Each reverted row is deleted, so the next portion picks up the remaining ones; the head row is deleted when the counter reaches the total.

A revert must not log itself as a new changeremove_all_actions() on the logging hook before writing old values

revert() detaches the history writer, decodes the stored value by field type (term ID lists, JSON meta arrays) and writes it back through the same update_page_field() used for edits, so save_post and edit_post fire for a revert as they do for an edit.

Single edits and bulk runs must show in one timelineSingle rows and bulk heads merged and sorted by time, per user and post type

get_history() reads the single rows and the bulk heads of the current user and post type, merges them and sorts them by time, newest first, so one list shows both kinds of change.

The free build shows the two latest entries and prunes older single rows and bulk heads when the list is opened.

Meta fields and structured content

shipped · 2019

What it givesCustom data such as lists, flags and image galleries can be edited safely without breaking it, even when staff do not know how it is stored.

Problem
Custom post types keep most of their data in postmeta, often as serialized arrays, lists of image IDs or flags with site-specific values, which a plain text cell would corrupt.
Solution

Meta keys are registered with a type and an edit view, and each view has its own reader, writer, filter and bulk handler.

Stack
  • PHP
  • postmeta API
  • jQuery
  • Block editor data store
Tasks · 5
Admins do not know which meta keys a post type usesKeys read from a sample post; registered keys become columns, filters, bulk fields

wpbe_meta_get_keys returns the meta keys of a post ID entered by the admin. A registered key is injected through wpbe_extend_fields, wpbe_filter_* and wpbe_bulk_* hooks, so it appears in the table, the filter panel and the bulk panel at once. Keys are trimmed but not lowercased, because keys like _woocs_sale_price_USD are case-sensitive.

The free build uses the first two registered meta fields only.

Serialized arrays must be edited without breaking structurePopup builder posts key/value arrays; server rebuilds nested arrays

The popup sends keys[]/values[] for flat and nested arrays and keys2[][]/values2[][] for lists of objects. __process_jsoned_meta_data() rebuilds the PHP array, decodes values that are valid JSON, and falls back to the raw value for plain strings. update_post_meta() then stores it serialized as WordPress expects. The same path is used by the bulk worker.

Flags are stored as 'yes'/'no' or other site-specific pairsMapping rules 'key:on^off' translate between the 1/0 switch and stored values

over_switcher_swicher_to_val() and over_switcher_val_to_swicher() read a settings string such as 'dist:yes,stest:true' and convert in both directions, so the switcher UI works with values written by other plugins.

Image galleries kept in meta as lists of attachment IDsGallery meta type saved as a comma list of attachment IDs

The gallery popup posts a URL-encoded form; the handler detects the encoding, parses it with parse_str(), sanitizes it and stores the IDs as a comma-separated string. The bulk worker accepts the same format.

Block editor content cannot be edited in a plain textareaPost opened in the block editor inside an iframe; content read via wp.data

Option 'Use gutenberg'. For post types that use the block editor, the popup loads post.php with wpbe_popup_editor=1; wp_editor_compatibility() hides publish and sidebar controls in that frame and exposes a function that returns wp.data.select('core/editor').getEditedPostContent(). The parent page reads it and saves through the normal field update.

Access control and request hardening

shipped · 2023

What it givesOnly trusted roles can use the editor, authors work only with their own posts, and staff see only the fields they are allowed to change.

Problem
A tool that can rewrite any field of any post in bulk is a high-value target: every AJAX endpoint must check the caller, and filter input ends up close to SQL.
Solution

One role gate before any endpoint is registered, nonces on field, bulk, tool and settings actions, field-level rights for non-admin roles, and prepared SQL with column allowlists.

Stack
  • WordPress roles
  • Nonces
  • $wpdb->prepare
  • wp_kses
Tasks · 5
Only trusted roles may reach any editor endpointRole check in init() runs before any wp_ajax_wpbe_* handler is added

can_manage_data() allows administrators, editors and roles added through the wpbe_author_area_roles filter. If it fails, init() returns before extensions load, so the AJAX actions do not exist for that user. Field updates, bulk edit and bulk delete, the tools panel (create, duplicate, delete, terms), meta settings, the options form, the history list and single reverts also check a nonce.

Editors should not see or change every field and post typeAdmin sets field visibility and allowed post types for non-admin roles (premium)

For non-admin roles get_fields() drops hidden columns, so they are not offered in the table or the bulk panel; update_page_field() checks is_current_user_can_edit_field() and returns 'forbidden' for a hidden field; filter_post_types() narrows the post type selector, and the settings constructor moves the user to an allowed post type. In the free build the visibility checkboxes are rendered disabled.

Authors should work with their own posts onlyAuthor-area roles get author=self in the editor's queries and cannot change post_author

For roles added through the wpbe_author_area_roles filter, WPBE_AUTHOR_AREA adds 'author' => current user ID through the query filter used by the table, the bulk ID list and export, so these lists show only the user's own posts. Its wpbe_user_can_edit callback blocks post_author for these roles.

Filter input and copied post data end up in SQLColumn names from allowlists, every value bound through $wpdb->prepare()

Text and range filters accept only listed wp_posts columns and bind values through prepare(). History queries bind user, post type and bulk key, and build NOT IN lists from array_fill() placeholders.

Duplication copies terms by slug and all postmeta except _wp_old_slug in one INSERT ... SELECT %d, %s, %s UNION ALL statement, each row built with prepare(), then sets the copy to draft.

Content fields must keep embeds but drop scriptswp_kses with the post allowlist plus a narrow iframe attribute list

wpbe_update_page_field() sanitizes arrays recursively with wp_kses and the post allowlist. For content, excerpt and rich-text fields it adds iframe with a fixed set of attributes so video embeds survive. A filter can switch to sanitize_text_field() for stricter sites.

Numbers

wordpress.org
  • Active installs 4,000+ wordpress.org counter.
  • Reviews 27 Average 4.7 out of 5.
  • Years supported 7 Since 3 September 2019, last update 12 October 2026.