# Implementation Plan — Tabbed Category Manipulations Page (lazy partial loading)
**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.
---
## 1. Why the page times out today
`views/manipulations.php` runs all of the following synchronously on every page load, before output:
```php
$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.
---
## 2. Target behaviour
The page **Products → Category Manipulations** becomes:
- A title, the existing notice area, and a horizontal **tab bar** (WordPress core `.nav-tab-wrapper` markup).
- An empty content container `#studiou-wcpcm-tab-content`.
- On load, the controller activates a tab (from the URL hash, else the first tab) and fetches its partial via AJAX.
- Clicking a tab fetches that tab's partial **once**, then caches the rendered HTML in the DOM. Re-clicking a cached tab just shows it — no new request.
- The active tab is reflected in the URL hash (`#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.
---
## 3. Files to create / modify
### 3.1 New files
| 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 |
### 3.2 Modified files
| 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.
---
## 4. Shell page — `views/manipulations.php`
Replace the entire current contents with a shell that does **no** `$manipulator` calls:
```php
__('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'),
);
?>
```
`render_manipulations_page()` in the main plugin file is unchanged — it still just `include`s this file. The expensive `$manipulator` calls are deleted from here entirely.
---
## 5. Backend — `class-studiou-wc-product-cat-manage-manipulator.php`
### 5.1 Register the new AJAX action
In `__construct()`, alongside the existing `add_action('wp_ajax_studiou_wcpcm_*', ...)` lines:
```php
add_action('wp_ajax_studiou_wcpcm_load_tab', array($this, 'handle_load_tab_ajax'));
```
### 5.2 New handler `handle_load_tab_ajax()`
Follows the established handler pattern in this file (`ob` cleaning, `error_reporting(0)` save/restore, nonce, capability):
```php
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-.php` filename pattern means the `include` path can never be influenced into a directory traversal.
### 5.3 Each partial is self-contained
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
get_categories_with_counts();
?>
... existing "Move" card markup, copied verbatim from current manipulations.php lines 30-81 ...
```
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 ``; no data call).
The `.studiou-wcpcm-card` wrapper stays inside each partial so the existing CSS keeps applying unchanged.
### 5.4 Cache the N+1 category counts (addresses the "calculations" cost)
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:
```php
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()`:
```php
$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.
---
## 6. Frontend — `assets/js/admin.js`
### 6.1 The problem with the current init model
Today 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`.
### 6.2 Tab controller
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:
```js
safeInit('Manipulation tabs', '.studiou-wcpcm-tabs', initManipulationTabs);
```
```js
// 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('' +
(studiouWcpcm.i18n.tabLoading || 'Loading...') + '
');
$.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('' +
((response.data && response.data.message) ||
studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + '
');
}
},
error: function(xhr, status, error) {
$content.html('' +
(studiouWcpcm.i18n.tabLoadError || 'Failed to load tab') + ': ' + error + '
');
}
});
}
$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]);
}
```
### 6.3 Changes to the existing init functions
- **Remove** these lines from the `$(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', ...)`.
- **Keep** the `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.
- The six `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`.
- Each tab partial is fetched at most once and cached, so each `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.
- Operations that currently call `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()`.
- The `$(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.
---
## 7. Translations / i18n
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 `` headings), so no new label translations are required. Recompile `cs_CZ.mo` with `msgfmt` after editing the `.po`.
---
## 8. CSS — `assets/css/admin.css`
The 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:
```css
.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`.
---
## 9. Documentation updates
### 9.1 `CLAUDE.md`
- Bump *Current Version* `1.5.3` → `1.6.0`.
- Under *Views (views/)*, change the `manipulations.php` bullet to note it is now a lightweight shell, and add a `views/manipulations/` sub-folder bullet listing the 7 tab partials.
- Under *AJAX Handlers → Category Operations*, add `studiou_wcpcm_load_tab` — renders a manipulations tab partial on demand (NEW in v1.6.0).
- Under the manipulator class bullets, note the new transient-cached `get_categories_with_counts()` / `get_media_categories_with_counts()` wrappers.
### 9.2 `readme.md`
- Bump version to `1.6.0`.
- Under *Features*, note the Category Manipulations page is now tabbed with lazy-loaded sections.
- 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)
```
### 9.3 `directory-structure.txt`
- Add the `views/manipulations/` folder and its 7 `tab-*.php` files.
---
## 10. Step-by-step execution order
1. Bump version constant + plugin header to `1.6.0`.
2. Create the `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.
3. Replace `views/manipulations.php` with the shell from §4.
4. Add `handle_load_tab_ajax()` + register `studiou_wcpcm_load_tab` in the manipulator constructor.
5. Add the transient-cached count wrappers + invalidation hooks (§5.4).
6. Add `initManipulationTabs()` + the `TAB_INIT` registry to `admin.js`; remove the six obsolete `safeInit` calls from `$(document).ready()`; trim the window-load debug block.
7. Extend `wp_localize_script` `i18n` with `tabLoading` / `tabLoadError`.
8. Add the tab-content CSS rules.
9. Update `.pot`, `cs_CZ.po`; regenerate `cs_CZ.mo`.
10. Update `readme.md`, `CLAUDE.md`, `directory-structure.txt`.
11. Manual smoke test — see §11.
12. Hard-refresh in the browser (studiou.cz strips `?ver=` query strings, so the asset version bump alone will not bust the cache — instruct the user to hard-refresh).
---
## 11. Test plan
1. **Shell loads instantly** — open Category Manipulations; the page renders immediately with the tab bar and a "Loading..." placeholder; no timeout even on a store with hundreds of categories.
2. **First tab auto-loads** — with no URL hash, the *Move* tab fetches and renders; its category dropdowns populate.
3. **Lazy loading** — open the *Clear Media Categories* tab; confirm the media-category scan runs only now (not on initial page load) — check via query monitor / debug log.
4. **Tab caching** — switch away from a tab and back; no second AJAX request fires; the form state is preserved.
5. **Each operation still works** — Move, Batch description, Delete products, Clear media, Remove unassigned media, UPVP — run each from inside its tab; handlers, spinners, progress bars, and confirmation dialogs behave as before.
6. **Post-operation reload** — after a Move/Delete/Clear that calls `location.reload()`, the page returns to the same tab (via hash) and shows refreshed counts.
7. **URL hash** — manually load `…&page=studiou-category-manipulations#upvp`; the UPVP tab opens directly. An invalid hash falls back to the first tab.
8. **Count caching** — open *Move*, then *Delete products*, then *UPVP* in succession; the N+1 category-count query runs once, subsequent tabs read the transient. After editing a category, the next tab load recomputes.
9. **Media fallback** — on a store with no media-category taxonomy, the *Clear Media Categories* tab still shows the graceful "install a media category plugin" message.
10. **Security** — `studiou_wcpcm_load_tab` with a bad nonce → "Security check failed"; an unknown `tab` value → "Unknown tab"; a user without `manage_woocommerce` → permission error.
11. **Path safety** — confirm a crafted `tab` param (e.g. `../../wp-config`) is rejected by the whitelist and never reaches `include`.
12. **Translations** — switch the site to `cs_CZ`; tab labels and the "Loading..." / error strings render in Czech.
13. **Other pages unaffected** — Import/Export Categories and Product Import/Export pages still work (their `safeInit` calls are untouched).
---
## 12. Rollback
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.