Target version: 1.6.0 (bump from current 1.5.3)
Scope: Restructure the Products → Category Manipulations admin page so its 6 operation sections + the Important Notes block become tabs, where each tab's HTML (and its expensive data) is fetched via a separate AJAX "partial" request only when the tab is first opened.
Goal: Eliminate the page-load timeout. Today views/manipulations.php runs three expensive data calls before any HTML is sent, so one slow page request must finish all of them. After this change the shell page renders instantly and each tab pays only for its own calculation, in its own request.
views/manipulations.php runs all of the following synchronously on every page load, before output:
$categories = $manipulator->get_categories_with_counts(); // N+1 queries
$media_categories = $manipulator->get_media_categories_with_counts(); // N+1 queries
$unassigned_media_count = $manipulator->get_unassigned_unused_media_count(); // heavy multi-table scan
get_categories_with_counts() — loads every product_cat term, then runs one count query per category (get_category_product_count()). On a store with hundreds of categories this is hundreds of queries.get_media_categories_with_counts() — same N+1 shape for the media-category taxonomy.get_unassigned_unused_media_count() — calls get_unassigned_unused_media_ids(), which scans the whole posts/postmeta tables plus every _product_image_gallery row.All three must complete inside a single PHP request before the page renders. On a large store that single request exceeds max_execution_time. The fix is to (a) render the page shell with zero heavy calls, and (b) move each section's data fetch into its own on-demand AJAX request.
The page Products → Category Manipulations becomes:
.nav-tab-wrapper markup).#studiou-wcpcm-tab-content.#move, #clear-media, …) so a browser refresh — and the location.reload() calls that several operations already perform on success — return to the same tab and re-fetch its (now up-to-date) partial.Seven tabs, in this order:
| Slug | Tab label (reuses existing translated heading) | Heavy data it needs |
|---|---|---|
move |
Move products between categories | get_categories_with_counts() |
batch-description |
Batch description between categories | get_categories_with_counts() |
delete-products |
Delete products in category | get_categories_with_counts() |
clear-media |
Clear Media Categories | get_media_categories_with_counts() |
remove-unassigned |
Remove Not Assigned Media | get_unassigned_unused_media_count() |
upvp |
Update product variant prices | get_categories_with_counts() |
notes |
Important Notes | none (static) |
Each tab request computes only its own data. A user who only needs the Move tab never pays for the media scans.
| File | Purpose |
|---|---|
views/manipulations/tab-move.php |
Partial — "Move products between categories" card body |
views/manipulations/tab-batch-description.php |
Partial — "Batch description between categories" card body |
views/manipulations/tab-delete-products.php |
Partial — "Delete products in category" card body |
views/manipulations/tab-clear-media.php |
Partial — "Clear Media Categories" card body |
views/manipulations/tab-remove-unassigned.php |
Partial — "Remove Not Assigned Media" card body |
views/manipulations/tab-upvp.php |
Partial — "Update product variant prices" card body |
views/manipulations/tab-notes.php |
Partial — "Important Notes" card body |
| File | Change |
|---|---|
studiou-wc-product-cat-manage.php |
Bump Version: header and STUDIOU_WCPCM_VERSION to 1.6.0. Add 3 new i18n keys (tabLoading, tabLoadError, see §7). |
includes/class-studiou-wc-product-cat-manage-manipulator.php |
Add handle_load_tab_ajax() + register the studiou_wcpcm_load_tab action. Add cached wrappers get_categories_with_counts() / get_media_categories_with_counts() + cache-invalidation hooks (see §5). |
views/manipulations.php |
Replace the whole body with the lightweight shell (tab bar + empty container). No heavy data calls. |
assets/css/admin.css |
Add styles for the tab content container loading state (tab bar itself uses WP core styles). |
assets/js/admin.js |
Add the tab controller; convert per-tab init*Form() functions to be invoked after the partial is injected, via a slug→init registry. |
languages/studiou-wc-product-cat-manage.pot |
Add new translation strings. |
languages/studiou-wc-product-cat-manage-cs_CZ.po |
Add Czech translations. |
languages/studiou-wc-product-cat-manage-cs_CZ.mo |
Regenerate (msgfmt). |
readme.md |
Bump version, note the tabbed UI under Features, add Version 1.6.0 changelog entry. |
CLAUDE.md |
Bump Current Version to 1.6.0, document the tab partials, the new AJAX endpoint, the views/manipulations/ folder. |
directory-structure.txt |
Add the new views/manipulations/ folder and its 7 partial files. |
No existing AJAX handler, no existing operation logic, and no DB schema changes.
views/manipulations.phpReplace the entire current contents with a shell that does no $manipulator calls:
<?php
// If this file is called directly, abort.
if (!defined('WPINC')) { die; }
$tabs = array(
'move' => __('Move products between categories', 'studiou-wc-product-cat-manage'),
'batch-description' => __('Batch description between categories', 'studiou-wc-product-cat-manage'),
'delete-products' => __('Delete products in category', 'studiou-wc-product-cat-manage'),
'clear-media' => __('Clear Media Categories', 'studiou-wc-product-cat-manage'),
'remove-unassigned' => __('Remove Not Assigned Media', 'studiou-wc-product-cat-manage'),
'upvp' => __('Update product variant prices', 'studiou-wc-product-cat-manage'),
'notes' => __('Important Notes', 'studiou-wc-product-cat-manage'),
);
?>
<div class="wrap studiou-wcpcm-wrap">
<h1><?php echo esc_html__('Category Manipulations', 'studiou-wc-product-cat-manage'); ?></h1>
<div class="studiou-wcpcm-notice-area"></div>
<h2 class="nav-tab-wrapper studiou-wcpcm-tabs">
<?php foreach ($tabs as $slug => $label): ?>
<a href="#<?php echo esc_attr($slug); ?>"
class="nav-tab"
data-tab="<?php echo esc_attr($slug); ?>">
<?php echo esc_html($label); ?>
</a>
<?php endforeach; ?>
</h2>
<div id="studiou-wcpcm-tab-content" class="studiou-wcpcm-tab-content">
<div class="studiou-wcpcm-tab-loading">
<?php echo esc_html__('Loading...', 'studiou-wc-product-cat-manage'); ?>
</div>
</div>
</div>
render_manipulations_page() in the main plugin file is unchanged — it still just includes this file. The expensive $manipulator calls are deleted from here entirely.
class-studiou-wc-product-cat-manage-manipulator.phpIn __construct(), alongside the existing add_action('wp_ajax_studiou_wcpcm_*', ...) lines:
add_action('wp_ajax_studiou_wcpcm_load_tab', array($this, 'handle_load_tab_ajax'));
handle_load_tab_ajax()Follows the established handler pattern in this file (ob cleaning, error_reporting(0) save/restore, nonce, capability):
public function handle_load_tab_ajax() {
while (ob_get_level()) { ob_end_clean(); }
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'studiou-wcpcm-nonce')) {
wp_send_json_error(array('message' => __('Security check failed', 'studiou-wc-product-cat-manage')));
return;
}
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(array('message' => __('You do not have sufficient permissions', 'studiou-wc-product-cat-manage')));
return;
}
$tab = isset($_POST['tab']) ? sanitize_key($_POST['tab']) : '';
$allowed = array('move', 'batch-description', 'delete-products', 'clear-media', 'remove-unassigned', 'upvp', 'notes');
if (!in_array($tab, $allowed, true)) {
wp_send_json_error(array('message' => __('Unknown tab', 'studiou-wc-product-cat-manage')));
return;
}
$partial = STUDIOU_WCPCM_PLUGIN_DIR . 'views/manipulations/tab-' . $tab . '.php';
if (!file_exists($partial)) {
wp_send_json_error(array('message' => __('Tab content not found', 'studiou-wc-product-cat-manage')));
return;
}
// $manipulator is available to the partial; each partial fetches only the data it needs.
$manipulator = $this;
try {
ob_start();
include $partial;
$html = ob_get_clean();
wp_send_json_success(array('tab' => $tab, 'html' => $html));
} catch (Exception $e) {
if (ob_get_level()) { ob_end_clean(); }
error_log('STUDIOU WC TABS: tab "' . $tab . '" render failed: ' . $e->getMessage());
wp_send_json_error(array('message' => $e->getMessage()));
}
}
The whitelist ($allowed) plus sanitize_key() plus the explicit tab-<slug>.php filename pattern means the include path can never be influenced into a directory traversal.
Each partial receives $manipulator (set to $this by the handler) and fetches only what it needs at the top, e.g. views/manipulations/tab-move.php:
<?php
if (!defined('WPINC')) { die; }
/** @var Studiou_WC_Product_Cat_Manage_Manipulator $manipulator */
$categories = $manipulator->get_categories_with_counts();
?>
<div class="studiou-wcpcm-card">
<h2><?php echo esc_html__('Move products between categories', 'studiou-wc-product-cat-manage'); ?></h2>
... existing "Move" card markup, copied verbatim from current manipulations.php lines 30-81 ...
</div>
Mapping of which existing markup moves into which partial (line numbers refer to the current views/manipulations.php):
tab-move.php — current lines 29–82 (Move card). Needs $categories.tab-batch-description.php — lines 85–141. Needs $categories.tab-delete-products.php — lines 144–203. Needs $categories.tab-clear-media.php — lines 206–269 (keeps the empty($media_categories) fallback branch). Needs $media_categories = $manipulator->get_media_categories_with_counts();.tab-remove-unassigned.php — lines 272–302. Needs $unassigned_media_count = $manipulator->get_unassigned_unused_media_count();.tab-upvp.php — lines 305–390. Needs $categories.tab-notes.php — lines 393–406 (static <ul>; no data call).The .studiou-wcpcm-card wrapper stays inside each partial so the existing CSS keeps applying unchanged.
Four of the seven tabs (move, batch-description, delete-products, upvp) each call get_categories_with_counts(). With lazy loading that is now four separate requests, each re-running the full N+1. To stop repeated recomputation, wrap the two N+1 methods in a transient cache.
Rename the current bodies to private *_uncached() methods and add caching public wrappers:
public function get_categories_with_counts() {
$key = 'studiou_wcpcm_cat_counts';
$data = get_transient($key);
if (false === $data) {
$data = $this->get_categories_with_counts_uncached();
set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
}
return $data;
}
public function get_media_categories_with_counts() {
$key = 'studiou_wcpcm_media_cat_counts';
$data = get_transient($key);
if (false === $data) {
$data = $this->get_media_categories_with_counts_uncached();
set_transient($key, $data, 5 * MINUTE_IN_SECONDS);
}
return $data;
}
Invalidate both transients whenever categories or product/category relationships change. Register in __construct():
$bust = function() {
delete_transient('studiou_wcpcm_cat_counts');
delete_transient('studiou_wcpcm_media_cat_counts');
};
add_action('created_term', $bust);
add_action('edited_term', $bust);
add_action('delete_term', $bust);
add_action('set_object_terms', $bust); // product <-> category assignment changes
Also call the buster directly at the end of move_products, delete_products/delete_products_batch, clear_media/clear_media_batch so the counts shown after an operation's location.reload() are fresh even within the 5-minute window.
The 5-minute TTL is a safety net only; the explicit invalidation keeps the numbers correct. The get_unassigned_unused_media_count() scan is left uncached — it is only triggered by its own single tab and there is no cheap, reliable invalidation signal for "media used anywhere".
Note: §5.4 caching is the secondary optimisation. The primary timeout fix is §4 + §6 — splitting one giant request into seven small on-demand ones. Caching can be implemented in the same release or deferred without affecting the timeout fix.
assets/js/admin.jsToday every form is initialised in $(document).ready() via safeInit(name, selector, initFn). With lazy tabs, the manipulation forms do not exist in the DOM at ready time — they arrive later inside an AJAX partial. So their init*Form() functions must run after the partial is injected, not on ready.
Add a controller that owns tab switching, lazy fetch, caching, and post-inject init. Register it on ready only when the tab bar is present:
safeInit('Manipulation tabs', '.studiou-wcpcm-tabs', initManipulationTabs);
// Maps a tab slug to the init function that wires its partial's handlers.
var TAB_INIT = {
'move': initMoveForm,
'batch-description': initBatchDescriptionForm,
'delete-products': initDeleteProductsForm,
'clear-media': initClearMediaForm,
'remove-unassigned': initRemoveUnassignedMediaForm,
'upvp': initUpdateVariantPricesForm,
'notes': null // static, nothing to wire
};
function initManipulationTabs() {
var $tabs = $('.studiou-wcpcm-tabs .nav-tab');
var $content = $('#studiou-wcpcm-tab-content');
var cache = {}; // slug -> rendered HTML, populated on first load
var allowed = $tabs.map(function() { return $(this).data('tab'); }).get();
function activate(slug) {
if (allowed.indexOf(slug) === -1) { slug = allowed[0]; }
$tabs.removeClass('nav-tab-active')
.filter('[data-tab="' + slug + '"]').addClass('nav-tab-active');
if (window.history && history.replaceState) {
history.replaceState(null, '', '#' + slug);
} else {
window.location.hash = slug;
}
if (cache[slug] !== undefined) {
$content.html(cache[slug]);
return; // already loaded + already initialised; just show it
}
$content.html('<div class="studiou-wcpcm-tab-loading">' +
(studiouWcpcm.i18n.tabLoading || 'Loading...') + '</div>');
$.ajax({
url: studiouWcpcm.ajaxUrl,
type: 'POST',
data: { action: 'studiou_wcpcm_load_tab', nonce: studiouWcpcm.nonce, tab: slug },
success: function(response) {
if (response.success && response.data && response.data.html) {
cache[slug] = response.data.html;
$content.html(response.data.html);
var initFn = TAB_INIT[slug];
if (typeof initFn === 'function') {
try { initFn(); }
catch (e) { console.error('STUDIOU WC: tab init "' + slug + '" failed', e); }
}
} else {
$content.html('<div class="studiou-wcpcm-tab-error">' +
((response.data && response.data.message) ||
studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + '</div>');
}
},
error: function(xhr, status, error) {
$content.html('<div class="studiou-wcpcm-tab-error">' +
(studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + ': ' + error + '</div>');
}
});
}
$tabs.on('click', function(e) {
e.preventDefault();
activate($(this).data('tab'));
});
// Initial tab: URL hash if valid, else first tab.
var initial = (window.location.hash || '').replace(/^#/, '');
activate(allowed.indexOf(initial) !== -1 ? initial : allowed[0]);
}
$(document).ready() block (their forms no longer exist at ready):
safeInit('Move form', ...), safeInit('Batch description form', ...), safeInit('Delete products form', ...), safeInit('Clear media form', ...), safeInit('Remove unassigned media form', ...), safeInit('Update variant prices form', ...).safeInit calls for Import form, Export form, Sample download, Product import form, Product export form — those live on other admin pages and still load normally.init*Form() function bodies themselves are unchanged — they already query by ID/selector and bind handlers; they just get invoked from TAB_INIT after injection instead of on ready.init*Form() runs exactly once → no double-bound handlers. (If a tab were ever re-fetched, re-binding would duplicate handlers; the cache guarantees it is not. A defensive .off() before .on() inside each init is an optional hardening step.)initUpdateVariantPricesForm() already calls loadAttributes() at the end of its body — that AJAX now fires when the UPVP tab is opened, which is the desired lazy behaviour.location.reload() on success (move, delete, clear-media, remove-unassigned) keep doing so. After reload the shell re-reads the URL hash and re-fetches that tab's partial, which now shows refreshed counts. No JS change needed for that path beyond the hash being set by activate().$(window).on('load', ...) debug block that logs Move form exists, UPVP form exists, etc. will now log false for tab forms at window-load time. Update it to drop the stale per-form checks (or leave it — it is debug-only and harmless). Recommended: trim it to just the tab-bar presence check.New strings to add to the i18n map in wp_localize_script('studiou-wcpcm-admin', 'studiouWcpcm', ...) in studiou-wc-product-cat-manage.php:
| Key | English | Czech |
|---|---|---|
tabLoading |
Loading... | Načítání... |
tabLoadError |
Failed to load tab | Nepodařilo se načíst záložku |
New translatable strings introduced in PHP (shell + handler), to add to .pot / cs_CZ.po:
| English | Czech |
|---|---|
| Loading... | Načítání... |
| Unknown tab | Neznámá záložka |
| Tab content not found | Obsah záložky nenalezen |
All seven tab labels reuse strings that are already translated (they are the current section <h2> headings), so no new label translations are required. Recompile cs_CZ.mo with msgfmt after editing the .po.
assets/css/admin.cssThe tab bar uses WordPress core classes (.nav-tab-wrapper, .nav-tab, .nav-tab-active) which are already styled by wp-admin. Only add styling for the content container states:
.studiou-wcpcm-tab-content {
margin-top: 20px;
}
.studiou-wcpcm-tab-loading,
.studiou-wcpcm-tab-error {
padding: 40px;
text-align: center;
color: #666;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
}
.studiou-wcpcm-tab-error {
color: #a00;
}
The existing .studiou-wcpcm-card rules are reused untouched — each partial still wraps its body in a .studiou-wcpcm-card.
CLAUDE.md1.5.3 → 1.6.0.manipulations.php bullet to note it is now a lightweight shell, and add a views/manipulations/ sub-folder bullet listing the 7 tab partials.studiou_wcpcm_load_tab — renders a manipulations tab partial on demand (NEW in v1.6.0).get_categories_with_counts() / get_media_categories_with_counts() wrappers.readme.md1.6.0.Add changelog entry:
### Version 1.6.0
- Category Manipulations page restructured into tabs
- Each tab's content + data is loaded on demand via a separate AJAX request
- Fixes page-load timeout on stores with many categories / media files
- Active tab is preserved in the URL hash across refreshes
- New AJAX handler: studiou_wcpcm_load_tab
- Category/media count queries are now transient-cached (5 min, invalidated on term changes)
directory-structure.txtviews/manipulations/ folder and its 7 tab-*.php files.1.6.0.views/manipulations/ folder and the 7 tab-*.php partials by moving each section's existing markup out of views/manipulations.php (see §5.3 line mapping). Add the $manipulator data-fetch line at the top of each non-static partial.views/manipulations.php with the shell from §4.handle_load_tab_ajax() + register studiou_wcpcm_load_tab in the manipulator constructor.initManipulationTabs() + the TAB_INIT registry to admin.js; remove the six obsolete safeInit calls from $(document).ready(); trim the window-load debug block.wp_localize_script i18n with tabLoading / tabLoadError..pot, cs_CZ.po; regenerate cs_CZ.mo.readme.md, CLAUDE.md, directory-structure.txt.?ver= query strings, so the asset version bump alone will not bust the cache — instruct the user to hard-refresh).location.reload(), the page returns to the same tab (via hash) and shows refreshed counts.…&page=studiou-category-manipulations#upvp; the UPVP tab opens directly. An invalid hash falls back to the first tab.studiou_wcpcm_load_tab with a bad nonce → "Security check failed"; an unknown tab value → "Unknown tab"; a user without manage_woocommerce → permission error.tab param (e.g. ../../wp-config) is rejected by the whitelist and never reaches include.cs_CZ; tab labels and the "Loading..." / error strings render in Czech.safeInit calls are untouched).The change is structural but non-destructive — no DB schema changes, no data writes, all existing operation handlers and their logic are untouched. To roll back: restore the previous monolithic views/manipulations.php, delete the views/manipulations/ folder, revert admin.js / admin.css / the manipulator class / the main plugin file to 1.5.3. The two new transients (studiou_wcpcm_cat_counts, studiou_wcpcm_media_cat_counts) are self-expiring and need no cleanup.