base64_encode( $salt ), 'key' => base64_encode( Crypto::key( $password, $salt ) ), ); } $gw = new Job_Gateway(); $id = $gw->insert( 'backup', array( 'phase' => 'prepare' ) ); // New backups go into the backup/ sub-folder (falls back to the store root on error). $bstore = Local_Store::backup_dir(); $dest = is_wp_error( $bstore ) ? $store : $bstore; $archive = trailingslashit( $dest ) . Local_Store::filename(); $work = Local_Store::work_dir( $id ); if ( is_wp_error( $work ) ) { Lock::release( self::LOCK ); return $work; } $gw->update( $id, array( 'status' => 'running', 'archive_file' => basename( $archive ), 'state' => array( 'phase' => 'prepare', 'scope' => $scope, 'excludes' => isset( $settings['excludes'] ) ? (array) $settings['excludes'] : array(), 'max_bytes' => isset( $settings['max_file_mb'] ) ? ( (int) $settings['max_file_mb'] * 1024 * 1024 ) : 0, // G2: split into capped volumes when max_part_mb > 0 (0 = single file). Kept as-is: // splitting is NOT forced by default because off-site cloud upload only handles // single-file archives (see Backup_Sync). A single huge file instead stays safe // at finalize because do_finalize() size-caps its per-volume hashing. 'part_bytes' => isset( $settings['max_part_mb'] ) ? ( (int) $settings['max_part_mb'] * 1024 * 1024 ) : 0, // G1 incremental bookkeeping. 'mode' => $mode, 'cycle' => $cycle, 'seq' => $seq, 'base_file' => $base_file, 'deletions' => array(), 'sr_pairs' => $sr_pairs, 'archive' => $archive, 'work' => $work, 'list' => trailingslashit( $work ) . 'files.list', 'enc' => $enc, ), ) ); if ( class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( 'full' === $mode ? __( 'Backup started', 'ai-siteark' ) : ( 'base' === $mode ? __( 'Backup started (new incremental base)', 'ai-siteark' ) : __( 'Backup started (incremental)', 'ai-siteark' ) ) ); } return $id; } /** * Run one step for a job. * * @param int $id Job id. * @return array Status: { done, progress, phase, message, job, log, error }. */ public static function step( $id ) { $gw = new Job_Gateway(); $job = $gw->get( $id ); if ( ! $job ) { return array( 'error' => true, 'message' => __( 'Backup job not found.', 'ai-siteark' ) ); } // Task controls: a paused job stops advancing; a cancelled one is already // cleaned up (see cancel()). Returning done makes the browser loop stop. $jstatus = isset( $job['status'] ) ? (string) $job['status'] : 'running'; if ( 'paused' === $jstatus ) { return array( 'done' => true, 'paused' => true, 'progress' => (int) ( $job['progress'] ?? 0 ), 'phase' => $job['step'] ?? '', 'message' => __( 'Paused', 'ai-siteark' ), 'job' => (int) $id ); } if ( 'cancelled' === $jstatus ) { return array( 'done' => true, 'cancelled' => true, 'progress' => (int) ( $job['progress'] ?? 0 ), 'message' => __( 'Cancelled', 'ai-siteark' ), 'job' => (int) $id ); } $st = $job['state']; $t0 = microtime( true ); $log = ''; // Keep the lock warm while this job is actively making progress. Lock::touch( self::LOCK, self::LOCK_TTL ); try { switch ( isset( $st['phase'] ) ? $st['phase'] : 'prepare' ) { case 'prepare': $log = self::do_prepare( $st ); break; case 'db': $log = self::do_db( $st, $t0 ); break; case 'dbmeta': $log = self::do_dbmeta( $st ); break; case 'files': $log = self::do_files( $st, $t0 ); break; case 'finalize': $log = self::do_finalize( $st ); break; } } catch ( \Throwable $e ) { Lock::release( self::LOCK ); // A dead job must not hold the lock. Local_Store::clean_work_dir( $id ); // Don't leave scratch files behind. $gw->update( $id, array( 'status' => 'failed', 'message' => $e->getMessage() ) ); return array( 'error' => true, 'message' => $e->getMessage(), 'job' => $id ); } $done = ( 'done' === $st['phase'] ); $progress = self::progress( $st ); $gw->update( $id, array( 'status' => $done ? 'done' : 'running', 'step' => $st['phase'], 'progress' => $progress, 'message' => self::label( $st ), 'state' => $st, ) ); if ( $done ) { // Guarantee temp cleanup: remove this job's work dir and sweep any older // abandoned temp files/dirs from crashed runs. Local_Store::clean_work_dir( $id ); Local_Store::sweep_temp(); } return array( 'done' => $done, 'progress' => $progress, 'phase' => $st['phase'], 'message' => self::label( $st ), 'job' => (int) $id, 'log' => $log, ); } /* ---- task controls -------------------------------------------------- */ /** * Cancel a backup job: stop it, release the lock, drop its work dir and any * partial archive it had started (so a half-written file isn't left as a * "backup"). * * @param int $id Job id. * @return void */ public static function cancel( $id ) { $gw = new Job_Gateway(); $job = $gw->get( $id ); Lock::release( self::LOCK ); Local_Store::clean_work_dir( $id ); if ( $job && ! empty( $job['state']['archive'] ) ) { Local_Store::delete_all_volumes( $job['state']['archive'] ); } $gw->update( $id, array( 'status' => 'cancelled', 'message' => __( 'Cancelled by user', 'ai-siteark' ) ) ); } /** * Pause a backup job (it stops advancing until resumed). The lock is released so * it doesn't block other work while paused; resume re-acquires it. * * @param int $id Job id. * @return void */ public static function pause( $id ) { ( new Job_Gateway() )->update( $id, array( 'status' => 'paused', 'message' => __( 'Paused', 'ai-siteark' ) ) ); Lock::release( self::LOCK ); } /** * Resume a paused backup — refuses if another backup grabbed the lock meanwhile. * * @param int $id Job id. * @return bool */ public static function resume( $id ) { $gw = new Job_Gateway(); if ( ! $gw->get( $id ) ) { return false; } if ( ! Lock::acquire( self::LOCK, self::LOCK_TTL ) ) { return false; // Another backup is running. } $gw->update( $id, array( 'status' => 'running', 'message' => __( 'Resumed', 'ai-siteark' ) ) ); if ( ! wp_next_scheduled( 'aisiteark_job_continue' ) ) { wp_schedule_single_event( time() + 3, 'aisiteark_job_continue' ); } return true; } /* ---- phases --------------------------------------------------------- */ /** * Prepare: write the manifest, list tables + files, choose the next phase. * * @param array $st Job state (by reference). * @return string */ /** * P6: fail a backup before it starts writing if the disk cannot hold it. * * Files are stored uncompressed, so they need their own size verbatim. The database * is gzipped into the archive, which compresses SQL text to roughly a third — we * budget generously rather than optimistically. A small headroom is added on top so * we do not fill the volume to the last byte. * * Hosts that disable `disk_free_space()` simply skip the check. * * @param array $st Job state. * @param int $file_bytes Total bytes of the files about to be archived. * @param bool $with_db Whether the database is in scope. * @return void * @throws \RuntimeException When there is not enough free space. */ private static function assert_disk_space( array $st, $file_bytes, $with_db ) { if ( ! function_exists( 'disk_free_space' ) ) { return; } $dir = dirname( (string) $st['archive'] ); $free = @disk_free_space( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $free || $free <= 0 ) { return; // Host hides it — do not block the backup on a number we cannot read. } $db_bytes = 0; if ( $with_db ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $raw = (float) $wpdb->get_var( 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = DATABASE()' ); $db_bytes = (int) ceil( $raw / 3 ); // gzip -6 on SQL text. } $needed = (int) ceil( ( (int) $file_bytes + $db_bytes ) * 1.05 ); if ( $free >= $needed ) { return; } throw new \RuntimeException( esc_html( sprintf( /* translators: 1: space this backup needs, 2: space free on disk. */ __( 'Not enough disk space to write this backup: it needs about %1$s but only %2$s is free. Free up space, or exclude some folders under Settings → What to include.', 'ai-siteark' ), size_format( $needed ), size_format( $free ) ) ) ); } private static function do_prepare( array &$st ) { // Enumerate first so the manifest can carry an inventory (table names + // file count) — restore uses it to detect a missing table/file up front. $tables = ! empty( $st['scope']['database'] ) ? DB_Exporter::tables() : array(); $files = array( 'count' => 0 ); $want_files = ! empty( $st['scope']['uploads'] ) || ! empty( $st['scope']['themes'] ) || ! empty( $st['scope']['plugins'] ) || ! empty( $st['scope']['muplugins'] ) || ! empty( $st['scope']['other'] ); $mode = isset( $st['mode'] ) ? $st['mode'] : 'full'; if ( $want_files ) { if ( 'full' === $mode ) { $files = File_Scanner::build_list( $st['scope'], $st['excludes'], $st['max_bytes'], $st['list'] ); } } // P6: refuse up front when the disk cannot hold the archive, rather than dying // half-written after a long run. The real file total is only known once the scan // above has finished, which is why the check lives here. self::assert_disk_space( $st, (int) ( $files['bytes'] ?? 0 ), ! empty( $st['scope']['database'] ) ); $writer = new Archive_Writer(); $ok = $writer->open( $st['archive'], false, (int) ( $st['part_bytes'] ?? 0 ) ); if ( is_wp_error( $ok ) ) { throw new \RuntimeException( esc_html( $ok->get_error_message() ) ); } $enc_meta = null; if ( ! empty( $st['enc']['key'] ) ) { $enc_meta = array( 'salt' => base64_decode( $st['enc']['salt'] ), 'key' => base64_decode( $st['enc']['key'] ), ); } $inventory = array( 'tables' => array_values( $tables ), 'file_count' => (int) $files['count'] ); // G1: record the cycle/sequence so restore knows how to reassemble. $extra = array(); if ( 'full' !== $mode && ! empty( $st['cycle'] ) ) { $extra = array( 'cycle' => (string) $st['cycle'], 'seq' => (int) $st['seq'], 'base_file' => (string) $st['base_file'], 'deletions' => isset( $st['deletions'] ) ? array_values( $st['deletions'] ) : array(), ); } // Manifest itself stays unencrypted so restore can validate + read the salt. $writer->write_entry( array( 'type' => 'manifest', 'enc' => 'raw' ), wp_json_encode( Manifest::build( $st['scope'], $enc_meta, $inventory, $extra ) ) ); $writer->close(); $st['tables'] = array_values( $tables ); $st['table_count'] = count( $tables ); $st['table_index'] = 0; $st['row_offset'] = 0; $st['file_count'] = (int) $files['count']; $st['file_cursor'] = 0; $st['list_pos'] = 0; $st['files_failed'] = 0; $st['fail_sample'] = array(); $st['db_failed'] = 0; $want_db = ! empty( $st['scope']['database'] ); $after_db = $want_db ? 'dbmeta' : ( $st['file_count'] ? 'files' : 'finalize' ); $st['phase'] = $st['table_count'] ? 'db' : $after_db; return sprintf( /* translators: 1: table count, 2: file count. */ __( 'Prepared: %1$d tables, %2$d files.', 'ai-siteark' ), $st['table_count'], $st['file_count'] ); } /** * Database: export CREATE + row chunks until the time budget or completion. * * @param array $st Job state (by reference). * @param float $t0 Step start time. * @return string */ private static function do_db( array &$st, $t0 ) { $writer = new Archive_Writer(); $writer->open( $st['archive'], true, (int) ( $st['part_bytes'] ?? 0 ) ); if ( ! empty( $st['enc']['key'] ) ) { $writer->set_key( base64_decode( $st['enc']['key'] ) ); } while ( ( microtime( true ) - $t0 ) < self::TIME_BUDGET && $st['table_index'] < $st['table_count'] ) { $table = $st['tables'][ $st['table_index'] ]; if ( 0 === (int) $st['row_offset'] ) { $create = DB_Exporter::create_statement( $table ); $writer->write_entry( array( 'type' => 'db', 'table' => $table, 'kind' => 'create', 'enc' => 'gzip' ), gzencode( $create, 6 ) ); } $chunk = DB_Exporter::export_rows( $table, $st['row_offset'], self::CHUNK_ROWS, isset( $st['sr_pairs'] ) && is_array( $st['sr_pairs'] ) ? $st['sr_pairs'] : array() ); if ( '' !== (string) ( $chunk['err'] ?? '' ) ) { // The read failed mid-table (not an empty end-of-table). Record it and move on // so the backup can still finish, but it is now flagged as incomplete at // finalize — never reported as a clean "complete" backup. See do_finalize(). $st['db_failed'] = (int) ( $st['db_failed'] ?? 0 ) + 1; if ( count( (array) ( $st['db_fail_sample'] ?? array() ) ) < 20 ) { $st['db_fail_sample'][] = $table . ': ' . substr( (string) $chunk['err'], 0, 180 ); } $st['table_index']++; $st['row_offset'] = 0; } elseif ( $chunk['rows'] > 0 ) { $writer->write_entry( array( 'type' => 'db', 'table' => $table, 'kind' => 'rows', 'enc' => 'gzip' ), gzencode( $chunk['sql'], 6 ) ); $st['row_offset'] += $chunk['rows']; } else { $st['table_index']++; $st['row_offset'] = 0; } } $writer->close(); if ( $st['table_index'] >= $st['table_count'] ) { $st['phase'] = 'dbmeta'; } return sprintf( /* translators: 1: current table index, 2: total tables. */ __( 'Database: %1$d/%2$d tables', 'ai-siteark' ), min( $st['table_index'] + 1, $st['table_count'] ), $st['table_count'] ); } /** * Database meta: export views, triggers and events (each a single small CREATE * entry) after all table data. Runs in one step — these objects are few and tiny. * * @param array $st Job state (by reference). * @return string */ private static function do_dbmeta( array &$st ) { $writer = new Archive_Writer(); $writer->open( $st['archive'], true, (int) ( $st['part_bytes'] ?? 0 ) ); if ( ! empty( $st['enc']['key'] ) ) { $writer->set_key( base64_decode( $st['enc']['key'] ) ); } $views = DB_Exporter::views(); $triggers = DB_Exporter::triggers(); $events = DB_Exporter::events(); foreach ( $views as $name => $sql ) { $writer->write_entry( array( 'type' => 'db', 'kind' => 'view', 'name' => (string) $name, 'enc' => 'gzip' ), gzencode( $sql, 6 ) ); } foreach ( $triggers as $name => $meta ) { $writer->write_entry( array( 'type' => 'db', 'kind' => 'trigger', 'name' => (string) $name, 'table' => (string) $meta['table'], 'enc' => 'gzip' ), gzencode( $meta['sql'], 6 ) ); } foreach ( $events as $name => $sql ) { $writer->write_entry( array( 'type' => 'db', 'kind' => 'event', 'name' => (string) $name, 'enc' => 'gzip' ), gzencode( $sql, 6 ) ); } $writer->close(); $st['meta_counts'] = array( 'views' => count( $views ), 'triggers' => count( $triggers ), 'events' => count( $events ) ); $st['phase'] = $st['file_count'] ? 'files' : 'finalize'; return sprintf( /* translators: 1: view count, 2: trigger count, 3: event count. */ __( 'DB objects: %1$d views, %2$d triggers, %3$d events', 'ai-siteark' ), count( $views ), count( $triggers ), count( $events ) ); } /** * Files: stream files from the list into the archive until the budget. * * @param array $st Job state (by reference). * @param float $t0 Step start time. * @return string */ private static function do_files( array &$st, $t0 ) { $writer = new Archive_Writer(); $writer->open( $st['archive'], true, (int) ( $st['part_bytes'] ?? 0 ) ); if ( ! empty( $st['enc']['key'] ) ) { $writer->set_key( base64_decode( $st['enc']['key'] ) ); } $content = rtrim( WP_CONTENT_DIR, '/\\' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen $lf = @fopen( $st['list'], 'rb' ); if ( $lf ) { fseek( $lf, (int) $st['list_pos'] ); while ( ( microtime( true ) - $t0 ) < self::TIME_BUDGET ) { $line = fgets( $lf ); if ( false === $line ) { $st['files_done'] = true; break; } $rel = trim( $line ); if ( '' === $rel ) { continue; } $abs = $content . '/' . $rel; $ok = false; if ( is_file( $abs ) ) { $ok = $writer->write_file_entry( array( 'type' => 'file', 'path' => $rel, 'mtime' => (int) filemtime( $abs ) ), $abs ); } if ( ! $ok ) { // Listed at scan time but now unreadable/gone — record it instead of // silently reporting a clean backup (the archive stays consistent; // write_file_entry writes nothing for a file it can't open). $st['files_failed'] = (int) ( $st['files_failed'] ?? 0 ) + 1; if ( count( (array) ( $st['fail_sample'] ?? array() ) ) < 20 ) { $st['fail_sample'][] = $rel; } } $st['file_cursor']++; } $st['list_pos'] = ftell( $lf ); fclose( $lf ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } else { $st['files_done'] = true; } $writer->close(); if ( ! empty( $st['files_done'] ) ) { $st['phase'] = 'finalize'; } return sprintf( /* translators: 1: files archived, 2: total files. */ __( 'Files: %1$d/%2$d', 'ai-siteark' ), (int) $st['file_cursor'], (int) $st['file_count'] ); } /** * Finalize: record history, clean up, fire the off-site hook. * * @param array $st Job state (by reference). * @return string */ private static function do_finalize( array &$st ) { global $wpdb; // Hashing a multi-GB archive is the one finalize step that can outlive a proxy/gateway // timeout — the "server error 504 while Finishing…" a large site hits. So finalize hashes // RESUMABLY, ONE volume per step (crc + .sha256 + optional .mac together), never letting the // default execution limit cut a pass short, and it SKIPS any single volume larger than a safe // size (default 3 GB, filterable): a lone 28 GB file can't be hashed inside one request // without 504-ing, so we complete the backup without an integrity sidecar for it rather than // fail the whole backup. Restore tolerates a missing sidecar; the note records why, and a // user who wants per-volume integrity can enable "Split archive into parts". State persists // in $st['fin'] between steps. @set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $volumes = Local_Store::volumes_for( $st['archive'] ); $size = 0; foreach ( $volumes as $v ) { $size += (int) filesize( $v ); } $need_mac = ! empty( $st['enc']['key'] ); $max_hash = (int) apply_filters( 'aisiteark_max_hash_bytes', 3 * 1024 * 1024 * 1024 ); // Per-volume hash budget. if ( ! isset( $st['fin'] ) ) { $st['fin'] = array( 'idx' => 0, 'checksum' => '', 'skipped' => 0 ); } $idx = (int) $st['fin']['idx']; if ( ! empty( $volumes ) && $idx < count( $volumes ) ) { $vol = $volumes[ $idx ]; $vsize = (int) filesize( $vol ); if ( $max_hash > 0 && $vsize > $max_hash ) { // Too large to hash inside one request — skip integrity for this volume. $st['fin']['skipped'] = (int) $st['fin']['skipped'] + 1; } else { if ( 0 === $idx ) { $st['fin']['checksum'] = ( $vsize && is_file( $vol ) ) ? (string) hash_file( 'crc32b', $vol ) : ''; } // Unkeyed integrity sidecar (all backups). write_checksum() on the backup-written // hook is now idempotent, so it won't re-read what we've already hashed here. $sha = hash_file( 'sha256', $vol ); if ( $sha ) { @file_put_contents( $vol . '.sha256', $sha ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged } if ( $need_mac ) { // $st['enc']['key'] is base64-encoded (as everywhere crypto uses it); the raw key // bytes are what restore derives via Manifest::verify_password, so decode before // MACing or restore would reject every genuine backup. $mac = \AtoZ\SiteArk\Helpers\Crypto::file_mac( $vol, base64_decode( $st['enc']['key'] ) ); if ( '' !== $mac ) { @file_put_contents( $vol . '.mac', $mac ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged } } } $st['fin']['idx'] = $idx + 1; if ( $st['fin']['idx'] < count( $volumes ) ) { // More volumes to secure — yield; the browser calls the next step. Phase stays // 'finalize', so we resume here, never restart the backup. return sprintf( /* translators: 1: volumes secured so far, 2: total volumes. */ __( 'Finalizing… secured %1$d of %2$d volumes', 'ai-siteark' ), (int) $st['fin']['idx'], count( $volumes ) ); } } $checksum = (string) ( $st['fin']['checksum'] ?? '' ); $hash_skipped = (int) ( $st['fin']['skipped'] ?? 0 ); // Partial-success: if any file couldn't be read, OR a database table failed to export // mid-way, don't report a clean backup. A DB failure is the more serious of the two — // a partially-exported table would restore incomplete — so it always flags the backup. $failed = (int) ( $st['files_failed'] ?? 0 ); $db_failed = (int) ( $st['db_failed'] ?? 0 ); $status = ( $failed > 0 || $db_failed > 0 ) ? 'warnings' : 'complete'; // status is varchar(20). $note = ! empty( $st['enc']['key'] ) ? 'encrypted' : ''; if ( count( $volumes ) > 1 ) { /* translators: %d: number of archive volumes. */ $vnote = sprintf( __( '%d volumes', 'ai-siteark' ), count( $volumes ) ); $note = $note ? $note . '; ' . $vnote : $vnote; } if ( $hash_skipped > 0 ) { $snote = __( 'integrity checksum skipped (archive too large to hash in one pass — enable “Split archive into parts” for per-volume integrity)', 'ai-siteark' ); $note = $note ? $note . '; ' . $snote : $snote; } if ( $failed > 0 ) { /* translators: %d: number of files that could not be read. */ $warn = sprintf( _n( '%d file could not be read and was skipped', '%d files could not be read and were skipped', $failed, 'ai-siteark' ), $failed ); $note = $note ? $note . '; ' . $warn : $warn; self::log_partial( $st, $failed ); } if ( $db_failed > 0 ) { /* translators: %d: number of database tables that failed to export. */ $dbwarn = sprintf( _n( '%d database table failed to export — this backup is INCOMPLETE and must not be relied on to restore that table', '%d database tables failed to export — this backup is INCOMPLETE and must not be relied on to restore those tables', $db_failed, 'ai-siteark' ), $db_failed ); $note = $note ? $note . '; ' . $dbwarn : $dbwarn; if ( class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( $dbwarn . ' (' . implode( '; ', (array) ( $st['db_fail_sample'] ?? array() ) ) . ')' ); } } $mode = isset( $st['mode'] ) ? $st['mode'] : 'full'; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'aisiteark_backups', array( 'filename' => basename( $st['archive'] ), 'size' => $size, 'destination' => 'local', 'checksum' => $checksum, 'scope' => wp_json_encode( $st['scope'] ), 'status' => $status, 'note' => $note, 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ) ); $st['files_failed_final'] = $failed; // Retention. For incremental cycles, prune WHOLE cycles (never orphan a base an // increment depends on); for plain full backups, the per-file retention. $sched = Settings::get()['schedule'] ?? array(); $keep = isset( $sched['retention'] ) ? (int) $sched['retention'] : 5; if ( 'full' === $mode ) { Local_Store::prune( $keep, isset( $sched['retention_days'] ) ? (int) $sched['retention_days'] : 0 ); } // Off-site destinations (Pro) hook in here later. do_action( 'aisiteark_backup_written', $st['archive'] ); $st['enc'] = null; // Wipe the transient key from the stored job state. $st['phase'] = 'done'; $st['size'] = $size; Lock::release( self::LOCK ); // Backup finished — let the next one run. if ( class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( sprintf( /* translators: 1: backup type, 2: size. */ __( 'Backup completed: %1$s (%2$s)', 'ai-siteark' ), 'incr' === $mode ? __( 'incremental', 'ai-siteark' ) : ( 'base' === $mode ? __( 'full base', 'ai-siteark' ) : __( 'full', 'ai-siteark' ) ), size_format( $size ) ) ); } if ( $db_failed > 0 ) { return sprintf( /* translators: 1: archive size, 2: number of tables that failed to export. */ _n( 'Backup finished with warnings (%1$s) — %2$d database table failed to export; this backup is INCOMPLETE, do not rely on it to restore.', 'Backup finished with warnings (%1$s) — %2$d database tables failed to export; this backup is INCOMPLETE, do not rely on it to restore.', $db_failed, 'ai-siteark' ), size_format( $size ), $db_failed ); } if ( $failed > 0 ) { return sprintf( /* translators: 1: archive size, 2: number of skipped files. */ _n( 'Backup complete (%1$s) — %2$d file was unreadable and skipped.', 'Backup complete (%1$s) — %2$d files were unreadable and skipped.', $failed, 'ai-siteark' ), size_format( $size ), $failed ); } return sprintf( /* translators: %s: human-readable archive size. */ __( 'Backup complete (%s).', 'ai-siteark' ), size_format( $size ) ); } /** * Record skipped files to the activity log (with a short sample) so a * "complete with warnings" backup is traceable. * * @param array $st Job state. * @param int $failed Skipped-file count. * @return void */ private static function log_partial( array $st, $failed ) { if ( ! class_exists( '\\AtoZ\\SiteArk\\Modules\\Backup\\Controller' ) ) { return; } $sample = implode( ', ', array_slice( (array) ( $st['fail_sample'] ?? array() ), 0, 5 ) ); \AtoZ\SiteArk\Modules\Backup\Controller::log_activity( sprintf( /* translators: 1: count, 2: sample file paths. */ __( 'Backup finished with warnings: %1$d file(s) skipped (%2$s)', 'ai-siteark' ), (int) $failed, $sample ) ); } /* ---- helpers -------------------------------------------------------- */ /** * Approximate percentage from the current phase/cursors. * * @param array $st Job state. * @return int */ private static function progress( array $st ) { switch ( isset( $st['phase'] ) ? $st['phase'] : 'prepare' ) { case 'prepare': return 3; case 'db': $frac = $st['table_count'] ? ( $st['table_index'] / $st['table_count'] ) : 1; return (int) round( 5 + 43 * $frac ); case 'dbmeta': return 49; case 'files': $frac = $st['file_count'] ? ( $st['file_cursor'] / $st['file_count'] ) : 1; return (int) round( 50 + 45 * min( 1, $frac ) ); case 'finalize': return 97; case 'done': return 100; } return 0; } /** * Short human label for the current phase. * * @param array $st Job state. * @return string */ private static function label( array $st ) { $map = array( 'prepare' => __( 'Preparing…', 'ai-siteark' ), 'db' => __( 'Exporting database…', 'ai-siteark' ), 'dbmeta' => __( 'Exporting views & triggers…', 'ai-siteark' ), 'files' => __( 'Archiving files…', 'ai-siteark' ), 'finalize' => __( 'Finishing…', 'ai-siteark' ), 'done' => __( 'Done.', 'ai-siteark' ), ); $phase = isset( $st['phase'] ) ? $st['phase'] : 'prepare'; return isset( $map[ $phase ] ) ? $map[ $phase ] : ''; } }