Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ Once you have a FilesystemOperator, you can call methods from the
[Filesystem API](https://flysystem.thephpleague.com/v2/docs/usage/filesystem-api/)
to interact with your storage.

If you need to transfer files between the local filesystem and one of your configured storages, the bundle also provides two console commands:

```bash
bin/console flysystem:push <storage> <local-source> [remote-destination]
bin/console flysystem:pull <storage> <remote-source> [local-destination]
```

The `<storage>` argument is the configured Flysystem storage name (for example `default.storage`), not the adapter type. When the destination is omitted, the basename of the source path is used.

## Full documentation

1. [Getting started](docs/1-getting-started.md)
Expand Down
165 changes: 165 additions & 0 deletions src/Command/AbstractTransferCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

/*
* This file is part of the flysystem-bundle project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\FlysystemBundle\Command;

use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ServiceLocator;

abstract class AbstractTransferCommand extends Command
{
public function __construct(private readonly ServiceLocator $storages)
{
parent::__construct();
}

protected function configure(): void
{
$this
->addArgument('storage', InputArgument::REQUIRED, 'The configured Flysystem storage name.')
->addArgument('source', InputArgument::REQUIRED, 'The source path to transfer.')
->addArgument('destination', InputArgument::OPTIONAL, 'The destination path. Defaults to the source basename.');
}

protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);

if (null === $input->getArgument('storage')) {
$input->setArgument('storage', $io->askQuestion($this->createStorageQuestion()));
}

if (null === $input->getArgument('source')) {
$input->setArgument('source', $io->askQuestion($this->createRequiredQuestion(
'What is the source path to transfer?',
'The source path cannot be empty.'
)));
}

if (null === $input->getArgument('destination')) {
$input->setArgument('destination', $io->askQuestion($this->createDestinationQuestion((string) $input->getArgument('source'))));
}
}

final protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$storageName = (string) $input->getArgument('storage');
$source = (string) $input->getArgument('source');
$destination = $input->getArgument('destination');
$destination = null === $destination ? basename($source) : (string) $destination;
$destination = $this->normalizeDestination($source, $destination);

try {
$storage = $this->getStorage($storageName);
$this->transfer($storage, $source, $destination);
} catch (InvalidArgumentException|\InvalidArgumentException $exception) {
$io->error($exception->getMessage());

return self::INVALID;
} catch (FilesystemException|\RuntimeException $exception) {
$io->error($exception->getMessage());

return self::FAILURE;
}

$io->success($this->createSuccessMessage($storageName, $source, $destination));

return self::SUCCESS;
}

private function createStorageQuestion(): Question
{
$storageNames = array_keys($this->storages->getProvidedServices());
sort($storageNames);

$question = new Question('Which configured Flysystem storage should be used?', 1 === count($storageNames) ? $storageNames[0] : null);
$question->setAutocompleterValues($storageNames);
$question->setValidator(function (?string $answer): string {
$answer = trim((string) $answer);

if ('' === $answer) {
throw new \RuntimeException('The storage name cannot be empty.');
}

if (!$this->storages->has($answer)) {
throw new \RuntimeException(sprintf('The storage "%s" does not exist.', $answer));
}

return $answer;
});

return $question;
}

private function createDestinationQuestion(string $source): Question
{
$question = new Question('What is the destination path?', basename($source));
$question->setValidator(function (?string $answer): string {
$answer = trim((string) $answer);

if ('' === $answer) {
throw new \RuntimeException('The destination path cannot be empty.');
}

return $answer;
});

return $question;
}

private function createRequiredQuestion(string $label, string $errorMessage): Question
{
$question = new Question($label);
$question->setValidator(function (?string $answer) use ($errorMessage): string {
$answer = trim((string) $answer);

if ('' === $answer) {
throw new \RuntimeException($errorMessage);
}

return $answer;
});

return $question;
}

private function getStorage(string $storageName): FilesystemOperator
{
if (!$this->storages->has($storageName)) {
throw new InvalidArgumentException(sprintf('The storage "%s" does not exist.', $storageName));
}

$storage = $this->storages->get($storageName);
if (!$storage instanceof FilesystemOperator) {
throw new \RuntimeException(sprintf('The storage "%s" is not a Flysystem operator.', $storageName));
}

return $storage;
}

protected function normalizeDestination(string $source, string $destination): string
{
return $destination;
}

abstract protected function transfer(FilesystemOperator $storage, string $source, string $destination): void;

abstract protected function createSuccessMessage(string $storageName, string $source, string $destination): string;
}
62 changes: 62 additions & 0 deletions src/Command/PullCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/*
* This file is part of the flysystem-bundle project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\FlysystemBundle\Command;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: 'flysystem:pull', description: 'Pull a file from a configured Flysystem storage to the local filesystem.')]
final class PullCommand extends AbstractTransferCommand
{
protected function normalizeDestination(string $source, string $destination): string
{
if (!is_dir($destination)) {
return $destination;
}

return rtrim($destination, '/\\').DIRECTORY_SEPARATOR.basename($source);
}

protected function transfer(FilesystemOperator $storage, string $source, string $destination): void
{
$directory = dirname($destination);
if ('.' !== $directory && !is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
Comment thread
maxhelias marked this conversation as resolved.
Outdated
throw new \RuntimeException(sprintf('Unable to create the destination directory "%s".', $directory));
}

$resource = $storage->readStream($source);
if (!is_resource($resource)) {
throw new \RuntimeException(sprintf('Unable to read the source file "%s" from storage.', $source));
}

$local = fopen($destination, 'wb');
Comment thread
maxhelias marked this conversation as resolved.
if (false === $local) {
if (is_resource($resource)) {
fclose($resource);
}

throw new \RuntimeException(sprintf('Unable to open the destination file "%s" for writing.', $destination));
}

try {
stream_copy_to_stream($resource, $local);
Comment thread
maxhelias marked this conversation as resolved.
Outdated
} finally {
fclose($resource);
fclose($local);
}
}

protected function createSuccessMessage(string $storageName, string $source, string $destination): string
{
return sprintf('Pulled "%s" from storage "%s" to "%s".', $source, $storageName, $destination);
}
}
42 changes: 42 additions & 0 deletions src/Command/PushCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the flysystem-bundle project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace League\FlysystemBundle\Command;

use League\Flysystem\FilesystemOperator;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: 'flysystem:push', description: 'Push a local file to a configured Flysystem storage.')]
final class PushCommand extends AbstractTransferCommand
{
protected function transfer(FilesystemOperator $storage, string $source, string $destination): void
{
if (!is_file($source)) {
throw new \InvalidArgumentException(sprintf('The source file "%s" does not exist or is not a regular file.', $source));
}

$resource = fopen($source, 'rb');
if (false === $resource) {
throw new \RuntimeException(sprintf('Unable to open the source file "%s" for reading.', $source));
}

try {
$storage->writeStream($destination, $resource);
} finally {
fclose($resource);
}
}

protected function createSuccessMessage(string $storageName, string $source, string $destination): string
{
return sprintf('Pushed "%s" to "%s" on storage "%s".', $source, $destination, $storageName);
}
}
30 changes: 30 additions & 0 deletions src/DependencyInjection/FlysystemExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@
use League\Flysystem\FilesystemWriter;
use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter;
use League\FlysystemBundle\Adapter\AdapterDefinitionFactory;
use League\FlysystemBundle\Command\PullCommand;
use League\FlysystemBundle\Command\PushCommand;
use League\FlysystemBundle\Exception\MissingPackageException;
use League\FlysystemBundle\Lazy\LazyFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\OptionsResolver\OptionsResolver;

use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_locator;

/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
Expand All @@ -40,9 +45,34 @@ public function load(array $configs, ContainerBuilder $container): void
->setPublic(false)
;

if (ContainerBuilder::willBeAvailable('symfony/console', Command::class, ['symfony/framework-bundle'])) {
$this->registerPushCommand($container);
$this->registerPullCommand($container);
}

$this->createStoragesDefinitions($config, $container);
}

private function registerPushCommand(ContainerBuilder $container): void
{
$container
->register(PushCommand::class, PushCommand::class)
->setPublic(false)
->setArgument('$storages', tagged_locator('flysystem.storage', 'storage'))
->addTag('console.command')
;
}

private function registerPullCommand(ContainerBuilder $container): void
{
$container
->register(PullCommand::class, PullCommand::class)
->setPublic(false)
->setArgument('$storages', tagged_locator('flysystem.storage', 'storage'))
->addTag('console.command')
;
}

private function createStoragesDefinitions(array $config, ContainerBuilder $container): void
{
$definitionFactory = new AdapterDefinitionFactory();
Expand Down
Loading
Loading