---
name: tools-backup
description: Guide for building Vvveb CMS admin backup tools with AJAX stepped backup/restore to avoid PHP timeouts — covers the controller save/nextBackup/nextRestore/restore/download/delete/index pattern, Vtpl template loops, admin HTML sources, progress-bar JS controllers, menu registration and storage layout. Use when creating a new backup tool, editing tools/backup, tools/plugins-backup, tools/themes-backup or tools/media-backup, adding per-file actions inside a backup folder, or debugging a backup that stalls, times out or never finishes.
---

# Vvveb Tools Backup

Backup tools live in `admin/controller/tools/<name>-backup.php` (module `tools/<name>-backup`) with a matching `admin/template/tools/<name>-backup.tpl`, HTML source `public/admin/default/src/tools/<name>-backup.html` (built to `public/admin/default/tools/<name>-backup.html` via gulp), JS controller `public/js/admin/controller/<name>-backup.js`, and a `config/admin-menu.php` entry under `tools`.

Reference implementations — copy patterns from these, do not invent structure:
- `admin/controller/tools/backup.php` — database backup (SQL dump, `.sql` files in `storage/backup/db/`)
- `admin/controller/tools/plugins-backup.php` — per-plugin zips (`storage/backup/plugins/Y-m-d-H-i/<slug>.zip`)
- `admin/controller/tools/themes-backup.php` — same shape as plugins but `DIR_THEMES` → `storage/backup/themes/`
- `admin/controller/tools/media-backup.php` — single `media.zip` from `DIR_MEDIA` → `storage/backup/media/`

## Workflow

1. Copy the closest existing backup controller (`plugins-backup.php` for selectable multi-zip, `media-backup.php` for single-archive).
2. Add/adjust the `.tpl`, HTML source, and JS controller together — all four files must agree on param names and progress element ids.
3. Register the menu entry in `config/admin-menu.php`.
4. Verify: `php -l` every PHP file, `node --check` the JS, rebuild HTML with `npx gulp fileinclude` in `public/admin/default`, exercise save → nextBackup loop → restore → download → delete with a stubbed harness.

## Storage layout

```
storage/backup/db/<file>.sql
storage/backup/plugins/<Y-m-d-H-i>/<slug>.zip   (+ transient <folder>/plugins.json resume file)
storage/backup/themes/<Y-m-d-H-i>/<slug>.zip    (+ transient <folder>/themes.json)
storage/backup/media/<Y-m-d-H-i>/media.zip
```

Folder names use `date('Y-m-d-H-i')` with collision fallback: `Y-m-d-H-i-s`, then `Y-m-d-H-i-<n>`. Zip entries carry a top-level prefix (`<slug>/...` for plugins/themes, bare relative paths for media).

## Controller pattern

Every backup controller implements the same six actions plus helpers:

```php
namespace Vvveb\Controller\Tools;

class PluginsBackup extends Base {
	private string $backupDir;

	function __construct() {
		$this->backupDir = DIR_BACKUP . 'plugins' . DS;
		if (! is_dir($this->backupDir)) {
			@mkdir($this->backupDir, 0755, true);
		}
	}
}
```

Helpers: `generateFolderName()`, slug sanitizer (`basename()` + `preg_replace('/[^a-zA-Z0-9_\-]/', '', ...)`), `zip*()`/`unzip*()` via `\ZipArchive` (`CREATE | OVERWRITE`, `RecursiveDirectoryIterator::SKIP_DOTS`, `LEAVES_ONLY`), media variant uses `getMediaFiles()` (sorted relative paths including dirs).

### save() — create run folder, return first step

- Guard `class_exists('\ZipArchive')` → ajax `{'error': ...}` else `$this->view->errors[]` + `$this->index()`.
- Plugins/themes: read selection from `post['plugins'] ?? get['plugins']` (string → `explode(',')`, array stays), sanitize, reject empty, validate against `getList()` slugs or `is_dir(DIR_*)`. Media: no selection, just check `is_dir(DIR_MEDIA)`.
- `mkdir($folderPath)`, write resume file (`plugins.json`/`themes.json`) for plugins/themes so later steps can recover a truncated URL.
- Ajax: `die(json_encode($url + ['url' => url($url), 'position' => 0, 'count' => N, ...]))`. Non-ajax: `$this->redirect($url); $this->index();`.

### nextBackup() — time-boxed chunk loop

```php
$folder   = sanitizeFileName($this->request->get['folder'] ?? '');
$position = (int)($this->request->get['position'] ?? 0);
// plugins/themes also parse get['plugins'] (comma string or array) + post fallback + <folder>/plugins.json fallback
$start = microtime(true);
while (microtime(true) - $start < 8 && ($position < $count)) {
	// zip one plugin/theme or N media files, then $position++
}
if ($position >= $count) {
	@unlink($metaFile); // plugins/themes only
	$url = ['module' => 'tools/plugins-backup', 'success' => __('Backup finished!')];
	die(json_encode($url + ['url' => url($url), 'file' => $folder, 'position' => $position, 'count' => $count, 'success' => $message]));
}
$url = ['module' => 'tools/plugins-backup', 'action' => 'nextBackup', 'folder' => $folder, 'plugins' => implode(',', $plugins), 'position' => $position, 'count' => $count];
die(json_encode($url + ['url' => url($url), 'file' => $folder, 'position' => $position, 'count' => $count]));
```

