diff --git a/src/Console/Commands/AssetsMetaClean.php b/src/Console/Commands/AssetsMetaClean.php new file mode 100644 index 00000000000..734d31caf66 --- /dev/null +++ b/src/Console/Commands/AssetsMetaClean.php @@ -0,0 +1,125 @@ +getContainers()->keyBy->handle(); + + $orphanedMetaFilesByContainer = $containers->map(fn ($container) => $this->getOrphanedMetaFiles($container)); + $orphanedMetaFilesCount = $orphanedMetaFilesByContainer->sum->count(); + + if ($orphanedMetaFilesCount === 0) { + $this->components->info('No orphaned metadata files were found.'); + + return self::SUCCESS; + } + + $flatOrphanedMetaFiles = $orphanedMetaFilesByContainer + ->flatMap(fn (Collection $paths, string $container) => $paths->map(fn ($path) => [ + 'container' => $container, + 'path' => $path, + ])) + ->values(); + + if ($this->option('dry-run')) { + $this->components->warn("Found {$orphanedMetaFilesCount} orphaned metadata ".Str::plural('file', $orphanedMetaFilesCount)); + + $flatOrphanedMetaFiles->each(function (array $metaFile) { + $this->line("[{$metaFile['container']}] {$metaFile['path']}"); + }); + + return self::SUCCESS; + } + + progress( + label: 'Deleting orphaned asset metadata...', + steps: $flatOrphanedMetaFiles, + callback: function (array $metaFile, $progress) use ($containers) { + $containers->get($metaFile['container'])->disk()->delete($metaFile['path']); + $progress->advance(); + } + ); + + $orphanedMetaFilesByContainer->each(function (Collection $metaFiles, string $container) use ($containers) { + $this->deleteEmptyMetaDirectories($containers->get($container), $metaFiles); + }); + + $this->components->warn("Deleted {$orphanedMetaFilesCount} orphaned metadata ".Str::plural('file', $orphanedMetaFilesCount)); + + return self::SUCCESS; + } + + private function getContainers(): Collection + { + if (! $container = $this->argument('container')) { + return AssetContainer::all(); + } + + return collect([AssetContainer::findOrFail($container)]); + } + + private function getOrphanedMetaFiles(AssetsContainer $container): Collection + { + $assetPaths = $container->files()->flip(); + + return $container->metaFiles() + ->filter(fn (string $path) => Str::endsWith($path, '.yaml')) + ->reject(fn (string $path) => $assetPaths->has($this->metaPathToAssetPath($path))) + ->values(); + } + + private function metaPathToAssetPath(string $metaPath): string + { + $pathWithoutYamlExtension = Str::endsWith($metaPath, '.yaml') + ? substr($metaPath, 0, -5) + : $metaPath; + + $pathWithoutMetaDirectory = str_replace('/.meta/', '/', $pathWithoutYamlExtension); + + if (Str::startsWith($pathWithoutMetaDirectory, '.meta/')) { + $pathWithoutMetaDirectory = Str::replaceFirst('.meta/', '', $pathWithoutMetaDirectory); + } + + return ltrim($pathWithoutMetaDirectory, '/'); + } + + private function deleteEmptyMetaDirectories(AssetsContainer $container, Collection $metaFiles): void + { + $metaDirectories = $metaFiles + ->map(fn (string $metaFile) => dirname($metaFile)) + ->unique() + ->sortByDesc(fn (string $directory) => substr_count($directory, '/')); + + $metaDirectories->each(function (string $metaDirectory) use ($container) { + $disk = $container->disk(); + + if (! $disk->exists($metaDirectory)) { + return; + } + + if ($disk->isEmpty($metaDirectory)) { + $disk->filesystem()->deleteDirectory($metaDirectory); + } + }); + } +} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 2720b1fca85..8d058c405ed 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -14,6 +14,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\AssetsCacheClear::class, Commands\AssetsGeneratePresets::class, Commands\AssetsMeta::class, + Commands\AssetsMetaClean::class, Commands\GlideClear::class, Commands\Install::class, Commands\InstallCollaboration::class, diff --git a/tests/Console/Commands/AssetsMetaCleanTest.php b/tests/Console/Commands/AssetsMetaCleanTest.php new file mode 100644 index 00000000000..619ef077f58 --- /dev/null +++ b/tests/Console/Commands/AssetsMetaCleanTest.php @@ -0,0 +1,114 @@ +disk('test')->save(); + + Storage::disk('test')->put('.meta/root.txt.yaml', 'size: 123'); + + $this->artisan('statamic:assets:meta-clean test --dry-run') + ->expectsOutputToContain('Found 1 orphaned metadata file.') + ->expectsOutputToContain('[test] .meta/root.txt.yaml'); + + $this->assertTrue(Storage::disk('test')->exists('.meta/root.txt.yaml')); + } + + #[Test] + public function it_deletes_orphaned_meta_files_and_cleans_up_empty_meta_directories() + { + AssetContainer::make('test')->disk('test')->save(); + + Storage::disk('test')->put('foo/.meta/bar.txt.yaml', 'size: 123'); + + $this->assertTrue(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml')); + $this->assertTrue(Storage::disk('test')->exists('foo/.meta')); + + $this->artisan('statamic:assets:meta-clean test') + ->expectsOutputToContain('Deleted 1 orphaned metadata file.'); + + $this->assertFalse(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml')); + $this->assertFalse(Storage::disk('test')->exists('foo/.meta')); + } + + #[Test] + public function it_preserves_meta_files_with_matching_assets() + { + AssetContainer::make('test')->disk('test')->save(); + + Storage::disk('test')->put('foo/bar.txt', 'bar'); + Storage::disk('test')->put('foo/.meta/bar.txt.yaml', 'size: 123'); + + $this->artisan('statamic:assets:meta-clean test') + ->expectsOutputToContain('No orphaned metadata files were found.'); + + $this->assertTrue(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml')); + } + + #[Test] + public function it_only_cleans_the_requested_container() + { + AssetContainer::make('one')->disk('test')->save(); + AssetContainer::make('two')->disk('test_two')->save(); + + Storage::disk('test')->put('foo/.meta/one.jpg.yaml', 'size: 1'); + Storage::disk('test_two')->put('foo/.meta/two.jpg.yaml', 'size: 2'); + + $this->artisan('statamic:assets:meta-clean one') + ->expectsOutputToContain('Deleted 1 orphaned metadata file.'); + + $this->assertFalse(Storage::disk('test')->exists('foo/.meta/one.jpg.yaml')); + $this->assertTrue(Storage::disk('test_two')->exists('foo/.meta/two.jpg.yaml')); + } + + #[Test] + public function it_cleans_all_containers_when_no_container_argument_is_provided() + { + AssetContainer::make('one')->disk('test')->save(); + AssetContainer::make('two')->disk('test_two')->save(); + + Storage::disk('test')->put('foo/.meta/one.jpg.yaml', 'size: 1'); + Storage::disk('test_two')->put('foo/.meta/two.jpg.yaml', 'size: 2'); + + $this->artisan('statamic:assets:meta-clean') + ->expectsOutputToContain('Deleted 2 orphaned metadata files.'); + + $this->assertFalse(Storage::disk('test')->exists('foo/.meta/one.jpg.yaml')); + $this->assertFalse(Storage::disk('test_two')->exists('foo/.meta/two.jpg.yaml')); + } + + #[Test] + public function it_detects_orphaned_meta_files_in_root_and_nested_meta_directories() + { + AssetContainer::make('test')->disk('test')->save(); + + Storage::disk('test')->put('.meta/root.jpg.yaml', 'size: 1'); + Storage::disk('test')->put('foo/.meta/nested.jpg.yaml', 'size: 2'); + + $this->artisan('statamic:assets:meta-clean test') + ->expectsOutputToContain('Deleted 2 orphaned metadata files.'); + + $this->assertFalse(Storage::disk('test')->exists('.meta/root.jpg.yaml')); + $this->assertFalse(Storage::disk('test')->exists('foo/.meta/nested.jpg.yaml')); + } +}