PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.2
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.2
4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Backup / Ajax / Explore.php
wp-staging / Backup / Ajax Last commit date
Backup 2 months ago FileList 4 months ago Restore 2 months ago Backup.php 2 years ago BackupDownloader.php 1 day ago BackupSizeCalculator.php 1 day ago BackupSpeedIndex.php 9 months ago BaseFileList.php 1 year ago BaseListing.php 9 months ago Delete.php 4 months ago Edit.php 4 months ago Explore.php 3 months ago ExploreCache.php 3 months ago FileInfo.php 9 months ago FileList.php 1 year ago Listing.php 7 months ago Parts.php 1 day ago Restore.php 2 years ago ScheduleList.php 1 year ago Upload.php 1 month ago
Explore.php
854 lines
1 <?php
2
3 namespace WPStaging\Backup\Ajax;
4
5 use WPStaging\Backup\BackupFileIndex;
6 use WPStaging\Backup\Entity\BackupMetadata;
7 use WPStaging\Backup\FileHeader;
8 use WPStaging\Backup\Utils\BackupPathResolver;
9 use WPStaging\Core\WPStaging;
10 use WPStaging\Framework\Component\AbstractTemplateComponent;
11 use WPStaging\Framework\Filesystem\FileObject;
12 use WPStaging\Framework\Filesystem\Filesystem;
13 use WPStaging\Framework\Filesystem\PathIdentifier;
14 use WPStaging\Framework\TemplateEngine\TemplateEngine;
15
16 /**
17 * Handles listing and extracting backup contents for the Explore & Extract feature.
18 *
19 * Uses ExploreCache for server-side caching of the parsed file index,
20 * turning repeated folder lookups from full index scans into O(1) array reads.
21 */
22 class Explore extends AbstractTemplateComponent
23 {
24 /**
25 * @var int
26 */
27 const MAX_PER_PAGE = 200;
28
29 /**
30 * @var int
31 */
32 const MAX_TREE_ITEMS = 300;
33
34 /**
35 * @var PathIdentifier
36 */
37 private $pathIdentifier;
38
39 /** @var BackupPathResolver */
40 private $backupPathResolver;
41
42 /** @var Filesystem */
43 private $filesystem;
44
45 /** @var ExploreCache */
46 private $exploreCache;
47
48 public function __construct(
49 TemplateEngine $templateEngine,
50 PathIdentifier $pathIdentifier,
51 BackupPathResolver $backupPathResolver,
52 Filesystem $filesystem,
53 ExploreCache $exploreCache
54 ) {
55 parent::__construct($templateEngine);
56 $this->pathIdentifier = $pathIdentifier;
57 $this->backupPathResolver = $backupPathResolver;
58 $this->filesystem = $filesystem;
59 $this->exploreCache = $exploreCache;
60 }
61
62 /**
63 * Combined endpoint: returns list entries + tree directories in one response
64 *
65 * @return void
66 */
67 public function browse()
68 {
69 if (!$this->canRenderAjax()) {
70 return;
71 }
72
73 $filePath = isset($_POST['filePath']) ? sanitize_text_field(wp_unslash($_POST['filePath'])) : '';
74 if (empty($filePath)) {
75 wp_send_json_error(['message' => __('Backup file is missing.', 'wp-staging')]);
76 }
77
78 $backupFile = $this->backupPathResolver->resolveBackupPath($filePath);
79 if (empty($backupFile) || !file_exists($backupFile)) {
80 wp_send_json_error(['message' => __('Backup file not found.', 'wp-staging')]);
81 }
82
83 try {
84 $metadata = (new BackupMetadata())->hydrateByFilePath($backupFile);
85 } catch (\Throwable $e) {
86 wp_send_json_error(['message' => $e->getMessage()]);
87 }
88
89 $perPage = isset($_POST['perPage']) ? absint($_POST['perPage']) : self::MAX_PER_PAGE;
90 $perPage = max(1, min(self::MAX_PER_PAGE, $perPage));
91
92 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
93 $page = max(1, $page);
94
95 $folder = isset($_POST['folder']) ? sanitize_text_field(wp_unslash($_POST['folder'])) : '';
96 $folder = $this->normalizeFolder($folder);
97
98 $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
99 $sort = 'name_asc';
100
101 $withTree = isset($_POST['withTree'])
102 ? filter_var(wp_unslash($_POST['withTree']), FILTER_VALIDATE_BOOLEAN)
103 : false;
104
105 try {
106 $entries = $this->getDirectoryEntries($backupFile, $metadata, $folder, $search, $sort);
107 } catch (\Throwable $e) {
108 wp_send_json_error(['message' => $e->getMessage()]);
109 }
110
111 $totalEntries = count($entries);
112 $totalPages = (int)ceil($totalEntries / $perPage);
113 $offset = ($page - 1) * $perPage;
114 $pagedEntries = array_slice($entries, $offset, $perPage);
115
116 $response = [
117 'entries' => $pagedEntries,
118 'paging' => [
119 'totalItems' => $totalEntries,
120 'totalPages' => $totalPages,
121 'page' => $page,
122 'hasMore' => $page < $totalPages,
123 ],
124 ];
125
126 if ($withTree) {
127 try {
128 $response['directories'] = $this->getDirectoryTree($backupFile, $metadata, $folder);
129 } catch (\Throwable $e) {
130 $response['directories'] = [];
131 }
132 }
133
134 wp_send_json_success($response);
135 }
136
137 /**
138 * @return void
139 */
140 public function listFiles()
141 {
142 if (!$this->canRenderAjax()) {
143 return;
144 }
145
146 $filePath = isset($_POST['filePath']) ? sanitize_text_field(wp_unslash($_POST['filePath'])) : '';
147 if (empty($filePath)) {
148 wp_send_json_error(['message' => __('Backup file is missing.', 'wp-staging')]);
149 }
150
151 $backupFile = $this->backupPathResolver->resolveBackupPath($filePath);
152 if (empty($backupFile) || !file_exists($backupFile)) {
153 wp_send_json_error(['message' => __('Backup file not found.', 'wp-staging')]);
154 }
155
156 try {
157 $metadata = (new BackupMetadata())->hydrateByFilePath($backupFile);
158 } catch (\Throwable $e) {
159 wp_send_json_error(['message' => $e->getMessage()]);
160 }
161
162 $perPage = isset($_POST['perPage']) ? absint($_POST['perPage']) : self::MAX_PER_PAGE;
163 $perPage = max(1, min(self::MAX_PER_PAGE, $perPage));
164
165 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
166 $page = max(1, $page);
167
168 $folder = isset($_POST['folder']) ? sanitize_text_field(wp_unslash($_POST['folder'])) : '';
169 $folder = $this->normalizeFolder($folder);
170
171 $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
172 $sort = 'name_asc';
173
174 try {
175 $entries = $this->getDirectoryEntries($backupFile, $metadata, $folder, $search, $sort);
176 } catch (\Throwable $e) {
177 wp_send_json_error(['message' => $e->getMessage()]);
178 }
179
180 $totalEntries = count($entries);
181 $totalPages = (int)ceil($totalEntries / $perPage);
182 $offset = ($page - 1) * $perPage;
183 $pagedEntries = array_slice($entries, $offset, $perPage);
184
185 wp_send_json_success([
186 'entries' => $pagedEntries,
187 'paging' => [
188 'totalItems' => $totalEntries,
189 'totalPages' => $totalPages,
190 'page' => $page,
191 'hasMore' => $page < $totalPages,
192 ],
193 ]);
194 }
195
196 /**
197 * @return void
198 */
199 public function listTree()
200 {
201 if (!$this->canRenderAjax()) {
202 return;
203 }
204
205 $filePath = isset($_POST['filePath']) ? sanitize_text_field(wp_unslash($_POST['filePath'])) : '';
206 if (empty($filePath)) {
207 wp_send_json_error(['message' => __('Backup file is missing.', 'wp-staging')]);
208 }
209
210 $backupFile = $this->backupPathResolver->resolveBackupPath($filePath);
211 if (empty($backupFile) || !file_exists($backupFile)) {
212 wp_send_json_error(['message' => __('Backup file not found.', 'wp-staging')]);
213 }
214
215 try {
216 $metadata = (new BackupMetadata())->hydrateByFilePath($backupFile);
217 } catch (\Throwable $e) {
218 wp_send_json_error(['message' => $e->getMessage()]);
219 }
220
221 $folder = isset($_POST['folder']) ? sanitize_text_field(wp_unslash($_POST['folder'])) : '';
222 $folder = $this->normalizeFolder($folder);
223
224 try {
225 $directories = $this->getDirectoryTree($backupFile, $metadata, $folder);
226 } catch (\Throwable $e) {
227 wp_send_json_error(['message' => $e->getMessage()]);
228 }
229
230 wp_send_json_success([
231 'directories' => $directories,
232 ]);
233 }
234
235 /**
236 * @return void
237 */
238 public function listDirectoryFiles()
239 {
240 if (!$this->canRenderAjax()) {
241 return;
242 }
243
244 $filePath = isset($_POST['filePath']) ? sanitize_text_field(wp_unslash($_POST['filePath'])) : '';
245 if (empty($filePath)) {
246 wp_send_json_error(['message' => __('Backup file is missing.', 'wp-staging')]);
247 }
248
249 $backupFile = $this->backupPathResolver->resolveBackupPath($filePath);
250 if (empty($backupFile) || !file_exists($backupFile)) {
251 wp_send_json_error(['message' => __('Backup file not found.', 'wp-staging')]);
252 }
253
254 try {
255 $metadata = (new BackupMetadata())->hydrateByFilePath($backupFile);
256 } catch (\Throwable $e) {
257 wp_send_json_error(['message' => $e->getMessage()]);
258 }
259
260 $folder = isset($_POST['folder']) ? sanitize_text_field(wp_unslash($_POST['folder'])) : '';
261 $folder = $this->normalizeFolder($folder);
262 $summaryOnly = isset($_POST['summaryOnly'])
263 ? filter_var(wp_unslash($_POST['summaryOnly']), FILTER_VALIDATE_BOOLEAN)
264 : false;
265
266 try {
267 if ($summaryOnly) {
268 $summary = $this->getDirectoryStatsForSelection($backupFile, $metadata, $folder);
269 wp_send_json_success([
270 'summary' => $summary,
271 ]);
272 }
273
274 $files = $this->getDirectoryFilesForSelection($backupFile, $metadata, $folder);
275 } catch (\Throwable $e) {
276 wp_send_json_error(['message' => $e->getMessage()]);
277 }
278
279 wp_send_json_success([
280 'files' => $files,
281 ]);
282 }
283
284 /**
285 * Get directory entries, using the cache when available (non-search queries).
286 *
287 * @param string $backupFile
288 * @param BackupMetadata $metadata
289 * @param string $folder
290 * @param string $search
291 * @param string $sort
292 * @return array
293 */
294 private function getDirectoryEntries(string $backupFile, BackupMetadata $metadata, string $folder, string $search, string $sort): array
295 {
296 $isSearching = $search !== '';
297
298 // Try cache for non-search queries
299 if (!$isSearching) {
300 $tree = $this->exploreCache->getOrBuild($backupFile, $metadata);
301 if ($tree !== null) {
302 return $this->getEntriesFromCache($tree, $folder, $sort);
303 }
304 }
305
306 return $this->getDirectoryEntriesFromIndex($backupFile, $metadata, $folder, $search, $sort);
307 }
308
309 /**
310 * Read entries from the cached folder tree
311 *
312 * @param array $tree
313 * @param string $folder
314 * @param string $sort
315 * @return array
316 */
317 private function getEntriesFromCache(array $tree, string $folder, string $sort): array
318 {
319 if (!isset($tree[$folder])) {
320 return [];
321 }
322
323 $bucket = $tree[$folder];
324 $directories = [];
325 foreach ($bucket['dirs'] as $dir) {
326 $directories[] = [
327 'type' => 'dir',
328 'name' => $dir['name'],
329 'path' => $dir['path'],
330 'items' => $dir['items'] ?? 0,
331 'hasChildren' => $dir['hasChildren'] ?? false,
332 ];
333 }
334
335 $files = [];
336 foreach ($bucket['files'] as $file) {
337 $files[] = [
338 'type' => 'file',
339 'name' => $file['name'],
340 'path' => $file['path'],
341 'size' => $file['size'],
342 'sizeFormatted' => size_format($file['size'], 2),
343 'offset' => $file['offset'],
344 ];
345 }
346
347 $directories = $this->sortDirectories($directories, $sort);
348 $files = $this->sortFiles($files, $sort);
349
350 return array_merge($directories, $files);
351 }
352
353 /**
354 * Get tree directories from cache when available
355 *
356 * @param string $backupFile
357 * @param BackupMetadata $metadata
358 * @param string $folder
359 * @return array
360 */
361 private function getDirectoryTree(string $backupFile, BackupMetadata $metadata, string $folder): array
362 {
363 $tree = $this->exploreCache->getOrBuild($backupFile, $metadata);
364 if ($tree !== null) {
365 return $this->getTreeFromCache($tree, $folder);
366 }
367
368 return $this->getDirectoryTreeFromIndex($backupFile, $metadata, $folder);
369 }
370
371 /**
372 * Read tree directories from the cached folder tree
373 *
374 * @param array $tree
375 * @param string $folder
376 * @return array
377 */
378 private function getTreeFromCache(array $tree, string $folder): array
379 {
380 if (!isset($tree[$folder])) {
381 return [];
382 }
383
384 $dirs = $tree[$folder]['dirs'];
385
386 $result = [];
387 foreach ($dirs as $dir) {
388 $result[] = [
389 'name' => $dir['name'],
390 'path' => $dir['path'],
391 'hasChildren' => $dir['hasChildren'] ?? false,
392 ];
393 }
394
395 usort($result, function ($a, $b) {
396 return strcasecmp($a['name'], $b['name']);
397 });
398
399 return array_slice($result, 0, self::MAX_TREE_ITEMS);
400 }
401
402 /**
403 * Get directory stats from cache when available
404 *
405 * @param string $backupFile
406 * @param BackupMetadata $metadata
407 * @param string $folder
408 * @return array
409 */
410 private function getDirectoryStatsForSelection(string $backupFile, BackupMetadata $metadata, string $folder): array
411 {
412 $tree = $this->exploreCache->getOrBuild($backupFile, $metadata);
413 if ($tree !== null) {
414 return $this->getStatsFromCache($tree, $folder);
415 }
416
417 return $this->getDirectoryStatsFromIndex($backupFile, $metadata, $folder);
418 }
419
420 /**
421 * Compute stats recursively from the cached tree
422 *
423 * @param array $tree
424 * @param string $folder
425 * @return array{count: int, size: int}
426 */
427 private function getStatsFromCache(array $tree, string $folder): array
428 {
429 $count = 0;
430 $size = 0;
431
432 $stack = [$folder];
433 while (!empty($stack)) {
434 $dir = array_pop($stack);
435 if (!isset($tree[$dir])) {
436 continue;
437 }
438
439 foreach ($tree[$dir]['files'] as $file) {
440 $count++;
441 $size += $file['size'];
442 }
443
444 foreach ($tree[$dir]['dirs'] as $subdir) {
445 $stack[] = $subdir['path'];
446 }
447 }
448
449 return [
450 'count' => $count,
451 'size' => $size,
452 ];
453 }
454
455 /**
456 * Get all files recursively from cache for directory selection
457 *
458 * @param string $backupFile
459 * @param BackupMetadata $metadata
460 * @param string $folder
461 * @return array
462 */
463 private function getDirectoryFilesForSelection(string $backupFile, BackupMetadata $metadata, string $folder): array
464 {
465 $tree = $this->exploreCache->getOrBuild($backupFile, $metadata);
466 if ($tree !== null) {
467 return $this->getFilesFromCache($tree, $folder);
468 }
469
470 return $this->getDirectoryFilesFromIndex($backupFile, $metadata, $folder);
471 }
472
473 /**
474 * Collect all files recursively from the cached tree
475 *
476 * @param array $tree
477 * @param string $folder
478 * @return array
479 */
480 private function getFilesFromCache(array $tree, string $folder): array
481 {
482 $files = [];
483 $stack = [$folder];
484
485 while (!empty($stack)) {
486 $dir = array_pop($stack);
487 if (!isset($tree[$dir])) {
488 continue;
489 }
490
491 foreach ($tree[$dir]['files'] as $file) {
492 $files[] = [
493 'offset' => $file['offset'],
494 'path' => $file['path'],
495 'size' => $file['size'],
496 ];
497 }
498
499 foreach ($tree[$dir]['dirs'] as $subdir) {
500 $stack[] = $subdir['path'];
501 }
502 }
503
504 return $files;
505 }
506
507 // ── Fallback: direct index scanning (used when cache is unavailable or for search) ──
508
509 /**
510 * @param string $backupFile
511 * @param BackupMetadata $metadata
512 * @param string $folder
513 * @param string $search
514 * @param string $sort
515 * @return array
516 */
517 private function getDirectoryEntriesFromIndex(string $backupFile, BackupMetadata $metadata, string $folder, string $search, string $sort): array
518 {
519 $isSearching = $search !== '';
520 $prefix = $isSearching ? '' : ($folder === '' ? '' : trailingslashit($folder));
521 $directories = [];
522 $directoryChildren = [];
523 $directoryHasSubdirs = [];
524 $files = [];
525
526 $indexLineDto = $this->createIndexLineDto($metadata);
527 $fileObject = new FileObject($backupFile, FileObject::MODE_READ);
528 $fileObject->fseek((int)$metadata->getHeaderStart());
529
530 while ($fileObject->valid() && $fileObject->ftell() < (int)$metadata->getHeaderEnd()) {
531 $indexOffset = $fileObject->ftell();
532 $rawIndexFile = $fileObject->readAndMoveNext();
533 if (!$indexLineDto->isIndexLine($rawIndexFile)) {
534 continue;
535 }
536
537 $backupFileIndex = $indexLineDto->readIndexLine($rawIndexFile);
538 $relativePath = $this->pathIdentifier->transformIdentifiableToRelativePath($backupFileIndex->getIdentifiablePath());
539 $relativePath = $this->filesystem->normalizePath($relativePath);
540
541 if ($prefix !== '' && strpos($relativePath, $prefix) !== 0) {
542 continue;
543 }
544
545 $remaining = $prefix === '' ? $relativePath : substr($relativePath, strlen($prefix));
546 if ($remaining === '') {
547 continue;
548 }
549
550 $parts = explode('/', $remaining);
551 if (!$isSearching && count($parts) > 1) {
552 $dirName = $parts[0];
553 $childName = $parts[1] ?? '';
554 if (!empty($childName)) {
555 $directoryChildren[$dirName][$childName] = true;
556 }
557
558 if (count($parts) > 2) {
559 $directoryHasSubdirs[$dirName] = true;
560 }
561
562 if ($this->matchesSearch($dirName, $search)) {
563 $directories[$dirName] = [
564 'type' => 'dir',
565 'name' => $dirName,
566 'path' => $prefix . $dirName,
567 'items' => 0,
568 ];
569 }
570
571 continue;
572 }
573
574 $fileName = basename($relativePath);
575 if (!$this->matchesSearch($fileName, $search)) {
576 continue;
577 }
578
579 $size = (int)$backupFileIndex->getUncompressedSize();
580 $files[] = [
581 'type' => 'file',
582 'name' => $fileName,
583 'path' => $relativePath,
584 'size' => $size,
585 'sizeFormatted' => size_format($size, 2),
586 'offset' => (int)$indexOffset,
587 ];
588 }
589
590 $fileObject = null;
591
592 if (!$isSearching) {
593 foreach ($directories as $dirName => $dirData) {
594 $directories[$dirName]['items'] = isset($directoryChildren[$dirName]) ? count($directoryChildren[$dirName]) : 0;
595 $directories[$dirName]['hasChildren'] = isset($directoryHasSubdirs[$dirName]);
596 }
597
598 $directories = array_values($directories);
599 $directories = $this->sortDirectories($directories, $sort);
600 $files = $this->sortFiles($files, $sort);
601
602 return array_merge($directories, $files);
603 }
604
605 return $this->sortFiles($files, $sort);
606 }
607
608 /**
609 * @param string $backupFile
610 * @param BackupMetadata $metadata
611 * @param string $folder
612 * @return array
613 */
614 private function getDirectoryTreeFromIndex(string $backupFile, BackupMetadata $metadata, string $folder): array
615 {
616 $prefix = $folder === '' ? '' : trailingslashit($folder);
617 $directories = [];
618
619 $indexLineDto = $this->createIndexLineDto($metadata);
620 $fileObject = new FileObject($backupFile, FileObject::MODE_READ);
621 $fileObject->fseek((int)$metadata->getHeaderStart());
622
623 while ($fileObject->valid() && $fileObject->ftell() < (int)$metadata->getHeaderEnd()) {
624 $rawIndexFile = $fileObject->readAndMoveNext();
625 if (!$indexLineDto->isIndexLine($rawIndexFile)) {
626 continue;
627 }
628
629 $backupFileIndex = $indexLineDto->readIndexLine($rawIndexFile);
630 $relativePath = $this->pathIdentifier->transformIdentifiableToRelativePath($backupFileIndex->getIdentifiablePath());
631 $relativePath = $this->filesystem->normalizePath($relativePath);
632
633 if ($prefix !== '' && strpos($relativePath, $prefix) !== 0) {
634 continue;
635 }
636
637 $remaining = $prefix === '' ? $relativePath : substr($relativePath, strlen($prefix));
638 if ($remaining === '') {
639 continue;
640 }
641
642 $parts = explode('/', $remaining);
643 if (count($parts) <= 1) {
644 continue;
645 }
646
647 $dirName = $parts[0];
648 if (!isset($directories[$dirName])) {
649 $directories[$dirName] = [
650 'name' => $dirName,
651 'path' => $prefix . $dirName,
652 'hasChildren' => false,
653 ];
654 }
655
656 if (count($parts) > 2) {
657 $directories[$dirName]['hasChildren'] = true;
658 }
659
660 if (count($directories) >= self::MAX_TREE_ITEMS) {
661 break;
662 }
663 }
664
665 $fileObject = null;
666
667 $directories = array_values($directories);
668 usort($directories, function ($a, $b) {
669 return strcasecmp($a['name'], $b['name']);
670 });
671
672 return $directories;
673 }
674
675 /**
676 * @param string $backupFile
677 * @param BackupMetadata $metadata
678 * @param string $folder
679 * @return array
680 */
681 private function getDirectoryFilesFromIndex(string $backupFile, BackupMetadata $metadata, string $folder): array
682 {
683 $prefix = $folder === '' ? '' : trailingslashit($folder);
684
685 $files = [];
686 $indexLineDto = $this->createIndexLineDto($metadata);
687 $fileObject = new FileObject($backupFile, FileObject::MODE_READ);
688 $fileObject->fseek((int)$metadata->getHeaderStart());
689
690 while ($fileObject->valid() && $fileObject->ftell() < (int)$metadata->getHeaderEnd()) {
691 $indexOffset = $fileObject->ftell();
692 $rawIndexFile = $fileObject->readAndMoveNext();
693 if (!$indexLineDto->isIndexLine($rawIndexFile)) {
694 continue;
695 }
696
697 $backupFileIndex = $indexLineDto->readIndexLine($rawIndexFile);
698 $relativePath = $this->pathIdentifier->transformIdentifiableToRelativePath($backupFileIndex->getIdentifiablePath());
699 $relativePath = $this->filesystem->normalizePath($relativePath);
700
701 if ($prefix !== '' && strpos($relativePath, $prefix) !== 0) {
702 continue;
703 }
704
705 if ($relativePath === $prefix) {
706 continue;
707 }
708
709 $files[] = [
710 'offset' => (int)$indexOffset,
711 'path' => $relativePath,
712 'size' => (int)$backupFileIndex->getUncompressedSize(),
713 ];
714 }
715
716 $fileObject = null;
717
718 return $files;
719 }
720
721 /**
722 * @param string $backupFile
723 * @param BackupMetadata $metadata
724 * @param string $folder
725 * @return array
726 */
727 private function getDirectoryStatsFromIndex(string $backupFile, BackupMetadata $metadata, string $folder): array
728 {
729 $prefix = $folder === '' ? '' : trailingslashit($folder);
730
731 $count = 0;
732 $size = 0;
733
734 $indexLineDto = $this->createIndexLineDto($metadata);
735 $fileObject = new FileObject($backupFile, FileObject::MODE_READ);
736 $fileObject->fseek((int)$metadata->getHeaderStart());
737
738 while ($fileObject->valid() && $fileObject->ftell() < (int)$metadata->getHeaderEnd()) {
739 $rawIndexFile = $fileObject->readAndMoveNext();
740 if (!$indexLineDto->isIndexLine($rawIndexFile)) {
741 continue;
742 }
743
744 $backupFileIndex = $indexLineDto->readIndexLine($rawIndexFile);
745 $relativePath = $this->pathIdentifier->transformIdentifiableToRelativePath($backupFileIndex->getIdentifiablePath());
746 $relativePath = $this->filesystem->normalizePath($relativePath);
747
748 if ($prefix !== '' && strpos($relativePath, $prefix) !== 0) {
749 continue;
750 }
751
752 if ($relativePath === $prefix) {
753 continue;
754 }
755
756 $count++;
757 $size += (int)$backupFileIndex->getUncompressedSize();
758 }
759
760 $fileObject = null;
761
762 return [
763 'count' => $count,
764 'size' => $size,
765 ];
766 }
767
768 // ── Shared helpers ──
769
770 /**
771 * @param string $folder
772 * @return string
773 */
774 private function normalizeFolder(string $folder): string
775 {
776 $folder = trim($folder);
777 $folder = trim($folder, '/');
778
779 return $this->filesystem->normalizePath($folder);
780 }
781
782 /**
783 * @param string $name
784 * @param string $search
785 * @return bool
786 */
787 private function matchesSearch(string $name, string $search): bool
788 {
789 if ($search === '') {
790 return true;
791 }
792
793 $normalizedName = basename($name);
794
795 // Search only from the beginning
796 return stripos($normalizedName, $search) === 0;
797 }
798
799 /**
800 * @param array $directories
801 * @param string $sort
802 * @return array
803 */
804 private function sortDirectories(array $directories, string $sort): array
805 {
806 usort($directories, function ($a, $b) use ($sort) {
807 if ($sort === 'name_desc') {
808 return strcasecmp($b['name'], $a['name']);
809 }
810
811 return strcasecmp($a['name'], $b['name']);
812 });
813
814 return $directories;
815 }
816
817 /**
818 * @param array $files
819 * @param string $sort
820 * @return array
821 */
822 private function sortFiles(array $files, string $sort): array
823 {
824 usort($files, function ($a, $b) use ($sort) {
825 switch ($sort) {
826 case 'size_asc':
827 return $a['size'] <=> $b['size'];
828 case 'size_desc':
829 return $b['size'] <=> $a['size'];
830 case 'name_desc':
831 return strcasecmp($b['name'], $a['name']);
832 case 'name_asc':
833 default:
834 return strcasecmp($a['name'], $b['name']);
835 }
836 });
837
838 return $files;
839 }
840
841 /**
842 * @param BackupMetadata $metadata
843 * @return \WPStaging\Backup\Interfaces\IndexLineInterface
844 */
845 private function createIndexLineDto(BackupMetadata $metadata)
846 {
847 if ($metadata->getIsBackupFormatV1()) {
848 return new BackupFileIndex();
849 }
850
851 return WPStaging::make(FileHeader::class);
852 }
853 }
854