DB variant differs: paginates one table at a time (`get['table']`, `get['page']`, `getTableDump($table, $page++)`, `array_search($table, $tables)` for position, appends to `.sql` file). Media variant zips `getMediaFiles()` entries into one `media.zip` (`CREATE` on step 0 with `OVERWRITE`, plain `CREATE` after).

### restore() → nextRestore()

`restore()` only validates and returns the first `nextRestore` URL. It accepts **both** granularities:
- whole folder: `{folder}` → restores every `*.zip` in the folder
- single file: `{file: "folder/name.zip"}` (or separate `folder` + `file`) → restores one zip

Split `file` on `DS` (`folder/file`), `basename()` the file part. Ajax: `die(json_encode($url + ['url' => url($url)]))`.

`nextRestore()` mirrors `nextBackup()`: same 8-second loop, `unzip*()` / `extractTo()` per file. Single-file mode sets `$files = [$singlePath]` (validate `file_exists` first); folder mode uses `glob($folderPath . DS . '*.zip')`. Keep `file` in follow-up URLs and JSON (`'file' => $folder . '/' . $fileParam`) so progress keeps context. Non-ajax mid-loop: `$this->redirect($url); die('Processing');`.

### download() / delete()

Both accept `folder` + `file` with the same `DS`-split. `file` may be `folder/name.zip` in one param.

- Single file: stream it (`Content-Type: application/zip`, `Content-Length`, `Content-Disposition: attachment`) or `unlink()` it (rmdir parent if no `*.zip` remain).
- Folder: download builds a temp zip of the folder (media short-circuits: single `media.zip` streams directly); delete uses `\Vvveb\rrmdir($path); @rmdir($path)`.
- Always finish non-stream paths with `$this->index()`.

### index()

Sets selection data (`view->plugins` / `view->themes` sorted by name, or `view->mediaCount`/`view->mediaSize` via `formatBytes()`), plus per-run `view->backups[]`:

```php
$backups[] = [
	'name' => $folder, 'folder' => $folder, 'file' => $folder, 'key' => $index,
	'size' => formatBytes($size), 'created_at' => date('Y/m/d H:i', filemtime($folderPath)),
	'count' => count($files), 'files' => $backupFiles, // each: name/file/slug/folder/file_path/size + download/delete/restore-url
	'restore-url' => url([...'restore']), 'download-url' => url([...'download']), 'delete-url' => url([...'delete']),
];
usort($backups, fn ($a, $b) => strcmp($b['name'], $a['name']));
$view->backupUrl = url([...'save']);
$view->hasZip = class_exists('\ZipArchive');
```

`file_path` is always `folder . '/' . name` (forward slash — the tpl/JS send it back as `file`).

## JS controller (`public/js/admin/controller/<name>-backup.js`)

Copy `plugins-backup.js` (or `backup.js` for DB). Shape:

```js
class PluginsBackupController {
	action(url, params, btn) { /* disableBtn, fetch POST with X-Requested-With + csrf, step json, update #progress-*, ajaxStack.add(request) while position < count */ }
	make(url, params = {}, btn = false) { /* default '?module=tools/plugins-backup&action=save' */ }
	restore(url, params = {}, btn = false) { return this.action(url, params, btn); }
}
let PluginsBackup = new PluginsBackupController();
export {PluginsBackup};
window.PluginsBackup = PluginsBackup;
```

Rules:
- POST body is `URLSearchParams({...bodyParams, csrf})` with `Content-Type: application/x-www-form-urlencoded`.
- Selection arrays must be comma-joined before encoding: `if (Array.isArray(bodyParams.plugins)) bodyParams.plugins = bodyParams.plugins.join(',');` (PHP accepts comma string or array).
- Progress ids consumed: `#progress`, `#progress-bar`, `#progress-status`, `#progress-file`, `#progress-position`, `#progress-count`, plus `#progress-plugin` (plugins) / `#progress-theme` (themes). Media only needs file/position/count.
- Chain via global `ajaxStack.add(request)` while `json.url && position < count`; on `success` append `?success=` or `navigation.reload()`, on `error` add `bg-danger` + `displayToast`.
- `make()` default URL must match the controller module; stale comments referencing another tool (e.g. media JS mentioning themes) must be fixed on copy.

## Vtpl template (`admin/template/tools/<name>-backup.tpl`)

