# 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 `