├── registration.php
├── etc
├── module.xml
├── config.xml
├── adminhtml
│ └── system.xml
└── di.xml
├── DataObject
└── GalleryValue.php
├── LICENSE
├── Console
├── ProgressIndicator.php
├── Command
│ ├── RemoveObsoleteDatabaseEntries.php
│ ├── RemoveUnusedImageFiles.php
│ ├── RemoveCorruptResizedFiles.php
│ └── RemoveUnusedCacheHashDirectories.php
└── UserInteraction.php
├── Config
└── ConfigReader.php
├── Logger
└── Logger.php
├── composer.json
├── Deleter
├── DatabaseGalleryDeleter.php
└── MediaDeleter.php
├── Stats
└── FileStatsCalculator.php
├── Finder
├── ObsoleteDatabaseEntriesFinder.php
├── CorruptResizedFilesFinder.php
├── UnusedCacheHashDirectoriesFinder.php
└── UnusedFilesFinder.php
├── Service
└── HyvaThemeFallbackStoreThemeResolver.php
└── README.md
/registration.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/etc/config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | webp,avif
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/DataObject/GalleryValue.php:
--------------------------------------------------------------------------------
1 | valueId = $valueId;
15 | $this->value = $value;
16 | }
17 |
18 | public function getValueId(): int
19 | {
20 | return $this->valueId;
21 | }
22 |
23 | public function __toString()
24 | {
25 | return sprintf(
26 | '[valueId %s] %s',
27 | $this->valueId,
28 | $this->value
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/etc/adminhtml/system.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | webp,avif
10 | The module will not remove dynamically generated files with these alternative extensions in case the original uploaded image is still being used]]>
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Baldwin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Console/ProgressIndicator.php:
--------------------------------------------------------------------------------
1 | output = $output;
21 | $this->progressBar = new ProgressBar($output);
22 | }
23 |
24 | public function start(string $message): void
25 | {
26 | $this->progressBar->setFormat(" %message%:\n %current% [%bar%]");
27 | $this->progressBar->setMessage($message);
28 | $this->progressBar->start();
29 | }
30 |
31 | public function setMax(int $max): void
32 | {
33 | $this->progressBar->start($max);
34 | }
35 |
36 | public function advance(): void
37 | {
38 | $this->progressBar->advance();
39 | }
40 |
41 | public function stop(): void
42 | {
43 | $this->progressBar->finish();
44 | $this->output->writeln('');
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Config/ConfigReader.php:
--------------------------------------------------------------------------------
1 | scopeConfig = $scopeConfig;
19 | }
20 |
21 | /**
22 | * @return array
23 | */
24 | public function getAlternativeExtensions(): array
25 | {
26 | $extensions = $this->scopeConfig->getValue(
27 | self::CONFIG_PATH_ALT_EXTENSIONS
28 | );
29 |
30 | if (!is_string($extensions)) {
31 | return [];
32 | }
33 |
34 | // array_diff is to remove empty strings from the array
35 | $extensions = array_diff(explode(',', trim($extensions)), ['']);
36 |
37 | // remove spaces and dots in case somebody did add those
38 | $extensions = array_map(function (string $ext) {
39 | return trim(trim($ext), '.');
40 | }, $extensions);
41 |
42 | return $extensions;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Logger/Logger.php:
--------------------------------------------------------------------------------
1 | logger = $logger;
17 | }
18 |
19 | public function logPathRemoval(string $path): void
20 | {
21 | $this->logger->info(sprintf('Removed path "%s"', $path));
22 | }
23 |
24 | public function logDbRowRemoval(string $rowRepresentation, string $dbTable): void
25 | {
26 | $this->logger->info(sprintf('Removed db value "%s" from table %s', $rowRepresentation, $dbTable));
27 | }
28 |
29 | public function logNoActionTaken(): void
30 | {
31 | $this->logger->info('Nothing found to cleanup, all is good!');
32 | }
33 |
34 | public function logFinalSummary(int $numberOfFilesRemoved, string $formattedBytesRemoved): void
35 | {
36 | $this->logger->info(sprintf(
37 | '-- Summary: removed %d files in total which cleared up %s of diskspace',
38 | $numberOfFilesRemoved,
39 | $formattedBytesRemoved
40 | ));
41 | }
42 |
43 | public function logFinalDbSummary(int $numberOfDbRowsRemoved, string $dbTable): void
44 | {
45 | $this->logger->info(sprintf(
46 | '-- Summary: removed %d rows in the %s database table',
47 | $numberOfDbRowsRemoved,
48 | $dbTable
49 | ));
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "baldwin/magento2-module-image-cleanup",
3 | "description": "Magento 2 module which can cleanup old image files that are no longer being used",
4 | "license": "MIT",
5 | "type": "magento2-module",
6 | "authors": [
7 | {
8 | "name": "Pieter Hoste",
9 | "email": "pieter@baldwin.be",
10 | "role": "Problem Solver"
11 | }
12 | ],
13 | "require": {
14 | "php": "~7.3.0 || ~7.4.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
15 | "magento/framework": "^102.0.4 || ^103.0",
16 | "magento/module-catalog": "^103.0.4 || ^104.0",
17 | "magento/module-eav": "^102.0.4",
18 | "magento/module-store": "^101.0.4",
19 | "magento/module-theme": "^101.0.4",
20 | "symfony/console": "^4.0 || ^5.0 || ^6.0"
21 | },
22 | "require-dev": {
23 | "bamarni/composer-bin-plugin": "^1.7",
24 | "ergebnis/composer-normalize": "^2.17"
25 | },
26 | "repositories": [
27 | {
28 | "type": "composer",
29 | "url": "https://repo.magento.com/"
30 | }
31 | ],
32 | "autoload": {
33 | "psr-4": {
34 | "Baldwin\\ImageCleanup\\": ""
35 | },
36 | "files": [
37 | "registration.php"
38 | ]
39 | },
40 | "config": {
41 | "allow-plugins": {
42 | "bamarni/composer-bin-plugin": true,
43 | "ergebnis/composer-normalize": true,
44 | "magento/composer-dependency-version-audit-plugin": true
45 | },
46 | "sort-packages": true
47 | },
48 | "extra": {
49 | "bamarni-bin": {
50 | "bin-links": false,
51 | "forward-command": true
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/etc/di.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | - Baldwin\ImageCleanup\Console\Command\RemoveCorruptResizedFiles
7 | - Baldwin\ImageCleanup\Console\Command\RemoveObsoleteDatabaseEntries
8 | - Baldwin\ImageCleanup\Console\Command\RemoveUnusedCacheHashDirectories
9 | - Baldwin\ImageCleanup\Console\Command\RemoveUnusedImageFiles
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | - Baldwin\ImageCleanup\Service\HyvaThemeFallbackStoreThemeResolver
18 |
19 |
20 |
21 |
22 |
23 |
24 | /var/log/baldwin-imagecleanup.log
25 |
26 |
27 |
28 |
29 |
30 | Baldwin::ImageCleanup
31 |
32 | - Baldwin\ImageCleanup\Log\Handler
33 |
34 |
35 |
36 |
37 |
38 |
39 | Baldwin\ImageCleanup\Log\Logger
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/Deleter/DatabaseGalleryDeleter.php:
--------------------------------------------------------------------------------
1 | */
18 | private $deletedValueIds = [];
19 |
20 | /** @var int */
21 | private $deletedValuesCount = 0;
22 |
23 | public function __construct(
24 | ResourceConnection $resource,
25 | Logger $logger
26 | ) {
27 | $this->resource = $resource;
28 | $this->logger = $logger;
29 | }
30 |
31 | /**
32 | * @param array $values
33 | */
34 | public function deleteGalleryValues(array $values): void
35 | {
36 | $this->resetState();
37 |
38 | $valueIds = array_map(
39 | function (GalleryValue $value) {
40 | return $value->getValueId();
41 | },
42 | $values
43 | );
44 |
45 | $valueIdChunks = array_chunk($valueIds, 5000);
46 | foreach ($valueIdChunks as $valueIdChunk) {
47 | $deleteResult = $this->resource->getConnection()->delete(
48 | $this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE),
49 | [
50 | $this->resource->getConnection()->quoteInto('value_id IN (?)', $valueIdChunk),
51 | ]
52 | );
53 |
54 | $this->deletedValuesCount += $deleteResult;
55 | foreach ($valueIdChunk as $valueId) {
56 | $prefixedValueId = sprintf('valueId: %d', $valueId);
57 |
58 | $this->deletedValueIds[] = $prefixedValueId;
59 | $this->logger->logDbRowRemoval($prefixedValueId, GalleryResourceModel::GALLERY_TABLE);
60 | }
61 | }
62 | }
63 |
64 | private function resetState(): void
65 | {
66 | $this->deletedValueIds = [];
67 | $this->deletedValuesCount = 0;
68 | }
69 |
70 | /**
71 | * @return array
72 | */
73 | public function getDeletedValues(): array
74 | {
75 | return $this->deletedValueIds;
76 | }
77 |
78 | public function getNumberOfValuesDeleted(): int
79 | {
80 | return $this->deletedValuesCount;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Stats/FileStatsCalculator.php:
--------------------------------------------------------------------------------
1 | filesystemFileDriver = $filesystemFileDriver;
24 | }
25 |
26 | public function resetStats(): void
27 | {
28 | $this->numberOfFiles = 0;
29 | $this->totalSizeInBytes = 0;
30 | }
31 |
32 | public function calculateStatsForPath(string $path): void
33 | {
34 | if ($this->skipCalculation === true) {
35 | return;
36 | }
37 |
38 | if ($this->filesystemFileDriver->isDirectory($path)) {
39 | $fileIterator = new \RecursiveIteratorIterator(
40 | new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
41 | );
42 |
43 | /** @var \SplFileInfo $file */
44 | foreach ($fileIterator as $file) {
45 | if ($file->isFile()) {
46 | ++$this->numberOfFiles;
47 | $this->totalSizeInBytes += $file->getSize();
48 | }
49 | }
50 | } elseif ($this->filesystemFileDriver->isFile($path)) {
51 | $file = new \SplFileInfo($path);
52 |
53 | ++$this->numberOfFiles;
54 | $this->totalSizeInBytes += $file->getSize();
55 | }
56 | }
57 |
58 | /**
59 | * @param array $paths
60 | */
61 | public function calculateStatsForPaths(array $paths): void
62 | {
63 | if ($this->skipCalculation === true) {
64 | return;
65 | }
66 |
67 | foreach ($paths as $path) {
68 | $this->calculateStatsForPath($path);
69 | }
70 | }
71 |
72 | public function setSkipCalculation(bool $skip): void
73 | {
74 | $this->skipCalculation = $skip;
75 | }
76 |
77 | public function canCalculate(): bool
78 | {
79 | return !$this->skipCalculation;
80 | }
81 |
82 | public function getNumberOfFiles(): int
83 | {
84 | return $this->numberOfFiles;
85 | }
86 |
87 | public function getTotalSizeInBytes(): int
88 | {
89 | return $this->totalSizeInBytes;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Console/Command/RemoveObsoleteDatabaseEntries.php:
--------------------------------------------------------------------------------
1 | userInteraction = $userInteraction;
28 | $this->dbGalleryDeleter = $dbGalleryDeleter;
29 | $this->obsoleteDbEntriesFinder = $obsoleteDbEntriesFinder;
30 |
31 | parent::__construct();
32 | }
33 |
34 | protected function configure(): void
35 | {
36 | $this->setName('catalog:images:remove-obsolete-db-entries');
37 | $this->setDescription(
38 | sprintf(
39 | 'Removes values from the %s db table which are no longer needed.',
40 | GalleryResourceModel::GALLERY_TABLE
41 | )
42 | );
43 |
44 | parent::configure();
45 | }
46 |
47 | protected function execute(InputInterface $input, OutputInterface $output)
48 | {
49 | $entries = $this->obsoleteDbEntriesFinder->find();
50 |
51 | $accepted = $this->userInteraction->showDbValuesToDeleteAndAskForConfirmation(
52 | array_map('strval', $entries),
53 | GalleryResourceModel::GALLERY_TABLE,
54 | $input,
55 | $output
56 | );
57 | if ($accepted) {
58 | $this->dbGalleryDeleter->deleteGalleryValues($entries);
59 | $deletedValues = $this->dbGalleryDeleter->getDeletedValues();
60 | $numberOfValuesDeleted = $this->dbGalleryDeleter->getNumberOfValuesDeleted();
61 |
62 | $this->userInteraction->showFinalDbInfo(
63 | $deletedValues,
64 | $numberOfValuesDeleted,
65 | GalleryResourceModel::GALLERY_TABLE,
66 | $output
67 | );
68 | }
69 |
70 | return Cli::RETURN_SUCCESS;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Finder/ObsoleteDatabaseEntriesFinder.php:
--------------------------------------------------------------------------------
1 | resource = $resource;
23 | $this->attributeRepository = $attributeRepository;
24 | }
25 |
26 | /**
27 | * @return array
28 | */
29 | public function find(): array
30 | {
31 | $values = [];
32 |
33 | // SQL queries that will find obsolete entries:
34 | //
35 | // 1) SELECT * FROM catalog_product_entity_media_gallery
36 | // WHERE media_type = 'image'
37 | // AND value_id NOT IN (SELECT DISTINCT value_id FROM catalog_product_entity_media_gallery_value_to_entity);
38 | // 2) SELECT * FROM catalog_product_entity_media_gallery cpemg
39 | // LEFT JOIN catalog_product_entity_media_gallery_value_to_entity cpemgvte
40 | // ON cpemg.value_id = cpemgvte.value_id
41 | // WHERE cpemgvte.value_id IS NULL AND cpemg.media_type = 'image';
42 | //
43 | // the second one is about 5 to 50 times faster on a db with
44 | // 218043 entries in catalog_product_entity_media_gallery and
45 | // 101860 entries in catalog_product_entity_media_gallery_value_to_entity
46 |
47 | $mediaGalleryAttr = $this->attributeRepository->get(ProductModel::ENTITY, 'media_gallery');
48 | $mediaGalleryId = $mediaGalleryAttr->getAttributeId();
49 |
50 | $entriesQuery = $this->resource->getConnection()->select()
51 | ->from(
52 | ['cpemg' => $this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE)],
53 | ['value_id', 'value']
54 | )
55 | ->joinLeft(
56 | ['cpemgvte' => $this->resource->getTableName(GalleryResourceModel::GALLERY_VALUE_TO_ENTITY_TABLE)],
57 | 'cpemg.value_id = cpemgvte.value_id',
58 | ''
59 | )
60 | ->where(
61 | 'cpemgvte.value_id IS NULL AND cpemg.media_type = "image" AND cpemg.attribute_id = :media_gallery_id'
62 | )
63 | ;
64 |
65 | $entries = $this->resource->getConnection()->fetchAll($entriesQuery, ['media_gallery_id' => $mediaGalleryId]);
66 |
67 | foreach ($entries as $entry) {
68 | $values[] = new GalleryValue((int) $entry['value_id'], $entry['value']);
69 | }
70 |
71 | return $values;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Console/Command/RemoveUnusedImageFiles.php:
--------------------------------------------------------------------------------
1 | userInteraction = $userInteraction;
31 | $this->progressIndicator = $progressIndicator;
32 | $this->mediaDeleter = $mediaDeleter;
33 | $this->unusedFilesFinder = $unusedFilesFinder;
34 |
35 | parent::__construct();
36 | }
37 |
38 | protected function configure(): void
39 | {
40 | $this->setName('catalog:images:remove-unused-files');
41 | $this->setDescription(
42 | 'Remove unused product image files from the filesystem. '
43 | . 'We compare the data that\'s in the database with the files on disk and remove the ones that don\'t match'
44 | );
45 | $this->addOption(
46 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS,
47 | null,
48 | InputOption::VALUE_NONE,
49 | 'Skip calculating and outputting stats (filesizes, number of files, ...), '
50 | . 'this can speed up the command in case it runs slowly.'
51 | );
52 |
53 | parent::configure();
54 | }
55 |
56 | protected function execute(InputInterface $input, OutputInterface $output)
57 | {
58 | $this->progressIndicator->init($output);
59 |
60 | $files = $this->unusedFilesFinder->find();
61 |
62 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($files, $input, $output);
63 | if ($accepted) {
64 | $this->mediaDeleter->deletePaths($files);
65 | $deletedPaths = $this->mediaDeleter->getDeletedPaths();
66 | $skippedPaths = $this->mediaDeleter->getSkippedPaths();
67 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted();
68 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted();
69 |
70 | $this->userInteraction->showFinalInfo(
71 | $deletedPaths,
72 | $skippedPaths,
73 | $numberOfFilesRemoved,
74 | $bytesRemoved,
75 | $output
76 | );
77 | }
78 |
79 | return Cli::RETURN_SUCCESS;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Console/Command/RemoveCorruptResizedFiles.php:
--------------------------------------------------------------------------------
1 | userInteraction = $userInteraction;
31 | $this->progressIndicator = $progressIndicator;
32 | $this->mediaDeleter = $mediaDeleter;
33 | $this->corruptResizedFilesFinder = $corruptResizedFilesFinder;
34 |
35 | parent::__construct();
36 | }
37 |
38 | protected function configure(): void
39 | {
40 | $this->setName('catalog:images:remove-corrupt-resized-files');
41 | $this->setDescription(
42 | 'Remove corrupt resized image files from the filesystem. '
43 | . 'Magento will re-generate them again afterwards'
44 | );
45 | $this->addOption(
46 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS,
47 | null,
48 | InputOption::VALUE_NONE,
49 | 'Skip calculating and outputting stats (filesizes, number of files, ...), '
50 | . 'this can speed up the command in case it runs slowly.'
51 | );
52 |
53 | parent::configure();
54 | }
55 |
56 | protected function execute(InputInterface $input, OutputInterface $output)
57 | {
58 | $this->progressIndicator->init($output);
59 |
60 | $files = $this->corruptResizedFilesFinder->find();
61 |
62 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($files, $input, $output);
63 | if ($accepted) {
64 | $this->mediaDeleter->deletePaths($files);
65 | $deletedPaths = $this->mediaDeleter->getDeletedPaths();
66 | $skippedPaths = $this->mediaDeleter->getSkippedPaths();
67 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted();
68 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted();
69 |
70 | $this->userInteraction->showFinalInfo(
71 | $deletedPaths,
72 | $skippedPaths,
73 | $numberOfFilesRemoved,
74 | $bytesRemoved,
75 | $output
76 | );
77 | }
78 |
79 | return Cli::RETURN_SUCCESS;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Service/HyvaThemeFallbackStoreThemeResolver.php:
--------------------------------------------------------------------------------
1 | themeProvider = $themeProvider;
30 | $this->appEmulation = $appEmulation;
31 | }
32 |
33 | public function setIsActive(bool $isActive): void
34 | {
35 | $this->isActive = $isActive;
36 | }
37 |
38 | public function getThemes(StoreInterface $store): array
39 | {
40 | $themeIds = [];
41 |
42 | if ($this->isActive && class_exists(HyvaThemeFallbackConfig::class)) {
43 | $hyvaFallbackThemeId = $this->getHyvaFallbackThemeId($store);
44 | if ($hyvaFallbackThemeId !== null) {
45 | $themeIds[] = $hyvaFallbackThemeId;
46 | }
47 | }
48 |
49 | return $themeIds;
50 | }
51 |
52 | private function getHyvaFallbackThemeId(StoreInterface $store): ?int
53 | {
54 | $themeId = null;
55 | $hyvaThemeFallbackConfig = $this->getHyvaThemeFallbackConfig();
56 |
57 | // need to emulate frontend storeview
58 | // because we can't pass on the incoming store param to the calls to hyvaThemeFallbackConfig unfortunately
59 | $this->appEmulation->startEnvironmentEmulation($store->getId());
60 |
61 | if ($hyvaThemeFallbackConfig->isEnabled()) {
62 | $fallbackThemePath = $hyvaThemeFallbackConfig->getThemeFullPath();
63 | $fallbackTheme = $this->themeProvider->getThemeByFullPath($fallbackThemePath);
64 |
65 | $fallbackThemeId = $fallbackTheme->getId();
66 |
67 | if ($fallbackThemeId !== null && is_numeric($fallbackThemeId)) {
68 | $themeId = (int) $fallbackThemeId;
69 | }
70 | }
71 |
72 | $this->appEmulation->stopEnvironmentEmulation();
73 |
74 | return $themeId;
75 | }
76 |
77 | /**
78 | * Can't inject in constructor because it's a soft dependency
79 | */
80 | private function getHyvaThemeFallbackConfig(): HyvaThemeFallbackConfig
81 | {
82 | if ($this->hyvaThemeFallbackConfig === null) {
83 | /** @var HyvaThemeFallbackConfig $hyvaThemeFallbackConfig */
84 | $hyvaThemeFallbackConfig = ObjectManager::getInstance()->get(HyvaThemeFallbackConfig::class);
85 |
86 | $this->hyvaThemeFallbackConfig = $hyvaThemeFallbackConfig;
87 | }
88 |
89 | return $this->hyvaThemeFallbackConfig;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Deleter/MediaDeleter.php:
--------------------------------------------------------------------------------
1 | */
22 | private $deletedPaths = [];
23 | /** @var array */
24 | private $skippedPaths = [];
25 |
26 | public function __construct(
27 | FileStatsCalculator $fileStatsCalculator,
28 | Filesystem $filesystem,
29 | FilesystemFileDriver $filesystemFileDriver,
30 | Logger $logger
31 | ) {
32 | $this->fileStatsCalculator = $fileStatsCalculator;
33 | $this->filesystem = $filesystem;
34 | $this->filesystemFileDriver = $filesystemFileDriver;
35 | $this->logger = $logger;
36 | }
37 |
38 | /**
39 | * @param array $paths
40 | */
41 | public function deletePaths(array $paths): void
42 | {
43 | $this->resetState();
44 |
45 | $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
46 | $mediaDirectoryPath = $this->filesystemFileDriver->getRealPath($mediaDirectory->getAbsolutePath());
47 | if (!is_string($mediaDirectoryPath)) {
48 | throw new FileSystemException(
49 | __('Can\'t find media directory path: "%1"', $mediaDirectory->getAbsolutePath())
50 | );
51 | }
52 |
53 | foreach ($paths as $path) {
54 | $regex = '#^' . preg_quote($mediaDirectoryPath) . '#';
55 | if (preg_match($regex, $path) !== 1) {
56 | $this->skippedPaths[] = $path;
57 | } else {
58 | $relativePath = preg_replace($regex, '', $path);
59 |
60 | $this->fileStatsCalculator->calculateStatsForPath($path);
61 | $this->deletedPaths[] = $path;
62 | $mediaDirectory->delete($relativePath);
63 |
64 | $this->logger->logPathRemoval($path);
65 | }
66 | }
67 | }
68 |
69 | private function resetState(): void
70 | {
71 | $this->skippedPaths = [];
72 | $this->deletedPaths = [];
73 |
74 | $this->fileStatsCalculator->resetStats();
75 | }
76 |
77 | /**
78 | * @return array
79 | */
80 | public function getDeletedPaths(): array
81 | {
82 | return $this->deletedPaths;
83 | }
84 |
85 | /**
86 | * @return array
87 | */
88 | public function getSkippedPaths(): array
89 | {
90 | return $this->skippedPaths;
91 | }
92 |
93 | public function getNumberOfFilesDeleted(): int
94 | {
95 | return $this->fileStatsCalculator->getNumberOfFiles();
96 | }
97 |
98 | public function getBytesDeleted(): int
99 | {
100 | return $this->fileStatsCalculator->getTotalSizeInBytes();
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Console/Command/RemoveUnusedCacheHashDirectories.php:
--------------------------------------------------------------------------------
1 | appState = $appState;
35 | $this->userInteraction = $userInteraction;
36 | $this->mediaDeleter = $mediaDeleter;
37 | $this->unusedCacheHashDirFinder = $unusedCacheHashDirFinder;
38 | $this->hyvaThemeFallbackStoreThemeResolver = $hyvaThemeFallbackStoreThemeResolver;
39 |
40 | parent::__construct();
41 | }
42 |
43 | protected function configure(): void
44 | {
45 | $this->setName('catalog:images:remove-unused-hash-directories');
46 | $this->setDescription(
47 | 'Remove unused resized hash directories (like: pub/media/catalog/product/cache/xxyyzz). '
48 | . 'These directories can be a leftover from older Magento versions, or from image definitions that got '
49 | . 'removed from the etc/view.xml file of a custom theme for example.'
50 | );
51 | $this->addOption(
52 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS,
53 | null,
54 | InputOption::VALUE_NONE,
55 | 'Skip calculating and outputting stats (filesizes, number of files, ...), '
56 | . 'this can speed up the command in case it runs slowly.'
57 | );
58 |
59 | parent::configure();
60 | }
61 |
62 | protected function execute(InputInterface $input, OutputInterface $output)
63 | {
64 | // needed to avoid 'Area code is not set'
65 | // mimicking same area as core magento (global) from the catalog:images:resize command
66 | $this->appState->setAreaCode(AppArea::AREA_GLOBAL);
67 |
68 | $this->hyvaThemeFallbackStoreThemeResolver->setIsActive(true);
69 |
70 | $directories = $this->unusedCacheHashDirFinder->find();
71 |
72 | $this->hyvaThemeFallbackStoreThemeResolver->setIsActive(false);
73 |
74 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($directories, $input, $output);
75 | if ($accepted) {
76 | $this->mediaDeleter->deletePaths($directories);
77 | $deletedPaths = $this->mediaDeleter->getDeletedPaths();
78 | $skippedPaths = $this->mediaDeleter->getSkippedPaths();
79 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted();
80 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted();
81 |
82 | $this->userInteraction->showFinalInfo(
83 | $deletedPaths,
84 | $skippedPaths,
85 | $numberOfFilesRemoved,
86 | $bytesRemoved,
87 | $output
88 | );
89 | }
90 |
91 | return Cli::RETURN_SUCCESS;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Finder/CorruptResizedFilesFinder.php:
--------------------------------------------------------------------------------
1 | progressIndicator = $progressIndicator;
22 | $this->filesystem = $filesystem;
23 | }
24 |
25 | /**
26 | * @return array
27 | */
28 | public function find(): array
29 | {
30 | $this->progressIndicator->start('Searching for corrupt files');
31 |
32 | $corruptFiles = $this->getCorruptFilesOnDisk();
33 |
34 | $this->progressIndicator->stop();
35 |
36 | return $corruptFiles;
37 | }
38 |
39 | /**
40 | * @return array
41 | */
42 | private function getCorruptFilesOnDisk(): array
43 | {
44 | $corruptFiles = [];
45 |
46 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA);
47 | $resizedImageDirectories = $this->getResizedImageDirectories($mediaDirectory);
48 | $this->progressIndicator->setMax(count($resizedImageDirectories));
49 |
50 | foreach ($resizedImageDirectories as $resizedImageDir) {
51 | $fileIterator = new \RecursiveIteratorIterator(
52 | new \RecursiveDirectoryIterator(
53 | $mediaDirectory->getAbsolutePath($resizedImageDir),
54 | \FilesystemIterator::SKIP_DOTS
55 | )
56 | );
57 |
58 | /** @var \SplFileInfo $file */
59 | foreach ($fileIterator as $file) {
60 | if ($file->isFile() && $this->isCorruptFile($file)) {
61 | $corruptFiles[] = $file->getRealPath();
62 | }
63 | }
64 |
65 | $this->progressIndicator->advance();
66 | }
67 |
68 | return $corruptFiles;
69 | }
70 |
71 | /**
72 | * @return array
73 | */
74 | private function getResizedImageDirectories(ReadInterface $mediaDirectory): array
75 | {
76 | // find all cached hash subdirectories,
77 | // so the ones starting with a single letter in their directory name
78 | $resizedImageDirectories = [];
79 |
80 | $cacheDir = 'catalog/product/cache';
81 | if ($mediaDirectory->isExist($cacheDir)) {
82 | /** @var array */
83 | $hashDirectories = $mediaDirectory->read($cacheDir);
84 | foreach ($hashDirectories as $hashDir) {
85 | if ($mediaDirectory->isDirectory($hashDir)) {
86 | $resizedImageDirectories[] = $mediaDirectory->read($hashDir);
87 | }
88 | }
89 | }
90 | $resizedImageDirectories = array_merge([], ...$resizedImageDirectories);
91 |
92 | // only allow directories that have one character as lowest path
93 | $resizedImageDirectories = array_filter(
94 | $resizedImageDirectories,
95 | function (string $path) use ($mediaDirectory) {
96 | return $mediaDirectory->isDirectory($path) && preg_match('#/.{1}$#', $path) === 1;
97 | }
98 | );
99 |
100 | return $resizedImageDirectories;
101 | }
102 |
103 | private function isCorruptFile(\SplFileInfo $file): bool
104 | {
105 | $size = $file->getSize();
106 |
107 | // no idea if a false value means corrupt, but let's choose not to for now
108 | if ($size === false) {
109 | return false;
110 | }
111 |
112 | // check if filesize is 0, if 0, file is corrupt
113 | if ($size === 0) {
114 | return true;
115 | }
116 |
117 | // if imagesize can't be determined, we can assume the image is corrupt
118 | // phpcs:ignore Magento2.Functions.DiscouragedFunction.DiscouragedWithAlternative
119 | $imageSizeResult = getimagesize($file->getRealPath());
120 | if ($imageSizeResult === false) {
121 | return true;
122 | }
123 |
124 | return false;
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Image Cleanup module for Magento 2
2 |
3 | ## Purpose
4 |
5 | When adding products in your webshop, eventually you'll also have to delete some of those products.
6 | But sometimes Magento doesn't remove images associated with the products that you delete.
7 | So you'll have to manually delete them from time to time from disk, which is hard to do manually.
8 | This module gives you some options to delete those lingering unused images from the disk so you can recover some diskspace on your server.
9 |
10 | ## Implemented Features
11 |
12 | - It can find product image files on disk that are not referenced in the database and remove them
13 | - It can do the same for the resized (cached) versions of those images
14 | - It tries to not delete dynamically generated images files (like `webp` of `avif` files) if the original file is still being used, see [configuration](#configuration)
15 | - It can detect entire unused resized (cached) directories that are no longer valid and remove them with all the files in there, see [below](#documentation-about-resizedcached-directories)
16 | - It can detect and remove obsolete values in the `catalog_product_entity_media_gallery` database table
17 | - It can find resized product image files that are corrupt and remove them
18 |
19 | ## Watch out
20 |
21 | - The module will always first output what it will delete, make sure you check the entire list before confirming, so that you aren't removing files you don't want to remove. Do **not** _test_ this module on a production environment first before you fully understand what it will do!
22 | - This module hasn't been tested when your Magento shop is configured to store image files in the database, your mileage may vary when you use that way of working. Feel free to open issues in case any occur, and we'll see if we can fix something...
23 |
24 | ## Compatibility
25 |
26 | - This module should work with Magento 2.3.4 or higher
27 | - The module should be compatible with PHP 7.3, 7.4, 8.1, 8.2, 8.3 and 8.4
28 |
29 | ## Installation
30 |
31 | You can use composer to install this module:
32 |
33 | ```sh
34 | composer require baldwin/magento2-module-image-cleanup
35 | ```
36 |
37 | Or download the code and put all the files in the directory `app/code/Baldwin/ImageCleanup`
38 |
39 | After which you can then activate it in Magento using:
40 |
41 | ```sh
42 | bin/magento setup:upgrade
43 | ```
44 |
45 | ## Usage
46 |
47 | There are 4 command line commands you can use execute:
48 |
49 | - `bin/magento catalog:images:remove-obsolete-db-entries`
50 | - `bin/magento catalog:images:remove-unused-hash-directories`
51 | - `bin/magento catalog:images:remove-unused-files`
52 | - `bin/magento catalog:images:remove-corrupt-resized-files`
53 |
54 | There are some extra options for some of these commands:
55 |
56 | ```
57 | --no-stats Skip calculating and outputting stats (filesizes, number of files, ...), this can speed up the command in case it runs slowly.
58 | -n, --no-interaction Do not ask any interactive question
59 | ```
60 |
61 | The `-n` option can be used if you want to setup a cronjob to regularly call these cleanup commands, it will not ask for confirmation before removing files, and will just assume you said 'yes, go ahead' (which can be dangerous!)
62 |
63 | The module will output all the things it deleted in a log file `{magento-project}/var/log/baldwin-imagecleanup.log` so you can inspect it later in case you want to figure out why something got removed.
64 |
65 | For optimal & fastest cleanup, it's advised to run the commands in this order:
66 |
67 | 1. `bin/magento catalog:images:remove-obsolete-db-entries`
68 | 2. `bin/magento catalog:images:remove-unused-hash-directories`
69 | 3. `bin/magento catalog:images:remove-unused-files`
70 | 4. `bin/magento catalog:images:remove-corrupt-resized-files`
71 |
72 | If you don't run these in this order, it might mean you'll need to run some of them a second time for them to find more things to cleanup or it might mean that they'll take longer then needed.
73 |
74 |
75 | ## Configuration
76 |
77 | There is a configuration section in the backoffice under: Stores > Configuration > Catalog > Catalog > Product Image Cleanup Settings
78 |
79 | - **List of dynamically generated image file extensions**: Some Magento shops might have modules installed to dynamically generate `webp` or `avif` image files out of the original product image files. These files are usually not referenced in the database of Magento so by specifying those file extensions in the configuration, we can prevent them from being deleted accidentally. The module will still be able to remove those type of files when the original file is no longer referenced in the database.
80 | This feature only works properly when the dynamically generated image files use the same filename as the original file, so they can only be different in the file extension being used (either replaced or appended).
81 |
82 | ## Documentation about resized/cached directories
83 |
84 | Magento saves resized product images in certain directories in `pub/media/catalog/product/cache`
85 | The directory names are basically an md5 hash of a bunch of parameters like: width, height, background-color, quality, rotation, ... (which tend to be defined in the `etc/view.xml` file of themes)
86 | Sometimes, Magento tweaks how the hash gets calculated in certain newer versions of Magento, or your theme changes some parameter which both can make those hashes no longer being used.
87 |
88 | This module has the option to detect such directories and can remove them together with all the files in there.
89 |
90 | ## Note to self
91 |
92 | In our class `Baldwin\ImageCleanup\Finder\UnusedCacheHashDirectoriesFinder`, we borrowed some code from core Magento that was private and not easily callable. We made only very slight changes to deal with coding standards and static analysis, but it's mostly the same as the original source. These pieces of code were based on code that didn't really change since Magento 2.3.4.
93 |
94 | It's important that we check with every single new Magento version that gets released, that the code in `Magento\MediaStorage\Service\ImageResize` doesn't change in such a way that we need to adapt our own implementation.
95 |
96 | So this is something that needs to be double checked with every new Magento release.
97 |
--------------------------------------------------------------------------------
/Console/UserInteraction.php:
--------------------------------------------------------------------------------
1 | fileStatsCalculator = $fileStatsCalculator;
26 | $this->logger = $logger;
27 | }
28 |
29 | /**
30 | * @param array $paths
31 | */
32 | public function showPathsToDeleteAndAskForConfirmation(
33 | array $paths,
34 | InputInterface $input,
35 | OutputInterface $output
36 | ): bool {
37 | $skipCalculationOption = (bool) $input->getOption(self::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS);
38 | $this->fileStatsCalculator->setSkipCalculation($skipCalculationOption);
39 |
40 | $output->writeln('');
41 |
42 | if ($paths === []) {
43 | $output->writeln('No paths found to cleanup, all is good!');
44 | $this->logger->logNoActionTaken();
45 |
46 | return false;
47 | }
48 |
49 | if ($input->isInteractive()) {
50 | $this->displayPaths($paths, $output);
51 | $this->calculateAndDisplayStats($paths, $output);
52 | }
53 |
54 | return $this->askForConfirmation($input, $output);
55 | }
56 |
57 | /**
58 | * @param array $values
59 | */
60 | public function showDbValuesToDeleteAndAskForConfirmation(
61 | array $values,
62 | string $dbTable,
63 | InputInterface $input,
64 | OutputInterface $output
65 | ): bool {
66 | $output->writeln('');
67 |
68 | if ($values === []) {
69 | $output->writeln('No db values found to cleanup, all is good!');
70 | $this->logger->logNoActionTaken();
71 |
72 | return false;
73 | }
74 |
75 | if ($input->isInteractive()) {
76 | $this->displayDbValues($values, $dbTable, $output);
77 | }
78 |
79 | return $this->askForConfirmation($input, $output);
80 | }
81 |
82 | /**
83 | * @param array $deletedPaths
84 | * @param array $skippedPaths
85 | */
86 | public function showFinalInfo(
87 | array $deletedPaths,
88 | array $skippedPaths,
89 | int $numberOfFilesRemoved,
90 | int $bytesRemoved,
91 | OutputInterface $output
92 | ): void {
93 | if ($skippedPaths !== []) {
94 | $output->writeln(sprintf("Skipped these paths:\n- %s", implode("\n- ", $skippedPaths)));
95 | $output->writeln('');
96 | }
97 |
98 | if ($deletedPaths !== []) {
99 | $output->writeln(sprintf("Deleted these paths:\n- %s", implode("\n- ", $deletedPaths)));
100 | $output->writeln('');
101 |
102 | if ($this->fileStatsCalculator->canCalculate()) {
103 | $output->writeln(sprintf(
104 | 'Cleaned up %d files, was able to cleanup %s!',
105 | $numberOfFilesRemoved,
106 | $this->formatBytes($bytesRemoved)
107 | ));
108 | $this->logger->logFinalSummary($numberOfFilesRemoved, $this->formatBytes($bytesRemoved));
109 | }
110 | }
111 | }
112 |
113 | /**
114 | * @param array $deletedValues
115 | */
116 | public function showFinalDbInfo(
117 | array $deletedValues,
118 | int $numberOfValuesDeleted,
119 | string $dbTable,
120 | OutputInterface $output
121 | ): void {
122 | if ($deletedValues !== []) {
123 | $output->writeln(sprintf(
124 | "- %s\n\nDeleted the above %d values",
125 | implode("\n- ", $deletedValues),
126 | count($deletedValues)
127 | ));
128 | $output->writeln('');
129 |
130 | $this->logger->logFinalDbSummary($numberOfValuesDeleted, $dbTable);
131 | }
132 | }
133 |
134 | /**
135 | * @param array $paths
136 | */
137 | private function displayPaths(array $paths, OutputInterface $output): void
138 | {
139 | // MAYBE TODO: if the amount of paths is bigger than some number (100?)
140 | // then try to group the output in something like: "/pub/media/catalog/product/xxx/ (xx items)""
141 | $output->writeln('We found the following paths to delete:');
142 |
143 | foreach ($paths as $path) {
144 | $output->writeln(sprintf('- %s', $path));
145 | }
146 | $output->writeln('');
147 | }
148 |
149 | /**
150 | * @param array $values
151 | */
152 | private function displayDbValues(array $values, string $dbTable, OutputInterface $output): void
153 | {
154 | foreach ($values as $value) {
155 | $output->writeln(sprintf('- %s', $value));
156 | }
157 |
158 | $output->writeln('');
159 | $output->writeln(sprintf(
160 | 'We found the above %d values to delete in the database table %s:',
161 | count($values),
162 | $dbTable
163 | ));
164 | }
165 |
166 | /**
167 | * @param array $paths
168 | */
169 | private function calculateAndDisplayStats(array $paths, OutputInterface $output): void
170 | {
171 | if ($this->fileStatsCalculator->canCalculate()) {
172 | $this->fileStatsCalculator->resetStats();
173 | $this->fileStatsCalculator->calculateStatsForPaths($paths);
174 |
175 | $numberOfFiles = $this->fileStatsCalculator->getNumberOfFiles();
176 | $totalSizeInBytes = $this->fileStatsCalculator->getTotalSizeInBytes();
177 |
178 | $output->writeln(sprintf('Total files: %d', $numberOfFiles));
179 | $output->writeln(sprintf('Filesize: %s', $this->formatBytes($totalSizeInBytes)));
180 | $output->writeln('');
181 | }
182 | }
183 |
184 | // from: https://stackoverflow.com/a/2510459
185 | private function formatBytes(int $bytes, int $precision = 2): string
186 | {
187 | $units = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
188 |
189 | $bytes = max($bytes, 0);
190 | $pow = floor(($bytes > 0 ? log($bytes) : 0) / log(1024));
191 | $pow = (int) min($pow, count($units) - 1);
192 |
193 | $bytes /= (1 << (10 * $pow));
194 |
195 | return round($bytes, $precision) . ' ' . $units[$pow];
196 | }
197 |
198 | private function askForConfirmation(InputInterface $input, OutputInterface $output): bool
199 | {
200 | $result = false;
201 |
202 | if ($input->isInteractive()) {
203 | $question = new ConfirmationQuestion('Continue with the deletion of these [y/N]? ', false);
204 | $questionHelper = new QuestionHelper();
205 |
206 | if ((bool) $questionHelper->ask($input, $output, $question)) {
207 | $result = true;
208 | $output->writeln('');
209 | }
210 | } else {
211 | $result = true;
212 | }
213 |
214 | return $result;
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/Finder/UnusedCacheHashDirectoriesFinder.php:
--------------------------------------------------------------------------------
1 | paramsBuilder = $paramsBuilder;
45 | $this->assetImageFactory = $assetImageFactory;
46 | $this->filesystem = $filesystem;
47 | $this->filesystemFileDriver = $filesystemFileDriver;
48 | $this->viewConfig = $viewConfig;
49 | $this->storeManager = $storeManager;
50 | $this->themeCustomizationConfig = $themeCustomizationConfig;
51 | $this->themeCollection = $themeCollection;
52 | }
53 |
54 | /**
55 | * @return array
56 | */
57 | public function find(): array
58 | {
59 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA);
60 |
61 | $cacheDir = 'catalog/product/cache';
62 | $allDirectories = [];
63 | if ($mediaDirectory->isExist($cacheDir)) {
64 | $allDirectories = $mediaDirectory->read($cacheDir);
65 | }
66 | $usedDirectories = $this->getUsedCacheHashDirectories($mediaDirectory);
67 |
68 | /** @var array */
69 | $unusedDirectories = array_diff($allDirectories, $usedDirectories);
70 |
71 | $unusedDirectories = array_map(function (string $directory) use ($mediaDirectory): string {
72 | $absolutePath = $this->filesystemFileDriver->getRealPath($mediaDirectory->getAbsolutePath($directory));
73 |
74 | if (!is_string($absolutePath)) {
75 | throw new FileSystemException(
76 | __('Can\'t find media directory path: "%1"', $mediaDirectory->getAbsolutePath($directory))
77 | );
78 | }
79 |
80 | return $absolutePath;
81 | }, $unusedDirectories);
82 |
83 | return $unusedDirectories;
84 | }
85 |
86 | /**
87 | * @return array
88 | */
89 | private function getUsedCacheHashDirectories(ReadDirectory $mediaDirectory): array
90 | {
91 | $directories = [];
92 |
93 | $originalImageName = 'baldwin_imagecleanup_test_file.jpg';
94 |
95 | foreach ($this->getViewImages($this->getThemesInUse()) as $imageParams) {
96 | // Copied first few lines from Magento\MediaStorage\Service\ImageResize::resize
97 | // - this hasn't been changed between Magento 2.3.4 and 2.4.7
98 | // - there were some small changes introduced in 2.4.0, but those won't affect the result over here
99 | unset($imageParams['id']);
100 | $imageAsset = $this->assetImageFactory->create(
101 | [
102 | 'miscParams' => $imageParams,
103 | 'filePath' => $originalImageName,
104 | ]
105 | );
106 | $imageAssetPath = $imageAsset->getPath();
107 | $mediaStorageFilename = $mediaDirectory->getRelativePath($imageAssetPath);
108 | // End Copied code
109 |
110 | if (preg_match(
111 | '#^(catalog/product/cache/[a-f0-9]{32})/' . preg_quote($originalImageName) . '$#',
112 | $mediaStorageFilename,
113 | $matches
114 | ) !== 1) {
115 | throw new LocalizedException(
116 | __('File: "%1" doesn\'t seem like a valid cache directory', $mediaStorageFilename)
117 | );
118 | }
119 | $directory = $matches[1];
120 |
121 | $directories[] = $directory;
122 | }
123 |
124 | $directories = array_unique($directories);
125 |
126 | return $directories;
127 | }
128 |
129 | /**
130 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.4 and 2.4.7
131 | *
132 | * @param array $themes
133 | *
134 | * @return array>
135 | */
136 | private function getViewImages(array $themes): array
137 | {
138 | $viewImages = [];
139 | $stores = $this->storeManager->getStores(true);
140 | /** @var Theme $theme */
141 | foreach ($themes as $theme) {
142 | $config = $this->viewConfig->getViewConfig(
143 | [
144 | 'area' => Area::AREA_FRONTEND,
145 | 'themeModel' => $theme,
146 | ]
147 | );
148 | $images = $config->getMediaEntities('Magento_Catalog', ImageHelper::MEDIA_TYPE_CONFIG_NODE);
149 | foreach ($images as $imageId => $imageData) {
150 | foreach ($stores as $store) {
151 | $data = $this->paramsBuilder->build($imageData, (int) $store->getId());
152 | $uniqIndex = $this->getUniqueImageIndex($data);
153 | $data['id'] = $imageId;
154 | $viewImages[$uniqIndex] = $data;
155 | }
156 | }
157 | }
158 |
159 | return $viewImages;
160 | }
161 |
162 | /**
163 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.0 and 2.4.7
164 | *
165 | * @param array $imageData
166 | */
167 | private function getUniqueImageIndex(array $imageData): string
168 | {
169 | ksort($imageData);
170 | unset($imageData['type']);
171 |
172 | // phpcs:ignore Magento2.Security.InsecureFunction
173 | return md5(json_encode($imageData) ?: '');
174 | }
175 |
176 | /**
177 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.0 and 2.4.7
178 | *
179 | * @return array
180 | */
181 | private function getThemesInUse(): array
182 | {
183 | $themesInUse = [];
184 | /** @var ThemeCollection */
185 | $registeredThemes = $this->themeCollection->loadRegisteredThemes();
186 | $storesByThemes = $this->themeCustomizationConfig->getStoresByThemes();
187 | $keyType = is_int(key($storesByThemes)) ? 'getId' : 'getCode';
188 | foreach ($registeredThemes as $registeredTheme) {
189 | if (array_key_exists($registeredTheme->$keyType(), $storesByThemes)) {
190 | $themesInUse[] = $registeredTheme;
191 | }
192 | }
193 |
194 | return $themesInUse;
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/Finder/UnusedFilesFinder.php:
--------------------------------------------------------------------------------
1 | > */
26 | private $fileMapping = [];
27 |
28 | public function __construct(
29 | ProgressIndicator $progressIndicator,
30 | ResourceConnection $resource,
31 | Filesystem $filesystem,
32 | FileIo $fileIo,
33 | ConfigReader $configReader
34 | ) {
35 | $this->progressIndicator = $progressIndicator;
36 | $this->resource = $resource;
37 | $this->filesystem = $filesystem;
38 | $this->fileIo = $fileIo;
39 | $this->configReader = $configReader;
40 | }
41 |
42 | /**
43 | * @return array
44 | */
45 | public function find(): array
46 | {
47 | $this->progressIndicator->start('Searching for unused files');
48 |
49 | $filesInDb = $this->getFilenamesFromDb();
50 | $filesInDb = $this->extendWithAlternatives($filesInDb);
51 |
52 | $this->progressIndicator->advance();
53 |
54 | $filesOnDisk = $this->getFilesOnDisk();
55 |
56 | $unusedFiles = array_diff($filesOnDisk, $filesInDb);
57 | $unusedFiles = $this->getUnusedFilesAbsolutePaths($unusedFiles);
58 |
59 | $this->progressIndicator->stop();
60 |
61 | return $unusedFiles;
62 | }
63 |
64 | /**
65 | * @return array
66 | */
67 | private function getFilenamesFromDb(): array
68 | {
69 | // we don't check here if the gallery entries are assigned to an existing entity
70 | // we have the catalog:images:remove-obsolete-db-entries command to get rid of those obsolete entries first
71 |
72 | $connection = $this->resource->getConnection();
73 | $select = $connection->select()
74 | ->from($this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE))
75 | ->reset(Select::COLUMNS)
76 | ->columns(new \Zend_Db_Expr('DISTINCT value'))
77 | // we only search for filenames that start with /x/,
78 | // because we will always assume this sort of filepath later when we compare with files on the disk
79 | ->where("value LIKE '/_/%'")
80 | ;
81 |
82 | /** @var array */
83 | $filenames = $connection->fetchCol($select);
84 |
85 | $filenames = array_map(
86 | function (string $file) {
87 | return ltrim($file, '/');
88 | },
89 | $filenames
90 | );
91 |
92 | return $filenames;
93 | }
94 |
95 | /**
96 | * @return array
97 | */
98 | private function getFilesOnDisk(): array
99 | {
100 | $filesOnDisk = [];
101 |
102 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA);
103 | $mediaDirectoryPath = $mediaDirectory->getAbsolutePath();
104 |
105 | $productImageDirectories = $this->getProductImageDirectories($mediaDirectory);
106 | $this->progressIndicator->setMax(count($productImageDirectories));
107 |
108 | foreach ($productImageDirectories as $productImageDir) {
109 | $fileIterator = new \RecursiveIteratorIterator(
110 | new \RecursiveDirectoryIterator(
111 | $mediaDirectory->getAbsolutePath($productImageDir),
112 | \FilesystemIterator::SKIP_DOTS
113 | )
114 | );
115 |
116 | /** @var \SplFileInfo $file */
117 | foreach ($fileIterator as $file) {
118 | if ($file->isFile()) {
119 | $absPath = $file->getRealPath();
120 | $filenameWithoutExtension = $file->getBasename('.' . $file->getExtension());
121 | $filename = $this->normalizeFilename($absPath, $mediaDirectoryPath);
122 | if ($filename !== null) {
123 | // keep track of mapping between relative filename and absolute paths,
124 | // because a single relative filename could match multiple absolute paths
125 | if (!array_key_exists($filename, $this->fileMapping)) {
126 | $this->fileMapping[$filename] = [];
127 | }
128 | $this->fileMapping[$filename][] = $absPath;
129 |
130 | $filesOnDisk[] = $filename;
131 | }
132 | }
133 | }
134 |
135 | $this->progressIndicator->advance();
136 | }
137 |
138 | return array_unique($filesOnDisk);
139 | }
140 |
141 | /**
142 | * @return array
143 | */
144 | private function getProductImageDirectories(ReadInterface $mediaDirectory): array
145 | {
146 | // find all cached hash subdirectories,
147 | // so the ones starting with a single letter in their directory name
148 | $subHashDirectories = [];
149 |
150 | $cacheDir = 'catalog/product/cache';
151 | if ($mediaDirectory->isExist($cacheDir)) {
152 | /** @var array */
153 | $hashDirectories = $mediaDirectory->read($cacheDir);
154 | foreach ($hashDirectories as $hashDir) {
155 | if ($mediaDirectory->isDirectory($hashDir)) {
156 | $subHashDirectories[] = $mediaDirectory->read($hashDir);
157 | }
158 | }
159 | }
160 |
161 | /** @var array */
162 | $productImageDirectories = array_merge(
163 | $mediaDirectory->read('catalog/product'),
164 | ...$subHashDirectories
165 | );
166 |
167 | // only allow directories that have one character as lowest path
168 | $productImageDirectories = array_filter(
169 | $productImageDirectories,
170 | function (string $path) use ($mediaDirectory) {
171 | return $mediaDirectory->isDirectory($path) && preg_match('#/.{1}$#', $path) === 1;
172 | }
173 | );
174 |
175 | return $productImageDirectories;
176 | }
177 |
178 | private function normalizeFilename(string $filename, string $mediaDirectoryPath): ?string
179 | {
180 | // remove media directory path from filename
181 | $regex = '#^' . preg_quote($mediaDirectoryPath) . '#';
182 | $filename = preg_replace($regex, '', $filename);
183 |
184 | if ($filename !== null) {
185 | // reduce filename path even further so we end up with
186 | // only the part from the directory with one single character
187 | $pathParts = explode('/', $filename);
188 | foreach ($pathParts as $index => $part) {
189 | if (strlen($part) === 1) {
190 | $filename = implode('/', array_splice($pathParts, $index));
191 |
192 | return $filename;
193 | }
194 | }
195 | }
196 |
197 | return null;
198 | }
199 |
200 | /**
201 | * @param array $files
202 | *
203 | * @return array
204 | */
205 | private function extendWithAlternatives(array $files): array
206 | {
207 | $alternativeExtensions = $this->configReader->getAlternativeExtensions();
208 |
209 | $extraFiles = [];
210 | array_walk($files, function (string $filename) use ($alternativeExtensions, &$extraFiles) {
211 | /** @var array */
212 | $pathInfo = $this->fileIo->getPathInfo($filename);
213 |
214 | $fileWithoutExtension = $pathInfo['dirname'] . '/' . $pathInfo['filename'];
215 |
216 | foreach ($alternativeExtensions as $ext) {
217 | $extraFiles[] = $fileWithoutExtension . '.' . $ext;
218 | $extraFiles[] = $filename . '.' . $ext;
219 | }
220 | });
221 |
222 | return array_unique(array_merge($files, $extraFiles));
223 | }
224 |
225 | /**
226 | * @param array $paths
227 | *
228 | * @return array
229 | */
230 | private function getUnusedFilesAbsolutePaths(array $paths): array
231 | {
232 | $result = [];
233 |
234 | foreach ($paths as $path) {
235 | $result[] = $this->fileMapping[$path];
236 | }
237 |
238 | return array_merge([], ...$result);
239 | }
240 | }
241 |
--------------------------------------------------------------------------------