```css
import(common.tpl)

@plugin = [data-v-plugins] [data-v-plugin]
@plugin|deleteAllButFirstChild

@backup = [data-v-backups] [data-v-backup]
@backup|deleteAllButFirstChild

@backupFile = [data-v-backups] [data-v-backup] [data-v-backup-files] [data-v-backup-file]
@backupFile|deleteAllButFirstChild

@plugin|before = <?php
if(isset($this->plugins) && is_array($this->plugins))  {
	foreach ($this->plugins as $index => $plugin) {
	?>
		@plugin input[data-v-plugin-input]|value = $plugin['slug']
		@plugin [data-v-plugin-name]|innerText = $plugin['name']
		@plugin img[data-v-plugin-thumb_url]|src = $plugin['thumb_url']
		@plugin img[data-v-plugin-thumb_url]|hide = <?php echo empty($plugin['thumb_url']) ? '1' : ''; ?>
	@plugin|after = <?php }
}?>

@backup|before = <?php /* foreach backups, set data-bs-target/id from key, wildcard innerText/value/href, then nested @backupFile loop over $backup['files'] */ ?>
```

- Media has no `@plugin` block; instead `[data-v-media-count]|innerText = $this->mediaCount` lines.
- Nested file loop must use the **full** wildcard: `[data-v-backup-file-*]` → `$file['@@__data-v-backup-file-(*)__@@']`. The short form (`[data-v-*]` → `$file['@@__data-v-(*)__@@']`) never matches — see media tpl history.
- `hide` needs `<?php echo ... ?>`; a bare `<?php $var ?>` outputs nothing and never hides.

## Admin HTML source (`public/admin/default/src/tools/<name>-backup.html`)

Built with `@@include()` + `gulp fileinclude` (run `npx gulp fileinclude` in `public/admin/default`, never hand-edit the built file):

- Progress block with the ids the JS reads (`progress`, `progress-bar`, `progress-file/status`, `progress-plugin|theme`, `progress-position/count`).
- `hasZip` warning (`data-v-if-not="this.hasZip"`) and `data-v-if="this.hasZip"` on the Backup button.
- Plugins/themes: selection card (`data-v-themes`/`data-v-theme`, `data-v-theme-input`, toggle-all checkbox, `name="themes[]"`), footer `formaction="{$this.backupUrl}"` button class `<name>-backup-btn`.
- Backups accordion (`data-v-backups`/`data-v-backup`, unique `data-v-backup-target/id` per key): per-file rows (`data-v-backup-files`/`data-v-backup-file` with `data-v-backup-file-name/slug/size` + per-file delete/download/restore buttons using `name="file" value="{$file.file_path}"`) plus folder-level buttons (`name="folder"`, classes `restore-btn` for folder, `<name>-restore-btn` for single file).
- Inline click handler: backup button collects checked `input[name="themes[]"]` (reject empty with toast) → `X.make(formAction, {themes}, btn)`; restore buttons send `{file}` vs `{folder}` to `X.restore()`.

## Menu registration (`config/admin-menu.php`)

```php
'plugins-backup' => [
	'name'   => __('Plugins Backup'),
	'url'    => $admin_path . '?module=tools/plugins-backup',
	'module' => 'tools/plugins-backup',
	'action' => 'index',
	'icon'   => 'la la-plug',
],
```

## Pitfalls

- Param-name mismatch across the four files (controller `themes` vs JS `plugins` vs `name="themes[]"`) silently yields "No themes selected" — grep all four after copying.
- Forgetting the array→comma join in JS breaks large selections; forgetting the `plugins.json`/`themes.json` fallback breaks resume after URL truncation.
- `sanitizeFileName()` normalizes `/` to `DS` — split `file` on `DS` *after* sanitizing, `basename()` the remainder; validate `is_dir($folderPath)` and `file_exists($singlePath)` before use.
- `ZipArchive` missing → gate the UI with `hasZip` and fail `save()`/`nextBackup()` early.
- Never delete `plugins.json`/`themes.json` until `$position >= $count`.
- Per-file `file_path` uses `/`; controller splits on `DS` — on Linux both are `/`, keep the forward slash.
- DB backup is the odd one: `.sql` append mode, `table`+`page` params, `TRUNCATE`/`INSERT` compat shims — don't force the zip shape onto it.

## Verification

1. `php -l` every touched PHP file; `node --check` the JS.
2. Rebuild HTML: `npx gulp fileinclude` in `public/admin/default`, confirm the built file contains the new ids/buttons.
3. Stub `Vvveb\` helpers + `Base`, require the controller, drive `save()` → loop `nextBackup()` until `success` → `restore()` → `nextRestore()` → `delete()` in subprocesses (each `die(json_encode())` terminates, so one process per step), asserting `position/count` advance and zips appear in `storage/backup/<type>/`.
4. Compile-and-render check for tpl changes via the vtpl harness (see `skills/vtpl-building/SKILL.md`).
