From 127fadd47b67d9216cfde99ec82342db188e5175 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Thu, 23 Jul 2026 12:44:53 +0200 Subject: [PATCH 01/12] fix: filter special characters from password field Signed-off-by: Carl Schwan --- apps/user_ldap/lib/Configuration.php | 2 ++ apps/user_ldap/tests/ConfigurationTest.php | 4 ++++ 2 files changed, 6 insertions(+) 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]], From 8425628979f2dec71a2d438ec319afc300170207 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Wed, 5 Aug 2026 15:52:15 +0200 Subject: [PATCH 02/12] feat(console): Provide a modern API in OCP for commands The API is inspired by modern command registration in Symfony/Console. Signed-off-by: Carl Schwan --- lib/composer/composer/autoload_classmap.php | 12 + lib/composer/composer/autoload_static.php | 12 + lib/private/Console/Application.php | 20 ++ lib/private/Console/CommandAdapter.php | 216 ++++++++++++++++++ lib/private/Console/InputAdapter.php | 29 +++ lib/private/Console/OutputAdapter.php | 29 +++ lib/private/Console/QuestionHelperAdapter.php | 28 +++ lib/private/Console/ReflectionMember.php | 115 ++++++++++ lib/public/Console/Attribute/Argument.php | 72 ++++++ lib/public/Console/Attribute/AsCommand.php | 76 ++++++ lib/public/Console/Attribute/Option.php | 74 ++++++ lib/public/Console/ExitCode.php | 28 +++ lib/public/Console/IInput.php | 30 +++ lib/public/Console/IOutput.php | 30 +++ lib/public/Console/IQuestionHelper.php | 23 ++ 15 files changed, 794 insertions(+) create mode 100644 lib/private/Console/CommandAdapter.php create mode 100644 lib/private/Console/InputAdapter.php create mode 100644 lib/private/Console/OutputAdapter.php create mode 100644 lib/private/Console/QuestionHelperAdapter.php create mode 100644 lib/private/Console/ReflectionMember.php create mode 100644 lib/public/Console/Attribute/Argument.php create mode 100644 lib/public/Console/Attribute/AsCommand.php create mode 100644 lib/public/Console/Attribute/Option.php create mode 100644 lib/public/Console/ExitCode.php create mode 100644 lib/public/Console/IInput.php create mode 100644 lib/public/Console/IOutput.php create mode 100644 lib/public/Console/IQuestionHelper.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 8e2cdfc266db6..33802f7f7bb8d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -338,7 +338,14 @@ '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\\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\\IQuestionHelper' => $baseDir . '/lib/public/Console/IQuestionHelper.php', 'OCP\\Console\\ReservedOptions' => $baseDir . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => $baseDir . '/lib/public/Constants.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => $baseDir . '/lib/public/Contacts/ContactsMenu/IAction.php', @@ -1374,6 +1381,11 @@ '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\\OutputAdapter' => $baseDir . '/lib/private/Console/OutputAdapter.php', + 'OC\\Console\\QuestionHelperAdapter' => $baseDir . '/lib/private/Console/QuestionHelperAdapter.php', + 'OC\\Console\\ReflectionMember' => $baseDir . '/lib/private/Console/ReflectionMember.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..ab24145ee3553 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -379,7 +379,14 @@ 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\\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\\IQuestionHelper' => __DIR__ . '/../../..' . '/lib/public/Console/IQuestionHelper.php', 'OCP\\Console\\ReservedOptions' => __DIR__ . '/../../..' . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => __DIR__ . '/../../..' . '/lib/public/Constants.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IAction.php', @@ -1415,6 +1422,11 @@ 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\\OutputAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/OutputAdapter.php', + 'OC\\Console\\QuestionHelperAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/QuestionHelperAdapter.php', + 'OC\\Console\\ReflectionMember' => __DIR__ . '/../../..' . '/lib/private/Console/ReflectionMember.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..2cff7aa3328fb --- /dev/null +++ b/lib/private/Console/CommandAdapter.php @@ -0,0 +1,216 @@ + */ + 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 { + parent::configure(); + + $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(); + 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 options.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + } + $allowNull = $reflection->isNullable(); + $default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; + $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); + + $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])) { + $parameters[] = $input->getOption($this->options[$name]['option']->name); + continue; + } + + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && $type->getName() === IOutput::class) { + $parameters[] = new OutputAdapter($output); + continue; + } + + if ($type instanceof \ReflectionNamedType && $type->getName() === IInput::class) { + $parameters[] = new InputAdapter($input); + continue; + } + + if ($type instanceof \ReflectionNamedType && $type->getName() === IQuestionHelper::class) { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + $parameters[] = new QuestionHelperAdapter($input, $output, $questionHelper); + continue; + } + + throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, IQuestionHelper::class)); + } + + if ($this->method !== null) { + $result = $instance->{$this->method}(...$parameters); + } else { + $result = $instance(...$parameters); + } + + return $result instanceof ExitCode ? $result->value : $result; + } +} diff --git a/lib/private/Console/InputAdapter.php b/lib/private/Console/InputAdapter.php new file mode 100644 index 0000000000000..fea53c7ae932c --- /dev/null +++ b/lib/private/Console/InputAdapter.php @@ -0,0 +1,29 @@ +input->getArguments(); + } + + #[Override] + public function getArgument(string $name): string|bool|int|float|array|null { + return $this->input->getArgument($name); + } +} diff --git a/lib/private/Console/OutputAdapter.php b/lib/private/Console/OutputAdapter.php new file mode 100644 index 0000000000000..9fb396350b2eb --- /dev/null +++ b/lib/private/Console/OutputAdapter.php @@ -0,0 +1,29 @@ +output->write($messages, $newline); + } + + #[Override] + public function writeln(iterable|string $messages): void { + $this->output->writeln($messages); + } +} diff --git a/lib/private/Console/QuestionHelperAdapter.php b/lib/private/Console/QuestionHelperAdapter.php new file mode 100644 index 0000000000000..6ecc147cd39c7 --- /dev/null +++ b/lib/private/Console/QuestionHelperAdapter.php @@ -0,0 +1,28 @@ +questionHelper->ask($this->input, $this->output, $question); + } +} 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/public/Console/Attribute/Argument.php b/lib/public/Console/Attribute/Argument.php new file mode 100644 index 0000000000000..18565e14ee66c --- /dev/null +++ b/lib/public/Console/Attribute/Argument.php @@ -0,0 +1,72 @@ + 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 + */ + 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..7075069e0baca --- /dev/null +++ b/lib/public/Console/Attribute/AsCommand.php @@ -0,0 +1,76 @@ + 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. + * + * @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 + */ + public function __construct( + public string $description = '', + public string $name = '', + public array|string|null $shortcut = null, + ) { + } +} diff --git a/lib/public/Console/ExitCode.php b/lib/public/Console/ExitCode.php new file mode 100644 index 0000000000000..5e0530f082930 --- /dev/null +++ b/lib/public/Console/ExitCode.php @@ -0,0 +1,28 @@ + + */ + public function getArguments(): array; + + /** + * Returns the argument value for a given argument name. + * + * @return string|bool|int|float|array|null + * + * @throws InvalidArgumentException When argument given doesn't exist + */ + public function getArgument(string $name): string|bool|int|float|array|null; +} diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php new file mode 100644 index 0000000000000..1a63f59bd374a --- /dev/null +++ b/lib/public/Console/IOutput.php @@ -0,0 +1,30 @@ + Date: Wed, 5 Aug 2026 16:11:12 +0200 Subject: [PATCH 03/12] refactor: Use the new AsCommand attribute to port two files commands Signed-off-by: Carl Schwan --- apps/files/lib/Command/Move.php | 87 +++++++++++++++----------------- apps/files/lib/Command/Touch.php | 55 +++++++++----------- 2 files changed, 65 insertions(+), 77 deletions(-) diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php index ab4b9f86bfab8..82cb7f5ee15b7 100644 --- a/apps/files/lib/Command/Move.php +++ b/apps/files/lib/Command/Move.php @@ -9,89 +9,83 @@ 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\IOutput; +use OCP\Console\IQuestionHelper; 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, + IQuestionHelper $questionHelper, + #[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; + $question = new ConfirmationQuestion('' . $target . ' already exists, overwrite? [y/N] ', false); + if (!$questionHelper->ask($question)) { + return ExitCode::Failure; } } } @@ -100,9 +94,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/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); From cca2ebeaed6db1d24734af00e973b2ccb9bdf806 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Wed, 5 Aug 2026 19:02:11 +0200 Subject: [PATCH 04/12] feat(api): Extend a bit IInput and IOutput Make sure it is somewhat feature complete with the API used in Base. Signed-off-by: Carl Schwan --- lib/private/Console/CommandAdapter.php | 20 ++++++++- lib/private/Console/InputAdapter.php | 20 +++++++++ lib/private/Console/OutputAdapter.php | 38 ++++++++++++++++ lib/public/Console/Attribute/Argument.php | 1 + lib/public/Console/Attribute/AsCommand.php | 1 + lib/public/Console/Attribute/Option.php | 1 + lib/public/Console/IInput.php | 37 +++++++++++++++- lib/public/Console/IOutput.php | 50 ++++++++++++++++++++++ 8 files changed, 165 insertions(+), 3 deletions(-) diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index 2cff7aa3328fb..cbda962d56755 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -186,7 +186,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && $type->getName() === IOutput::class) { - $parameters[] = new OutputAdapter($output); + $parameters[] = new OutputAdapter($output, $input, $this); continue; } @@ -213,4 +213,22 @@ public function execute(InputInterface $input, OutputInterface $output): int { return $result instanceof ExitCode ? $result->value : $result; } + + #[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 index fea53c7ae932c..9dc8063ff92ba 100644 --- a/lib/private/Console/InputAdapter.php +++ b/lib/private/Console/InputAdapter.php @@ -26,4 +26,24 @@ public function getArguments(): array { 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); + } } diff --git a/lib/private/Console/OutputAdapter.php b/lib/private/Console/OutputAdapter.php index 9fb396350b2eb..0fe58ee2e5d75 100644 --- a/lib/private/Console/OutputAdapter.php +++ b/lib/private/Console/OutputAdapter.php @@ -9,11 +9,14 @@ use OCP\Console\IOutput; use Override; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class OutputAdapter implements IOutput { public function __construct( public readonly OutputInterface $output, + public readonly InputInterface $input, + public readonly CommandAdapter $commandAdapter, ) { } @@ -26,4 +29,39 @@ public function write(iterable|string $messages, bool $newline = false): void { public function writeln(iterable|string $messages): void { $this->output->writeln($messages); } + + #[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); + } } diff --git a/lib/public/Console/Attribute/Argument.php b/lib/public/Console/Attribute/Argument.php index 18565e14ee66c..f7f590d5f5996 100644 --- a/lib/public/Console/Attribute/Argument.php +++ b/lib/public/Console/Attribute/Argument.php @@ -63,6 +63,7 @@ final class Argument { * * @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 = '', diff --git a/lib/public/Console/Attribute/AsCommand.php b/lib/public/Console/Attribute/AsCommand.php index 7075069e0baca..7a1481d5c3ccf 100644 --- a/lib/public/Console/Attribute/AsCommand.php +++ b/lib/public/Console/Attribute/AsCommand.php @@ -64,6 +64,7 @@ final class AsCommand { * @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command * @param string|null $help The help content of the command, displayed with the help page * @param string[] $usages The list of usage examples, displayed with the help page + * @since 35.0.0 */ public function __construct( public string $name, diff --git a/lib/public/Console/Attribute/Option.php b/lib/public/Console/Attribute/Option.php index f7b98df304772..ce298e4ae5f27 100644 --- a/lib/public/Console/Attribute/Option.php +++ b/lib/public/Console/Attribute/Option.php @@ -64,6 +64,7 @@ final class Option { * @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 = '', diff --git a/lib/public/Console/IInput.php b/lib/public/Console/IInput.php index fcc6ec65a3097..cdb4349a3fbba 100644 --- a/lib/public/Console/IInput.php +++ b/lib/public/Console/IInput.php @@ -10,21 +10,54 @@ use InvalidArgumentException; use OCP\AppFramework\Attribute\Consumable; +/** + * Interface that lets you retrieve the input from a command. + * + * @since 35.0.0 + */ #[Consumable(since: '35.0.0')] interface IInput { /** * Returns all the given arguments merged with the default values. * * @return array + * @since 35.0.0 */ public function getArguments(): array; /** * Returns the argument value for a given argument name. * - * @return string|bool|int|float|array|null - * * @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; } diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php index 1a63f59bd374a..489394ea7f89b 100644 --- a/lib/public/Console/IOutput.php +++ b/lib/public/Console/IOutput.php @@ -10,6 +10,8 @@ use OCP\AppFramework\Attribute\Consumable; /** + * Interface that lets you output content from a command. + * * @since 35.0.0 */ #[Consumable(since: '35.0.0')] @@ -27,4 +29,52 @@ public function write(string|iterable $messages, bool $newline = false): void; * @since 35.0.0 */ public function writeln(string|iterable $messages): void; + + /** + * Returns whether verbosity is quiet (-q). + * @since 35.0.0 + */ + public function isQuiet() : bool; + + /** + * Returns whether verbosity is verbose (-v). + * @since 35.0.0 + */ + public function isVerbose() : bool; + + /** + * Returns whether verbosity is very verbose (-vv). + * @since 35.0.0 + */ + public function isVeryVerbose() : bool; + + /** + * Returns whether verbosity is debug (-vvv). + * @since 35.0.0 + */ + public function isDebug() : bool; + + /** + * Write a list of items in the format specified with --output + * + * @param list $items + * @since 35.0.0 + */ + public function writeArrayInOutputFormat(iterable $items, string $prefix = ' - '): void; + + /** + * Write a multidimensional array of items in the format specified with --output + * + * @param list> $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; } From 8b29e3292403d99df4d659637679a31f021e7c2d Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Thu, 6 Aug 2026 10:00:50 +0200 Subject: [PATCH 05/12] feat(console): Add Verbosity enum Allow to change output depending on the verbosity set by the user. Signed-off-by: Carl Schwan --- lib/private/Console/OutputAdapter.php | 19 +++++++++++++++---- lib/public/Console/IOutput.php | 19 +++++++++++++++++-- lib/public/Console/Verbosity.php | 26 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 lib/public/Console/Verbosity.php diff --git a/lib/private/Console/OutputAdapter.php b/lib/private/Console/OutputAdapter.php index 0fe58ee2e5d75..7837f8eeb2830 100644 --- a/lib/private/Console/OutputAdapter.php +++ b/lib/private/Console/OutputAdapter.php @@ -8,6 +8,7 @@ namespace OC\Console; use OCP\Console\IOutput; +use OCP\Console\Verbosity; use Override; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -21,13 +22,13 @@ public function __construct( } #[Override] - public function write(iterable|string $messages, bool $newline = false): void { - $this->output->write($messages, $newline); + public function write(iterable|string $messages, bool $newline = false, Verbosity $verbosity = Verbosity::Normal): void { + $this->output->write($messages, $newline, $verbosity->value); } #[Override] - public function writeln(iterable|string $messages): void { - $this->output->writeln($messages); + public function writeln(iterable|string $messages, Verbosity $verbosity = Verbosity::Normal): void { + $this->output->writeln($messages, $verbosity->value); } #[Override] @@ -64,4 +65,14 @@ public function writeTableInOutputFormat(array $items): void { 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()); + } } diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php index 489394ea7f89b..727a97344ab17 100644 --- a/lib/public/Console/IOutput.php +++ b/lib/public/Console/IOutput.php @@ -20,15 +20,30 @@ interface IOutput { * Writes a message to the output. * * @param bool $newline Whether to add a newline + * @param Verbosity $verbosity Only write messages if verbosity is set to at least that level. * @since 35.0.0 */ - public function write(string|iterable $messages, bool $newline = false): void; + public function write(string|iterable $messages, bool $newline = false, Verbosity $verbosity = Verbosity::Normal): void; /** * Writes a message to the output and adds a newline at the end. + * + * @param Verbosity $verbosity Only write messages if verbosity is set to at least that level. + * @since 35.0.0 + */ + public function writeln(string|iterable $messages, Verbosity $verbosity = Verbosity::Normal): void; + + /** + * Sets the verbosity of the output. + * @since 35.0.0 + */ + public function setVerbosity(Verbosity $level): void; + + /** + * Gets the current verbosity of the output. * @since 35.0.0 */ - public function writeln(string|iterable $messages): void; + public function getVerbosity(): Verbosity; /** * Returns whether verbosity is quiet (-q). diff --git a/lib/public/Console/Verbosity.php b/lib/public/Console/Verbosity.php new file mode 100644 index 0000000000000..4a1122e26a97f --- /dev/null +++ b/lib/public/Console/Verbosity.php @@ -0,0 +1,26 @@ + Date: Thu, 6 Aug 2026 10:02:36 +0200 Subject: [PATCH 06/12] refactor: Port most remaining files commands to AsArgument Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Carl Schwan --- apps/files/lib/Command/Copy.php | 100 +++++++++--------- apps/files/lib/Command/Delete.php | 70 ++++++------ .../files/lib/Command/DeleteOrphanedFiles.php | 40 +++---- apps/files/lib/Command/Get.php | 65 ++++++------ apps/files/lib/Command/Mkdir.php | 36 +++---- apps/files/lib/Command/Mount/ListMounts.php | 48 ++++----- apps/files/lib/Command/Mount/Refresh.php | 36 +++---- apps/files/lib/Command/Object/Delete.php | 47 ++++---- apps/files/lib/Command/Object/Get.php | 58 +++++----- apps/files/lib/Command/Object/Info.php | 56 +++++----- apps/files/lib/Command/Object/ListObject.php | 44 ++++---- .../files/lib/Command/Object/Multi/Rename.php | 54 ++++------ apps/files/lib/Command/Object/Multi/Users.php | 54 +++++----- apps/files/lib/Command/Object/ObjectUtil.php | 4 +- apps/files/lib/Command/Object/Orphans.php | 44 ++++---- apps/files/lib/Command/Object/Put.php | 64 +++++------ apps/files/lib/Command/Put.php | 65 ++++++------ apps/files/lib/Command/RepairTree.php | 47 ++++---- apps/files/lib/Command/SanitizeFilenames.php | 89 +++++++--------- .../Command/WindowsCompatibleFilenames.php | 48 ++++----- .../tests/Command/DeleteOrphanedFilesTest.php | 10 +- 21 files changed, 491 insertions(+), 588 deletions(-) diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php index 1d905e73e986b..54d2be72f74da 100644 --- a/apps/files/lib/Command/Copy.php +++ b/apps/files/lib/Command/Copy.php @@ -9,77 +9,77 @@ 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\IOutput; +use OCP\Console\IQuestionHelper; 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, + IQuestionHelper $questionHelper, + #[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 +87,18 @@ 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; + $question = new ConfirmationQuestion('' . $target . ' already exists, overwrite? [y/N] ', false); + if (!$questionHelper->ask($question)) { + return ExitCode::Failure; } } } @@ -110,9 +107,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..17ac1f09c121c 100644 --- a/apps/files/lib/Command/Delete.php +++ b/apps/files/lib/Command/Delete.php @@ -11,58 +11,54 @@ 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\IOutput; +use OCP\Console\IQuestionHelper; 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, + IQuestionHelper $questionHelper, + #[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); + $question = new ConfirmationQuestion("$file in a shared file, do you want to unshare the file from $user instead of deleting the source file? [Y/n] ", true); + if ($questionHelper->ask($question)) { $storage->unshareStorage(); - return self::SUCCESS; + return ExitCode::Success; } else { $node = $storage->getShare()->getNode(); $output->writeln(''); @@ -76,8 +72,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(''); @@ -89,7 +85,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $maybeContents = ''; } $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); - $deleteConfirmed = $helper->ask($input, $output, $question); + $deleteConfirmed = $questionHelper->ask($question); } 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..1ca4ba722f33b 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..13ecd76bc64f9 100644 --- a/apps/files/lib/Command/Get.php +++ b/apps/files/lib/Command/Get.php @@ -9,65 +9,60 @@ 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..a937bc839fdf0 100644 --- a/apps/files/lib/Command/Mkdir.php +++ b/apps/files/lib/Command/Mkdir.php @@ -9,46 +9,42 @@ 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..91407fc53e653 100644 --- a/apps/files/lib/Command/Mount/ListMounts.php +++ b/apps/files/lib/Command/Mount/ListMounts.php @@ -8,44 +8,41 @@ 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\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', +)] +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, + #[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) { @@ -65,7 +62,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { $format = $input->getOption('output'); - if ($format === self::OUTPUT_FORMAT_PLAIN) { + if ($format === 'plain') { foreach ($mounts as $mount) { $output->writeln('' . $mount->getMountPoint() . ': ' . $mount->getStorageId()); if (isset($cachedByMountPoint[$mount->getMountPoint()])) { @@ -101,12 +98,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..cf9a4c8b8fc06 100644 --- a/apps/files/lib/Command/Mount/Refresh.php +++ b/apps/files/lib/Command/Mount/Refresh.php @@ -8,38 +8,35 @@ 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 +46,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/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php index 575bf5df069cb..ce3e7183de9cf 100644 --- a/apps/files/lib/Command/Object/Delete.php +++ b/apps/files/lib/Command/Object/Delete.php @@ -8,34 +8,31 @@ 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 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\IQuestionHelper; use Symfony\Component\Console\Question\ConfirmationQuestion; -class Delete extends Command { +#[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, + IQuestionHelper $questionHelper, + #[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 +48,10 @@ 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 ($questionHelper->ask($question)) { $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..8cb9072ea2e9e 100644 --- a/apps/files/lib/Command/Object/Get.php +++ b/apps/files/lib/Command/Object/Get.php @@ -8,41 +8,36 @@ 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 +45,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..9a06d1e8d70f7 100644 --- a/apps/files/lib/Command/Object/Info.php +++ b/apps/files/lib/Command/Object/Info.php @@ -8,49 +8,46 @@ 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\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', +)] +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, + #[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,7 +55,7 @@ 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'])) { @@ -74,9 +71,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..248969a055240 100644 --- a/apps/files/lib/Command/Object/ListObject.php +++ b/apps/files/lib/Command/Object/ListObject.php @@ -8,45 +8,43 @@ 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\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', +)] +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, + #[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, $input->getOption('output') === '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..bcefe5474a997 100644 --- a/apps/files/lib/Command/Object/Multi/Rename.php +++ b/apps/files/lib/Command/Object/Multi/Rename.php @@ -8,54 +8,48 @@ 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\IOutput; +use OCP\Console\IQuestionHelper; 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, + IQuestionHelper $questionHelper, + #[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 +61,8 @@ 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 ($questionHelper->ask($question)) { $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..197cf29ff757e 100644 --- a/apps/files/lib/Command/Object/Multi/Users.php +++ b/apps/files/lib/Command/Object/Multi/Users.php @@ -8,47 +8,43 @@ namespace OCA\Files\Command\Object\Multi; -use OC\Core\Command\Base; use OC\Files\ObjectStore\PrimaryObjectStoreConfig; +use OCP\Console\Attribute\AsCommand; +use OCP\Console\Attribute\Option; +use OCP\Console\ExitCode; +use OCP\Console\IOutput; use OCP\IConfig; 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', +)] +class Users { public function __construct( private readonly IUserManager $userManager, private readonly PrimaryObjectStoreConfig $objectStoreConfig, private readonly IConfig $config, ) { - 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)); } elseif ($bucket === '' && $objectStore !== '') { @@ -63,8 +59,8 @@ public function execute(InputInterface $input, OutputInterface $output): int { } } - $this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100); - return 0; + $output->writeStreamingTableInOutputFormat($this->infoForUsers($users), 100); + return ExitCode::Success; } /** 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..bfe060523926e 100644 --- a/apps/files/lib/Command/Object/Orphans.php +++ b/apps/files/lib/Command/Object/Orphans.php @@ -8,15 +8,20 @@ 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\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', +)] +class Orphans { private const CHUNK_SIZE = 100; private ?IQueryBuilder $query = null; @@ -25,7 +30,6 @@ public function __construct( private readonly ObjectUtil $objectUtils, private readonly IDBConnection $connection, ) { - parent::__construct(); } private function getQuery(): IQueryBuilder { @@ -38,25 +42,19 @@ 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, + #[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 +64,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, $input->getOption('output') === '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..729c146b92a8a 100644 --- a/apps/files/lib/Command/Object/Put.php +++ b/apps/files/lib/Command/Object/Put.php @@ -8,63 +8,55 @@ 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\IOutput; +use OCP\Console\IQuestionHelper; 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, + IQuestionHelper $questionHelper, + #[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 (!$questionHelper->ask($question)) { 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..250970550124f 100644 --- a/apps/files/lib/Command/Put.php +++ b/apps/files/lib/Command/Put.php @@ -9,66 +9,61 @@ 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..56c0a0be98dd1 100644 --- a/apps/files/lib/Command/RepairTree.php +++ b/apps/files/lib/Command/RepairTree.php @@ -9,39 +9,34 @@ 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 { +#[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 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 +52,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 +74,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/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php index d595d5731b68b..86e59fe952d57 100644 --- a/apps/files/lib/Command/WindowsCompatibleFilenames.php +++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php @@ -9,47 +9,43 @@ 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/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'); From 3b290997a2596fbda57a64b33360ccf8d344d571 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Mon, 10 Aug 2026 13:30:08 +0200 Subject: [PATCH 07/12] feat(console): Expose output format Signed-off-by: Carl Schwan --- apps/files/lib/Command/Copy.php | 12 ++++-- apps/files/lib/Command/Delete.php | 9 +++-- .../files/lib/Command/DeleteOrphanedFiles.php | 2 +- apps/files/lib/Command/Get.php | 6 ++- apps/files/lib/Command/Mkdir.php | 3 +- apps/files/lib/Command/Mount/ListMounts.php | 13 +++--- apps/files/lib/Command/Mount/Refresh.php | 3 +- apps/files/lib/Command/Move.php | 9 +++-- apps/files/lib/Command/Object/Delete.php | 6 ++- apps/files/lib/Command/Object/Get.php | 9 +++-- apps/files/lib/Command/Object/Info.php | 11 +++-- apps/files/lib/Command/Object/ListObject.php | 8 +++- .../files/lib/Command/Object/Multi/Rename.php | 6 ++- apps/files/lib/Command/Object/Multi/Users.php | 26 +++++++----- apps/files/lib/Command/Object/Orphans.php | 8 +++- apps/files/lib/Command/Object/Put.php | 9 +++-- apps/files/lib/Command/Put.php | 6 ++- apps/files/lib/Command/RepairTree.php | 11 +++-- .../Command/WindowsCompatibleFilenames.php | 6 ++- build/psalm-baseline.xml | 8 ---- lib/composer/composer/LICENSE | 2 - lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Console/CommandAdapter.php | 26 +++++++++++- lib/public/Console/Attribute/AsCommand.php | 2 + lib/public/Console/IOutput.php | 2 +- lib/public/Console/OutputFormat.php | 40 +++++++++++++++++++ 27 files changed, 178 insertions(+), 69 deletions(-) create mode 100644 lib/public/Console/OutputFormat.php diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php index 54d2be72f74da..6e908e3be70aa 100644 --- a/apps/files/lib/Command/Copy.php +++ b/apps/files/lib/Command/Copy.php @@ -31,17 +31,21 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[Argument(description: 'Source file id or path')] string $source, - #[Argument(description: 'Target path')] string $target, + #[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, + )] + 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, + )] + bool $noTargetDirectory = false, ): ExitCode { $node = $this->fileUtils->getNode($source); $targetNode = $this->fileUtils->getNode($target); diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php index 17ac1f09c121c..2917bdaf68819 100644 --- a/apps/files/lib/Command/Delete.php +++ b/apps/files/lib/Command/Delete.php @@ -34,12 +34,15 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[Argument(description: 'File id or path')] string $file, + #[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, + )] + 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); diff --git a/apps/files/lib/Command/DeleteOrphanedFiles.php b/apps/files/lib/Command/DeleteOrphanedFiles.php index 1ca4ba722f33b..0563584d75be2 100644 --- a/apps/files/lib/Command/DeleteOrphanedFiles.php +++ b/apps/files/lib/Command/DeleteOrphanedFiles.php @@ -26,7 +26,7 @@ help: 'Deletes orphaned filecache and mount entries (those without an existing storage).', )] class DeleteOrphanedFiles { - public const CHUNK_SIZE = 200; + public const int CHUNK_SIZE = 200; public function __construct( protected readonly IDBConnection $connection, diff --git a/apps/files/lib/Command/Get.php b/apps/files/lib/Command/Get.php index 13ecd76bc64f9..40a98e519839d 100644 --- a/apps/files/lib/Command/Get.php +++ b/apps/files/lib/Command/Get.php @@ -27,8 +27,10 @@ public function __construct( 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, + #[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); diff --git a/apps/files/lib/Command/Mkdir.php b/apps/files/lib/Command/Mkdir.php index a937bc839fdf0..ae17735969868 100644 --- a/apps/files/lib/Command/Mkdir.php +++ b/apps/files/lib/Command/Mkdir.php @@ -30,7 +30,8 @@ public function __construct( public function __invoke( IOutput $output, - #[Argument(description: 'Target Nextcloud path for the new folder')] string $path, + #[Argument(description: 'Target Nextcloud path for the new folder')] + string $path, ): ExitCode { $node = $this->fileUtils->getNode($path); diff --git a/apps/files/lib/Command/Mount/ListMounts.php b/apps/files/lib/Command/Mount/ListMounts.php index 91407fc53e653..c8f6d5c8b243d 100644 --- a/apps/files/lib/Command/Mount/ListMounts.php +++ b/apps/files/lib/Command/Mount/ListMounts.php @@ -14,6 +14,7 @@ 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; @@ -23,6 +24,7 @@ #[AsCommand( name: 'files:mount:list', description: 'List of mounts for a user', + supportsOutputFormat: true, )] class ListMounts { public function __construct( @@ -35,8 +37,11 @@ public function __construct( public function __invoke( IInput $input, IOutput $output, - #[Argument(description: 'User to list mounts for')] string $user, - #[Option(name: 'cached-only', description: 'Only return cached mounts, prevents filesystem setup')] bool $cachedOnly = false, + 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); @@ -60,9 +65,7 @@ public function __invoke( /** @var array $cachedByMountpoint */ $cachedByMountPoint = array_combine(array_map(fn (ICachedMountInfo $mount) => $mount->getMountPoint(), $cachedMounts), $cachedMounts); - $format = $input->getOption('output'); - - if ($format === 'plain') { + if ($outputFormat === OutputFormat::Plain) { foreach ($mounts as $mount) { $output->writeln('' . $mount->getMountPoint() . ': ' . $mount->getStorageId()); if (isset($cachedByMountPoint[$mount->getMountPoint()])) { diff --git a/apps/files/lib/Command/Mount/Refresh.php b/apps/files/lib/Command/Mount/Refresh.php index cf9a4c8b8fc06..59bc0c30814e0 100644 --- a/apps/files/lib/Command/Mount/Refresh.php +++ b/apps/files/lib/Command/Mount/Refresh.php @@ -30,7 +30,8 @@ public function __construct( public function __invoke( IOutput $output, - #[Argument(description: 'User to refresh mounts for')] string $user, + #[Argument(description: 'User to refresh mounts for')] + string $user, ): ExitCode { $userId = $user; $user = $this->userManager->get($userId); diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php index 82cb7f5ee15b7..57c4f660a42db 100644 --- a/apps/files/lib/Command/Move.php +++ b/apps/files/lib/Command/Move.php @@ -32,12 +32,15 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[Argument(description: 'Source file id or path')] string $source, - #[Argument(description: 'Target path')] string $target, + #[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, + )] + bool $force = false, ): ExitCode { $node = $this->fileUtils->getNode($source); $targetNode = $this->fileUtils->getNode($target); diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php index ce3e7183de9cf..06019157bc43c 100644 --- a/apps/files/lib/Command/Object/Delete.php +++ b/apps/files/lib/Command/Object/Delete.php @@ -29,8 +29,10 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[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, + #[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) { diff --git a/apps/files/lib/Command/Object/Get.php b/apps/files/lib/Command/Object/Get.php index 8cb9072ea2e9e..60fd99901df39 100644 --- a/apps/files/lib/Command/Object/Get.php +++ b/apps/files/lib/Command/Object/Get.php @@ -26,9 +26,12 @@ public function __construct( 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, + #[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) { diff --git a/apps/files/lib/Command/Object/Info.php b/apps/files/lib/Command/Object/Info.php index 9a06d1e8d70f7..5e99a29642326 100644 --- a/apps/files/lib/Command/Object/Info.php +++ b/apps/files/lib/Command/Object/Info.php @@ -14,6 +14,7 @@ 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; @@ -21,6 +22,7 @@ #[AsCommand( name: 'files:object:info', description: 'Get the metadata of an object', + supportsOutputFormat: true, )] class Info { public function __construct( @@ -32,8 +34,11 @@ public function __construct( public function __invoke( IInput $input, IOutput $output, - #[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, + 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) { @@ -58,7 +63,7 @@ public function __invoke( 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'])) { diff --git a/apps/files/lib/Command/Object/ListObject.php b/apps/files/lib/Command/Object/ListObject.php index 248969a055240..f20c8d63170c9 100644 --- a/apps/files/lib/Command/Object/ListObject.php +++ b/apps/files/lib/Command/Object/ListObject.php @@ -13,11 +13,13 @@ use OCP\Console\ExitCode; use OCP\Console\IInput; use OCP\Console\IOutput; +use OCP\Console\OutputFormat; use OCP\Files\ObjectStore\IObjectStoreMetaData; #[AsCommand( name: 'files:object:list', description: 'List all objects in the object store', + supportsOutputFormat: true, )] class ListObject { private const CHUNK_SIZE = 100; @@ -30,7 +32,9 @@ public function __construct( public function __invoke( IInput $input, IOutput $output, - #[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, + 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) { @@ -42,7 +46,7 @@ public function __invoke( return ExitCode::Failure; } $objects = $objectStore->listObjects(); - $objects = $this->objectUtils->formatObjects($objects, $input->getOption('output') === 'plain'); + $objects = $this->objectUtils->formatObjects($objects, $outputFormat === OutputFormat::Plain); $output->writeStreamingTableInOutputFormat($objects, self::CHUNK_SIZE); return ExitCode::Success; diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php index bcefe5474a997..b7c0c9698aec5 100644 --- a/apps/files/lib/Command/Object/Multi/Rename.php +++ b/apps/files/lib/Command/Object/Multi/Rename.php @@ -33,8 +33,10 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[Argument(description: 'Object store configuration to rename')] string $source, - #[Argument(description: 'New name for the object store configuration')] string $target, + #[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])) { diff --git a/apps/files/lib/Command/Object/Multi/Users.php b/apps/files/lib/Command/Object/Multi/Users.php index 197cf29ff757e..a1090712eebb6 100644 --- a/apps/files/lib/Command/Object/Multi/Users.php +++ b/apps/files/lib/Command/Object/Multi/Users.php @@ -9,31 +9,35 @@ namespace OCA\Files\Command\Object\Multi; use OC\Files\ObjectStore\PrimaryObjectStoreConfig; +use OCP\Config\IUserConfig; use OCP\Console\Attribute\AsCommand; use OCP\Console\Attribute\Option; use OCP\Console\ExitCode; use OCP\Console\IOutput; -use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; #[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, ) { } 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, + #[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); @@ -46,13 +50,13 @@ public function __invoke( $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(); @@ -64,10 +68,10 @@ public function __invoke( } /** - * @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/Orphans.php b/apps/files/lib/Command/Object/Orphans.php index bfe060523926e..ae8a9af401140 100644 --- a/apps/files/lib/Command/Object/Orphans.php +++ b/apps/files/lib/Command/Object/Orphans.php @@ -13,6 +13,7 @@ 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; @@ -20,6 +21,7 @@ #[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; @@ -45,7 +47,9 @@ private function getQuery(): IQueryBuilder { public function __invoke( IInput $input, IOutput $output, - #[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, + 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) { @@ -64,7 +68,7 @@ public function __invoke( return !$this->fileIdInDb($fileId); }); - $orphans = $this->objectUtils->formatObjects($orphans, $input->getOption('output') === 'plain'); + $orphans = $this->objectUtils->formatObjects($orphans, $outputFormat === OutputFormat::Plain); $output->writeStreamingTableInOutputFormat($orphans, self::CHUNK_SIZE); return ExitCode::Success; diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php index 729c146b92a8a..f6cdf6eeecaee 100644 --- a/apps/files/lib/Command/Object/Put.php +++ b/apps/files/lib/Command/Object/Put.php @@ -31,9 +31,12 @@ public function __construct( public function __invoke( IOutput $output, IQuestionHelper $questionHelper, - #[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, + #[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) { diff --git a/apps/files/lib/Command/Put.php b/apps/files/lib/Command/Put.php index 250970550124f..864b607eeb378 100644 --- a/apps/files/lib/Command/Put.php +++ b/apps/files/lib/Command/Put.php @@ -30,8 +30,10 @@ public function __construct( 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, + #[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); diff --git a/apps/files/lib/Command/RepairTree.php b/apps/files/lib/Command/RepairTree.php index 56c0a0be98dd1..32233b95db836 100644 --- a/apps/files/lib/Command/RepairTree.php +++ b/apps/files/lib/Command/RepairTree.php @@ -22,7 +22,7 @@ description: 'Try and repair malformed filesystem tree structures (may be necessary to run multiple times for nested malformations)', )] class RepairTree { - public const CHUNK_SIZE = 200; + public const int CHUNK_SIZE = 200; public function __construct( private readonly IDBConnection $connection, @@ -31,9 +31,12 @@ public function __construct( 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, + #[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; diff --git a/apps/files/lib/Command/WindowsCompatibleFilenames.php b/apps/files/lib/Command/WindowsCompatibleFilenames.php index 86e59fe952d57..5de9b13ed1b15 100644 --- a/apps/files/lib/Command/WindowsCompatibleFilenames.php +++ b/apps/files/lib/Command/WindowsCompatibleFilenames.php @@ -28,8 +28,10 @@ public function __construct( public function __invoke( IOutput $output, - #[Option(description: 'Enable windows naming constraints')] bool $enable = false, - #[Option(description: 'Disable windows naming constraints')] bool $disable = false, + #[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()) { 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/lib/composer/composer/LICENSE b/lib/composer/composer/LICENSE index f27399a042d95..62ecfd8d0046b 100644 --- a/lib/composer/composer/LICENSE +++ b/lib/composer/composer/LICENSE @@ -1,4 +1,3 @@ - Copyright (c) Nils Adermann, Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy @@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 33802f7f7bb8d..257aa790c7e24 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -346,7 +346,9 @@ 'OCP\\Console\\IInput' => $baseDir . '/lib/public/Console/IInput.php', 'OCP\\Console\\IOutput' => $baseDir . '/lib/public/Console/IOutput.php', 'OCP\\Console\\IQuestionHelper' => $baseDir . '/lib/public/Console/IQuestionHelper.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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index ab24145ee3553..46aa7290c169b 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -387,7 +387,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Console\\IInput' => __DIR__ . '/../../..' . '/lib/public/Console/IInput.php', 'OCP\\Console\\IOutput' => __DIR__ . '/../../..' . '/lib/public/Console/IOutput.php', 'OCP\\Console\\IQuestionHelper' => __DIR__ . '/../../..' . '/lib/public/Console/IQuestionHelper.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', diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index cbda962d56755..3d4c97e624937 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -15,6 +15,9 @@ use OCP\Console\IInput; use OCP\Console\IOutput; use OCP\Console\IQuestionHelper; +use OCP\Console\OutputFormat; +use OCP\Defaults; +use OCP\Server; use Override; use Psr\Container\ContainerInterface; use Symfony\Component\Console\Helper\QuestionHelper; @@ -97,7 +100,17 @@ public function __construct( #[Override] public function configure(): void { - parent::configure(); + $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); @@ -202,7 +215,16 @@ public function execute(InputInterface $input, OutputInterface $output): int { continue; } - throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, IQuestionHelper::class)); + 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, IQuestionHelper::class, OutputFormat::class)); } if ($this->method !== null) { diff --git a/lib/public/Console/Attribute/AsCommand.php b/lib/public/Console/Attribute/AsCommand.php index 7a1481d5c3ccf..01d09523f931e 100644 --- a/lib/public/Console/Attribute/AsCommand.php +++ b/lib/public/Console/Attribute/AsCommand.php @@ -64,6 +64,7 @@ final class AsCommand { * @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command * @param string|null $help The help content of the command, displayed with the help page * @param string[] $usages The list of usage examples, displayed with the help page + * @param bool $supportsOutputFormat If true, the command can output the result in JSON * @since 35.0.0 */ public function __construct( @@ -72,6 +73,7 @@ public function __construct( public ?string $help = null, public array $usages = [], public bool $hidden = false, + public bool $supportsOutputFormat = false, ) { } } diff --git a/lib/public/Console/IOutput.php b/lib/public/Console/IOutput.php index 727a97344ab17..a5c960f46a1b0 100644 --- a/lib/public/Console/IOutput.php +++ b/lib/public/Console/IOutput.php @@ -72,7 +72,7 @@ public function isDebug() : bool; /** * Write a list of items in the format specified with --output * - * @param list $items + * @param array $items * @since 35.0.0 */ public function writeArrayInOutputFormat(iterable $items, string $prefix = ' - '): 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 @@ + Date: Tue, 11 Aug 2026 08:59:26 +0200 Subject: [PATCH 08/12] feat(commands): Provide way to handle signals Signed-off-by: Carl Schwan --- core/Command/InterruptedException.php | 2 +- lib/private/Console/CommandAdapter.php | 14 +++++- lib/private/Console/SignalHandlerAdapter.php | 23 +++++++++ .../Exception/InterruptedException.php | 16 +++++++ lib/public/Console/ISignalHandler.php | 47 +++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 lib/private/Console/SignalHandlerAdapter.php create mode 100644 lib/public/Console/Exception/InterruptedException.php create mode 100644 lib/public/Console/ISignalHandler.php 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/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index 3d4c97e624937..30b5b8ecd50c4 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -15,6 +15,7 @@ use OCP\Console\IInput; use OCP\Console\IOutput; use OCP\Console\IQuestionHelper; +use OCP\Console\ISignalHandler; use OCP\Console\OutputFormat; use OCP\Defaults; use OCP\Server; @@ -215,6 +216,11 @@ public function execute(InputInterface $input, OutputInterface $output): int { 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) { @@ -224,7 +230,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { 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, IQuestionHelper::class, OutputFormat::class)); + throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s, %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, IQuestionHelper::class, ISignalHandler::class, OutputFormat::class)); } if ($this->method !== null) { @@ -236,6 +242,12 @@ public function execute(InputInterface $input, OutputInterface $output): int { 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 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/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 @@ +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; +} From 4f0094b66cdc206dcb12a68c22a9853b56198e59 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 11 Aug 2026 09:33:55 +0200 Subject: [PATCH 09/12] feat(console): Add support for optional options Signed-off-by: Carl Schwan --- lib/private/Console/CommandAdapter.php | 38 +++++++++++++++++++++++-- lib/public/Console/Attribute/Option.php | 5 ++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index 30b5b8ecd50c4..6e9d5e45ba8c7 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -31,6 +31,9 @@ * @template T */ class CommandAdapter extends Base { + /** Union types allowed for an option whose value is itself optional */ + private const OPTION_VALUE_OPTIONAL_UNION_TYPES = ['bool|float', 'bool|int', 'bool|string']; + private AsCommand $asCommand; private \ReflectionMethod $reflectionMethod; @@ -147,11 +150,35 @@ public function configure(): void { $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, Union or Intersection types are not supported for command options.', $reflection->getMemberName(), $name, $reflection->getSourceName())); + 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(); - $default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; $typeName = $type->getName(); if ($typeName === 'bool' && $allowNull && \in_array($default, [true, false], true)) { @@ -194,7 +221,12 @@ public function execute(InputInterface $input, OutputInterface $output): int { } if (isset($this->options[$name])) { - $parameters[] = $input->getOption($this->options[$name]['option']->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; } diff --git a/lib/public/Console/Attribute/Option.php b/lib/public/Console/Attribute/Option.php index ce298e4ae5f27..baf152d7ed388 100644 --- a/lib/public/Console/Attribute/Option.php +++ b/lib/public/Console/Attribute/Option.php @@ -61,6 +61,11 @@ 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 From f05bea3324f1584eef21a8c8e40cc15314bf5a70 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 11 Aug 2026 10:01:15 +0200 Subject: [PATCH 10/12] feat(console): Expose more methods from SymfonyStyle Allow to ask questions and display a progress bar. Signed-off-by: Carl Schwan --- lib/private/Console/CommandAdapter.php | 7 +- lib/private/Console/InputAdapter.php | 22 ++++++ lib/private/Console/NullOutput.php | 84 ++++++++++++++++++++ lib/private/Console/OutputAdapter.php | 22 ++++++ lib/public/Console/IInput.php | 104 +++++++++++++++++++++++++ lib/public/Console/IOutput.php | 63 +++++++++++++++ 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 lib/private/Console/NullOutput.php diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index 6e9d5e45ba8c7..6dccba6be32c1 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -26,6 +26,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; /** * @template T @@ -211,6 +212,8 @@ 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(); @@ -232,12 +235,12 @@ public function execute(InputInterface $input, OutputInterface $output): int { $type = $parameter->getType(); if ($type instanceof \ReflectionNamedType && $type->getName() === IOutput::class) { - $parameters[] = new OutputAdapter($output, $input, $this); + $parameters[] = new OutputAdapter($output, $input, $symfonyStyle, $this); continue; } if ($type instanceof \ReflectionNamedType && $type->getName() === IInput::class) { - $parameters[] = new InputAdapter($input); + $parameters[] = new InputAdapter($input, $symfonyStyle); continue; } diff --git a/lib/private/Console/InputAdapter.php b/lib/private/Console/InputAdapter.php index 9dc8063ff92ba..24f60338b1d82 100644 --- a/lib/private/Console/InputAdapter.php +++ b/lib/private/Console/InputAdapter.php @@ -10,10 +10,12 @@ use OCP\Console\IInput; use Override; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; class InputAdapter implements IInput { public function __construct( public readonly InputInterface $input, + public readonly SymfonyStyle $symfonyStyle, ) { } @@ -46,4 +48,24 @@ public function getOption(string $name): string|bool|int|float|array|null { 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->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/public/Console/IInput.php b/lib/public/Console/IInput.php index cdb4349a3fbba..2494741c1cd1a 100644 --- a/lib/public/Console/IInput.php +++ b/lib/public/Console/IInput.php @@ -60,4 +60,108 @@ public function getOption(string $name): string|bool|int|float|array|null; * @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 index a5c960f46a1b0..5629caa86b1d8 100644 --- a/lib/public/Console/IOutput.php +++ b/lib/public/Console/IOutput.php @@ -92,4 +92,67 @@ public function writeTableInOutputFormat(array $items): void; * @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; } From 79207f76cca101e84049d87ad14145cd0eee2123 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 11 Aug 2026 10:24:43 +0200 Subject: [PATCH 11/12] refactor(commands): Remove IQuestionHelper Instead use ask methods from IInput Signed-off-by: Carl Schwan --- apps/files/lib/Command/Copy.php | 8 ++---- apps/files/lib/Command/Delete.php | 11 +++----- apps/files/lib/Command/Move.php | 8 ++---- apps/files/lib/Command/Object/Delete.php | 8 ++---- .../files/lib/Command/Object/Multi/Rename.php | 8 ++---- apps/files/lib/Command/Object/Put.php | 8 ++---- lib/composer/composer/LICENSE | 2 ++ lib/composer/composer/autoload_classmap.php | 6 ++-- lib/composer/composer/autoload_static.php | 6 ++-- lib/private/Console/CommandAdapter.php | 11 +------- lib/private/Console/QuestionHelperAdapter.php | 28 ------------------- lib/public/Console/IQuestionHelper.php | 23 --------------- 12 files changed, 30 insertions(+), 97 deletions(-) delete mode 100644 lib/private/Console/QuestionHelperAdapter.php delete mode 100644 lib/public/Console/IQuestionHelper.php diff --git a/apps/files/lib/Command/Copy.php b/apps/files/lib/Command/Copy.php index 6e908e3be70aa..fe9a00befcde1 100644 --- a/apps/files/lib/Command/Copy.php +++ b/apps/files/lib/Command/Copy.php @@ -13,10 +13,9 @@ 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\IQuestionHelper; use OCP\Files\Folder; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:copy', @@ -30,7 +29,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + IInput $input, #[Argument(description: 'Source file id or path')] string $source, #[Argument(description: 'Target path')] @@ -100,8 +99,7 @@ public function __invoke( } if (!$force && $targetNode) { - $question = new ConfirmationQuestion('' . $target . ' already exists, overwrite? [y/N] ', false); - if (!$questionHelper->ask($question)) { + if (!$input->confirm('' . $target . ' already exists, overwrite? [y/N] ', false)) { return ExitCode::Failure; } } diff --git a/apps/files/lib/Command/Delete.php b/apps/files/lib/Command/Delete.php index 2917bdaf68819..7d1e31ab4c5cb 100644 --- a/apps/files/lib/Command/Delete.php +++ b/apps/files/lib/Command/Delete.php @@ -15,10 +15,9 @@ 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\IQuestionHelper; use OCP\Files\Folder; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:delete', @@ -33,7 +32,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + IInput $input, #[Argument(description: 'File id or path')] string $file, #[Option( @@ -58,8 +57,7 @@ public function __invoke( if (!$inputIsId && $storage->instanceOfStorage(SharedStorage::class) && $node->getInternalPath() === '') { /** @var SharedStorage $storage */ [,$user] = explode('/', $file, 3); - $question = new ConfirmationQuestion("$file in a shared file, do you want to unshare the file from $user instead of deleting the source file? [Y/n] ", true); - if ($questionHelper->ask($question)) { + 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 ExitCode::Success; } else { @@ -87,8 +85,7 @@ public function __invoke( } else { $maybeContents = ''; } - $question = new ConfirmationQuestion('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); - $deleteConfirmed = $questionHelper->ask($question); + $deleteConfirmed = $input->confirm('Delete ' . $node->getPath() . $maybeContents . '? [y/N] ', false); } if ($deleteConfirmed) { diff --git a/apps/files/lib/Command/Move.php b/apps/files/lib/Command/Move.php index 57c4f660a42db..7f8e03fa3370b 100644 --- a/apps/files/lib/Command/Move.php +++ b/apps/files/lib/Command/Move.php @@ -13,11 +13,10 @@ 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\IQuestionHelper; use OCP\Files\File; use OCP\Files\Folder; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:move', @@ -31,7 +30,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + IInput $input, #[Argument(description: 'Source file id or path')] string $source, #[Argument(description: 'Target path')] @@ -86,8 +85,7 @@ public function __invoke( } if (!$force) { - $question = new ConfirmationQuestion('' . $target . ' already exists, overwrite? [y/N] ', false); - if (!$questionHelper->ask($question)) { + if (!$input->confirm('' . $target . ' already exists, overwrite? [y/N] ', false)) { return ExitCode::Failure; } } diff --git a/apps/files/lib/Command/Object/Delete.php b/apps/files/lib/Command/Object/Delete.php index 06019157bc43c..6d900e3b1389b 100644 --- a/apps/files/lib/Command/Object/Delete.php +++ b/apps/files/lib/Command/Object/Delete.php @@ -12,9 +12,8 @@ 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\IQuestionHelper; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:object:delete', @@ -28,7 +27,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + 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')] @@ -50,8 +49,7 @@ public function __invoke( return -1; } - $question = new ConfirmationQuestion("Delete $object? [y/N] ", false); - if ($questionHelper->ask($question)) { + if ($input->confirm("Delete $object? [y/N] ", false)) { $objectStore->deleteObject($object); } return ExitCode::Success; diff --git a/apps/files/lib/Command/Object/Multi/Rename.php b/apps/files/lib/Command/Object/Multi/Rename.php index b7c0c9698aec5..29034a49cdddf 100644 --- a/apps/files/lib/Command/Object/Multi/Rename.php +++ b/apps/files/lib/Command/Object/Multi/Rename.php @@ -12,11 +12,10 @@ use OCP\Console\Attribute\Argument; use OCP\Console\Attribute\AsCommand; use OCP\Console\ExitCode; +use OCP\Console\IInput; use OCP\Console\IOutput; -use OCP\Console\IQuestionHelper; use OCP\IConfig; use OCP\IDBConnection; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:object:multi:rename-config', @@ -32,7 +31,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + IInput $input, #[Argument(description: 'Object store configuration to rename')] string $source, #[Argument(description: 'New name for the object store configuration')] @@ -63,8 +62,7 @@ public function __invoke( $output->writeln(''); $output->writeln('Failure to check these requirements will lead to data loss for users.'); - $question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false); - if ($questionHelper->ask($question)) { + if ($input->confirm('Automatically create target object store configuration? [y/N] ', false)) { $configs[$target] = $configs[$source]; // update all aliases diff --git a/apps/files/lib/Command/Object/Put.php b/apps/files/lib/Command/Object/Put.php index f6cdf6eeecaee..022eb8397f36b 100644 --- a/apps/files/lib/Command/Object/Put.php +++ b/apps/files/lib/Command/Object/Put.php @@ -12,10 +12,9 @@ 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\IQuestionHelper; use OCP\Files\IMimeTypeDetector; -use Symfony\Component\Console\Question\ConfirmationQuestion; #[AsCommand( name: 'files:object:put', @@ -30,7 +29,7 @@ public function __construct( public function __invoke( IOutput $output, - IQuestionHelper $questionHelper, + IInput $consoleInput, #[Argument(description: 'Source local path, use - to read from STDIN')] string $input, #[Argument(description: 'Object to write')] @@ -48,8 +47,7 @@ public function __invoke( $output->writeln("You can use occ files:put $input $fileId to write to the file safely."); $output->writeln(''); - $question = new ConfirmationQuestion('Write to the object anyway? [y/N] ', false); - if (!$questionHelper->ask($question)) { + if (!$consoleInput->confirm('Write to the object anyway? [y/N] ', false)) { return -1; } } diff --git a/lib/composer/composer/LICENSE b/lib/composer/composer/LICENSE index 62ecfd8d0046b..f27399a042d95 100644 --- a/lib/composer/composer/LICENSE +++ b/lib/composer/composer/LICENSE @@ -1,3 +1,4 @@ + Copyright (c) Nils Adermann, Jordi Boggiano Permission is hereby granted, free of charge, to any person obtaining a copy @@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 257aa790c7e24..e4c6e9172b27a 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -342,10 +342,11 @@ '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\\IQuestionHelper' => $baseDir . '/lib/public/Console/IQuestionHelper.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', @@ -1385,9 +1386,10 @@ '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\\QuestionHelperAdapter' => $baseDir . '/lib/private/Console/QuestionHelperAdapter.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 46aa7290c169b..c909123b64889 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -383,10 +383,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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\\IQuestionHelper' => __DIR__ . '/../../..' . '/lib/public/Console/IQuestionHelper.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', @@ -1426,9 +1427,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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\\QuestionHelperAdapter' => __DIR__ . '/../../..' . '/lib/private/Console/QuestionHelperAdapter.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/CommandAdapter.php b/lib/private/Console/CommandAdapter.php index 6dccba6be32c1..c8809fcb2288f 100644 --- a/lib/private/Console/CommandAdapter.php +++ b/lib/private/Console/CommandAdapter.php @@ -14,14 +14,12 @@ use OCP\Console\ExitCode; use OCP\Console\IInput; use OCP\Console\IOutput; -use OCP\Console\IQuestionHelper; use OCP\Console\ISignalHandler; use OCP\Console\OutputFormat; use OCP\Defaults; use OCP\Server; use Override; use Psr\Container\ContainerInterface; -use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -244,13 +242,6 @@ public function execute(InputInterface $input, OutputInterface $output): int { continue; } - if ($type instanceof \ReflectionNamedType && $type->getName() === IQuestionHelper::class) { - /** @var QuestionHelper $questionHelper */ - $questionHelper = $this->getHelper('question'); - $parameters[] = new QuestionHelperAdapter($input, $output, $questionHelper); - continue; - } - if ($type instanceof \ReflectionNamedType && $type->getName() === ISignalHandler::class) { $parameters[] = new SignalHandlerAdapter($this); continue; @@ -265,7 +256,7 @@ public function execute(InputInterface $input, OutputInterface $output): int { continue; } - throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s, %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, IQuestionHelper::class, ISignalHandler::class, OutputFormat::class)); + 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) { diff --git a/lib/private/Console/QuestionHelperAdapter.php b/lib/private/Console/QuestionHelperAdapter.php deleted file mode 100644 index 6ecc147cd39c7..0000000000000 --- a/lib/private/Console/QuestionHelperAdapter.php +++ /dev/null @@ -1,28 +0,0 @@ -questionHelper->ask($this->input, $this->output, $question); - } -} diff --git a/lib/public/Console/IQuestionHelper.php b/lib/public/Console/IQuestionHelper.php deleted file mode 100644 index 0fed9ada6eb0c..0000000000000 --- a/lib/public/Console/IQuestionHelper.php +++ /dev/null @@ -1,23 +0,0 @@ - Date: Tue, 11 Aug 2026 10:25:30 +0200 Subject: [PATCH 12/12] refactor: Port remainging commands from files app Now that we have all the required APIs Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Carl Schwan --- apps/files/lib/Command/Scan.php | 320 ++++++++---------- apps/files/lib/Command/ScanAppData.php | 162 ++++----- apps/files/lib/Command/TransferOwnership.php | 136 +++----- .../lib/Service/OwnershipTransferService.php | 65 ++-- apps/files/tests/Command/ScanAppDataTest.php | 67 ++-- 5 files changed, 331 insertions(+), 419 deletions(-) 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/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/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/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');