diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php index 1d905e73e986b..fe9a00befcde1 100644 --- a/apps/files/lib/Command/Copy.php +++ b/apps/files/lib/Command/Copy.php @@ -9,77 +9,80 @@ namespace OCA\Files\Command; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\Files\Folder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; - -class Copy extends Command { + +#[AsCommand( + name: 'files:copy', + description: 'Copy a file or folder', +)] +class Copy { public function __construct( - private FileUtils $fileUtils, + private readonly FileUtils $fileUtils, ) { - parent::__construct(); - } - - #[\Override] - protected function configure(): void { - $this - ->setName('files:copy') - ->setDescription('Copy a file or folder') - ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path') - ->addArgument('target', InputArgument::REQUIRED, 'Target path') - ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for confirmation and don't output any warnings") - ->addOption('no-target-directory', 'T', InputOption::VALUE_NONE, 'When target path is folder, overwrite the folder instead of copying into the folder'); } - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $sourceInput = $input->getArgument('source'); - $targetInput = $input->getArgument('target'); - $force = $input->getOption('force'); - $noTargetDir = $input->getOption('no-target-directory'); - - $node = $this->fileUtils->getNode($sourceInput); - $targetNode = $this->fileUtils->getNode($targetInput); + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(description: 'Source file id or path')] + string $source, + #[Argument(description: 'Target path')] + string $target, + #[Option( + description: "Don't ask for confirmation and don't output any warnings", + shortcut: 'f', + )] + bool $force = false, + #[Option( + name: 'no-target-directory', + description: 'When target path is folder, overwrite the folder instead of copying into the folder', + shortcut: 'T', + )] + bool $noTargetDirectory = false, + ): ExitCode { + $node = $this->fileUtils->getNode($source); + $targetNode = $this->fileUtils->getNode($target); if (!$node) { - $output->writeln("file $sourceInput not found"); - return 1; + $output->writeln("file $source not found"); + return ExitCode::Failure; } - $targetParentPath = dirname(rtrim($targetInput, '/')); + $targetParentPath = dirname(rtrim($target, '/')); $targetParent = $this->fileUtils->getNode($targetParentPath); if (!$targetParent) { $output->writeln("Target parent path $targetParentPath doesn't exist"); - return 1; + return ExitCode::Failure; } $wouldRequireDelete = false; if ($targetNode) { if (!$targetNode->isUpdateable()) { - $output->writeln("$targetInput isn't writable"); - return 1; + $output->writeln("$target isn't writable"); + return ExitCode::Failure; } if ($targetNode instanceof Folder) { - if ($noTargetDir) { + if ($noTargetDirectory) { if (!$force) { - $output->writeln("Warning: $sourceInput is a file, but $targetInput is a folder"); + $output->writeln("Warning: $source is a file, but $target is a folder"); } $wouldRequireDelete = true; } else { - $targetInput = $targetNode->getFullPath($node->getName()); - $targetNode = $this->fileUtils->getNode($targetInput); + $target = $targetNode->getFullPath($node->getName()); + $targetNode = $this->fileUtils->getNode($target); } } else { if ($node instanceof Folder) { if (!$force) { - $output->writeln("Warning: $sourceInput is a folder, but $targetInput is a file"); + $output->writeln("Warning: $source is a folder, but $target is a file"); } $wouldRequireDelete = true; } @@ -87,21 +90,17 @@ public function execute(InputInterface $input, OutputInterface $output): int { if ($wouldRequireDelete && $targetNode->getInternalPath() === '') { $output->writeln("Mount root can't be overwritten with a different type"); - return 1; + return ExitCode::Failure; } if ($wouldRequireDelete && !$targetNode->isDeletable()) { - $output->writeln("$targetInput can't be deleted to be replaced with $sourceInput"); - return 1; + $output->writeln("$target can't be deleted to be replaced with $source"); + return ExitCode::Failure; } if (!$force && $targetNode) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - - $question = new ConfirmationQuestion('' . $targetInput . ' already exists, overwrite? [y/N] ', false); - if (!$helper->ask($input, $output, $question)) { - return 1; + if (!$input->confirm('' . $target . ' already exists, overwrite? [y/N] ', false)) { + return ExitCode::Failure; } } } @@ -110,9 +109,8 @@ public function execute(InputInterface $input, OutputInterface $output): int { $targetNode->delete(); } - $node->copy($targetInput); + $node->copy($target); - return 0; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php index 627b993bcce5d..7d1e31ab4c5cb 100644 --- a/apps/files/lib/Command/Delete.php +++ b/apps/files/lib/Command/Delete.php @@ -11,58 +11,55 @@ use OC\Core\Command\Info\FileUtils; use OCA\Files_Sharing\SharedStorage; use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\Files\Folder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -class Delete extends Command { +#[AsCommand( + name: 'files:delete', + description: 'Delete a file or folder', +)] +class Delete { public function __construct( private readonly FileUtils $fileUtils, private readonly ?ITrashManager $trashManager = null, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:delete') - ->setDescription('Delete a file or folder') - ->addArgument('file', InputArgument::REQUIRED, 'File id or path') - ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings") - ->addOption('skip-trash', null, InputOption::VALUE_NONE, 'Bypass the trashbin when deleting the file or folder'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $fileInput = $input->getArgument('file'); - $inputIsId = is_numeric($fileInput); - $force = $input->getOption('force'); - $skipTrash = $input->getOption('skip-trash'); - $node = $this->fileUtils->getNode($fileInput); + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(description: 'File id or path')] + string $file, + #[Option( + description: "Don't ask for configuration and don't output any warnings", + shortcut: 'f', + )] + bool $force = false, + #[Option(name: 'skip-trash', description: 'Bypass the trashbin when deleting the file or folder')] + bool $skipTrash = false, + ): ExitCode { + $inputIsId = is_numeric($file); + $node = $this->fileUtils->getNode($file); if (!$node) { - $output->writeln("file $fileInput not found"); - return self::FAILURE; + $output->writeln("file $file not found"); + return ExitCode::Failure; } $deleteConfirmed = $force; if (!$deleteConfirmed) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); $storage = $node->getStorage(); if (!$inputIsId && $storage->instanceOfStorage(SharedStorage::class) && $node->getInternalPath() === '') { /** @var SharedStorage $storage */ - [,$user] = explode('/', $fileInput, 3); - $question = new ConfirmationQuestion("$fileInput in a shared file, do you want to unshare the file from $user instead of deleting the source file? [Y/n] ", true); - if ($helper->ask($input, $output, $question)) { + [,$user] = explode('/', $file, 3); + if ($input->confirm("$file in a shared file, do you want to unshare the file from $user instead of deleting the source file? [Y/n] ", true)) { $storage->unshareStorage(); - return self::SUCCESS; + return ExitCode::Success; } else { $node = $storage->getShare()->getNode(); $output->writeln(''); @@ -76,8 +73,8 @@ public function execute(InputInterface $input, OutputInterface $output): int { $output->writeln(''); foreach ($filesByUsers as $user => $filesByUser) { $output->writeln($user . ':'); - foreach ($filesByUser as $file) { - $output->writeln(' - ' . $file->getPath()); + foreach ($filesByUser as $userFile) { + $output->writeln(' - ' . $userFile->getPath()); } } $output->writeln(''); @@ -88,8 +85,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { } else { $maybeContents = ''; } - $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); - $deleteConfirmed = $helper->ask($input, $output, $question); + $deleteConfirmed = $input->confirm('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); } if ($deleteConfirmed) { @@ -104,6 +100,6 @@ public function execute(InputInterface $input, OutputInterface $output): int { } } - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php index c5a15c304a6fd..0563584d75be2 100644 --- a/apps/files/lib/Command/DeleteOrphanedFiles.php +++ b/apps/files/lib/Command/DeleteOrphanedFiles.php @@ -1,5 +1,7 @@ setName('files:cleanup') - ->setDescription('Clean up orphaned filecache and mount entries') - ->setHelp('Deletes orphaned filecache and mount entries (those without an existing storage).') - ->addOption('skip-filecache-extended', null, InputOption::VALUE_NONE, 'don\'t remove orphaned entries from filecache_extended'); } - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { + public function __invoke( + IOutput $output, + #[Option(name: 'skip-filecache-extended', description: 'don\'t remove orphaned entries from filecache_extended')] + bool $skipFilecacheExtended = false, + ): ExitCode { $fileIdsByStorage = []; $deletedStorages = array_diff($this->getReferencedStorages(), $this->getExistingStorages()); - $deleteExtended = !$input->getOption('skip-filecache-extended'); + $deleteExtended = !$skipFilecacheExtended; if ($deleteExtended) { $fileIdsByStorage = $this->getFileIdsForStorages($deletedStorages); } @@ -58,7 +58,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $deletedMounts = $this->cleanupOrphanedMounts(); $output->writeln("$deletedMounts orphaned mount entries deleted"); - return self::SUCCESS; + return ExitCode::Success; } private function getReferencedStorages(): array { diff --git a/apps/files/lib/Command/Get.php b/apps/files/lib/Command/Get.php index 81b9c308385fc..40a98e519839d 100644 --- a/apps/files/lib/Command/Get.php +++ b/apps/files/lib/Command/Get.php @@ -9,65 +9,62 @@ namespace OCA\Files\Command; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\Files\File; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -class Get extends Command { +#[AsCommand( + name: 'files:get', + description: 'Get the contents of a file', +)] +class Get { public function __construct( - private FileUtils $fileUtils, + private readonly FileUtils $fileUtils, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:get') - ->setDescription('Get the contents of a file') - ->addArgument('file', InputArgument::REQUIRED, 'Source file id or Nextcloud path') - ->addArgument('output', InputArgument::OPTIONAL, 'Target local file to output to, defaults to STDOUT'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $fileInput = $input->getArgument('file'); - $outputName = $input->getArgument('output'); - $node = $this->fileUtils->getNode($fileInput); + public function __invoke( + IOutput $output, + #[Argument(description: 'Source file id or Nextcloud path')] + string $file, + #[Argument(name: 'output', description: 'Target local file to output to, defaults to STDOUT')] + ?string $outputFile = null, + ): ExitCode { + $node = $this->fileUtils->getNode($file); if (!$node) { - $output->writeln("file $fileInput not found"); - return self::FAILURE; + $output->writeln("file $file not found"); + return ExitCode::Failure; } if (!($node instanceof File)) { - $output->writeln("$fileInput is a directory"); - return self::FAILURE; + $output->writeln("$file is a directory"); + return ExitCode::Failure; } $isTTY = stream_isatty(STDOUT); - if ($outputName === null && $isTTY && $node->getMimePart() !== 'text') { + if ($outputFile === null && $isTTY && $node->getMimePart() !== 'text') { $output->writeln([ 'Warning: Binary output can mess up your terminal', - " Use occ files:get $fileInput - to output it to the terminal anyway", - " Or occ files:get $fileInput to save to a file instead" + " Use occ files:get $file - to output it to the terminal anyway", + " Or occ files:get $file to save to a file instead" ]); - return self::FAILURE; + return ExitCode::Failure; } $source = $node->fopen('r'); if (!$source) { - $output->writeln("Failed to open $fileInput for reading"); - return self::FAILURE; + $output->writeln("Failed to open $file for reading"); + return ExitCode::Failure; } - $target = ($outputName === null || $outputName === '-') ? STDOUT : fopen($outputName, 'w'); + $target = ($outputFile === null || $outputFile === '-') ? STDOUT : fopen($outputFile, 'w'); if (!$target) { - $output->writeln("Failed to open $outputName for reading"); - return self::FAILURE; + $output->writeln("Failed to open $outputFile for reading"); + return ExitCode::Failure; } stream_copy_to_stream($source, $target); - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/Mkdir.php b/apps/files/lib/Command/Mkdir.php index a7c47ccd362b7..ae17735969868 100644 --- a/apps/files/lib/Command/Mkdir.php +++ b/apps/files/lib/Command/Mkdir.php @@ -9,46 +9,43 @@ namespace OCA\Files\Command; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -class Mkdir extends Command { +#[AsCommand( + name: 'files:mkdir', + description: 'Create a new directory', +)] +class Mkdir { public function __construct( private readonly FileUtils $fileUtils, private readonly IRootFolder $rootFolder, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:mkdir') - ->setDescription('Create a new directory') - ->addArgument('path', InputArgument::REQUIRED, 'Target Nextcloud path for the new folder'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $path = $input->getArgument('path'); + public function __invoke( + IOutput $output, + #[Argument(description: 'Target Nextcloud path for the new folder')] + string $path, + ): ExitCode { $node = $this->fileUtils->getNode($path); if ($node instanceof Folder) { $output->writeln("$path already exists"); - return self::SUCCESS; + return ExitCode::Success; } if ($node instanceof File) { $output->writeln("$path is a file"); - return self::FAILURE; + return ExitCode::Failure; } $this->rootFolder->newFolder($path); - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/Mount/ListMounts.php b/apps/files/lib/Command/Mount/ListMounts.php index ae72d236c97fb..c8f6d5c8b243d 100644 --- a/apps/files/lib/Command/Mount/ListMounts.php +++ b/apps/files/lib/Command/Mount/ListMounts.php @@ -8,44 +8,46 @@ namespace OCA\Files\Command\Mount; -use OC\Core\Command\Base; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\Config\IUserMountCache; use OCP\Files\Mount\IMountPoint; use OCP\IUserManager; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class ListMounts extends Base { +#[AsCommand( + name: 'files:mount:list', + description: 'List of mounts for a user', + supportsOutputFormat: true, +)] +class ListMounts { public function __construct( private readonly IUserManager $userManager, private readonly IUserMountCache $userMountCache, private readonly IMountProviderCollection $mountProviderCollection, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:mount:list') - ->setDescription('List of mounts for a user') - ->addArgument('user', InputArgument::REQUIRED, 'User to list mounts for') - ->addOption('cached-only', null, InputOption::VALUE_NONE, 'Only return cached mounts, prevents filesystem setup'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $userId = $input->getArgument('user'); - $cachedOnly = $input->getOption('cached-only'); + public function __invoke( + IInput $input, + IOutput $output, + OutputFormat $outputFormat, + #[Argument(description: 'User to list mounts for')] + string $user, + #[Option(name: 'cached-only', description: 'Only return cached mounts, prevents filesystem setup')] + bool $cachedOnly = false, + ): ExitCode { + $userId = $user; $user = $this->userManager->get($userId); if (!$user) { $output->writeln("User $userId not found"); - return 1; + return ExitCode::Failure; } if ($cachedOnly) { @@ -63,9 +65,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { /** @var array $cachedByMountpoint */ $cachedByMountPoint = array_combine(array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts), $cachedMounts); - $format = $input->getOption('output'); - - if ($format === self::OUTPUT_FORMAT_PLAIN) { + if ($outputFormat === OutputFormat::Plain) { foreach ($mounts as $mount) { $output->writeln('' . $mount->getMountPoint() . ': ' . $mount->getStorageId()); if (isset($cachedByMountPoint[$mount->getMountPoint()])) { @@ -101,12 +101,11 @@ public function execute(InputInterface $input, OutputInterface $output): int { 'storage_id' => $cachedMountInfo->getStorageId(), 'root_id' => $cachedMountInfo->getStorageRootId(), ], $mounts); - $this->writeArrayInOutputFormat($input, $output, array_filter([ + $output->writeArrayInOutputFormat(array_filter([ 'cached' => $cached, 'provided' => $cachedOnly ? null : $provided, ])); } - return 0; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Mount/Refresh.php b/apps/files/lib/Command/Mount/Refresh.php index 3995345041a60..59bc0c30814e0 100644 --- a/apps/files/lib/Command/Mount/Refresh.php +++ b/apps/files/lib/Command/Mount/Refresh.php @@ -8,38 +8,36 @@ namespace OCA\Files\Command\Mount; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\Files\Config\IMountProviderCollection; use OCP\Files\Config\IUserMountCache; use OCP\IUserManager; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -class Refresh extends Command { +#[AsCommand( + name: 'files:mount:refresh', + description: 'Refresh the list of mounts for a user', +)] +class Refresh { public function __construct( private readonly IUserManager $userManager, private readonly IUserMountCache $userMountCache, private readonly IMountProviderCollection $mountProviderCollection, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:mount:refresh') - ->setDescription('Refresh the list of mounts for a user') - ->addArgument('user', InputArgument::REQUIRED, 'User to refresh mounts for'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $userId = $input->getArgument('user'); + public function __invoke( + IOutput $output, + #[Argument(description: 'User to refresh mounts for')] + string $user, + ): ExitCode { + $userId = $user; $user = $this->userManager->get($userId); if (!$user) { $output->writeln("User $userId not found"); - return 1; + return ExitCode::Failure; } $mounts = $this->mountProviderCollection->getMountsForUser($user); @@ -49,7 +47,6 @@ public function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Registered ' . count($mounts) . ' mounts'); - return 0; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php index ab4b9f86bfab8..7f8e03fa3370b 100644 --- a/apps/files/lib/Command/Move.php +++ b/apps/files/lib/Command/Move.php @@ -9,89 +9,84 @@ namespace OCA\Files\Command; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\Files\File; use OCP\Files\Folder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; - -class Move extends Command { + +#[AsCommand( + name: 'files:move', + description: 'Move a file or a folder', +)] +class Move { public function __construct( - private FileUtils $fileUtils, + private readonly FileUtils $fileUtils, ) { - parent::__construct(); - } - - #[\Override] - protected function configure(): void { - $this - ->setName('files:move') - ->setDescription('Move a file or folder') - ->addArgument('source', InputArgument::REQUIRED, 'Source file id or path') - ->addArgument('target', InputArgument::REQUIRED, 'Target path') - ->addOption('force', 'f', InputOption::VALUE_NONE, "Don't ask for configuration and don't output any warnings"); } - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $sourceInput = $input->getArgument('source'); - $targetInput = $input->getArgument('target'); - $force = $input->getOption('force'); - - $node = $this->fileUtils->getNode($sourceInput); - $targetNode = $this->fileUtils->getNode($targetInput); + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(description: 'Source file id or path')] + string $source, + #[Argument(description: 'Target path')] + string $target, + #[Option( + description: "Don't ask for configuration and don't output any warnings", + shortcut: 'f', + )] + bool $force = false, + ): ExitCode { + $node = $this->fileUtils->getNode($source); + $targetNode = $this->fileUtils->getNode($target); if (!$node) { - $output->writeln("file $sourceInput not found"); - return 1; + $output->writeln("file $source not found"); + return ExitCode::Failure; } - $targetParentPath = dirname(rtrim($targetInput, '/')); + $targetParentPath = dirname(rtrim($target, '/')); $targetParent = $this->fileUtils->getNode($targetParentPath); if (!$targetParent) { $output->writeln("Target parent path $targetParentPath doesn't exist"); - return 1; + return ExitCode::Failure; } $wouldRequireDelete = false; if ($targetNode) { if (!$targetNode->isUpdateable()) { - $output->writeln("$targetInput already exists and isn't writable"); - return 1; + $output->writeln("$target already exists and isn't writable"); + return ExitCode::Failure; } if ($node instanceof Folder && $targetNode instanceof File) { - $output->writeln("Warning: $sourceInput is a folder, but $targetInput is a file"); + $output->writeln("Warning: $source is a folder, but $target is a file"); $wouldRequireDelete = true; } if ($node instanceof File && $targetNode instanceof Folder) { - $output->writeln("Warning: $sourceInput is a file, but $targetInput is a folder"); + $output->writeln("Warning: $source is a file, but $target is a folder"); $wouldRequireDelete = true; } if ($wouldRequireDelete && $targetNode->getInternalPath() === '') { $output->writeln("Mount root can't be overwritten with a different type"); - return 1; + return ExitCode::Failure; } if ($wouldRequireDelete && !$targetNode->isDeletable()) { - $output->writeln("$targetInput can't be deleted to be replaced with $sourceInput"); - return 1; + $output->writeln("$target can't be deleted to be replaced with $source"); + return ExitCode::Failure; } if (!$force) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - - $question = new ConfirmationQuestion('' . $targetInput . ' already exists, overwrite? [y/N] ', false); - if (!$helper->ask($input, $output, $question)) { - return 1; + if (!$input->confirm('' . $target . ' already exists, overwrite? [y/N] ', false)) { + return ExitCode::Failure; } } } @@ -100,9 +95,8 @@ public function execute(InputInterface $input, OutputInterface $output): int { $targetNode->delete(); } - $node->move($targetInput); + $node->move($target); - return 0; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php index 575bf5df069cb..6d900e3b1389b 100644 --- a/apps/files/lib/Command/Object/Delete.php +++ b/apps/files/lib/Command/Object/Delete.php @@ -8,34 +8,32 @@ namespace OCA\Files\Command\Object; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; - -class Delete extends Command { +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; + +#[AsCommand( + name: 'files:object:delete', + description: 'Delete an object from the object store', +)] +class Delete { public function __construct( - private ObjectUtil $objectUtils, + private readonly ObjectUtil $objectUtils, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:object:delete') - ->setDescription('Delete an object from the object store') - ->addArgument('object', InputArgument::REQUIRED, 'Object to delete') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to delete the object from, only required in cases where it can't be determined from the config"); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $object = $input->getArgument('object'); - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(description: 'Object to delete')] + string $object, + #[Option(description: "Bucket to delete the object from, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode|int { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { return -1; } @@ -51,12 +49,9 @@ public function execute(InputInterface $input, OutputInterface $output): int { return -1; } - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion("Delete $object? [y/N] ", false); - if ($helper->ask($input, $output, $question)) { + if ($input->confirm("Delete $object? [y/N] ", false)) { $objectStore->deleteObject($object); } - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php index dc88ff6a94d3f..60fd99901df39 100644 --- a/apps/files/lib/Command/Object/Get.php +++ b/apps/files/lib/Command/Object/Get.php @@ -8,41 +8,39 @@ namespace OCA\Files\Command\Object; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; - -class Get extends Command { +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; + +#[AsCommand( + name: 'files:object:get', + description: 'Get the contents of an object', +)] +class Get { public function __construct( - private ObjectUtil $objectUtils, + private readonly ObjectUtil $objectUtils, ) { - parent::__construct(); - } - - #[\Override] - protected function configure(): void { - $this - ->setName('files:object:get') - ->setDescription('Get the contents of an object') - ->addArgument('object', InputArgument::REQUIRED, 'Object to get') - ->addArgument('output', InputArgument::REQUIRED, 'Target local file to output to, use - for STDOUT') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config"); } - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $object = $input->getArgument('object'); - $outputName = $input->getArgument('output'); - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IOutput $output, + #[Argument(description: 'Object to get')] + string $object, + #[Argument(name: 'output', description: 'Target local file to output to, use - for STDOUT')] + string $outputFile, + #[Option(description: "Bucket to get the object from, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { - return self::FAILURE; + return ExitCode::Failure; } if (!$objectStore->objectExists($object)) { $output->writeln("Object $object does not exist"); - return self::FAILURE; + return ExitCode::Failure; } try { @@ -50,16 +48,15 @@ public function execute(InputInterface $input, OutputInterface $output): int { } catch (\Exception $e) { $msg = $e->getMessage(); $output->writeln("Failed to read $object from object store: $msg"); - return self::FAILURE; + return ExitCode::Failure; } - $target = $outputName === '-' ? STDOUT : fopen($outputName, 'w'); + $target = $outputFile === '-' ? STDOUT : fopen($outputFile, 'w'); if (!$target) { - $output->writeln("Failed to open $outputName for writing"); - return self::FAILURE; + $output->writeln("Failed to open $outputFile for writing"); + return ExitCode::Failure; } stream_copy_to_stream($source, $target); - return self::SUCCESS; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Object/Info.php b/apps/files/lib/Command/Object/Info.php index 14b2ac1aed99f..5e99a29642326 100644 --- a/apps/files/lib/Command/Object/Info.php +++ b/apps/files/lib/Command/Object/Info.php @@ -8,49 +8,51 @@ namespace OCA\Files\Command\Object; -use OC\Core\Command\Base; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\Files\IMimeTypeDetector; use OCP\Files\ObjectStore\IObjectStoreMetaData; use OCP\Util; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class Info extends Base { +#[AsCommand( + name: 'files:object:info', + description: 'Get the metadata of an object', + supportsOutputFormat: true, +)] +class Info { public function __construct( - private ObjectUtil $objectUtils, - private IMimeTypeDetector $mimeTypeDetector, + private readonly ObjectUtil $objectUtils, + private readonly IMimeTypeDetector $mimeTypeDetector, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:object:info') - ->setDescription('Get the metadata of an object') - ->addArgument('object', InputArgument::REQUIRED, 'Object to get') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to get the object from, only required in cases where it can't be determined from the config"); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $object = $input->getArgument('object'); - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IInput $input, + IOutput $output, + OutputFormat $outputFormat, + #[Argument(description: 'Object to get')] + string $object, + #[Option(description: "Bucket to get the object from, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { - return self::FAILURE; + return ExitCode::Failure; } if (!$objectStore instanceof IObjectStoreMetaData) { $output->writeln('Configured object store does currently not support retrieve metadata'); - return self::FAILURE; + return ExitCode::Failure; } if (!$objectStore->objectExists($object)) { $output->writeln("Object $object does not exist"); - return self::FAILURE; + return ExitCode::Failure; } try { @@ -58,10 +60,10 @@ public function execute(InputInterface $input, OutputInterface $output): int { } catch (\Exception $e) { $msg = $e->getMessage(); $output->writeln("Failed to read $object from object store: $msg"); - return self::FAILURE; + return ExitCode::Failure; } - if ($input->getOption('output') === 'plain' && isset($meta['size'])) { + if ($outputFormat === OutputFormat::Plain && isset($meta['size'])) { $meta['size'] = Util::humanFileSize($meta['size']); } if (isset($meta['mtime'])) { @@ -74,9 +76,8 @@ public function execute(InputInterface $input, OutputInterface $output): int { $meta['mimetype'] = $this->mimeTypeDetector->detectString($head); } - $this->writeArrayInOutputFormat($input, $output, $meta); + $output->writeArrayInOutputFormat($meta); - return self::SUCCESS; + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Object/ListObject.php b/apps/files/lib/Command/Object/ListObject.php index d72ad3ca77680..f20c8d63170c9 100644 --- a/apps/files/lib/Command/Object/ListObject.php +++ b/apps/files/lib/Command/Object/ListObject.php @@ -8,45 +8,47 @@ namespace OCA\Files\Command\Object; -use OC\Core\Command\Base; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\Files\ObjectStore\IObjectStoreMetaData; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class ListObject extends Base { +#[AsCommand( + name: 'files:object:list', + description: 'List all objects in the object store', + supportsOutputFormat: true, +)] +class ListObject { private const CHUNK_SIZE = 100; public function __construct( private readonly ObjectUtil $objectUtils, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:object:list') - ->setDescription('List all objects in the object store') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config"); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IInput $input, + IOutput $output, + OutputFormat $outputFormat, + #[Option(description: "Bucket to list the objects from, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { - return self::FAILURE; + return ExitCode::Failure; } if (!$objectStore instanceof IObjectStoreMetaData) { $output->writeln('Configured object store does currently not support listing objects'); - return self::FAILURE; + return ExitCode::Failure; } $objects = $objectStore->listObjects(); - $objects = $this->objectUtils->formatObjects($objects, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN); - $this->writeStreamingTableInOutputFormat($input, $output, $objects, self::CHUNK_SIZE); + $objects = $this->objectUtils->formatObjects($objects, $outputFormat === OutputFormat::Plain); + $output->writeStreamingTableInOutputFormat($objects, self::CHUNK_SIZE); - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php index 92a2718df63bd..29034a49cdddf 100644 --- a/apps/files/lib/Command/Object/Multi/Rename.php +++ b/apps/files/lib/Command/Object/Multi/Rename.php @@ -8,54 +8,49 @@ namespace OCA\Files\Command\Object\Multi; -use OC\Core\Command\Base; use OC\Files\ObjectStore\PrimaryObjectStoreConfig; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\IConfig; use OCP\IDBConnection; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -class Rename extends Base { +#[AsCommand( + name: 'files:object:multi:rename-config', + description: 'Rename an object store configuration and move all users over to the new configuration,', +)] +class Rename { public function __construct( private readonly IDBConnection $connection, private readonly PrimaryObjectStoreConfig $objectStoreConfig, private readonly IConfig $config, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:object:multi:rename-config') - ->setDescription('Rename an object store configuration and move all users over to the new configuration,') - ->addArgument('source', InputArgument::REQUIRED, 'Object store configuration to rename') - ->addArgument('target', InputArgument::REQUIRED, 'New name for the object store configuration'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $source = $input->getArgument('source'); - $target = $input->getArgument('target'); - + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(description: 'Object store configuration to rename')] + string $source, + #[Argument(description: 'New name for the object store configuration')] + string $target, + ): ExitCode { $configs = $this->objectStoreConfig->getObjectStoreConfigs(); if (!isset($configs[$source])) { $output->writeln('Unknown object store configuration: ' . $source . ''); - return 1; + return ExitCode::Failure; } if ($source === 'root') { $output->writeln('Renaming the root configuration is not supported.'); - return 1; + return ExitCode::Failure; } if ($source === 'default') { $output->writeln('Renaming the default configuration is not supported.'); - return 1; + return ExitCode::Failure; } if (!isset($configs[$target])) { @@ -67,10 +62,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $output->writeln(''); $output->writeln('Failure to check these requirements will lead to data loss for users.'); - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false); - if ($helper->ask($input, $output, $question)) { + if ($input->confirm('Automatically create target object store configuration? [y/N] ', false)) { $configs[$target] = $configs[$source]; // update all aliases @@ -81,14 +73,14 @@ public function execute(InputInterface $input, OutputInterface $output): int { } $this->config->setSystemValue('objectstore', $configs); } else { - return 0; + return ExitCode::Success; } } elseif (($configs[$source] !== $configs[$target]) || $configs[$source] !== $target) { $output->writeln('Source and target configuration differ.'); $output->writeln(''); $output->writeln('To ensure proper migration of users, the source and target configuration must be the same to ensure that the objects for the moved users exist on the target configuration.'); $output->writeln('The usual migration process consists of creating a clone of the old configuration, moving the users from the old configuration to the new one, and then adjust the old configuration that is longer used.'); - return 1; + return ExitCode::Failure; } $query = $this->connection->getQueryBuilder(); @@ -105,6 +97,6 @@ public function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('No users moved'); } - return 0; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/Object/Multi/Users.php b/apps/files/lib/Command/Object/Multi/Users.php index d33e8d7decaf1..a1090712eebb6 100644 --- a/apps/files/lib/Command/Object/Multi/Users.php +++ b/apps/files/lib/Command/Object/Multi/Users.php @@ -8,70 +8,70 @@ namespace OCA\Files\Command\Object\Multi; -use OC\Core\Command\Base; use OC\Files\ObjectStore\PrimaryObjectStoreConfig; -use OCP\IConfig; +use OCP\Config\IUserConfig; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\IUser; use OCP\IUserManager; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class Users extends Base { +#[AsCommand( + name: 'files:object:multi:users', + description: 'Get the mapping between users and object store buckets', + supportsOutputFormat: true, +)] +class Users { public function __construct( private readonly IUserManager $userManager, private readonly PrimaryObjectStoreConfig $objectStoreConfig, - private readonly IConfig $config, + private readonly IUserConfig $userConfig, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:object:multi:users') - ->setDescription('Get the mapping between users and object store buckets') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'Only list users using the specified bucket') - ->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'Only list users using the specified object store configuration') - ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - if ($userId = $input->getOption('user')) { - $user = $this->userManager->get($userId); - if (!$user) { - $output->writeln("User $userId not found"); - return 1; + public function __invoke( + IOutput $output, + #[Option(description: 'Only list users using the specified bucket', shortcut: 'b')] + ?string $bucket = null, + #[Option(name: 'object-store', description: 'Only list users using the specified object store configuration', shortcut: 'o')] + ?string $objectStore = null, + #[Option(description: 'Only show the mapping for the specified user, ignores all other options', shortcut: 'u')] + ?string $user = null, + ): ExitCode { + if ($user) { + $userObject = $this->userManager->get($user); + if (!$userObject) { + $output->writeln("User $user not found"); + return ExitCode::Failure; } - $users = new \ArrayIterator([$user]); + $users = new \ArrayIterator([$userObject]); } else { - $bucket = (string)$input->getOption('bucket'); - $objectStore = (string)$input->getOption('object-store'); + $bucket = (string)$bucket; + $objectStore = (string)$objectStore; if ($bucket !== '' && $objectStore === '') { - $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket)); + $users = $this->getUsers($this->userConfig->searchUsersByValueString('homeobjectstore', 'bucket', $bucket)); } elseif ($bucket === '' && $objectStore !== '') { - $users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)); + $users = $this->getUsers($this->userConfig->searchUsersByValueString('homeobjectstore', 'objectstore', $objectStore)); } elseif ($bucket) { $users = $this->getUsers(array_intersect( - $this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket), - $this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore) + iterator_to_array($this->userConfig->searchUsersByValueString('homeobjectstore', 'bucket', $bucket)), + iterator_to_array($this->userConfig->searchUsersByValueString('homeobjectstore', 'objectstore', $objectStore)) )); } else { $users = $this->userManager->getSeenUsers(); } } - $this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100); - return 0; + $output->writeStreamingTableInOutputFormat($this->infoForUsers($users), 100); + return ExitCode::Success; } /** - * @param string[] $userIds + * @param iterable $userIds * @return \Iterator */ - private function getUsers(array $userIds): \Iterator { + private function getUsers(iterable $userIds): \Iterator { foreach ($userIds as $userId) { $user = $this->userManager->get($userId); if ($user) { diff --git a/apps/files/lib/Command/Object/ObjectUtil.php b/apps/files/lib/Command/Object/ObjectUtil.php index 5f053c2c42fff..17aba384c1ad4 100644 --- a/apps/files/lib/Command/Object/ObjectUtil.php +++ b/apps/files/lib/Command/Object/ObjectUtil.php @@ -8,12 +8,12 @@ namespace OCA\Files\Command\Object; +use OCP\Console\IOutput; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\ObjectStore\IObjectStore; use OCP\IConfig; use OCP\IDBConnection; use OCP\Util; -use Symfony\Component\Console\Output\OutputInterface; class ObjectUtil { public function __construct( @@ -39,7 +39,7 @@ private function getObjectStoreConfig(): ?array { return null; } - public function getObjectStore(?string $bucket, OutputInterface $output): ?IObjectStore { + public function getObjectStore(?string $bucket, IOutput $output): ?IObjectStore { $config = $this->getObjectStoreConfig(); if (!$config) { $output->writeln('Instance is not using primary object store'); diff --git a/apps/files/lib/Command/Object/Orphans.php b/apps/files/lib/Command/Object/Orphans.php index 0376ac672afb2..ae8a9af401140 100644 --- a/apps/files/lib/Command/Object/Orphans.php +++ b/apps/files/lib/Command/Object/Orphans.php @@ -8,15 +8,22 @@ namespace OCA\Files\Command\Object; -use OC\Core\Command\Base; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\ObjectStore\IObjectStoreMetaData; use OCP\IDBConnection; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class Orphans extends Base { +#[AsCommand( + name: 'files:object:orphans', + description: 'List all objects in the object store that don\'t have a matching entry in the database', + supportsOutputFormat: true, +)] +class Orphans { private const CHUNK_SIZE = 100; private ?IQueryBuilder $query = null; @@ -25,7 +32,6 @@ public function __construct( private readonly ObjectUtil $objectUtils, private readonly IDBConnection $connection, ) { - parent::__construct(); } private function getQuery(): IQueryBuilder { @@ -38,25 +44,21 @@ private function getQuery(): IQueryBuilder { return $this->query; } - #[\Override] - protected function configure(): void { - parent::configure(); - $this - ->setName('files:object:orphans') - ->setDescription('List all objects in the object store that don\'t have a matching entry in the database') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket to list the objects from, only required in cases where it can't be determined from the config"); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IInput $input, + IOutput $output, + OutputFormat $outputFormat, + #[Option(description: "Bucket to list the objects from, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { - return self::FAILURE; + return ExitCode::Failure; } if (!$objectStore instanceof IObjectStoreMetaData) { $output->writeln('Configured object store does currently not support listing objects'); - return self::FAILURE; + return ExitCode::Failure; } $prefixLength = strlen('urn:oid:'); @@ -66,10 +68,10 @@ public function execute(InputInterface $input, OutputInterface $output): int { return !$this->fileIdInDb($fileId); }); - $orphans = $this->objectUtils->formatObjects($orphans, $input->getOption('output') === self::OUTPUT_FORMAT_PLAIN); - $this->writeStreamingTableInOutputFormat($input, $output, $orphans, self::CHUNK_SIZE); + $orphans = $this->objectUtils->formatObjects($orphans, $outputFormat === OutputFormat::Plain); + $output->writeStreamingTableInOutputFormat($orphans, self::CHUNK_SIZE); - return self::SUCCESS; + return ExitCode::Success; } private function fileIdInDb(int $fileId): bool { diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php index 97bd0009ba05f..022eb8397f36b 100644 --- a/apps/files/lib/Command/Object/Put.php +++ b/apps/files/lib/Command/Object/Put.php @@ -8,63 +8,56 @@ namespace OCA\Files\Command\Object; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\Files\IMimeTypeDetector; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -class Put extends Command { +#[AsCommand( + name: 'files:object:put', + description: 'Write a file to the object store', +)] +class Put { public function __construct( - private ObjectUtil $objectUtils, - private IMimeTypeDetector $mimeTypeDetector, + private readonly ObjectUtil $objectUtils, + private readonly IMimeTypeDetector $mimeTypeDetector, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:object:put') - ->setDescription('Write a file to the object store') - ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN') - ->addArgument('object', InputArgument::REQUIRED, 'Object to write') - ->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, "Bucket where to store the object, only required in cases where it can't be determined from the config"); - ; - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $object = $input->getArgument('object'); - $inputName = (string)$input->getArgument('input'); - $objectStore = $this->objectUtils->getObjectStore($input->getOption('bucket'), $output); + public function __invoke( + IOutput $output, + IInput $consoleInput, + #[Argument(description: 'Source local path, use - to read from STDIN')] + string $input, + #[Argument(description: 'Object to write')] + string $object, + #[Option(description: "Bucket where to store the object, only required in cases where it can't be determined from the config", shortcut: 'b')] + ?string $bucket = null, + ): ExitCode|int { + $objectStore = $this->objectUtils->getObjectStore($bucket, $output); if (!$objectStore) { return -1; } if ($fileId = $this->objectUtils->objectExistsInDb($object)) { $output->writeln("Warning, object $object belongs to an existing file, overwriting the object contents can lead to unexpected behavior."); - $output->writeln("You can use occ files:put $inputName $fileId to write to the file safely."); + $output->writeln("You can use occ files:put $input $fileId to write to the file safely."); $output->writeln(''); - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false); - if (!$helper->ask($input, $output, $question)) { + if (!$consoleInput->confirm('Write to the object anyway? [y/N] ', false)) { return -1; } } - $source = $inputName === '-' ? STDIN : fopen($inputName, 'r'); + $source = $input === '-' ? STDIN : fopen($input, 'r'); if (!$source) { - $output->writeln("Failed to open $inputName"); - return self::FAILURE; + $output->writeln("Failed to open $input"); + return ExitCode::Failure; } - $objectStore->writeObject($object, $source, $this->mimeTypeDetector->detectPath($inputName)); - return self::SUCCESS; + $objectStore->writeObject($object, $source, $this->mimeTypeDetector->detectPath($input)); + return ExitCode::Success; } - } diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php index 7907a3c6d2ee0..864b607eeb378 100644 --- a/apps/files/lib/Command/Put.php +++ b/apps/files/lib/Command/Put.php @@ -9,66 +9,63 @@ namespace OCA\Files\Command; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -class Put extends Command { +#[AsCommand( + name: 'files:put', + description: 'Write contents of a file', +)] +class Put { public function __construct( - private FileUtils $fileUtils, - private IRootFolder $rootFolder, + private readonly FileUtils $fileUtils, + private readonly IRootFolder $rootFolder, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:put') - ->setDescription('Write contents of a file') - ->addArgument('input', InputArgument::REQUIRED, 'Source local path, use - to read from STDIN') - ->addArgument('file', InputArgument::REQUIRED, 'Target Nextcloud file path to write to or fileid of existing file'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $fileOutput = $input->getArgument('file'); - $inputName = $input->getArgument('input'); - $node = $this->fileUtils->getNode($fileOutput); + public function __invoke( + IOutput $output, + #[Argument(description: 'Source local path, use - to read from STDIN')] + string $input, + #[Argument(description: 'Target Nextcloud file path to write to or fileid of existing file')] + string $file, + ): ExitCode { + $node = $this->fileUtils->getNode($file); if ($node instanceof Folder) { - $output->writeln("$fileOutput is a folder"); - return self::FAILURE; + $output->writeln("$file is a folder"); + return ExitCode::Failure; } - if (!$node && is_numeric($fileOutput)) { - $output->writeln("$fileOutput not found"); - return self::FAILURE; + if (!$node && is_numeric($file)) { + $output->writeln("$file not found"); + return ExitCode::Failure; } - $source = ($inputName === null || $inputName === '-') ? STDIN : fopen($inputName, 'r'); + $source = ($input === '-') ? STDIN : fopen($input, 'r'); if (!$source) { - $output->writeln("Failed to open $inputName"); - return self::FAILURE; + $output->writeln("Failed to open $input"); + return ExitCode::Failure; } if ($node instanceof File) { $target = $node->fopen('w'); if (!$target) { - $output->writeln("Failed to open $fileOutput"); - return self::FAILURE; + $output->writeln("Failed to open $file"); + return ExitCode::Failure; } stream_copy_to_stream($source, $target); } else { - $parentPath = dirname($fileOutput); + $parentPath = dirname($file); if (!$this->rootFolder->nodeExists($parentPath)) { $this->rootFolder->newFolder($parentPath); } - $this->rootFolder->newFile($fileOutput, $source); + $this->rootFolder->newFile($file, $source); } - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php index b7134d02a4dfc..32233b95db836 100644 --- a/apps/files/lib/Command/RepairTree.php +++ b/apps/files/lib/Command/RepairTree.php @@ -9,39 +9,37 @@ namespace OCA\Files\Command; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; +use OCP\Console\Verbosity; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class RepairTree extends Command { - public const CHUNK_SIZE = 200; +#[AsCommand( + name: 'files:repair-tree', + description: 'Try and repair malformed filesystem tree structures (may be necessary to run multiple times for nested malformations)', +)] +class RepairTree { + public const int CHUNK_SIZE = 200; public function __construct( - protected IDBConnection $connection, + private readonly IDBConnection $connection, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:repair-tree') - ->setDescription('Try and repair malformed filesystem tree structures (may be necessary to run multiple times for nested malformations)') - ->addOption('dry-run') - ->addOption('storage-id', 's', InputOption::VALUE_OPTIONAL, 'If set, only repair files within the given storage numeric ID', null) - ->addOption('path', 'p', InputOption::VALUE_OPTIONAL, 'If set, only repair files within the given path', null); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $rows = $this->findBrokenTreeBits( - $input->getOption('storage-id'), - $input->getOption('path'), - ); - $fix = !$input->getOption('dry-run'); + public function __invoke( + IOutput $output, + #[Option] + bool $dryRun = false, + #[Option(name: 'storage-id', description: 'If set, only repair files within the given storage numeric ID', shortcut: 's')] + ?string $storageId = null, + #[Option(description: 'If set, only repair files within the given path', shortcut: 'p')] + ?string $path = null, + ): ExitCode { + $rows = $this->findBrokenTreeBits($storageId, $path); + $fix = !$dryRun; $output->writeln('Found ' . count($rows) . ' file entries with an invalid path'); @@ -57,7 +55,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { ->where($query->expr()->eq('fileid', $query->createParameter('fileid'))); foreach ($rows as $row) { - $output->writeln("Path of file {$row['fileid']} is {$row['path']} but should be {$row['parent_path']}/{$row['name']} based on its parent", OutputInterface::VERBOSITY_VERBOSE); + $output->writeln("Path of file {$row['fileid']} is {$row['path']} but should be {$row['parent_path']}/{$row['name']} based on its parent", Verbosity::Verbose); if ($fix) { $fileId = $this->getFileId((int)$row['parent_storage'], $row['parent_path'] . '/' . $row['name']); @@ -79,7 +77,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $this->connection->commit(); } - return self::SUCCESS; + return ExitCode::Success; } private function getFileId(int $storage, string $path) { diff --git a/apps/files/lib/Command/SanitizeFilenames.php b/apps/files/lib/Command/SanitizeFilenames.php index 3432cc835a976..6734bab068cc7 100644 --- a/apps/files/lib/Command/SanitizeFilenames.php +++ b/apps/files/lib/Command/SanitizeFilenames.php @@ -10,10 +10,15 @@ namespace OCA\Files\Command; use Exception; -use OC\Core\Command\Base; use OC\Files\FilenameValidator; use OCA\Files\Service\SettingsService; use OCP\AppFramework\Services\IAppConfig; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; +use OCP\Console\Verbosity; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; @@ -22,59 +27,39 @@ use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Lock\LockedException; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class SanitizeFilenames extends Base { +#[AsCommand( + name: 'files:sanitize-filenames', + description: 'Renames files to match naming constraints', +)] +class SanitizeFilenames { - private OutputInterface $output; + private IOutput $output; private ?string $charReplacement; private bool $dryRun; private bool $errorsOrSkipped = false; public function __construct( - private IUserManager $userManager, - private IRootFolder $rootFolder, - private IUserSession $session, - private IFactory $l10nFactory, - private FilenameValidator $filenameValidator, - private SettingsService $service, - private IAppConfig $appConfig, + private readonly IUserManager $userManager, + private readonly IRootFolder $rootFolder, + private readonly IUserSession $session, + private readonly IFactory $l10nFactory, + private readonly FilenameValidator $filenameValidator, + private readonly SettingsService $service, + private readonly IAppConfig $appConfig, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - parent::configure(); - - $this - ->setName('files:sanitize-filenames') - ->setDescription('Renames files to match naming constraints') - ->addArgument( - 'user_id', - InputArgument::OPTIONAL | InputArgument::IS_ARRAY, - 'will only rename files the given user(s) have access to' - ) - ->addOption( - 'dry-run', - mode: InputOption::VALUE_NONE, - description: 'Do not actually rename any files but just check filenames.', - ) - ->addOption( - 'char-replacement', - 'c', - mode: InputOption::VALUE_REQUIRED, - description: 'Replacement for invalid character (by default space, underscore or dash is used)', - ); - - } - - #[\Override] - protected function execute(InputInterface $input, OutputInterface $output): int { - $this->charReplacement = $input->getOption('char-replacement'); + public function __invoke( + IOutput $output, + #[Argument(name: 'user_id', description: 'will only rename files the given user(s) have access to')] + array $userIds = [], + #[Option(name: 'dry-run', description: 'Do not actually rename any files but just check filenames.')] + bool $dryRun = false, + #[Option(name: 'char-replacement', description: 'Replacement for invalid character (by default space, underscore or dash is used)', shortcut: 'c')] + ?string $charReplacement = null, + ): ExitCode { + $this->charReplacement = $charReplacement; // check if replacement is needed $c = $this->filenameValidator->getForbiddenCharacters(); if (count($c) > 0) { @@ -86,19 +71,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int } else { $output->writeln('Invalid character replacement given'); } - return 1; + return ExitCode::Failure; } } - $this->dryRun = $input->getOption('dry-run'); + $this->dryRun = $dryRun; if ($this->dryRun) { $output->writeln('Dry run is enabled, no actual renaming will be applied.'); } $this->output = $output; - $users = $input->getArgument('user_id'); - if (!empty($users)) { - foreach ($users as $userId) { + if (!empty($userIds)) { + foreach ($userIds as $userId) { $user = $this->userManager->get($userId); if ($user === null) { $output->writeln("User '$userId' does not exist - skipping"); @@ -113,7 +97,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->appConfig->setAppValueInt('sanitize_filenames_status', SettingsService::STATUS_WCF_DONE); } } - return self::SUCCESS; + return ExitCode::Success; } private function sanitizeUserFiles(IUser $user): void { @@ -128,7 +112,7 @@ private function sanitizeUserFiles(IUser $user): void { private function sanitizeFiles(Folder $folder): void { foreach ($folder->getDirectoryListing() as $node) { - $this->output->writeln('scanning: ' . $node->getPath(), OutputInterface::VERBOSITY_VERBOSE); + $this->output->writeln('scanning: ' . $node->getPath(), Verbosity::Verbose); try { $oldName = $node->getName(); @@ -151,7 +135,7 @@ private function sanitizeFiles(Folder $folder): void { $this->output->writeln('skipping: ' . $node->getPath() . ' (no permissions)'); } catch (Exception $error) { $this->output->writeln('failed: ' . $node->getPath() . ''); - $this->output->writeln('' . $error->getMessage() . '', OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_VERBOSE); + $this->output->writeln('' . $error->getMessage() . '', Verbosity::Verbose); } if ($node instanceof Folder) { @@ -159,5 +143,4 @@ private function sanitizeFiles(Folder $folder): void { } } } - } diff --git a/apps/files/lib/Command/Scan.php b/apps/files/lib/Command/Scan.php index 8e17671e2ef97..d070a62842a72 100644 --- a/apps/files/lib/Command/Scan.php +++ b/apps/files/lib/Command/Scan.php @@ -1,5 +1,7 @@ setName('files:scan') - ->setDescription('rescan filesystem') - ->addArgument( - 'user_id', - InputArgument::OPTIONAL | InputArgument::IS_ARRAY, - 'will rescan all files of the given user(s)' - ) - ->addOption( - 'path', - 'p', - InputOption::VALUE_REQUIRED, - 'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored' - ) - ->addOption( - 'generate-metadata', - null, - InputOption::VALUE_OPTIONAL, - 'Generate metadata for all scanned files; if specified only generate for named value', - '' - ) - ->addOption( - 'all', - null, - InputOption::VALUE_NONE, - 'will rescan all files of all known users' - )->addOption( - 'unscanned', - null, - InputOption::VALUE_NONE, - 'only scan files which are marked as not fully scanned' - )->addOption( - 'shallow', - null, - InputOption::VALUE_NONE, - 'do not scan folders recursively' - )->addOption( - 'home-only', - null, - InputOption::VALUE_NONE, - 'only scan the home storage, ignoring any mounted external storage or share' - ); + public function __invoke( + IOutput $output, + ISignalHandler $signalHandler, + #[Argument(name: 'user_id', description: 'will rescan all files of the given user(s)')] + array $userIds = [], + #[Option(description: 'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored', shortcut: 'p')] + ?string $path = null, + #[Option(name: 'generate-metadata', description: 'Generate metadata for all scanned files; if specified only generate for named value')] + string|bool $generateMetadata = false, + #[Option(description: 'will rescan all files of all known users')] + bool $all = false, + #[Option(description: 'only scan files which are marked as not fully scanned')] + bool $unscanned = false, + #[Option(description: 'do not scan folders recursively')] + bool $shallow = false, + #[Option(name: 'home-only', description: 'only scan the home storage, ignoring any mounted external storage or share')] + bool $homeOnly = false, + ): ExitCode { + $inputPath = $path; + if ($inputPath) { + $inputPath = '/' . trim($inputPath, '/'); + [, $user,] = explode('/', $inputPath, 3); + $users = [$user]; + } elseif ($all) { + $users = $this->userManager->search(''); + } else { + $users = $userIds; + } + + # check quantity of users to be process and show it on the command line + $users_total = count($users); + if ($users_total === 0) { + $output->writeln('Please specify the user id to scan, --all to scan for all users or --path=...'); + return ExitCode::Failure; + } + + $this->initTools($output); + + // null if --generate-metadata is not set, empty if option has no value, value if set + $metadata = match (true) { + $generateMetadata === false => null, + $generateMetadata === true => '', + default => $generateMetadata, + }; + + $scannedStorages = []; + $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) { + if ($homeOnly && !$this->isHomeMount($mount)) { + return false; + } + + // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders) + // we can filter out any storage we've already scanned to avoid double work + $storage = $mount->getStorage(); + $storageKey = $storage->getId(); + while ($storage->instanceOfStorage(Jail::class)) { + $storageKey .= '/' . $storage->getUnjailedPath(''); + $storage = $storage->getUnjailedStorage(); + } + if (array_key_exists($storageKey, $scannedStorages)) { + return false; + } + + $scannedStorages[$storageKey] = true; + return true; + }; + + $user_count = 0; + foreach ($users as $user) { + if (is_object($user)) { + $user = $user->getUID(); + } + $scanPath = $inputPath ?: '/' . $user; + ++$user_count; + if ($this->userManager->userExists($user)) { + $output->writeln("Starting scan for user $user_count out of $users_total ($user)"); + $this->scanFiles( + $user, + $scanPath, + $metadata, + $output, + $signalHandler, + $mountFilter, + $unscanned, + !$shallow, + ); + $output->writeln('', Verbosity::Verbose); + } else { + $output->writeln("Unknown user $user_count $user"); + $output->writeln('', Verbosity::Verbose); + } + + try { + $signalHandler->abortIfInterrupted(); + } catch (InterruptedException) { + break; + } + } + + $this->presentStats($output); + return ExitCode::Success; } protected function scanFiles( string $user, string $path, ?string $scanMetadata, - OutputInterface $output, + IOutput $output, + ISignalHandler $signalHandler, callable $mountFilter, bool $backgroundScan = false, bool $recursive = true, @@ -123,10 +186,10 @@ protected function scanFiles( ); # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata): void { - $output->writeln("\tFile\t$path", OutputInterface::VERBOSITY_VERBOSE); + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $signalHandler, $scanMetadata): void { + $output->writeln("\tFile\t$path", Verbosity::Verbose); ++$this->filesCounter; - $this->abortIfInterrupted(); + $signalHandler->abortIfInterrupted(); if ($scanMetadata !== null) { $node = $this->rootFolder->get($path); $this->filesMetadataManager->refreshMetadata( @@ -137,14 +200,14 @@ protected function scanFiles( } }); - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void { - $output->writeln("\tFolder\t$path", OutputInterface::VERBOSITY_VERBOSE); + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output, $signalHandler): void { + $output->writeln("\tFolder\t$path", Verbosity::Verbose); ++$this->foldersCounter; - $this->abortIfInterrupted(); + $signalHandler->abortIfInterrupted(); }); $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void { - $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', Verbosity::Verbose); ++$this->errorsCounter; }); @@ -174,7 +237,7 @@ protected function scanFiles( $output->writeln(' ' . $e->getMessage()); $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as'); ++$this->errorsCounter; - } catch (InterruptedException $e) { + } catch (InterruptedException) { # exit the function if ctrl-c has been pressed $output->writeln('Interrupted by user'); } catch (NotFoundException $e) { @@ -198,96 +261,10 @@ public function isHomeMount(IMountPoint $mountPoint): bool { return substr_count($mountPoint->getMountPoint(), '/') <= 3; } - #[\Override] - protected function execute(InputInterface $input, OutputInterface $output): int { - $inputPath = $input->getOption('path'); - if ($inputPath) { - $inputPath = '/' . trim($inputPath, '/'); - [, $user,] = explode('/', $inputPath, 3); - $users = [$user]; - } elseif ($input->getOption('all')) { - $users = $this->userManager->search(''); - } else { - $users = $input->getArgument('user_id'); - } - - # check quantity of users to be process and show it on the command line - $users_total = count($users); - if ($users_total === 0) { - $output->writeln('Please specify the user id to scan, --all to scan for all users or --path=...'); - return self::FAILURE; - } - - $this->initTools($output); - - // getOption() logic on VALUE_OPTIONAL - $metadata = null; // null if --generate-metadata is not set, empty if option have no value, value if set - if ($input->getOption('generate-metadata') !== '') { - $metadata = $input->getOption('generate-metadata') ?? ''; - } - - $homeOnly = $input->getOption('home-only'); - $scannedStorages = []; - $mountFilter = function (IMountPoint $mount) use ($homeOnly, &$scannedStorages) { - if ($homeOnly && !$this->isHomeMount($mount)) { - return false; - } - - // when scanning multiple users, the scanner might encounter the same storage multiple times (e.g. external storages, or group folders) - // we can filter out any storage we've already scanned to avoid double work - $storage = $mount->getStorage(); - $storageKey = $storage->getId(); - while ($storage->instanceOfStorage(Jail::class)) { - $storageKey .= '/' . $storage->getUnjailedPath(''); - $storage = $storage->getUnjailedStorage(); - } - if (array_key_exists($storageKey, $scannedStorages)) { - return false; - } - - $scannedStorages[$storageKey] = true; - return true; - }; - - $user_count = 0; - foreach ($users as $user) { - if (is_object($user)) { - $user = $user->getUID(); - } - $path = $inputPath ?: '/' . $user; - ++$user_count; - if ($this->userManager->userExists($user)) { - $output->writeln("Starting scan for user $user_count out of $users_total ($user)"); - $this->scanFiles( - $user, - $path, - $metadata, - $output, - $mountFilter, - $input->getOption('unscanned'), - !$input->getOption('shallow'), - ); - $output->writeln('', OutputInterface::VERBOSITY_VERBOSE); - } else { - $output->writeln("Unknown user $user_count $user"); - $output->writeln('', OutputInterface::VERBOSITY_VERBOSE); - } - - try { - $this->abortIfInterrupted(); - } catch (InterruptedException $e) { - break; - } - } - - $this->presentStats($output); - return self::SUCCESS; - } - /** * Initialises some useful tools for the Command */ - protected function initTools(OutputInterface $output): void { + protected function initTools(IOutput $output): void { // Start the timer $this->execTime = -microtime(true); // Convert PHP errors to exceptions @@ -308,48 +285,35 @@ protected function initTools(OutputInterface $output): void { * @param string $file the filename that the error was raised in * @param int $line the line number the error was raised */ - public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool { + public function exceptionErrorHandler(IOutput $output, int $severity, string $message, string $file, int $line): bool { if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) { // Do not show deprecation warnings return false; } $e = new \ErrorException($message, 0, $severity, $file, $line); $output->writeln('Error during scan: ' . $e->getMessage() . ''); - $output->writeln('' . $e->getTraceAsString() . '', OutputInterface::VERBOSITY_VERY_VERBOSE); + $output->writeln('' . $e->getTraceAsString() . '', Verbosity::VeryVerbose); ++$this->errorsCounter; return true; } - protected function presentStats(OutputInterface $output): void { + protected function presentStats(IOutput $output): void { // Stop the timer $this->execTime += microtime(true); $this->logger->info("Completed scan of {$this->filesCounter} files in {$this->foldersCounter} folder. Found {$this->newCounter} new, {$this->updatedCounter} updated and {$this->removedCounter} removed items"); - $headers = [ - 'Folders', - 'Files', - 'New', - 'Updated', - 'Removed', - 'Errors', - 'Elapsed time', + $row = [ + 'Folders' => $this->foldersCounter, + 'Files' => $this->filesCounter, + 'New' => $this->newCounter, + 'Updated' => $this->updatedCounter, + 'Removed' => $this->removedCounter, + 'Errors' => $this->errorsCounter, + 'Elapsed time' => $this->formatExecTime(), ]; - $niceDate = $this->formatExecTime(); - $rows = [ - $this->foldersCounter, - $this->filesCounter, - $this->newCounter, - $this->updatedCounter, - $this->removedCounter, - $this->errorsCounter, - $niceDate, - ]; - $table = new Table($output); - $table - ->setHeaders($headers) - ->setRows([$rows]); - $table->render(); + + $output->writeTableInOutputFormat([$row]); } /** @@ -361,7 +325,7 @@ protected function formatExecTime(): string { return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60); } - protected function reconnectToDatabase(OutputInterface $output): Connection { + protected function reconnectToDatabase(IOutput $output): Connection { /** @var Connection $connection */ $connection = Server::get(Connection::class); try { diff --git a/apps/files/lib/Command/ScanAppData.php b/apps/files/lib/Command/ScanAppData.php index eab0237486c53..adf424c3600cd 100644 --- a/apps/files/lib/Command/ScanAppData.php +++ b/apps/files/lib/Command/ScanAppData.php @@ -1,5 +1,7 @@ getVerbosity()->value > Verbosity::Verbose->value) { + $output->setVerbosity(Verbosity::Verbose); + } + + $output->writeln('Scanning AppData for files'); + $output->writeln(''); + + // Start the timer + $this->execTime = -microtime(true); - $this - ->setName('files:scan-app-data') - ->setDescription('rescan the AppData folder'); + $this->initTools(); - $this->addArgument('folder', InputArgument::OPTIONAL, 'The appdata subfolder to scan', ''); + $exitCode = $this->scanFiles($output, $signalHandler, $folder); + if ($exitCode === ExitCode::Success) { + $this->presentStats($output); + } + return $exitCode; } - protected function getScanner(OutputInterface $output): Scanner { + protected function getScanner(IOutput $output): Scanner { $connection = $this->reconnectToDatabase($output); return new Scanner( null, new ConnectionAdapter($connection), - Server::get(IEventDispatcher::class), - Server::get(LoggerInterface::class), - Server::get(SetupManager::class), + $this->eventDispatcher, + $this->logger, + $this->setupManager, ); } - protected function scanFiles(OutputInterface $output, string $folder): int { + protected function scanFiles(IOutput $output, ISignalHandler $signalHandler, string $folder): ExitCode { if ($folder === 'preview' || $folder === '') { $this->previewsCounter = $this->previewStorage->scan(); if ($folder === 'preview') { - return self::SUCCESS; + return ExitCode::Success; } } @@ -81,7 +106,7 @@ protected function scanFiles(OutputInterface $output, string $folder): int { $appData = $this->getAppDataFolder(); } catch (NotFoundException $e) { $output->writeln('NoAppData folder found'); - return self::FAILURE; + return ExitCode::Failure; } if ($folder !== '') { @@ -89,27 +114,27 @@ protected function scanFiles(OutputInterface $output, string $folder): int { $appData = $appData->get($folder); } catch (NotFoundException $e) { $output->writeln('Could not find folder: ' . $folder . ''); - return self::FAILURE; + return ExitCode::Failure; } } $scanner = $this->getScanner($output); # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output): void { - $output->writeln("\tFile $path", OutputInterface::VERBOSITY_VERBOSE); + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function ($path) use ($output, $signalHandler): void { + $output->writeln("\tFile $path", Verbosity::Verbose); ++$this->filesCounter; - $this->abortIfInterrupted(); + $signalHandler->abortIfInterrupted(); }); - $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output): void { - $output->writeln("\tFolder $path", OutputInterface::VERBOSITY_VERBOSE); + $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output, $signalHandler): void { + $output->writeln("\tFolder $path", Verbosity::Verbose); ++$this->foldersCounter; - $this->abortIfInterrupted(); + $signalHandler->abortIfInterrupted(); }); $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output): void { - $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', Verbosity::Verbose); }); $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output): void { @@ -121,45 +146,21 @@ protected function scanFiles(OutputInterface $output, string $folder): int { } catch (ForbiddenException $e) { $output->writeln('Storage not writable'); $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as'); - return self::FAILURE; + return ExitCode::Failure; } catch (InterruptedException $e) { # exit the function if ctrl-c has been pressed $output->writeln('Interrupted by user'); - return self::FAILURE; + return ExitCode::Failure; } catch (NotFoundException $e) { $output->writeln('Path not found: ' . $e->getMessage() . ''); - return self::FAILURE; + return ExitCode::Failure; } catch (\Exception $e) { $output->writeln('Exception during scan: ' . $e->getMessage() . ''); $output->writeln('' . $e->getTraceAsString() . ''); - return self::FAILURE; - } - - return self::SUCCESS; - } - - #[\Override] - protected function execute(InputInterface $input, OutputInterface $output): int { - # restrict the verbosity level to VERBOSITY_VERBOSE - if ($output->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); + return ExitCode::Failure; } - $output->writeln('Scanning AppData for files'); - $output->writeln(''); - - $folder = $input->getArgument('folder'); - - // Start the timer - $this->execTime = -microtime(true); - - $this->initTools(); - - $exitCode = $this->scanFiles($output, $folder); - if ($exitCode === self::SUCCESS) { - $this->presentStats($output); - } - return $exitCode; + return ExitCode::Success; } /** @@ -190,45 +191,18 @@ public function exceptionErrorHandler(int $severity, string $message, string $fi throw new \ErrorException($message, 0, $severity, $file, $line); } - protected function presentStats(OutputInterface $output): void { + protected function presentStats(IOutput $output): void { // Stop the timer $this->execTime += microtime(true); + $row = []; if ($this->previewsCounter !== -1) { - $headers[] = 'Previews'; + $row['Previews'] = $this->previewsCounter; } - $headers[] = 'Folders'; - $headers[] = 'Files'; - $headers[] = 'Elapsed time'; - - $this->showSummary($headers, null, $output); - } - - /** - * Shows a summary of operations - * - * @param string[] $headers - * @param string[] $rows - */ - protected function showSummary(array $headers, ?array $rows, OutputInterface $output): void { - $niceDate = $this->formatExecTime(); - if (!$rows) { - if ($this->previewsCounter !== -1) { - $rows[] = $this->previewsCounter; - } - $rows[] = $this->foldersCounter; - $rows[] = $this->filesCounter; - $rows[] = $niceDate; - } - - $this->displayTable($output, $headers, $rows); - } + $row['Folders'] = $this->foldersCounter; + $row['Files'] = $this->filesCounter; + $row['Elapsed time'] = $this->formatExecTime(); - protected function displayTable($output, $headers, $rows): void { - $table = new Table($output); - $table - ->setHeaders($headers) - ->setRows([$rows]); - $table->render(); + $output->writeTableInOutputFormat([$row]); } /** @@ -240,7 +214,7 @@ protected function formatExecTime(): string { return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), (int)$secs % 60); } - protected function reconnectToDatabase(OutputInterface $output): Connection { + protected function reconnectToDatabase(IOutput $output): Connection { /** @var Connection $connection */ $connection = Server::get(Connection::class); try { diff --git a/apps/files/lib/Command/Touch.php b/apps/files/lib/Command/Touch.php index 217ce721aa4b4..65cedc18a5a35 100644 --- a/apps/files/lib/Command/Touch.php +++ b/apps/files/lib/Command/Touch.php @@ -10,46 +10,43 @@ use DateTimeImmutable; use OC\Core\Command\Info\FileUtils; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\Files\IRootFolder; use Psr\Clock\ClockInterface; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -class Touch extends Command { +#[AsCommand( + name: 'files:touch', + description: 'Update the last modified date of a file or folder, or create an empty file', +)] +class Touch { public function __construct( private readonly FileUtils $fileUtils, private readonly IRootFolder $rootFolder, private readonly ClockInterface $clock, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:touch') - ->setDescription('Update the last modified date of a file or folder, or create an empty file') - ->addArgument('file', InputArgument::REQUIRED, 'Nextcloud path or fileid for the file or folder to change the modified date of') - ->addOption('date', 'd', InputOption::VALUE_REQUIRED, 'Time to use as modified date instead of the current time. Acceptable formats are: ISO8601, "YYYY-MM-DD" and Unix time in seconds.') - ->addOption('no-create', 'c', InputOption::VALUE_NONE, 'Don\'t create an empty file if the target path doesn\'t exist'); - } - - #[\Override] - public function execute(InputInterface $input, OutputInterface $output): int { - $fileInput = $input->getArgument('file'); - $node = $this->fileUtils->getNode($fileInput); - $date = $input->getOption('date'); - $noCreate = $input->getOption('no-create'); + public function __invoke( + IOutput $output, + #[Argument(description: 'Nextcloud path or fileid for the file or folder to change the modified date of')] + string $file, + #[Option(name: 'no-create', description: 'Don\'t create an empty file if the target path doesn\'t exist', shortcut: 'c')] + bool $noCreate = false, + #[Option(description: 'Time to use as modified date instead of the current time. Acceptable formats are: ISO8601, "YYYY-MM-DD" and Unix time in seconds.', shortcut: 'd')] + ?string $date = null, + ): ExitCode { + $node = $this->fileUtils->getNode($file); if (!$node) { - if ($noCreate || is_numeric($fileInput)) { - $output->writeln("$fileInput doesn't exist"); - return self::FAILURE; + if ($noCreate || is_numeric($file)) { + $output->writeln("$file doesn't exist"); + return ExitCode::Failure; } - $node = $this->rootFolder->newFile($fileInput); + $node = $this->rootFolder->newFile($file); } if ($date) { @@ -62,15 +59,13 @@ public function execute(InputInterface $input, OutputInterface $output): int { } $node->touch($mtime->getTimestamp()); - return self::SUCCESS; + return ExitCode::Success; } /** * @return \DateTimeImmutable|false */ protected function parseDateOption(string $input) { - $date = false; - // Handle Unix timestamp if (filter_var($input, FILTER_VALIDATE_INT)) { return new DateTimeImmutable('@' . $input); diff --git a/apps/files/lib/Command/TransferOwnership.php b/apps/files/lib/Command/TransferOwnership.php index c38cd9af4f37b..c6d30e6e7ee16 100644 --- a/apps/files/lib/Command/TransferOwnership.php +++ b/apps/files/lib/Command/TransferOwnership.php @@ -12,109 +12,75 @@ use OCA\Files\Exception\TransferOwnershipException; use OCA\Files\Service\OwnershipTransferService; use OCA\Files_External\Config\ConfigAdapter; +use OCP\Console\Attribute\Argument; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IInput; +use OCP\Console\IOutput; use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -class TransferOwnership extends Command { +#[AsCommand( + name: 'files:transfer-ownership', + description: 'All files and folders are moved to another user - outgoing shares and incoming user file shares (optionally) are moved as well.', +)] +class TransferOwnership { public function __construct( private IUserManager $userManager, private OwnershipTransferService $transferService, private IConfig $config, private IMountManager $mountManager, ) { - parent::__construct(); } - #[\Override] - protected function configure(): void { - $this - ->setName('files:transfer-ownership') - ->setDescription('All files and folders are moved to another user - outgoing shares and incoming user file shares (optionally) are moved as well.') - ->addArgument( - 'source-user', - InputArgument::REQUIRED, - 'owner of files which shall be moved' - ) - ->addArgument( - 'destination-user', - InputArgument::REQUIRED, - 'user who will be the new owner of the files' - ) - ->addOption( - 'path', - null, - InputOption::VALUE_REQUIRED, - 'selectively provide the path to transfer. For example --path="folder_name"', - '' - )->addOption( - 'move', - null, - InputOption::VALUE_NONE, - 'move data from source user to root directory of destination user, which must be empty' - )->addOption( - 'transfer-incoming-shares', - null, - InputOption::VALUE_OPTIONAL, - 'Incoming shares are always transferred now, so this option does not affect the ownership transfer anymore', - '2' - )->addOption( - 'include-external-storage', - null, - InputOption::VALUE_NONE, - 'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory', - )->addOption( - 'force-include-external-storage', - null, - InputOption::VALUE_NONE, - 'don\'t ask for confirmation for transferring external storages', - ) - ->addOption( - 'use-user-id', - null, - InputOption::VALUE_NONE, - 'use user ID instead of display name in the transferred folder name', - ); - } - - #[\Override] - protected function execute(InputInterface $input, OutputInterface $output): int { - + public function __invoke( + IOutput $output, + IInput $input, + #[Argument(name: 'source-user', description: 'owner of files which shall be moved')] + string $sourceUser, + #[Argument(name: 'destination-user', description: 'user who will be the new owner of the files')] + string $destinationUser, + #[Option(description: 'selectively provide the path to transfer. For example --path="folder_name"')] + string $path = '', + #[Option(description: 'move data from source user to root directory of destination user, which must be empty')] + bool $move = false, + #[Option(name: 'transfer-incoming-shares', description: 'Incoming shares are always transferred now, so this option does not affect the ownership transfer anymore')] + string|bool $transferIncomingShares = false, + #[Option(name: 'include-external-storage', description: 'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory')] + bool $includeExternalStorage = false, + #[Option(name: 'force-include-external-storage', description: "don't ask for confirmation for transferring external storages")] + bool $forceIncludeExternalStorage = false, + #[Option(name: 'use-user-id', description: 'use user ID instead of display name in the transferred folder name')] + bool $useUserId = false, + ): ExitCode { /** * Check if source and destination users are same. If they are same then just ignore the transfer. */ - - if ($input->getArgument(('source-user')) === $input->getArgument('destination-user')) { + if ($sourceUser === $destinationUser) { $output->writeln("Ownership can't be transferred when Source and Destination users are the same user. Please check your input."); - return self::FAILURE; + return ExitCode::Failure; } - $sourceUserObject = $this->userManager->get($input->getArgument('source-user')); - $destinationUserObject = $this->userManager->get($input->getArgument('destination-user')); + $sourceUserObject = $this->userManager->get($sourceUser); + $destinationUserObject = $this->userManager->get($destinationUser); if (!$sourceUserObject instanceof IUser) { - $output->writeln('Unknown source user ' . $input->getArgument('source-user') . ''); - return self::FAILURE; + $output->writeln('Unknown source user ' . $sourceUser . ''); + return ExitCode::Failure; } if (!$destinationUserObject instanceof IUser) { - $output->writeln('Unknown destination user ' . $input->getArgument('destination-user') . ''); - return self::FAILURE; + $output->writeln('Unknown destination user ' . $destinationUser . ''); + return ExitCode::Failure; } - $path = ltrim($input->getOption('path'), '/'); - $includeExternalStorage = $input->getOption('include-external-storage'); + $normalizedPath = ltrim($path, '/'); if ($includeExternalStorage) { - $mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $path, '/')); + $mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $normalizedPath, '/')); /** @var IMountPoint[] $mounts */ $mounts = array_filter($mounts, fn ($mount) => $mount->getMountProvider() === ConfigAdapter::class); if (count($mounts) > 0) { @@ -125,12 +91,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); $output->writeln('Any other users with access to these external storages will lose access to the files.'); $output->writeln(''); - if (!$input->getOption('force-include-external-storage')) { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion('Are you sure you want to transfer external storages? (y/N) ', false); - if (!$helper->ask($input, $output, $question)) { - return self::FAILURE; + if (!$forceIncludeExternalStorage) { + if (!$input->confirm('Are you sure you want to transfer external storages? (y/N) ', false)) { + return ExitCode::Failure; } } } @@ -140,18 +103,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->transferService->transfer( $sourceUserObject, $destinationUserObject, - $path, + $normalizedPath, $output, - $input->getOption('move') === true, + $move, false, $includeExternalStorage, - $input->getOption('use-user-id') === true, + $useUserId, ); } catch (TransferOwnershipException $e) { $output->writeln('' . $e->getMessage() . ''); - return $e->getCode() !== 0 ? $e->getCode() : self::FAILURE; + $exitCode = $e->getCode() !== 0 ? ExitCode::tryFrom($e->getCode()) : null; + return $exitCode ?? ExitCode::Failure; } - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php index d595d5731b68b..5de9b13ed1b15 100644 --- a/apps/files/lib/Command/WindowsCompatibleFilenames.php +++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php @@ -9,47 +9,45 @@ namespace OCA\Files\Command; -use OC\Core\Command\Base; use OCA\Files\Service\SettingsService; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; - -class WindowsCompatibleFilenames extends Base { - +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; +use OCP\Console\Verbosity; + +#[AsCommand( + name: 'files:windows-compatible-filenames', + description: 'Enforce naming constraints for windows compatible filenames', +)] +class WindowsCompatibleFilenames { public function __construct( - private SettingsService $service, + private readonly SettingsService $service, ) { - parent::__construct(); - } - - #[\Override] - protected function configure(): void { - parent::configure(); - - $this - ->setName('files:windows-compatible-filenames') - ->setDescription('Enforce naming constraints for windows compatible filenames') - ->addOption('enable', description: 'Enable windows naming constraints') - ->addOption('disable', description: 'Disable windows naming constraints'); } - #[\Override] - protected function execute(InputInterface $input, OutputInterface $output): int { - if ($input->getOption('enable')) { + public function __invoke( + IOutput $output, + #[Option(description: 'Enable windows naming constraints')] + bool $enable = false, + #[Option(description: 'Disable windows naming constraints')] + bool $disable = false, + ): ExitCode { + if ($enable) { if ($this->service->hasFilesWindowsSupport()) { - $output->writeln('Windows compatible filenames already enforced.', OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Windows compatible filenames already enforced.', Verbosity::Verbose); } $this->service->setFilesWindowsSupport(true); $output->writeln('Windows compatible filenames enforced.'); - } elseif ($input->getOption('disable')) { + } elseif ($disable) { if (!$this->service->hasFilesWindowsSupport()) { - $output->writeln('Windows compatible filenames already disabled.', OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Windows compatible filenames already disabled.', Verbosity::Verbose); } $this->service->setFilesWindowsSupport(false); $output->writeln('Windows compatible filename constraints removed.'); } else { $output->writeln('Windows compatible filenames are ' . ($this->service->hasFilesWindowsSupport() ? 'enforced' : 'disabled')); } - return self::SUCCESS; + return ExitCode::Success; } } diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php index 5cfff142a526f..63045e79a826f 100644 --- a/apps/files/lib/Service/OwnershipTransferService.php +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -11,12 +11,15 @@ use Closure; use Exception; +use OC\Console\NullOutput; use OC\Files\Filesystem; use OC\Files\View; use OCA\Encryption\Util; use OCA\Files\Exception\TransferOwnershipException; use OCA\Files_External\Config\ConfigAdapter; use OCA\GroupFolders\Mount\GroupMountPoint; +use OCP\Console\IOutput; +use OCP\Console\Verbosity; use OCP\Encryption\IManager as IEncryptionManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\IHomeMountProvider; @@ -34,9 +37,6 @@ use OCP\Share\IManager as IShareManager; use OCP\Share\IShare; use OCP\User\Exceptions\UserNotFoundException; -use Symfony\Component\Console\Helper\ProgressBar; -use Symfony\Component\Console\Output\NullOutput; -use Symfony\Component\Console\Output\OutputInterface; use function array_merge; use function basename; use function count; @@ -63,7 +63,7 @@ public function __construct( * @param IUser $destinationUser * @param string $path * - * @param OutputInterface|null $output + * @param IOutput|null $output * @param bool $move * @throws TransferOwnershipException * @throws UserNotFoundException @@ -72,7 +72,7 @@ public function transfer( IUser $sourceUser, IUser $destinationUser, string $path, - ?OutputInterface $output = null, + ?IOutput $output = null, bool $move = false, bool $firstLogin = false, bool $includeExternalStorage = false, @@ -245,7 +245,7 @@ private function walkFiles(View $view, $path, Closure $callBack) { } /** - * @param OutputInterface $output + * @param IOutput $output * * @throws TransferOwnershipException */ @@ -254,7 +254,7 @@ protected function analyse( string $destinationUid, string $sourcePath, View $view, - OutputInterface $output, + IOutput $output, bool $includeExternalStorage = false, ): void { $output->writeln('Validating quota'); @@ -269,8 +269,7 @@ protected function analyse( } $output->writeln("Analysing files of $sourceUid ..."); - $progress = new ProgressBar($output); - $progress->start(); + $output->progressStart(); if ($this->encryptionManager->isEnabled()) { $masterKeyEnabled = Server::get(Util::class)->isMasterKeyEnabled(); @@ -284,7 +283,7 @@ protected function analyse( $encryptedFiles[] = $sourceFileInfo; } else { $this->walkFiles($view, $sourcePath, - function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) { + function (FileInfo $fileInfo) use ($output, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) { if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { $mount = $fileInfo->getMountPoint(); // only analyze into folders from main storage, @@ -301,7 +300,7 @@ function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFile return false; } } - $progress->advance(); + $output->progressAdvance(); if ($fileInfo->isEncrypted() && !$masterKeyEnabled) { /* Encrypted file means SSE, we can only transfer it if master key is enabled */ $encryptedFiles[] = $fileInfo; @@ -313,7 +312,7 @@ function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFile /* Encrypted file means SSE, we can only transfer it if master key is enabled */ $encryptedFiles[] = $sourceFileInfo; } - $progress->finish(); + $output->progressFinish(); $output->writeln(''); // no file is allowed to be encrypted @@ -332,14 +331,14 @@ function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFile */ private function collectUsersShares( string $sourceUid, - OutputInterface $output, + IOutput $output, View $view, string $path, ): array { $output->writeln("Collecting all share information for files and folders of $sourceUid ..."); $shares = []; - $progress = new ProgressBar($output); + $output->progressStart(); $normalizedPath = Filesystem::normalizePath($path); @@ -358,7 +357,7 @@ private function collectUsersShares( $offset = 0; while (true) { $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset, onlyValid: false); - $progress->advance(count($sharePage)); + $output->progressAdvance(count($sharePage)); if (empty($sharePage)) { break; } @@ -379,7 +378,7 @@ private function collectUsersShares( } } - $progress->finish(); + $output->progressFinish(); $output->writeln(''); return array_values(array_filter(array_map(function (IShare $share) use ($view, $normalizedPath, $output, $sourceUid) { @@ -399,19 +398,19 @@ private function collectUsersShares( private function collectIncomingShares( string $sourceUid, - OutputInterface $output, + IOutput $output, ?string $path, ): array { $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ..."); $shares = []; - $progress = new ProgressBar($output); + $output->progressStart(); $normalizedPath = Filesystem::normalizePath($path); $offset = 0; while (true) { $sharePage = $this->shareManager->getSharedWith($sourceUid, IShare::TYPE_USER, null, 50, $offset); - $progress->advance(count($sharePage)); + $output->progressAdvance(count($sharePage)); if (empty($sharePage)) { break; } @@ -433,7 +432,7 @@ private function collectIncomingShares( $offset += 50; } - $progress->finish(); + $output->progressFinish(); $output->writeln(''); return $shares; } @@ -446,7 +445,7 @@ protected function transferFiles( string $sourcePath, string $finalTarget, View $view, - OutputInterface $output, + IOutput $output, bool $includeExternalStorage, ): void { $output->writeln("Transferring files to $finalTarget ..."); @@ -510,14 +509,14 @@ private function restoreShares( string $destinationUid, string $targetLocation, array $shares, - OutputInterface $output, + IOutput $output, ):void { $output->writeln('Restoring shares ...'); - $progress = new ProgressBar($output, count($shares)); + $output->progressStart(count($shares)); foreach ($shares as ['share' => $share, 'suffix' => $suffix]) { try { - $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), OutputInterface::VERBOSITY_VERBOSE); + $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), Verbosity::Verbose); if ($share->getShareType() === IShare::TYPE_USER && $share->getSharedWith() === $destinationUid) { // Unmount the shares before deleting, so we don't try to get the storage later on. @@ -555,7 +554,7 @@ private function restoreShares( // Try to get the new ID from the target path and suffix of the share $node = $this->rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix)); $newNodeId = $node->getId(); - $output->writeln('Had to change node id to ' . $newNodeId, OutputInterface::VERBOSITY_VERY_VERBOSE); + $output->writeln('Had to change node id to ' . $newNodeId, Verbosity::VeryVerbose); } $share->setNodeId($newNodeId); @@ -568,9 +567,9 @@ private function restoreShares( $output->writeln('Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . ''); } $this->eventDispatcher->dispatchTyped(new ShareTransferredEvent($share)); - $progress->advance(); + $output->progressAdvance(); } - $progress->finish(); + $output->progressFinish(); $output->writeln(''); } @@ -579,13 +578,13 @@ private function transferIncomingShares( string $destinationUid, array $sourceShares, array $destinationShares, - OutputInterface $output, + IOutput $output, string $path, string $finalTarget, bool $move, ): void { $output->writeln('Restoring incoming shares ...'); - $progress = new ProgressBar($output, count($sourceShares)); + $output->progressStart(count($sourceShares)); $prefix = "$destinationUid/files"; $finalShareTarget = ''; if (str_starts_with($finalTarget, $prefix)) { @@ -618,7 +617,7 @@ private function transferIncomingShares( $share->setNodeId($share->getNode()->getId()); $this->shareManager->updateShare($share); // The share is already transferred. - $progress->advance(); + $output->progressAdvance(); if ($move) { continue; } @@ -640,7 +639,7 @@ private function transferIncomingShares( // otherwise the checks on the share update will fail due to the original node not being available in the new user scope $this->userMountCache->clear(); // The share is already transferred. - $progress->advance(); + $output->progressAdvance(); if ($move) { continue; } @@ -656,9 +655,9 @@ private function transferIncomingShares( } catch (\Throwable $e) { $output->writeln('Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . ''); } - $progress->advance(); + $output->progressAdvance(); } - $progress->finish(); + $output->progressFinish(); $output->writeln(''); } diff --git a/apps/files/tests/Command/DeleteOrphanedFilesTest.php b/apps/files/tests/Command/DeleteOrphanedFilesTest.php index f9db250eeabb4..4745669cebeec 100644 --- a/apps/files/tests/Command/DeleteOrphanedFilesTest.php +++ b/apps/files/tests/Command/DeleteOrphanedFilesTest.php @@ -11,13 +11,12 @@ use OC\Files\View; use OCA\Files\Command\DeleteOrphanedFiles; +use OCP\Console\IOutput; use OCP\Files\IRootFolder; use OCP\Files\StorageNotAvailableException; use OCP\IDBConnection; use OCP\IUserManager; use OCP\Server; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; use Test\TestCase; /** @@ -78,8 +77,7 @@ protected function getMountsCount(int $storageId): int { * Test clearing orphaned files */ public function testClearFiles(): void { - $input = $this->createMock(InputInterface::class); - $output = $this->createMock(OutputInterface::class); + $output = $this->createMock(IOutput::class); $rootFolder = Server::get(IRootFolder::class); @@ -99,7 +97,7 @@ public function testClearFiles(): void { $this->assertCount(1, $this->getFile($fileInfo->getId()), 'Asserts that file is available'); $this->assertEquals(1, $this->getMountsCount($numericStorageId), 'Asserts that mount is available'); - $this->command->execute($input, $output); + ($this->command)($output); $this->assertCount(1, $this->getFile($fileInfo->getId()), 'Asserts that file is still available'); $this->assertEquals(1, $this->getMountsCount($numericStorageId), 'Asserts that mount is still available'); @@ -125,7 +123,7 @@ public function testClearFiles(): void { $this->assertSame($expected, $message); }); - $this->command->execute($input, $output); + ($this->command)($output); $this->assertCount(0, $this->getFile($fileInfo->getId()), 'Asserts that file gets cleaned up'); $this->assertEquals(0, $this->getMountsCount($numericStorageId), 'Asserts that mount gets cleaned up'); diff --git a/apps/files/tests/Command/ScanAppDataTest.php b/apps/files/tests/Command/ScanAppDataTest.php index 04e55804fbd73..b2abed3263613 100644 --- a/apps/files/tests/Command/ScanAppDataTest.php +++ b/apps/files/tests/Command/ScanAppDataTest.php @@ -11,12 +11,18 @@ namespace OCA\Files\Tests\Command; use OC\Files\Mount\ObjectHomeMountProvider; +use OC\Files\SetupManager; use OC\Files\Utils\Scanner; use OC\Preview\Db\Preview; use OC\Preview\Db\PreviewMapper; use OC\Preview\PreviewService; use OC\Preview\Storage\StorageFactory; use OCA\Files\Command\ScanAppData; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; +use OCP\Console\ISignalHandler; +use OCP\Console\Verbosity; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IMimeTypeDetector; use OCP\Files\IMimeTypeLoader; @@ -33,8 +39,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use Psr\Log\LoggerInterface; use Test\TestCase; #[Group(name: 'DB')] @@ -42,8 +47,8 @@ class ScanAppDataTest extends TestCase { private IRootFolder $rootFolder; private IConfig $config; private StorageFactory $storageFactory; - private OutputInterface&MockObject $output; - private InputInterface&MockObject $input; + private IOutput&MockObject $output; + private ISignalHandler&MockObject $signalHandler; private Scanner&MockObject $internalScanner; private ScanAppData $scanner; private string $user; @@ -56,11 +61,19 @@ public function setUp(): void { $user = Server::get(IUserManager::class)->createUser($this->user, 'test'); Server::get(ISetupManager::class)->setupForUser($user); Server::get(IUserSession::class)->setUser($user); - $this->output = $this->createMock(OutputInterface::class); - $this->input = $this->createMock(InputInterface::class); + $this->output = $this->createMock(IOutput::class); + $this->output->method('getVerbosity')->willReturn(Verbosity::Normal); + $this->signalHandler = $this->createMock(ISignalHandler::class); $this->scanner = $this->getMockBuilder(ScanAppData::class) - ->onlyMethods(['displayTable', 'initTools', 'getScanner']) - ->setConstructorArgs([$this->rootFolder, $this->config, $this->storageFactory]) + ->onlyMethods(['initTools', 'getScanner']) + ->setConstructorArgs([ + $this->rootFolder, + $this->config, + $this->storageFactory, + Server::get(IEventDispatcher::class), + Server::get(LoggerInterface::class), + Server::get(SetupManager::class), + ]) ->getMock(); $this->internalScanner = $this->getMockBuilder(Scanner::class) ->onlyMethods(['scan']) @@ -103,23 +116,22 @@ public function testScanAppDataRoot(): void { $this->markTestSkipped(); } - $this->input->method('getArgument')->with('folder')->willReturn(''); $this->internalScanner->method('scan')->willReturnCallback(function (): void { $this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFile', ['path42']); $this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFolder', ['path42']); $this->internalScanner->emit('\OC\Files\Utils\Scanner', 'scanFolder', ['path42']); }); - $this->scanner->expects($this->once())->method('displayTable') - ->willReturnCallback(function (OutputInterface $output, array $headers, array $rows): void { - $this->assertEquals($this->output, $output); - $this->assertEquals(['Previews', 'Folders', 'Files', 'Elapsed time'], $headers); - $this->assertEquals(0, $rows[0]); - $this->assertEquals(2, $rows[1]); - $this->assertEquals(1, $rows[2]); + $this->output->expects($this->once())->method('writeTableInOutputFormat') + ->willReturnCallback(function (array $items): void { + $this->assertCount(1, $items); + $row = $items[0]; + $this->assertEquals(0, $row['Previews']); + $this->assertEquals(2, $row['Folders']); + $this->assertEquals(1, $row['Files']); }); - $errorCode = $this->invokePrivate($this->scanner, 'execute', [$this->input, $this->output]); - $this->assertEquals(ScanAppData::SUCCESS, $errorCode); + $exitCode = ($this->scanner)($this->output, $this->signalHandler, ''); + $this->assertEquals(ExitCode::Success, $exitCode); } public static function scanPreviewLocalData(): \Generator { @@ -137,7 +149,6 @@ public function testScanAppDataPreviewOnlyLocalFile(bool $migrationDone, ?bool $ if ($homeProvider->getHomeMountForUser($user, $this->createMock(IStorageFactory::class)) !== null) { $this->markTestSkipped(); } - $this->input->method('getArgument')->with('folder')->willReturn('preview'); $file = $this->rootFolder->getUserFolder($this->user)->newFile('myfile.jpeg'); @@ -213,16 +224,16 @@ public function testScanAppDataPreviewOnlyLocalFile(bool $migrationDone, ?bool $ $mimetypeLoader = $this->createMock(IMimeTypeLoader::class); $mimetypeLoader->method('getMimetypeById')->willReturn('image/jpeg'); - $this->scanner->expects($this->once())->method('displayTable') - ->willReturnCallback(function ($output, array $headers, array $rows): void { - $this->assertEquals($output, $this->output); - $this->assertEquals(['Previews', 'Folders', 'Files', 'Elapsed time'], $headers); - $this->assertEquals(3, $rows[0]); - $this->assertEquals(0, $rows[1]); - $this->assertEquals(0, $rows[2]); + $this->output->expects($this->once())->method('writeTableInOutputFormat') + ->willReturnCallback(function (array $items): void { + $this->assertCount(1, $items); + $row = $items[0]; + $this->assertEquals(3, $row['Previews']); + $this->assertEquals(0, $row['Folders']); + $this->assertEquals(0, $row['Files']); }); - $errorCode = $this->invokePrivate($this->scanner, 'execute', [$this->input, $this->output]); - $this->assertEquals(ScanAppData::SUCCESS, $errorCode); + $exitCode = ($this->scanner)($this->output, $this->signalHandler, 'preview'); + $this->assertEquals(ExitCode::Success, $exitCode); /** @var Folder $previewFolder */ $previewFolder = $this->rootFolder->get($this->rootFolder->getAppDataDirectoryName() . '/preview'); diff --git a/apps/user_ldap/lib/Configuration.php b/apps/user_ldap/lib/Configuration.php index 22b244e4a7ec1..b2e22f1b007ca 100644 --- a/apps/user_ldap/lib/Configuration.php +++ b/apps/user_ldap/lib/Configuration.php @@ -235,7 +235,9 @@ public function setConfiguration(array $config, ?array &$applied = null): void { $setMethod = 'setValue'; switch ($key) { + case 'ldapAgentName': case 'ldapAgentPassword': + $val = \filter_var($val, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW); $setMethod = 'setRawValue'; break; case 'homeFolderNamingRule': diff --git a/apps/user_ldap/tests/ConfigurationTest.php b/apps/user_ldap/tests/ConfigurationTest.php index 5d297aae32b98..4e0270882eb09 100644 --- a/apps/user_ldap/tests/ConfigurationTest.php +++ b/apps/user_ldap/tests/ConfigurationTest.php @@ -55,6 +55,9 @@ public static function configurationDataProvider(): array { $password = ' such a passw0rd '; + $dnWithCrlf = "cn=admin\r\nset foo bar\r\n,dc=example,dc=org"; + $expectedDn = 'cn=adminset foo bar,dc=example,dc=org'; + return [ 'set general base' => ['ldapBase', $inputWithDN, $expectWithDN], 'set user base' => ['ldapBaseUsers', $inputWithDN, $expectWithDN], @@ -70,6 +73,7 @@ public static function configurationDataProvider(): array { 'set login filter attributes' => ['ldapLoginFilterAttributes', $inputNames, $expectedNames], 'set agent password' => ['ldapAgentPassword', $password, $password], + 'set agent name strips CRLF' => ['ldapAgentName', $dnWithCrlf, $expectedDn], 'set home folder, variant 1' => ['homeFolderNamingRule', $inputHomeFolder[0], $expectedHomeFolder[0]], 'set home folder, variant 2' => ['homeFolderNamingRule', $inputHomeFolder[1], $expectedHomeFolder[1]], diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 0aac69f0d8ad1..324b33a7ac673 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -1437,14 +1437,6 @@ fileIsEncrypted]]> - - - - - - - - diff --git a/core/Command/InterruptedException.php b/core/Command/InterruptedException.php index 84b957c1e31a9..a43c34ec96102 100644 --- a/core/Command/InterruptedException.php +++ b/core/Command/InterruptedException.php @@ -13,5 +13,5 @@ /** * Exception for when the user hit ctrl-c */ -class InterruptedException extends \Exception { +class InterruptedException extends \OCP\Console\Exception\InterruptedException { } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 8e2cdfc266db6..e4c6e9172b27a 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -338,8 +338,18 @@ 'OCP\\Config\\Lexicon\\Preset' => $baseDir . '/lib/public/Config/Lexicon/Preset.php', 'OCP\\Config\\Lexicon\\Strictness' => $baseDir . '/lib/public/Config/Lexicon/Strictness.php', 'OCP\\Config\\ValueType' => $baseDir . '/lib/public/Config/ValueType.php', + 'OCP\\Console\\Attribute\\Argument' => $baseDir . '/lib/public/Console/Attribute/Argument.php', + 'OCP\\Console\\Attribute\\AsCommand' => $baseDir . '/lib/public/Console/Attribute/AsCommand.php', + 'OCP\\Console\\Attribute\\Option' => $baseDir . '/lib/public/Console/Attribute/Option.php', 'OCP\\Console\\ConsoleEvent' => $baseDir . '/lib/public/Console/ConsoleEvent.php', + 'OCP\\Console\\Exception\\InterruptedException' => $baseDir . '/lib/public/Console/Exception/InterruptedException.php', + 'OCP\\Console\\ExitCode' => $baseDir . '/lib/public/Console/ExitCode.php', + 'OCP\\Console\\IInput' => $baseDir . '/lib/public/Console/IInput.php', + 'OCP\\Console\\IOutput' => $baseDir . '/lib/public/Console/IOutput.php', + 'OCP\\Console\\ISignalHandler' => $baseDir . '/lib/public/Console/ISignalHandler.php', + 'OCP\\Console\\OutputFormat' => $baseDir . '/lib/public/Console/OutputFormat.php', 'OCP\\Console\\ReservedOptions' => $baseDir . '/lib/public/Console/ReservedOptions.php', + 'OCP\\Console\\Verbosity' => $baseDir . '/lib/public/Console/Verbosity.php', 'OCP\\Constants' => $baseDir . '/lib/public/Constants.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => $baseDir . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => $baseDir . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', @@ -1374,6 +1384,12 @@ 'OC\\Config\\UserConfig' => $baseDir . '/lib/private/Config/UserConfig.php', 'OC\\Config\\UserConfigEntry' => $baseDir . '/lib/private/Config/UserConfigEntry.php', 'OC\\Console\\Application' => $baseDir . '/lib/private/Console/Application.php', + 'OC\\Console\\CommandAdapter' => $baseDir . '/lib/private/Console/CommandAdapter.php', + 'OC\\Console\\InputAdapter' => $baseDir . '/lib/private/Console/InputAdapter.php', + 'OC\\Console\\NullOutput' => $baseDir . '/lib/private/Console/NullOutput.php', + 'OC\\Console\\OutputAdapter' => $baseDir . '/lib/private/Console/OutputAdapter.php', + 'OC\\Console\\ReflectionMember' => $baseDir . '/lib/private/Console/ReflectionMember.php', + 'OC\\Console\\SignalHandlerAdapter' => $baseDir . '/lib/private/Console/SignalHandlerAdapter.php', 'OC\\Console\\TimestampFormatter' => $baseDir . '/lib/private/Console/TimestampFormatter.php', 'OC\\ContactsManager' => $baseDir . '/lib/private/ContactsManager.php', 'OC\\Contacts\\ContactsMenu\\ActionFactory' => $baseDir . '/lib/private/Contacts/ContactsMenu/ActionFactory.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 5ae915263d482..c909123b64889 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -379,8 +379,18 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Config\\Lexicon\\Preset' => __DIR__ . '/../../..' . '/lib/public/Config/Lexicon/Preset.php', 'OCP\\Config\\Lexicon\\Strictness' => __DIR__ . '/../../..' . '/lib/public/Config/Lexicon/Strictness.php', 'OCP\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/public/Config/ValueType.php', + 'OCP\\Console\\Attribute\\Argument' => __DIR__ . '/../../..' . '/lib/public/Console/Attribute/Argument.php', + 'OCP\\Console\\Attribute\\AsCommand' => __DIR__ . '/../../..' . '/lib/public/Console/Attribute/AsCommand.php', + 'OCP\\Console\\Attribute\\Option' => __DIR__ . '/../../..' . '/lib/public/Console/Attribute/Option.php', 'OCP\\Console\\ConsoleEvent' => __DIR__ . '/../../..' . '/lib/public/Console/ConsoleEvent.php', + 'OCP\\Console\\Exception\\InterruptedException' => __DIR__ . '/../../..' . '/lib/public/Console/Exception/InterruptedException.php', + 'OCP\\Console\\ExitCode' => __DIR__ . '/../../..' . '/lib/public/Console/ExitCode.php', + 'OCP\\Console\\IInput' => __DIR__ . '/../../..' . '/lib/public/Console/IInput.php', + 'OCP\\Console\\IOutput' => __DIR__ . '/../../..' . '/lib/public/Console/IOutput.php', + 'OCP\\Console\\ISignalHandler' => __DIR__ . '/../../..' . '/lib/public/Console/ISignalHandler.php', + 'OCP\\Console\\OutputFormat' => __DIR__ . '/../../..' . '/lib/public/Console/OutputFormat.php', 'OCP\\Console\\ReservedOptions' => __DIR__ . '/../../..' . '/lib/public/Console/ReservedOptions.php', + 'OCP\\Console\\Verbosity' => __DIR__ . '/../../..' . '/lib/public/Console/Verbosity.php', 'OCP\\Constants' => __DIR__ . '/../../..' . '/lib/public/Constants.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', @@ -1415,6 +1425,12 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Config\\UserConfig' => __DIR__ . '/../../..' . '/lib/private/Config/UserConfig.php', 'OC\\Config\\UserConfigEntry' => __DIR__ . '/../../..' . '/lib/private/Config/UserConfigEntry.php', 'OC\\Console\\Application' => __DIR__ . '/../../..' . '/lib/private/Console/Application.php', + 'OC\\Console\\CommandAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/CommandAdapter.php', + 'OC\\Console\\InputAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/InputAdapter.php', + 'OC\\Console\\NullOutput' => __DIR__ . '/../../..' . '/lib/private/Console/NullOutput.php', + 'OC\\Console\\OutputAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/OutputAdapter.php', + 'OC\\Console\\ReflectionMember' => __DIR__ . '/../../..' . '/lib/private/Console/ReflectionMember.php', + 'OC\\Console\\SignalHandlerAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/SignalHandlerAdapter.php', 'OC\\Console\\TimestampFormatter' => __DIR__ . '/../../..' . '/lib/private/Console/TimestampFormatter.php', 'OC\\ContactsManager' => __DIR__ . '/../../..' . '/lib/private/ContactsManager.php', 'OC\\Contacts\\ContactsMenu\\ActionFactory' => __DIR__ . '/../../..' . '/lib/private/Contacts/ContactsMenu/ActionFactory.php', diff --git a/lib/private/Console/Application.php b/lib/private/Console/Application.php index e9b638a36e752..7f903693d1cf7 100644 --- a/lib/private/Console/Application.php +++ b/lib/private/Console/Application.php @@ -14,6 +14,7 @@ use OC\SystemConfig; use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; +use OCP\Console\Attribute\AsCommand; use OCP\Console\ConsoleEvent; use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; @@ -215,6 +216,25 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu */ private function loadCommandsFromInfoXml(iterable $commands): void { foreach ($commands as $command) { + if (class_exists($command)) { + $reflectionClass = new \ReflectionClass($command); + if ($reflectionClass->getAttributes(AsCommand::class) !== []) { + $this->application->addCommand(new CommandAdapter($command, null, \OC::$server)); + continue; + } + + $hasMethodCommands = false; + foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { + if ($reflectionMethod->getAttributes(AsCommand::class) !== []) { + $this->application->addCommand(new CommandAdapter($command, $reflectionMethod->getName(), \OC::$server)); + $hasMethodCommands = true; + } + } + if ($hasMethodCommands) { + continue; + } + } + try { $c = Server::get($command); } catch (ContainerExceptionInterface $e) { diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php new file mode 100644 index 0000000000000..c8809fcb2288f --- /dev/null +++ b/lib/private/Console/CommandAdapter.php @@ -0,0 +1,294 @@ + */ + private array $arguments = []; + + /** @var array */ + private array $options = []; + + /** + * @param class-string $className + */ + public function __construct( + private readonly string $className, + private readonly ?string $method, + private readonly ContainerInterface $container, + ) { + + if ($method !== null) { + $reflectionMethod = new \ReflectionMethod($className, $method); + $asCommands = $reflectionMethod->getAttributes(AsCommand::class); + if ($asCommands === []) { + throw new \RuntimeException('Missing #[AsCommand] attribute on method: ' . $method . ' from class: ' . $className); + } + + $this->asCommand = $asCommands[0]->newInstance(); + } else { + $reflectionClass = new \ReflectionClass($className); + $asCommands = $reflectionClass->getAttributes(AsCommand::class); + if ($asCommands === []) { + throw new \RuntimeException('Missing #[AsCommand] attribute on class: ' . $className); + } + + $this->asCommand = $asCommands[0]->newInstance(); + + $reflectionMethod = new \ReflectionMethod($className, '__invoke'); + } + + $this->reflectionMethod = $reflectionMethod; + + foreach ($reflectionMethod->getParameters() as $parameter) { + $args = $parameter->getAttributes(Argument::class); + if ($args !== []) { + /** @var Argument $argument */ + $argument = $args[0]->newInstance(); + if ($argument->name === '') { + $argument->name = $parameter->getName(); + } + + $this->arguments[$parameter->getName()] = ['arg' => $argument, 'parameter' => $parameter]; + } + + $args = $parameter->getAttributes(Option::class); + if ($args !== []) { + /** @var Option $option */ + $option = $args[0]->newInstance(); + if ($option->name === '') { + $option->name = $parameter->getName(); + } + + $this->options[$parameter->getName()] = ['option' => $option, 'parameter' => $parameter]; + } + } + + parent::__construct(); + } + + #[Override] + public function configure(): void { + $this->setHelp('More extensive and thorough documentation may be found at ' . Server::get(Defaults::class)->getDocBaseUrl() . PHP_EOL); + + if ($this->asCommand->supportsOutputFormat) { + $this->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + ); + } + + $this->setName($this->asCommand->name); + + if ($this->asCommand->description) { + $this->setDescription($this->asCommand->description); + } + + foreach ($this->arguments as $argument) { + /** @var Argument $arg */ + $arg = $argument['arg']; + $parameter = $argument['parameter']; + $reflection = new ReflectionMember($parameter); + $type = $reflection->getType(); + $name = $reflection->getName(); + if (!$type instanceof \ReflectionNamedType) { + throw new \LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + } + $isOptional = $reflection->hasDefaultValue() || $reflection->isNullable() || $reflection->isVariadic(); + $typeName = $type->getName(); + $mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + if ($typeName === 'array' || $reflection->isVariadic()) { + $mode |= InputArgument::IS_ARRAY; + } + $default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; + + $this->addArgument($arg->name, $mode, $arg->description, $default); + } + + foreach ($this->options as $option) { + $parameter = $option['parameter']; + /** @var Option $option */ + $option = $option['option']; + $reflection = new ReflectionMember($parameter); + $type = $reflection->getType(); + $name = $reflection->getName(); + $default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; + + if ($type instanceof \ReflectionUnionType) { + // A union of bool with string/int/float declares an option whose value + // is itself optional, e.g. "--foo" (true), "--foo=bar" (bar) or omitted (false) + $typeNames = array_map( + static fn (\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null, + $type->getTypes(), + ); + sort($typeNames); + $unionTypeName = implode('|', array_filter($typeNames)); + + if (!\in_array($unionTypeName, self::OPTION_VALUE_OPTIONAL_UNION_TYPES, true)) { + throw new \LogicException(\sprintf('The union type for option "$%s" of "%s" is not supported as a command option. Only "%s" types are allowed.', $name, $reflection->getSourceName(), implode('", "', self::OPTION_VALUE_OPTIONAL_UNION_TYPES))); + } + + if ($default !== false) { + throw new \LogicException(\sprintf('The option "$%s" of "%s" must have a default value of false.', $name, $reflection->getSourceName())); + } + + $this->addOption($option->name, $option->shortcut, InputOption::VALUE_OPTIONAL, $option->description, $default); + continue; + } + + if (!$type instanceof \ReflectionNamedType) { + throw new \LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped or Intersection types are not supported for command options.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + } + + $allowNull = $reflection->isNullable(); + $typeName = $type->getName(); + + if ($typeName === 'bool' && $allowNull && \in_array($default, [true, false], true)) { + throw new \LogicException(\sprintf('The option %s "$%s" of "%s" must not be nullable when it has a default boolean value.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + } + + if ($allowNull && $default !== null) { + throw new \LogicException(\sprintf('The option %s "$%s" of "%s" must either be not-nullable or have a default of null.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + } + + if ($typeName === 'bool') { + $mode = InputOption::VALUE_NONE; + if ($default !== false) { + $mode |= InputOption::VALUE_NEGATABLE; + } else { + $default = null; + } + } elseif ($typeName === 'array' || $reflection->isVariadic()) { + $mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY; + } else { + $mode = InputOption::VALUE_REQUIRED; + } + + $this->addOption($option->name, $option->shortcut, $mode, $option->description, $default); + } + } + + #[Override] + public function execute(InputInterface $input, OutputInterface $output): int { + /** @var T $instance */ + $instance = $this->container->get($this->className); + + $symfonyStyle = new SymfonyStyle($input, $output); + + $parameters = []; + foreach ($this->reflectionMethod->getParameters() as $parameter) { + $name = $parameter->getName(); + + if (isset($this->arguments[$name])) { + $parameters[] = $input->getArgument($this->arguments[$name]['arg']->name); + continue; + } + + if (isset($this->options[$name])) { + $value = $input->getOption($this->options[$name]['option']->name); + if ($value === null && $parameter->getType() instanceof \ReflectionUnionType) { + // The option was passed without a value, e.g. "--foo" + $value = true; + } + $parameters[] = $value; + continue; + } + + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && $type->getName() === IOutput::class) { + $parameters[] = new OutputAdapter($output, $input, $symfonyStyle, $this); + continue; + } + + if ($type instanceof \ReflectionNamedType && $type->getName() === IInput::class) { + $parameters[] = new InputAdapter($input, $symfonyStyle); + continue; + } + + if ($type instanceof \ReflectionNamedType && $type->getName() === ISignalHandler::class) { + $parameters[] = new SignalHandlerAdapter($this); + continue; + } + + if ($type instanceof \ReflectionNamedType && $type->getName() === OutputFormat::class) { + $output = OutputFormat::tryFrom($input->getOption('output')); + if ($output === null) { + $output = OutputFormat::Plain; + } + $parameters[] = $output; + continue; + } + + throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, ISignalHandler::class, OutputFormat::class)); + } + + if ($this->method !== null) { + $result = $instance->{$this->method}(...$parameters); + } else { + $result = $instance(...$parameters); + } + + return $result instanceof ExitCode ? $result->value : $result; + } + + #[Override] + public function abortIfInterrupted(): void { + // To make it public + parent::abortIfInterrupted(); + } + + #[Override] + public function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, iterable $items, string $prefix = ' - '): void { + // To make it public + parent::writeArrayInOutputFormat($input, $output, $items, $prefix); + } + + #[Override] + public function writeTableInOutputFormat(InputInterface $input, OutputInterface $output, array $items): void { + // To make it public + parent::writeTableInOutputFormat($input, $output, $items); + } + + #[Override] + public function writeStreamingTableInOutputFormat(InputInterface $input, OutputInterface $output, \Iterator $items, int $tableGroupSize): void { + // To make it public + parent::writeStreamingTableInOutputFormat($input, $output, $items, $tableGroupSize); + } +} diff --git a/lib/private/Console/InputAdapter.php b/lib/private/Console/InputAdapter.php new file mode 100644 index 0000000000000..24f60338b1d82 --- /dev/null +++ b/lib/private/Console/InputAdapter.php @@ -0,0 +1,71 @@ +input->getArguments(); + } + + #[Override] + public function getArgument(string $name): string|bool|int|float|array|null { + return $this->input->getArgument($name); + } + + #[Override] + public function hasArgument(string $name): bool { + return $this->input->hasArgument($name); + } + + #[Override] + public function getOptions(): array { + return $this->input->getOptions(); + } + + #[Override] + public function getOption(string $name): string|bool|int|float|array|null { + return $this->input->getOption($name); + } + + #[Override] + public function hasOption(string $name): bool { + return $this->input->hasOption($name); + } + + #[Override] + public function ask(string $question, ?string $default = null, ?callable $validator = null): mixed { + return $this->symfonyStyle->ask($question, $default, $validator); + } + + #[Override] + public function askHidden(string $question, ?callable $validator = null): mixed { + return $this->symfonyStyle->askHidden($question, $validator); + } + + #[Override] + public function confirm(string $question, bool $default = true): bool { + return $this->symfonyStyle->confirm($question, $default); + } + + #[Override] + public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false): mixed { + return $this->symfonyStyle->choice($question, $choices, $default, $multiSelect); + } +} diff --git a/lib/private/Console/NullOutput.php b/lib/private/Console/NullOutput.php new file mode 100644 index 0000000000000..5a9443d946b72 --- /dev/null +++ b/lib/private/Console/NullOutput.php @@ -0,0 +1,84 @@ +output->write($messages, $newline, $verbosity->value); + } + + #[Override] + public function writeln(iterable|string $messages, Verbosity $verbosity = Verbosity::Normal): void { + $this->output->writeln($messages, $verbosity->value); + } + + #[Override] + public function isQuiet(): bool { + return $this->output->isQuiet(); + } + + #[Override] + public function isVerbose(): bool { + return $this->output->isVerbose(); + } + + #[Override] + public function isVeryVerbose(): bool { + return $this->output->isVeryVerbose(); + } + + #[Override] + public function isDebug(): bool { + return $this->output->isDebug(); + } + + #[Override] + public function writeArrayInOutputFormat(iterable $items, string $prefix = ' - '): void { + $this->commandAdapter->writeArrayInOutputFormat($this->input, $this->output, $items, $prefix); + } + + #[Override] + public function writeTableInOutputFormat(array $items): void { + $this->commandAdapter->writeTableInOutputFormat($this->input, $this->output, $items); + } + + #[Override] + public function writeStreamingTableInOutputFormat(\Iterator $items, int $tableGroupSize): void { + $this->commandAdapter->writeStreamingTableInOutputFormat($this->input, $this->output, $items, $tableGroupSize); + } + + #[Override] + public function setVerbosity(Verbosity $level): void { + $this->output->setVerbosity($level->value); + } + + #[Override] + public function getVerbosity(): Verbosity { + return Verbosity::from($this->output->getVerbosity()); + } + + #[Override] + public function progressStart(int $max = 0): void { + $this->symfonyStyle->progressStart($max); + } + + #[Override] + public function progressAdvance(int $step = 1): void { + $this->symfonyStyle->progressAdvance($step); + } + + #[Override] + public function progressFinish(): void { + $this->symfonyStyle->progressFinish(); + } + + #[Override] + public function progressIterate(iterable $iterable, ?int $max = null): iterable { + return $this->symfonyStyle->progressIterate($iterable, $max); + } +} diff --git a/lib/private/Console/ReflectionMember.php b/lib/private/Console/ReflectionMember.php new file mode 100644 index 0000000000000..cea6200f20b97 --- /dev/null +++ b/lib/private/Console/ReflectionMember.php @@ -0,0 +1,115 @@ + +// SPDX-License-Identifier: MIT + +namespace OC\Console; + +/** + * @internal + */ +class ReflectionMember { + public function __construct( + private readonly \ReflectionParameter|\ReflectionProperty $member, + ) { + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return T|null + */ + public function getAttribute(string $class): ?object { + return ($this->member->getAttributes($class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance(); + } + + /** + * @template T of object + * + * @param class-string $class + * + * @return list + */ + public function getAttributes(string $class): array { + return array_map( + static fn (\ReflectionAttribute $attribute) => $attribute->newInstance(), + $this->member->getAttributes($class, \ReflectionAttribute::IS_INSTANCEOF) + ); + } + + public function getSourceName(): string { + if ($this->member instanceof \ReflectionProperty) { + return $this->member->class; + } + + $function = $this->member->getDeclaringFunction(); + + if ($function instanceof \ReflectionMethod) { + return $function->class . '::' . $function->name . '()'; + } + + return $function->name . '()'; + } + + public function getSourceThis(): ?object { + if ($this->member instanceof \ReflectionParameter) { + return $this->member->getDeclaringFunction()->getClosureThis(); + } + + return null; + } + + public function getType(): ?\ReflectionType { + return $this->member->getType(); + } + + public function getName(): string { + return $this->member->getName(); + } + + public function hasDefaultValue(): bool { + if ($this->member instanceof \ReflectionParameter) { + return $this->member->isDefaultValueAvailable(); + } + + return $this->member->hasDefaultValue(); + } + + public function getDefaultValue(): mixed { + $defaultValue = $this->member->getDefaultValue(); + + if ($defaultValue instanceof \BackedEnum) { + return $defaultValue->value; + } + + return $defaultValue; + } + + public function isNullable(): bool { + return (bool)$this->member->getType()?->allowsNull(); + } + + public function getMemberName(): string { + return $this->member instanceof \ReflectionParameter ? 'parameter' : 'property'; + } + + public function isParameter(): bool { + return $this->member instanceof \ReflectionParameter; + } + + public function isVariadic(): bool { + return $this->member instanceof \ReflectionParameter && $this->member->isVariadic(); + } + + public function isProperty(): bool { + return $this->member instanceof \ReflectionProperty; + } + + public function getMember(): \ReflectionParameter|\ReflectionProperty { + return $this->member; + } +} diff --git a/lib/private/Console/SignalHandlerAdapter.php b/lib/private/Console/SignalHandlerAdapter.php new file mode 100644 index 0000000000000..2f4e9676f62c0 --- /dev/null +++ b/lib/private/Console/SignalHandlerAdapter.php @@ -0,0 +1,23 @@ +commandAdapter->abortIfInterrupted(); + } +} diff --git a/lib/public/Console/Attribute/Argument.php b/lib/public/Console/Attribute/Argument.php new file mode 100644 index 0000000000000..f7f590d5f5996 --- /dev/null +++ b/lib/public/Console/Attribute/Argument.php @@ -0,0 +1,73 @@ + definition. + * + * Can be used in the parameters of the invoke method. + * + * ``` + * #[AsCommand(name: 'app:user:created') + * class CreateUserCommand { + * public function __invoke( + * #[Argument(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * return ExitCode::Success; + * } + * } + * ``` + * + * Or on methods parameters: + * + * ``` + * class UserCommands { + * #[AsCommand('app:user:create')] + * public function create( + * #[Argument(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * + * return ExitCode::Success; + * } + * + * #[AsCommand('app:user:delete')] + * public function delete( + * #[Argument(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * + * return ExitCode::Success; + * } + * } + * ``` + * + * @since 35.0.0 + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +#[Consumable(since: '35.0.0')] +final class Argument { + /** + * If unset, the `name` value will be inferred from the parameter definition. + * + * @param string $description The description of the argument, displayed with the help page + * @param string $name The name of the argument + * @since 35.0.0 + */ + public function __construct( + public string $description = '', + public string $name = '', + ) { + } +} diff --git a/lib/public/Console/Attribute/AsCommand.php b/lib/public/Console/Attribute/AsCommand.php new file mode 100644 index 0000000000000..01d09523f931e --- /dev/null +++ b/lib/public/Console/Attribute/AsCommand.php @@ -0,0 +1,79 @@ + definition. + * + * Can be used in the parameters of the invoke method. + * + * ``` + * #[AsCommand(name: 'app:user:created') + * class CreateUserCommand { + * public function __invoke( + * #[Option(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * return ExitCode::Success; + * } + * } + * ``` + * + * Or on methods parameters: + * + * ``` + * class UserCommands { + * #[AsCommand('app:user:create')] + * public function create( + * #[Option(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * + * return ExitCode::Success; + * } + * + * #[AsCommand('app:user:delete')] + * public function delete( + * #[Option(description: "The username of the user")] string $userId, + * IOutput $output, + * ): ExitCode { + * // ... + * + * return ExitCode::Success; + * } + * } + * ``` + * + * @since 35.0.0 + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +#[Consumable(since: '35.0.0')] +final class Option { + /** + * If unset, the `name` value will be inferred from the parameter definition. + * + * To declare an option whose value is itself optional (e.g. "--foo" alone returns + * `true`, "--foo=bar" returns `'bar'`, and omitting the option returns `false`), + * type the parameter as a union of `bool` with `string`, `int` or `float` + * (e.g. `string|bool $foo = false`). + * + * @param string $description The description of the option, displayed with the help page + * @param string $name The name of the option + * @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @since 35.0.0 + */ + public function __construct( + public string $description = '', + public string $name = '', + public array|string|null $shortcut = null, + ) { + } +} diff --git a/lib/public/Console/Exception/InterruptedException.php b/lib/public/Console/Exception/InterruptedException.php new file mode 100644 index 0000000000000..f7e5e0c2fa8d3 --- /dev/null +++ b/lib/public/Console/Exception/InterruptedException.php @@ -0,0 +1,16 @@ + + * @since 35.0.0 + */ + public function getArguments(): array; + + /** + * Returns the argument value for a given argument name. + * + * @throws InvalidArgumentException When argument given doesn't exist + * @since 35.0.0 + */ + public function getArgument(string $name): string|bool|int|float|array|null; + + /** + * Returns true if an argument exists by name or position. + * @since 35.0.0 + */ + public function hasArgument(string $name): bool; + + /** + * Returns all the given options merged with the default values. + * + * @return array + * @since 35.0.0 + */ + public function getOptions(): array; + + /** + * Returns the option value for a given option name. + * + * @throws InvalidArgumentException When option given doesn't exist + * @since 35.0.0 + */ + public function getOption(string $name): string|bool|int|float|array|null; + + /** + * Returns true if an option exists by name. + * @since 35.0.0 + */ + public function hasOption(string $name): bool; + + /** + * Asks the user to provide some value. + * + * ``` + * $input->ask('What is your name?'); + * ``` + * + * You can pass the default value as the second argument so the user can hit the key to select that value: + * + * ``` + * $input->ask('Where are you from?', 'United States'); + * ``` + * + * In case you need to validate the given value, pass a callback validator as the third argument: + * + * ``` + * $input->ask('Number of workers to start', '1', function (string $number): int { + * if (!is_numeric($number)) { + * throw new \RuntimeException('You must type a number.'); + * } + * + * return (int) $number; + * }); + * ``` + * + * @param callable(string):mixed|null $validator + * @since 35.0.0 + */ + public function ask(string $question, ?string $default = null, ?callable $validator = null): mixed; + + /** + * Ask the user to provide some value but the user's input will be hidden, and it cannot define a default value. + * + * Use it when asking for sensitive information: + * + * ``` + * $input->askHidden('What is your password?'); + * ``` + * + * In case you need to validate the given value, pass a callback validator as the second argument: + * + * ``` + * $input->askHidden('What is your password?', function (string $password): string { + * if (empty($password)) { + * throw new \RuntimeException('Password cannot be empty.'); + * } + * + * return $password; + * }); + * ``` + * + * @param callable(string):mixed|null $validator + * @since 35.0.0 + */ + public function askHidden(string $question, ?callable $validator = null): mixed; + + /** + * Ask a Yes/No question to the user, and it only returns true or false: + * + * ``` + * $input->confirm('Restart the web server?'); + * ``` + * + * You can pass the default value as the second argument so the user can hit the key to select that value: + * + * ``` + * $input->confirm('Restart the web server?', true); + * ``` + * + * @param string $question + * @since 35.0.0 + */ + public function confirm(string $question, bool $default = true): bool; + + /** + * Ask a question whose answer is constrained to the given list of valid answers: + * + * ``` + * $input->choice('Select the queue to analyze', ['queue1', 'queue2', 'queue3']); + * ``` + * + * You can pass the default value as the third argument so the user can hit the key to select that value: + * + * ``` + * $input->choice('Select the queue to analyze', ['queue1', 'queue2', 'queue3'], 'queue1'); + * ``` + * + * Choice questions display both the choice value and a numeric index, which starts from 0 by default. To use custom indices, pass an array with custom numeric keys as the choice values: + * + * ``` + * $input->choice('Select the queue to analyze', [5 => 'queue1', 6 => 'queue2', 7 => 'queue3']); + * ``` + * + * Finally, you can allow users to select multiple choices. To do so, users must separate each choice with a comma (e.g. typing 1, 2 will select choice 1 and 2): + * + * ``` + * $input->choice('Select the queue to analyze', ['queue1', 'queue2', 'queue3'], multiSelect: true); + * ``` + * + * @param array $choices + * @since 35.0.0 + */ + public function choice(string $question, array $choices, mixed $default = null, bool $multiSelect = false): mixed; +} diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php new file mode 100644 index 0000000000000..5629caa86b1d8 --- /dev/null +++ b/lib/public/Console/IOutput.php @@ -0,0 +1,158 @@ +> $items + * @since 35.0.0 + */ + public function writeTableInOutputFormat(array $items): void; + + /** + * Write a multidimensional iterator of items in the format specified with --output + * + * @param \Iterator> $items + * @since 35.0.0 + */ + public function writeStreamingTableInOutputFormat(\Iterator $items, int $tableGroupSize): void; + + /** + * Displays a progress bar with a number of steps equal to the argument passed to the method (don't pass any value if the length of the progress bar is unknown): + * + * ``` + * // displays a progress bar of unknown length + * $output->progressStart(); + * ``` + * + * ``` + * // displays a 100-step length progress bar + * $output->progressStart(100); + * ``` + * @since 35.0.0 + */ + public function progressStart(int $max = 0): void; + + /** + * Make the progress bar advance the given number of steps (or 1 step if no argument is passed): + * + * ``` + * // advances the progress bar 1 step + * $output->progressAdvance(); + * ``` + * + * ``` + * // advances the progress bar 10 steps + * $output->progressAdvance(10); + * ``` + * @since 35.0.0 + */ + public function progressAdvance(int $step = 1): void; + + /** + * Finish the progress bar (filling up all the remaining steps when its length is known): + * + * ``` + * $output->progressFinish(); + * ``` + * @since 35.0.0 + */ + public function progressFinish(): void; + + /** + * If your progress bar loops over an iterable collection, use the progressIterate() helper: + * + * ``` + * $iterable = [1, 2]; + * + * foreach ($output->progressIterate($iterable) as $value) { + * // ... do some work + * } + * ``` + * @template TKey + * @template TValue + * + * @param iterable $iterable + * @param int|null $max Number of steps to complete the bar (0 if indeterminate), if null it will be inferred from $iterable + * + * @return iterable + * @since 35.0.0 + */ + public function progressIterate(iterable $iterable, ?int $max = null): iterable; +} diff --git a/lib/public/Console/ISignalHandler.php b/lib/public/Console/ISignalHandler.php new file mode 100644 index 0000000000000..09dc56a8eda61 --- /dev/null +++ b/lib/public/Console/ISignalHandler.php @@ -0,0 +1,47 @@ +items() as $item) { + * $signalHandler->abortIfInterrupted(); + * $this->process($item); + * } + * } catch (InterruptedException) { + * $output->writeln('Interrupted by user'); + * return ExitCode::Failure; + * } + * + * return ExitCode::Success; + * } + * } + * ``` + * + * @since 35.0.0 + */ +#[Consumable(since: '35.0.0')] +interface ISignalHandler { + /** + * Throw when interrupted by user (Ctrl-C/SIGTERM) + * + * @throws InterruptedException + * @since 35.0.0 + */ + public function abortIfInterrupted(): void; +} diff --git a/lib/public/Console/OutputFormat.php b/lib/public/Console/OutputFormat.php new file mode 100644 index 0000000000000..a9fcdb939b9a2 --- /dev/null +++ b/lib/public/Console/OutputFormat.php @@ -0,0 +1,40 @@ +