diff --git a/Command/GenerateThumbnailsCommand.php b/Command/GenerateThumbnailsCommand.php new file mode 100644 index 0000000..46905b7 --- /dev/null +++ b/Command/GenerateThumbnailsCommand.php @@ -0,0 +1,69 @@ +addArgument('conf', InputArgument::REQUIRED, 'Name of the configuration (artgris_file_manager.conf.)') + ->addOption('force', null, InputOption::VALUE_NONE, 'Recreate the versions that already exist'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $conf = $this->filemanagerService->getBasePath(['conf' => $input->getArgument('conf')]); + $upload = $conf['upload'] ?? []; + // The empty key holds the options of the original, it is not a version + $versions = array_values(array_diff(array_keys($upload['image_versions'] ?? []), [''])); + if (!$versions) { + $output->writeln('No image version is defined in upload.image_versions.'); + + return Command::FAILURE; + } + + // Same extensions as UploadHandler::has_image_file_extension() + $images = (new Finder())->files()->in($conf['dir'])->name('/\.(gif|jpe?g|png)$/i') + ->filter(function (SplFileInfo $file) use ($versions) { + return !\in_array(basename($file->getRelativePath()), $versions, true); + }); + + $created = 0; + $failed = 0; + foreach ($images as $image) { + $directory = $image->getPath().\DIRECTORY_SEPARATOR; + // One handler per image: the GD backend keeps every opened image in memory as long as the handler lives + $handler = new FileManagerUploadHandler(['upload_dir' => $directory] + $upload, false); + foreach ($versions as $version) { + if (!$input->getOption('force') && is_file($directory.$version.\DIRECTORY_SEPARATOR.$image->getFilename())) { + continue; + } + if ($handler->createVersion($image->getFilename(), $version)) { + ++$created; + } else { + ++$failed; + $output->writeln(sprintf('Cannot create the "%s" version of %s', $version, $image->getPathname())); + } + } + } + + $output->writeln(sprintf('%d image version(s) created, %d failed.', $created, $failed)); + + return $failed ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/Controller/ManagerController.php b/Controller/ManagerController.php index e57712c..2b38268 100644 --- a/Controller/ManagerController.php +++ b/Controller/ManagerController.php @@ -65,6 +65,7 @@ public function indexAction(Request $request, FileTypeService $fileTypeService): // File search $finderFiles = new Finder(); $finderFiles->in($fileManager->getCurrentPath())->depth(0); + $this->excludeImageVersionDirectories($fileManager, $finderFiles); $regex = $fileManager->getRegex(); $orderBy = $fileManager->getQueryParameter('orderby'); @@ -220,6 +221,13 @@ public function renameFileAction(Request $request, string $fileName): RedirectRe try { $this->dispatch(FileManagerEvents::RENAME_FILE, ['oldFile'=> $oldfilePath,'newFile'=> $newfilePath]); $fs->rename($oldfilePath, $newfilePath); + // Generated image versions (e.g. thumbnail/) follow the original + foreach ($fileManager->getImageVersionNames() as $version) { + $versionDirectory = $fileManager->getCurrentPath().\DIRECTORY_SEPARATOR.$version.\DIRECTORY_SEPARATOR; + if (file_exists($versionDirectory.basename($oldfilePath))) { + $fs->rename($versionDirectory.basename($oldfilePath), $versionDirectory.$newfileName); + } + } $this->addFlash('success', $this->translator->trans('file.renamed.success')); //File has been renamed successfully } catch (IOException $exception) { @@ -311,6 +319,10 @@ public function deleteAction(Request $request): RedirectResponse { try { $this->dispatch(FileManagerEvents::PRE_DELETE_FILE); $fs->remove($filePath); + // Generated image versions (e.g. thumbnail/) go together with the original + foreach ($fileManager->getImageVersionNames() as $version) { + $fs->remove(\dirname($filePath).\DIRECTORY_SEPARATOR.$version.\DIRECTORY_SEPARATOR.basename($filePath)); + } $is_delete = true; $this->dispatch(FileManagerEvents::POST_DELETE_FILE); } catch (IOException $exception) { @@ -388,6 +400,8 @@ private function retrieveSubDirectories(FileManager $fileManager, string $path, if ($baseFolderName) { $directories->name($baseFolderName); + } else { + $this->excludeImageVersionDirectories($fileManager, $directories); } $directoriesList = null; @@ -428,6 +442,18 @@ private function retrieveSubDirectories(FileManager $fileManager, string $path, return $directoriesList; } + /** + * Image version directories (e.g. thumbnail/) belong to the originals, so they are not listed. + */ + private function excludeImageVersionDirectories(FileManager $fileManager, Finder $finder): void { + $versionNames = $fileManager->getImageVersionNames(); + if ($versionNames) { + $finder->filter(function (SplFileInfo $file) use ($versionNames) { + return !($file->isDir() && \in_array($file->getFilename(), $versionNames, true)); + }); + } + } + /** * Tree Iterator. */ diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 9a20e15..e7cdb0d 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -37,6 +37,7 @@ public function getConfigTreeBuilder(): TreeBuilder { ->scalarNode('root_name')->defaultValue(null)->end() ->scalarNode('twig_extension')->end() ->booleanNode('cachebreaker')->defaultValue(true)->end() + ->scalarNode('thumbnail_version')->defaultNull()->end() ->enumNode('view')->values(['thumbnail', 'list'])->defaultValue('list')->end() ->scalarNode('regex')->end() ->scalarNode('service')->end() diff --git a/Helpers/FileManager.php b/Helpers/FileManager.php index 2c57e92..a27d38c 100644 --- a/Helpers/FileManager.php +++ b/Helpers/FileManager.php @@ -199,6 +199,19 @@ public function getQueryParameter(string $parameter) return $this->getQueryParameters()[$parameter] ?? null; } + /** + * Names of the image version directories (non-empty keys of upload.image_versions). + * They are managed together with the originals only when thumbnail_version is set. + */ + public function getImageVersionNames(): array + { + if (null === $this->getConfigurationParameter('thumbnail_version')) { + return []; + } + + return array_values(array_filter(array_keys($this->getConfiguration()['upload']['image_versions'] ?? []), 'strlen')); + } + public function getConfigurationParameter(string $parameter) { return $this->getConfiguration()[$parameter] ?? null; diff --git a/Helpers/FileManagerUploadHandler.php b/Helpers/FileManagerUploadHandler.php index 62daf22..e1d8aba 100644 --- a/Helpers/FileManagerUploadHandler.php +++ b/Helpers/FileManagerUploadHandler.php @@ -8,6 +8,33 @@ class FileManagerUploadHandler extends UploadHandler { + /** + * Creates one image version (upload.image_versions) of a file already stored in upload_dir, + * without an upload request (used by the artgris:filemanager:thumbnails command). + */ + public function createVersion(string $fileName, string $version): bool { + return (bool) $this->create_scaled_image($fileName, $version, $this->options['image_versions'][$version]); + } + + protected function get_full_url(): string { + // On the console there is no HTTP host to build the URL from, and the URL is not needed there + if (!isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SERVER_NAME'])) { + return ''; + } + + return parent::get_full_url(); + } + + protected function get_server_var($id) { + // A missing key (e.g. HTTP_REFERER on the console) gave null, which string functions no longer accept + return $_SERVER[$id] ?? ''; + } + + protected function basename($filepath, $suffix = '') { + // The parent passes its default null suffix to basename(), deprecated since PHP 8.1 + return parent::basename($filepath, $suffix); + } + protected function get_unique_filename($file_path, $name, $size, $type, $error, $index, $content_range) { diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 1f6b46f..4ae5fd2 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -7,6 +7,9 @@ services: Artgris\Bundle\FileManagerBundle\Controller\: resource: '../../Controller/*' + Artgris\Bundle\FileManagerBundle\Command\: + resource: '../../Command/*' + Artgris\Bundle\FileManagerBundle\Twig\FileTypeExtension: tags: - { name: twig.extension } diff --git a/Resources/doc/book/1-basic-configuration.md b/Resources/doc/book/1-basic-configuration.md index 7828cc6..a9058db 100644 --- a/Resources/doc/book/1-basic-configuration.md +++ b/Resources/doc/book/1-basic-configuration.md @@ -179,6 +179,56 @@ Having a large number of files in a (sub-)directory can have a huge impact on pe |:------------------|:--------:|:--------:|:-------------:| | `root_name` | `String` | False | null | +## `thumbnail_version` to preview images from a smaller image version + +| Option | Type | Required | Default value | +|:--------------------|:--------:|:--------:|:-------------:| +| `thumbnail_version` | `String` | False | null | + +By default the list shows each image preview from the original file, so a folder of large photos downloads all of them in full size. +Set `thumbnail_version` to the name of one of the [`upload.image_versions`](#image_versions-image-version) to load the previews from that version instead: + +```yml +artgris_file_manager: + conf: + public: + dir: '%kernel.project_dir%/public/uploads' + thumbnail_version: thumbnail + upload: + image_versions: + '': { auto_orient: true } + thumbnail: { max_width: 200, max_height: 200 } +``` + +Each version is stored in a subfolder named after the version, next to the original (the layout of the upload widget), so every folder with images gets its own `thumbnail/`: + +``` +uploads/ +├── photo.jpg +├── thumbnail/photo.jpg +└── 2024/ + ├── scan.png + └── thumbnail/scan.png +``` + +In a public folder the versions are reachable by URL like the originals (here `/uploads/2024/thumbnail/scan.png`). + +When `thumbnail_version` is set: + +* the preview uses `/` when that file exists, and the original otherwise. The preview modal, the download link and the path sent to TinyMCE/CKEditor still use the original; +* the version folders (every non-empty key of `upload.image_versions`, here `thumbnail/`) are not listed; +* deleting or renaming a file also deletes or renames its versions. + +New uploads create the versions. For images that already exist, run: + +```bash +php bin/console artgris:filemanager:thumbnails public # creates the missing versions +php bin/console artgris:filemanager:thumbnails public --force # recreates all versions +``` + +The command uses `image_library` from `upload` (GD, Imagick or ImageMagick), like the upload does. +Run it as the user of the web server (e.g. `sudo -u www-data php bin/console ...`): the version folders it creates belong to the user running it, and the upload must be able to write into them later. + ## `upload` A non-exhaustive configuration of the File Upload widget [blueimp/jQuery-File-Upload](https://github.com/blueimp/jQuery-File-Upload) > [Exhaustive options](https://github.com/blueimp/jQuery-File-Upload/blob/master/server/php/UploadHandler.php) can only be defined with [The service configuration](2-service-configuration.md) diff --git a/Service/FileTypeService.php b/Service/FileTypeService.php index b5a9948..1d21059 100644 --- a/Service/FileTypeService.php +++ b/Service/FileTypeService.php @@ -8,7 +8,7 @@ use Twig\Environment; class FileTypeService { - const IMAGE_SIZE = [ + public const IMAGE_SIZE = [ FileManager::VIEW_LIST => '22', FileManager::VIEW_THUMBNAIL => '100', ]; @@ -34,7 +34,11 @@ public function preview(FileManager $fileManager, SplFileInfo $file) { if ('file' === $type) { $size = $this::IMAGE_SIZE[$fileManager->getView()]; - return $this->fileIcon($filePath, $extension, $size, true, $fileManager->getConfigurationParameter('twig_extension'), $fileManager->getConfigurationParameter('cachebreaker')); + $preview = $this->fileIcon($this->thumbnailPath($fileManager, $file) ?? $filePath, $extension, $size, true, $fileManager->getConfigurationParameter('twig_extension'), $fileManager->getConfigurationParameter('cachebreaker')); + // Only the list preview uses the thumbnail; the modal, editors and download keep the original + $preview['path'] = $filePath; + + return $preview; } if ('dir' === $type) { @@ -53,6 +57,25 @@ public function preview(FileManager $fileManager, SplFileInfo $file) { } } + /** + * URL of the thumbnail_version image of the file, when it has been generated. + */ + private function thumbnailPath(FileManager $fileManager, SplFileInfo $file): ?string { + $version = $fileManager->getConfigurationParameter('thumbnail_version'); + if (null === $version || !is_file($file->getPath().\DIRECTORY_SEPARATOR.$version.\DIRECTORY_SEPARATOR.$file->getFilename())) { + return null; + } + + if ($fileManager->getImagePath()) { + return $fileManager->getImagePath().rawurlencode($version).'/'.rawurlencode($file->getFilename()); + } + + return $this->router->generate('file_manager_file', array_merge($fileManager->getQueryParameters(), [ + 'fileName' => $file->getFilename(), + 'route' => $fileManager->getRoute().'/'.$version, + ])); + } + public function accept($type): bool|string { switch ($type) { case 'image': diff --git a/Tests/Controller/ThumbnailsTest.php b/Tests/Controller/ThumbnailsTest.php new file mode 100644 index 0000000..50c4321 --- /dev/null +++ b/Tests/Controller/ThumbnailsTest.php @@ -0,0 +1,103 @@ +initClient(['environment' => 'thumbnails']); + } + + protected function tearDown(): void + { + foreach (self::TEMPORARY_FILES as $file) { + @unlink(self::DIR.$file); + @unlink(self::DIR.'thumbnail/'.$file); + } + parent::tearDown(); + } + + public function testVersionDirectoryIsNotListed() + { + $crawler = $this->getBackendPage(['conf' => 'default', 'tree' => 0]); + $this->assertCount(1, $crawler->filter('.list-blk tbody tr')); + + $this->getBackendPage(['conf' => 'default', 'json' => 1]); + $treeData = json_decode($this->client->getResponse()->getContent(), true)['treeData']; + $this->assertNull($treeData[0]['children']); + } + + public function testPreviewUsesThumbnail() + { + $crawler = $this->getManagerPage(); + $this->assertStringStartsWith('/manager/file/image.png?conf=default&route=/thumbnail&time=', $crawler->filter('img.lazy')->attr('data-src')); + // The modal preview keeps the original + $this->assertSame('/manager/file/image.png?conf=default', $crawler->filter('.js-open-modal')->attr('data-href')); + } + + public function testPublicPreviewUsesThumbnailUrl() + { + $crawler = $this->getBackendPage(['conf' => 'public']); + $this->assertStringStartsWith('/thumbnails/thumbnail/image.png?time=', $crawler->filter('img.lazy')->attr('data-src')); + $this->assertSame('/thumbnails/image.png', $crawler->filter('.js-open-modal')->attr('data-href')); + } + + public function testWithoutThumbnailVersionNothingChanges() + { + $crawler = $this->getBackendPage(['conf' => 'no_thumbnail', 'tree' => 0]); + $this->assertCount(2, $crawler->filter('.list-blk tbody tr')); + $this->assertStringStartsWith('/manager/file/image.png?conf=no_thumbnail&tree=0&time=', $crawler->filter('img.lazy')->attr('data-src')); + } + + public function testDeleteRemovesVersions() + { + $this->createImage('delete.png'); + + $this->client->request('POST', '/manager/delete/?conf=default&delete[]=delete.png', ['delete_f' => ['DELETE' => '']]); + + $this->assertFileDoesNotExist(self::DIR.'delete.png'); + $this->assertFileDoesNotExist(self::DIR.'thumbnail/delete.png'); + } + + public function testRenameRenamesVersions() + { + $this->createImage('rename.png'); + + $this->client->request('POST', '/manager/rename/rename.png?conf=default', ['rename_f' => ['name' => 'renamed', 'extension' => 'png', 'send' => '']]); + + $this->assertFileExists(self::DIR.'renamed.png'); + $this->assertFileExists(self::DIR.'thumbnail/renamed.png'); + $this->assertFileDoesNotExist(self::DIR.'thumbnail/rename.png'); + } + + public function testCommandCreatesMissingVersions() + { + if (!\extension_loaded('gd') && !\extension_loaded('imagick')) { + $this->markTestSkipped('Scaling images needs ext-gd or ext-imagick.'); + } + copy(self::DIR.'image.png', self::DIR.'command.png'); + + $tester = new CommandTester((new Application(static::$kernel))->find('artgris:filemanager:thumbnails')); + $tester->execute(['conf' => 'default']); + + // image.png already has its thumbnail, only command.png gets one + $this->assertStringContainsString('1 image version(s) created, 0 failed.', $tester->getDisplay()); + $this->assertSame([2, 2], \array_slice(getimagesize(self::DIR.'thumbnail/command.png'), 0, 2)); + } + + private function createImage(string $name): void + { + copy(self::DIR.'image.png', self::DIR.$name); + copy(self::DIR.'thumbnail/image.png', self::DIR.'thumbnail/'.$name); + } +} diff --git a/Tests/Fixtures/App/config/config_thumbnails.yml b/Tests/Fixtures/App/config/config_thumbnails.yml new file mode 100644 index 0000000..219ca18 --- /dev/null +++ b/Tests/Fixtures/App/config/config_thumbnails.yml @@ -0,0 +1,30 @@ +imports: + - { resource: config.yml } + +# One environment for all the thumbnail tests: every new environment adds deprecations of the framework to the CI budget +artgris_file_manager: + web_dir: "%kernel.project_dir%/Tests/Fixtures/web" + conf: + # Outside of web_dir: previews use the file_manager_file route + default: + dir: "%kernel.project_dir%/Tests/Fixtures/thumbnails" + thumbnail_version: thumbnail + upload: + image_versions: + '': { auto_orient: true } + thumbnail: { max_width: 2, max_height: 2 } + # Under web_dir: previews use direct URLs + public: + dir: "%kernel.project_dir%/Tests/Fixtures/web/thumbnails" + thumbnail_version: thumbnail + upload: + image_versions: + '': { auto_orient: true } + thumbnail: { max_width: 2, max_height: 2 } + # Same directory and image versions without thumbnail_version: the previous behavior is kept + no_thumbnail: + dir: "%kernel.project_dir%/Tests/Fixtures/thumbnails" + upload: + image_versions: + '': { auto_orient: true } + thumbnail: { max_width: 2, max_height: 2 } diff --git a/Tests/Fixtures/thumbnails/image.png b/Tests/Fixtures/thumbnails/image.png new file mode 100644 index 0000000..6e4b723 Binary files /dev/null and b/Tests/Fixtures/thumbnails/image.png differ diff --git a/Tests/Fixtures/thumbnails/thumbnail/image.png b/Tests/Fixtures/thumbnails/thumbnail/image.png new file mode 100644 index 0000000..6a68df8 Binary files /dev/null and b/Tests/Fixtures/thumbnails/thumbnail/image.png differ diff --git a/Tests/Fixtures/web/thumbnails/image.png b/Tests/Fixtures/web/thumbnails/image.png new file mode 100644 index 0000000..6e4b723 Binary files /dev/null and b/Tests/Fixtures/web/thumbnails/image.png differ diff --git a/Tests/Fixtures/web/thumbnails/thumbnail/image.png b/Tests/Fixtures/web/thumbnails/thumbnail/image.png new file mode 100644 index 0000000..6a68df8 Binary files /dev/null and b/Tests/Fixtures/web/thumbnails/thumbnail/image.png differ diff --git a/composer.json b/composer.json index 3a8f872..de0b1d7 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "php": ">=8.1", "oomphinc/composer-installers-extender": "^2.0", "symfony/asset": "^6.0||^7.0||^8.0", + "symfony/console": "^6.0||^7.0||^8.0", "symfony/event-dispatcher": "^6.0||^7.0||^8.0", "symfony/expression-language": "^6.0||^7.0||^8.0", "symfony/flex": "^2.3",