db -> scan -> zip -> finalize -> upload -> retention -> done */ final class SWPB_Runner { public const AJAX_ACTION = SWPB_Plugin::WORKER_ACTION; public const PING_ACTION = SWPB_Plugin::PING_ACTION; public const HEARTBEAT_HOOK = SWPB_Plugin::HEARTBEAT_HOOK; private const MAX_ATTEMPTS = 4; private const MAX_UPLOAD_ATTEMPTS = 3; private const MAX_JOB_SECONDS = 12 * HOUR_IN_SECONDS; private SWPB_Settings $settings; private SWPB_Fs $fs; private SWPB_Registry $registry; private SWPB_Destinations $destinations; private SWPB_Notifier $notifier; private SWPB_Job_Store $jobs; /** @var array|null Job being processed, for the shutdown handler. */ private ?array $active = null; public function __construct(SWPB_Settings $settings, SWPB_Fs $fs, SWPB_Registry $registry, SWPB_Destinations $destinations, SWPB_Notifier $notifier, SWPB_Job_Store $jobs) { $this->settings = $settings; $this->fs = $fs; $this->registry = $registry; $this->destinations = $destinations; $this->notifier = $notifier; $this->jobs = $jobs; } public function current(): ?array { return $this->jobs->load(); } public function is_running(): bool { $job = $this->jobs->load(); return $job !== null && $job['status'] === 'running'; } /** * Start a new backup. $type: full|database|files, $trigger: manual|schedule|remote. */ public function start(string $type, string $trigger): array { if (!in_array($type, SWPB_Settings::TYPES, true)) { $type = 'full'; } if ($this->is_running()) { throw new SWPB_Exception(esc_html(__('A backup is already running. Wait for it to finish or cancel it first.', 'seljebackup'))); } $id = strtolower(wp_generate_password(8, false, false)); $base = SWPB_Fs::site_slug() . '_' . gmdate('Ymd-His') . '_' . $id; $job = [ 'id' => $id, 'type' => $type, 'trigger' => $trigger, 'status' => 'running', 'step' => 'init', 'started' => time(), 'finished' => 0, 'base' => $base, 'log' => $this->fs->base_dir() . '/' . $base . '_log.txt', 'kick_token' => wp_generate_password(32, false, false), 'attempts' => 0, 'in_tick' => false, 'budget_factor' => 1.0, 'warnings' => [], 'error' => '', 'label' => __('Starting…', 'seljebackup'), 'progress' => 0, 'files' => [], 'uploads' => [], ]; $this->jobs->clear_cancel(); $this->jobs->save($job); $log = new SWPB_Log($job['log']); $log->line(sprintf('Backup %s started (type: %s, trigger: %s)', $id, $type, $trigger)); $this->kick($job); return $job; } public function cancel(): void { $job = $this->jobs->load(); if ($job && $job['status'] === 'running') { $this->jobs->request_cancel((string)$job['id']); // If no worker is active right now, handle the cancel immediately. $this->tick('cancel', 5); } } public function handle_ping(): void { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo 'swpb-pong'; exit; } public function handle_worker_request(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authenticated by the per-job random token. $token = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : ''; $job = $this->jobs->load(); if (!$job || $token === '' || !hash_equals((string)$job['kick_token'], $token)) { wp_die('', '', ['response' => 403]); } // Low-impact mode: wait out the pause here (sleeping uses no CPU), then continue. $wait = (int)($job['pause_until'] ?? 0) - time(); if ($wait > 0) { ignore_user_abort(true); if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- the pause must not count against the step's time limit. @set_time_limit($wait + 60); } sleep(min($wait, 60)); } $this->tick('loopback'); exit; } /** * WP-Cron heartbeat (every 5 minutes) and cron URL: resume a job nobody is working on. */ public function heartbeat(): void { $this->maybe_resume(60, 'cron'); } public function maybe_resume(int $stale_after, string $source, int $max_budget = 0): void { $job = $this->jobs->load(); if ($job && $job['status'] === 'running' && (time() - (int)$job['updated']) >= $stale_after) { $this->tick($source, $max_budget); } } /** * Do as much work as fits in the time budget, then hand over to the next request. */ public function tick(string $source, int $max_budget = 0): ?array { $job = $this->jobs->load(); if (!$job || $job['status'] !== 'running') { return $job; } // Low-impact mode: the job is resting between steps. Only a cancel may interrupt. if ($source !== 'cancel' && (int)($job['pause_until'] ?? 0) > time()) { return $job; } $lock = new SWPB_Lock(); if (!$lock->acquire()) { return $job; } global $wpdb; $suppress = $wpdb->suppress_errors(true); try { $job = $this->jobs->load(); if (!$job || $job['status'] !== 'running') { return $job; } $log = new SWPB_Log((string)$job['log']); if (!empty($job['in_tick'])) { // The previous worker died mid-step (timeout, memory limit, host kill). $job['attempts'] = (int)$job['attempts'] + 1; $job['budget_factor'] = max(0.25, (float)$job['budget_factor'] / 2); $log->line(sprintf('Previous step was interrupted by the server (attempt %d). Retrying with smaller steps.', $job['attempts'])); } if ((int)$job['attempts'] >= self::MAX_ATTEMPTS) { $this->fail($job, sprintf( /* translators: %s: backup step name */ __('The server repeatedly stopped the backup during the "%s" step. Try lowering "Time per step" under Settings → Advanced, or exclude very large files.', 'seljebackup'), $job['step'] ), $log); $this->jobs->save($job); return $job; } if (time() - (int)$job['started'] > self::MAX_JOB_SECONDS) { $this->fail($job, __('The backup took longer than 12 hours and was stopped.', 'seljebackup'), $log); $this->jobs->save($job); return $job; } $job['in_tick'] = true; $job['last_source'] = $source; $this->jobs->save($job); $budget = $this->budget((float)$job['budget_factor'], $max_budget); if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- each step needs a little headroom above its time budget. @set_time_limit($budget + 60); } ignore_user_abort(true); $this->active = $job; register_shutdown_function([$this, 'on_shutdown']); $deadline = microtime(true) + $budget; try { while ($job['status'] === 'running' && microtime(true) < $deadline) { if ($this->jobs->is_cancel_requested((string)$job['id'])) { $this->finish_cancelled($job, $log); break; } $step = $job['step']; $complete = $this->run_step($job, $deadline, $log); $this->active = $job; if (!$complete && $job['step'] === $step) { break; // out of time inside this step } } $job['in_tick'] = false; $job['attempts'] = 0; } catch (Throwable $e) { $this->fail($job, SWPB_Exception::text($e), $log); } $this->active = null; $pause = (int)$this->settings->get('step_pause'); $job['pause_until'] = ($job['status'] === 'running' && $pause > 0) ? time() + $pause : 0; $this->jobs->save($job); } finally { $wpdb->suppress_errors($suppress); $lock->release(); } if ($job['status'] === 'running') { $this->kick($job); } return $job; } /** * If PHP dies (time or memory limit), log why and try to continue in a fresh request. */ public function on_shutdown(): void { if (!$this->active) { return; } $error = error_get_last(); if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) { $log = new SWPB_Log((string)$this->active['log']); $log->line('PHP stopped: ' . $error['message']); } $this->kick($this->active); } /** * Fire-and-forget request that continues the job in a new PHP process. */ public function kick(array $job): void { wp_remote_post(admin_url('admin-ajax.php'), [ 'timeout' => 0.01, 'blocking' => false, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter. 'sslverify' => apply_filters('https_local_ssl_verify', false), 'body' => [ 'action' => self::AJAX_ACTION, 'token' => (string)$job['kick_token'], ], ]); } /** * Blocking loopback test used by the system check. */ public function test_loopback(): bool { $resp = wp_remote_post(admin_url('admin-ajax.php'), [ 'timeout' => 10, // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter. 'sslverify' => apply_filters('https_local_ssl_verify', false), 'body' => ['action' => self::PING_ACTION], ]); return !is_wp_error($resp) && trim((string)wp_remote_retrieve_body($resp)) === 'swpb-pong'; } /** * Status summary for the admin UI and the remote API. */ public function status(): array { $job = $this->jobs->load(); if (!$job) { return ['running' => false, 'job' => null]; } $log = new SWPB_Log((string)$job['log']); return [ 'running' => $job['status'] === 'running', 'job' => [ 'id' => $job['id'], 'type' => $job['type'], 'trigger' => $job['trigger'], 'status' => $job['status'], 'step' => $job['step'], 'label' => $job['label'], 'last_source' => (string)($job['last_source'] ?? ''), 'pause_until' => (int)($job['pause_until'] ?? 0), 'progress' => (int)$job['progress'], 'started' => (int)$job['started'], 'updated' => (int)$job['updated'], 'finished' => (int)$job['finished'], 'error' => (string)$job['error'], 'warnings' => array_values((array)$job['warnings']), 'log_tail' => $log->tail(8), ], ]; } private function budget(float $factor, int $max_budget): int { $budget = (int)$this->settings->get('time_budget'); $limit = (int)ini_get('max_execution_time'); if ($limit > 0) { $budget = min($budget, max(5, $limit - 10)); } if ($max_budget > 0) { $budget = min($budget, $max_budget); } return max(3, (int)floor($budget * $factor)); } /** * Run (part of) the current step. Returns true when the step completed. */ private function run_step(array &$job, float $deadline, SWPB_Log $log): bool { switch ($job['step']) { case 'init': $this->step_init($job, $log); return true; case 'db': return $this->step_db($job, $deadline, $log); case 'scan': return $this->step_scan($job, $deadline, $log); case 'zip': return $this->step_zip($job, $deadline, $log); case 'finalize': $this->step_finalize($job, $log); return true; case 'upload': return $this->step_upload($job, $deadline, $log); case 'retention': $this->step_retention($job, $log); return true; } throw new SWPB_Exception(esc_html('Unknown backup step: ' . $job['step'])); } private function step_init(array &$job, SWPB_Log $log): void { if (!function_exists('gzopen') || !function_exists('deflate_init')) { throw new SWPB_Exception(esc_html(__('The PHP "zlib" extension is required to create backups. Ask your host to enable it.', 'seljebackup'))); } global $wpdb, $wp_version; $dir = $this->fs->job_dir((string)$job['id']); $job['dir'] = $dir; $log->line(sprintf( 'Environment: WordPress %s, PHP %s, database %s, memory limit %s, max execution time %ss, time per step %ss', $wp_version, PHP_VERSION, $wpdb->db_version(), (string)ini_get('memory_limit'), (string)ini_get('max_execution_time'), $this->budget(1.0, 0) )); if ($job['type'] !== 'files') { $job['db_file'] = $dir . '/' . $job['base'] . '_database.sql.gz'; $job['db_state'] = SWPB_Db_Dumper::init_state((bool)$this->settings->get('db_all_tables'), $this->settings->lines('exclude_tables')); $log->line(sprintf('Database: %d tables/views to back up', count($job['db_state']['tables']))); } if ($job['type'] !== 'database') { $job['list_file'] = $dir . '/filelist.txt'; $job['scan_state'] = SWPB_File_Scanner::init_state($this->settings->all(), $this->fs->base_dir()); } $job['step'] = $job['type'] === 'files' ? 'scan' : 'db'; $this->set_progress($job, 1, __('Preparing…', 'seljebackup')); } private function step_db(array &$job, float $deadline, SWPB_Log $log): bool { $state = $job['db_state']; $done = SWPB_Db_Dumper::run($state, (string)$job['db_file'], $deadline, $log, (float)$job['budget_factor']); $job['db_state'] = $state; $table_no = min(count($state['tables']), (int)$state['ti'] + 1); $range = $job['type'] === 'database' ? [2, 90] : [2, 25]; $this->set_progress($job, $this->lerp($range, SWPB_Db_Dumper::progress($state)), sprintf( /* translators: 1: current table number, 2: total tables */ __('Backing up database (table %1$d of %2$d)…', 'seljebackup'), $table_no, count($state['tables']) )); if ($done) { $log->line(sprintf('Database backup complete: %s rows, %s', number_format_i18n((int)$state['rows_done']), size_format((int)filesize((string)$job['db_file'])))); $job['step'] = $job['type'] === 'database' ? 'finalize' : 'scan'; } return $done; } private function step_scan(array &$job, float $deadline, SWPB_Log $log): bool { $state = $job['scan_state']; $done = SWPB_File_Scanner::run($state, (string)$job['list_file'], $deadline, $log); $job['scan_state'] = $state; $this->set_progress($job, $job['type'] === 'files' ? 3 : 26, sprintf( /* translators: %s: number of files found so far */ __('Finding files (%s found)…', 'seljebackup'), number_format_i18n((int)$state['count']) )); if (!$done) { return false; } $bytes = (int)$state['bytes']; $log->line(sprintf('File scan complete: %s files, %s (%d skipped)', number_format_i18n((int)$state['count']), size_format($bytes), (int)$state['skipped'])); if ((int)$state['skipped'] > 0) { /* translators: %d: number of files */ $job['warnings'][] = sprintf(__('%d unreadable or excluded-by-size files were skipped (see the log).', 'seljebackup'), (int)$state['skipped']); } $free = $this->fs->free_space($this->fs->base_dir()); if ($free !== null && $free < $bytes * 0.5 + 50 * MB_IN_BYTES) { throw new SWPB_Exception(esc_html(sprintf( /* translators: 1: needed size, 2: free size */ __('Not enough free disk space on the server. The backup needs about %1$s but only %2$s is free. Delete old backups, exclude large folders, or choose "Database only".', 'seljebackup'), size_format($bytes), size_format((int)$free) ))); } if ($free !== null && $free < $bytes) { $job['warnings'][] = __('Free disk space is low; the backup may fail if files do not compress well.', 'seljebackup'); } $job['files_total'] = (int)$state['count']; $job['bytes_total'] = max(1, $bytes); $job['files_done'] = 0; $job['bytes_done'] = 0; $job['list_offset'] = 0; $job['zip_line'] = ''; $job['part'] = 1; $job['zip_files'] = [$this->zip_part_path($job, 1)]; $job['zip_state'] = SWPB_Zip_Writer::new_state($job['zip_files'][0]); unset($job['scan_state']['stack']); $job['step'] = 'zip'; return true; } private function step_zip(array &$job, float $deadline, SWPB_Log $log): bool { $list = fopen((string)$job['list_file'], 'rb'); if (!$list) { throw new SWPB_Exception(esc_html(__('The temporary file list is missing. Please start the backup again.', 'seljebackup'))); } fseek($list, (int)$job['list_offset']); $part_limit = (int)$this->settings->get('part_size_mb') * MB_IN_BYTES; $state = $job['zip_state']; $handles = SWPB_Zip_Writer::open($state); $all_done = false; try { while (true) { if ($job['zip_line'] === '') { $line = fgets($list); if ($line === false) { $all_done = true; break; } $job['zip_line'] = rtrim($line, "\n"); $job['zip_line_end'] = ftell($list); } $parts = explode("\t", (string)$job['zip_line']); if (count($parts) < 4) { $job['zip_line'] = ''; $job['list_offset'] = (int)$job['zip_line_end']; continue; } [$zip_name, $abs, $size, $mtime] = $parts; if (($parts[4] ?? '') === SWPB_File_Scanner::CONFIG_FLAG) { // Cleaned in memory so the file's source never touches the disk outside the archive. $source = (int)@filesize($abs) <= SWPB_Config_Sanitizer::MAX_BYTES ? @file_get_contents($abs) : false; $clean = is_string($source) ? SWPB_Config_Sanitizer::sanitize($source) : null; if ($clean === null) { $log->line('Warning: left out ' . $zip_name . ' because it changed during the backup and its security keys and salts could not be removed safely.'); $job['warnings'][] = __('wp-config.php was left out of the backup because it changed while the backup was running (see the log).', 'seljebackup'); } else { SWPB_Zip_Writer::add_string($state, $handles, $zip_name, $clean, (int)$mtime); } } elseif (!SWPB_Zip_Writer::add_file($state, $handles, $abs, $zip_name, (int)$size, (int)$mtime, $deadline)) { break; // out of time in the middle of a large file } $job['list_offset'] = (int)$job['zip_line_end']; fseek($list, (int)$job['list_offset']); // needed when this file was resumed from an earlier request $job['zip_line'] = ''; $job['files_done']++; $job['bytes_done'] += (int)$size; if ($part_limit > 0 && $state['offset'] >= $part_limit) { SWPB_Zip_Writer::finalize($state, $handles); SWPB_Zip_Writer::close($handles); $this->cleanup_cd($state); $job['part']++; $path = $this->zip_part_path($job, (int)$job['part']); $job['zip_files'][] = $path; $state = SWPB_Zip_Writer::new_state($path); $handles = SWPB_Zip_Writer::open($state); $log->line('Started archive part ' . $job['part']); } if (microtime(true) >= $deadline) { break; } } if ($all_done) { SWPB_Zip_Writer::add_string($state, $handles, 'seljebackup-manifest.json', (string)wp_json_encode($this->manifest($job), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); SWPB_Zip_Writer::finalize($state, $handles); } } finally { SWPB_Zip_Writer::close($handles); fclose($list); } $job['zip_state'] = $state; $in_progress = !empty($state['cur']) ? (int)$state['cur']['read'] : 0; $fraction = ((int)$job['bytes_done'] + $in_progress) / max(1, (int)$job['bytes_total']); $range = $job['type'] === 'files' ? [5, 90] : [27, 90]; $this->set_progress($job, $this->lerp($range, $fraction), sprintf( /* translators: 1: files done, 2: total files */ __('Compressing files (%1$s of %2$s)…', 'seljebackup'), number_format_i18n((int)$job['files_done']), number_format_i18n((int)$job['files_total']) )); if ($all_done) { $this->cleanup_cd($state); $log->line(sprintf('Files archive complete: %s files in %d part(s)', number_format_i18n((int)$job['files_done']), count($job['zip_files']))); $job['step'] = 'finalize'; } return $all_done; } private function step_finalize(array &$job, SWPB_Log $log): void { $base_dir = $this->fs->base_dir(); $files = []; $sources = []; if (!empty($job['db_file'])) { $sources[] = [(string)$job['db_file'], 'database']; } foreach ((array)($job['zip_files'] ?? []) as $zip) { $sources[] = [(string)$zip, 'files']; } foreach ($sources as [$path, $kind]) { $target = $base_dir . '/' . basename($path); $already_moved = !is_file($path) && is_file($target); // retry after an interrupted request if (!$already_moved && !@rename($path, $target)) { throw new SWPB_Exception(esc_html(__('Could not move the finished backup into the backup folder.', 'seljebackup'))); } $files[] = ['name' => basename($target), 'kind' => $kind, 'size' => (int)filesize($target)]; } $entry = [ 'id' => $job['id'], 'created' => (int)$job['started'], 'type' => $job['type'], 'trigger' => $job['trigger'], 'files' => $files, 'local' => true, 'remote' => [], 'log' => basename((string)$job['log']), 'warnings' => [], 'duration' => 0, 'manifest' => $this->manifest($job), ]; $this->registry->put($entry); $job['files'] = $files; $this->fs->rrmdir((string)$job['dir']); $uploads = []; foreach ($this->destinations->all() as $dest) { if (empty($dest['enabled'])) { continue; } foreach ($files as $file) { $uploads[] = [ 'dest' => (string)$dest['id'], 'name' => $file['name'], 'status' => 'pending', 'session' => [], 'attempts' => 0, 'error' => '', ]; } } $job['uploads'] = $uploads; $log->line(sprintf('Backup files ready: %s', implode(', ', array_column($files, 'name')))); $this->set_progress($job, 91, __('Backup created.', 'seljebackup')); $job['step'] = $uploads ? 'upload' : 'retention'; } private function step_upload(array &$job, float $deadline, SWPB_Log $log): bool { $entry = $this->registry->get((string)$job['id']); if (!$entry) { $job['step'] = 'retention'; return true; } foreach ($job['uploads'] as &$item) { $item['session']['new_tick'] = true; } unset($item); foreach ($job['uploads'] as $i => &$item) { if ($item['status'] !== 'pending') { continue; } if (microtime(true) >= $deadline) { break; } $dest = $this->destinations->get((string)$item['dest']); $path = $this->fs->backup_file_path((string)$item['name']); if (!$dest || !$path) { $item['status'] = 'failed'; $item['error'] = __('Storage destination or backup file no longer exists.', 'seljebackup'); continue; } $this->set_progress($job, $this->lerp([91, 98], $this->upload_fraction($job)), sprintf( /* translators: 1: file name, 2: storage name */ __('Uploading %1$s to %2$s…', 'seljebackup'), $item['name'], (string)$dest['name'] )); try { $adapter = $this->destinations->adapter($dest); $ref = $adapter->upload($path, (string)$item['name'], $item['session'], $deadline); if ($ref === null) { break; // out of time, continue next tick } $item['status'] = 'done'; $item['session'] = []; $entry['remote'][$dest['id']]['refs'][$item['name']] = $ref; $entry['remote'][$dest['id']]['name'] = (string)$dest['name']; $entry['remote'][$dest['id']]['type'] = (string)$dest['type']; $this->registry->put($entry); $log->line(sprintf('Uploaded %s to %s', $item['name'], (string)$dest['name'])); } catch (Throwable $e) { $item['attempts']++; $item['error'] = SWPB_Exception::text($e); if ($item['attempts'] >= 2) { $item['session'] = []; // start the upload over on the last attempt } $log->line(sprintf('Upload of %s to %s failed (attempt %d): %s', $item['name'], (string)$dest['name'], $item['attempts'], SWPB_Exception::text($e))); if ($item['attempts'] >= self::MAX_UPLOAD_ATTEMPTS) { $item['status'] = 'failed'; /* translators: 1: file name, 2: storage name, 3: error */ $job['warnings'][] = sprintf(__('Could not upload %1$s to %2$s: %3$s', 'seljebackup'), $item['name'], (string)$dest['name'], SWPB_Exception::text($e)); $entry['remote'][$dest['id']]['error'] = SWPB_Exception::text($e); $entry['remote'][$dest['id']]['name'] = (string)$dest['name']; $entry['remote'][$dest['id']]['type'] = (string)$dest['type']; $this->registry->put($entry); continue; } break; // retry in the next request } } unset($item); foreach ($job['uploads'] as &$item) { unset($item['session']['new_tick']); } unset($item); $pending = array_filter($job['uploads'], static function ($u) { return $u['status'] === 'pending'; }); if (!$pending) { $job['step'] = 'retention'; return true; } return false; } private function step_retention(array &$job, SWPB_Log $log): void { $this->set_progress($job, 99, __('Removing old backups…', 'seljebackup')); foreach (SWPB_Retention::apply($this->registry, $this->destinations, $this->fs, $this->settings, $log) as $warning) { $job['warnings'][] = $warning; } $entry = $this->registry->get((string)$job['id']); if ($entry) { $entry['warnings'] = array_values((array)$job['warnings']); $entry['duration'] = time() - (int)$job['started']; $this->registry->put($entry); } $job['status'] = 'done'; $job['finished'] = time(); $job['step'] = 'done'; $this->set_progress($job, 100, $job['warnings'] ? __('Backup finished with warnings.', 'seljebackup') : __('Backup finished successfully.', 'seljebackup')); $log->line(sprintf('Backup finished in %s%s', human_time_diff((int)$job['started'], time()), $job['warnings'] ? ' with warnings' : '')); $this->jobs->clear_cancel(); $this->notifier->backup_finished($job, $entry); } private function fail(array &$job, string $message, SWPB_Log $log): void { $job['status'] = 'failed'; $job['error'] = $message; $job['finished'] = time(); $job['in_tick'] = false; $job['label'] = __('Backup failed.', 'seljebackup'); $log->line('ERROR: ' . $message); if (!empty($job['dir'])) { $this->fs->rrmdir((string)$job['dir']); } $this->jobs->clear_cancel(); $this->notifier->backup_finished($job, $this->registry->get((string)$job['id'])); } private function finish_cancelled(array &$job, SWPB_Log $log): void { $job['status'] = 'cancelled'; $job['finished'] = time(); $job['label'] = __('Backup cancelled.', 'seljebackup'); $log->line('Backup cancelled by user.'); if (!empty($job['dir'])) { $this->fs->rrmdir((string)$job['dir']); } if (!$this->registry->get((string)$job['id'])) { $log_path = (string)$job['log']; if (is_file($log_path)) { wp_delete_file($log_path); } } $this->jobs->clear_cancel(); } private function manifest(array $job): array { global $wpdb, $wp_version; $zip_names = array_map('basename', (array)($job['zip_files'] ?? [])); return [ 'generator' => 'seljeBackup ' . SWPB_PLUGIN_VERSION, 'backup_id' => $job['id'], 'type' => $job['type'], 'created_utc' => gmdate('c', (int)$job['started']), 'site_url' => site_url(), 'home_url' => home_url(), 'wordpress' => $wp_version, 'php' => PHP_VERSION, 'database_server' => $wpdb->db_version(), 'table_prefix' => $wpdb->prefix, 'charset' => $wpdb->charset, 'multisite' => is_multisite(), 'file_scope' => $this->settings->get('file_scope'), 'database_file' => !empty($job['db_file']) ? basename((string)$job['db_file']) : null, 'files_archives' => $zip_names, 'files_count' => (int)($job['files_done'] ?? 0), ]; } private function zip_part_path(array $job, int $part): string { $suffix = (int)$this->settings->get('part_size_mb') > 0 ? '_files-part' . $part . '.zip' : '_files.zip'; return (string)$job['dir'] . '/' . $job['base'] . $suffix; } private function cleanup_cd(array $state): void { if (is_file((string)$state['cd_path'])) { wp_delete_file((string)$state['cd_path']); } } private function upload_fraction(array $job): float { $total = count($job['uploads']); if ($total === 0) { return 1.0; } $done = count(array_filter($job['uploads'], static function ($u) { return $u['status'] !== 'pending'; })); return $done / $total; } private function set_progress(array &$job, float $percent, string $label): void { $job['progress'] = (int)max((int)($job['progress'] ?? 0), min(100, round($percent))); $job['label'] = $label; } private function lerp(array $range, float $fraction): float { return $range[0] + ($range[1] - $range[0]) * max(0.0, min(1.0, $fraction)); } }