TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:38 +02:00
commit 4392dbe2ce
142 changed files with 11824 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Scheduler;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Validation\Validator\TaskValidator;
/**
* CLI command for the 'scheduler' extension which executes
*
* @internal Specific command implementation, not part of TYPO3 API.
*/
#[AsCommand('scheduler:run', 'Start the TYPO3 Scheduler from the command line.')]
#[AsNonSchedulableCommand]
class SchedulerCommand extends Command
{
/**
* @var SymfonyStyle
*/
protected $io;
/**
* Array of tasks UIDs that should be executed. Null if task option is not provided.
*
* @var int[]|null
*/
protected $overwrittenTaskList;
/**
* This is true when the tasks should be marked as stopped instead of being executed.
*
* @var bool
*/
protected $stopTasks = false;
/**
* @var bool
*/
protected $forceExecution;
public function __construct(
protected readonly Scheduler $scheduler,
protected readonly SchedulerTaskRepository $taskRepository,
protected readonly TaskService $taskService,
) {
parent::__construct();
}
/**
* Configure the command by defining the name, options and arguments
*/
protected function configure(): void
{
$this
->setHelp('If no parameter is given, the scheduler executes any tasks that are overdue to run.
Call it like this: typo3/sysext/core/bin/typo3 scheduler:run --task=13 -f')
->addOption(
'task',
'i',
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
'UID of a specific task. Can be provided multiple times to execute multiple tasks sequentially.'
)
->addOption(
'force',
'f',
InputOption::VALUE_NONE,
'Force execution of the task which is passed with --task option'
)
->addOption(
'stop',
's',
InputOption::VALUE_NONE,
'Stop the task which is passed with --task option'
);
}
/**
* Execute scheduler tasks
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
// Make sure the _cli_ user is loaded
Bootstrap::initializeBackendAuthentication();
$overwrittenTaskList = $input->getOption('task');
$overwrittenTaskList = is_array($overwrittenTaskList) ? $overwrittenTaskList : [];
$overwrittenTaskList = array_filter($overwrittenTaskList, static fn($value) => MathUtility::canBeInterpretedAsInteger($value));
$overwrittenTaskList = array_map('intval', $overwrittenTaskList);
if ($overwrittenTaskList !== []) {
$this->overwrittenTaskList = $overwrittenTaskList;
}
$this->forceExecution = (bool)$input->getOption('force');
$this->stopTasks = $this->shouldStopTasks((bool)$input->getOption('stop'));
return $this->loopTasks() ? Command::SUCCESS : Command::FAILURE;
}
/**
* Checks if the tasks should be stopped instead of being executed.
*
* Stopping is only performed when the --stop option is provided together with the --task option.
*
* @param bool $stopOption
*/
protected function shouldStopTasks(bool $stopOption): bool
{
if (!$stopOption) {
return false;
}
if ($this->overwrittenTaskList !== []) {
return true;
}
if ($this->io->isVerbose()) {
$this->io->warning('Stopping tasks is only possible when the --task option is provided.');
}
return false;
}
/**
* Stop task
*/
protected function stopTask(AbstractTask $task)
{
$this->taskRepository->removeAllRegisteredExecutionsForTask($task);
if ($this->io->isVeryVerbose()) {
$this->io->writeln(sprintf('Task #%d was stopped', $task->getTaskUid()));
}
}
/**
* Return task a task for a given UID
*/
protected function getTask(int $taskUid): ?AbstractTask
{
$force = $this->stopTasks || $this->forceExecution;
if ($force) {
return $this->taskRepository->findByUid($taskUid);
}
return $this->taskRepository->findNextExecutableTaskForUid($taskUid);
}
/**
* Execute tasks in loop that are ready to execute
*/
protected function loopTasks(): bool
{
$hasError = false;
do {
$task = null;
// Try getting the next task and execute it
// If there are no more tasks to execute, an exception is thrown by \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
try {
$task = $this->fetchNextTask();
if ($task === null) {
break;
}
try {
$this->executeOrStopTask($task);
} catch (\Exception $e) {
$taskDetails = $this->taskService->getTaskDetailsFromTask($task);
$messages = [
$e->getMessage() . PHP_EOL,
'Exception in scheduler task #' . $task->getTaskUid() . ' (' . $task->getTaskType() . ' - ' . $taskDetails['title'] . ')',
];
$messages[] = 'File: ' . $e->getFile() . ':' . $e->getLine();
$this->io->getErrorStyle()->error($messages);
$hasError = true;
// We ignore any exception that may have been thrown during execution,
// as this is a background process.
// The exception message has been recorded to the database anyway
continue;
}
} catch (\UnexpectedValueException $e) {
$this->io->getErrorStyle()->error($e->getMessage());
$hasError = true;
continue;
}
} while ($task !== null);
// Record the run in the system registry
$this->scheduler->recordLastRun();
return !$hasError;
}
/**
* When the --task option is provided, the next task is fetched from the provided task UIDs. Depending
* on the --force option the task is fetched even if it is not marked for execution.
*
* Without the --task option we ask the scheduler for the next task with pending execution.
*
* @throws \UnexpectedValueException When no task is found by the provided UID or the task is not marked for execution.
*/
protected function fetchNextTask(): ?AbstractTask
{
if ($this->overwrittenTaskList === null) {
return $this->taskRepository->findNextExecutableTask();
}
if (count($this->overwrittenTaskList) === 0) {
return null;
}
$taskUid = (int)array_shift($this->overwrittenTaskList);
$task = $this->getTask($taskUid);
if (!(new TaskValidator())->isValid($task)) {
throw new \UnexpectedValueException(
sprintf('The task #%d is not scheduled for execution or does not exist.', $taskUid),
1547675557
);
}
return $task;
}
/**
* When in stop mode the given task is stopped. Otherwise the task is executed.
*/
protected function executeOrStopTask(AbstractTask $task): void
{
if ($this->stopTasks) {
$this->stopTask($task);
return;
}
$this->scheduler->executeTask($task);
if ($this->io->isVeryVerbose()) {
$this->io->writeln(sprintf('Task #%d was executed', $task->getTaskUid()));
}
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Scheduler;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* CLI command for EXT:scheduler to execute tasks
*/
#[AsCommand('scheduler:execute', 'Execute given TYPO3 Scheduler tasks.')]
#[AsNonSchedulableCommand]
class SchedulerExecuteCommand extends Command
{
protected SymfonyStyle $io;
public function __construct(
protected readonly Context $context,
protected readonly SchedulerTaskRepository $taskRepository,
protected readonly TaskService $taskService,
protected Scheduler $scheduler,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'task',
't',
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
'Execute tasks by given id. To run all tasks of a group prefix the group id with "g:", e.g. "g:1"',
);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
// Make sure the _cli_ user is loaded
Bootstrap::initializeBackendAuthentication();
$this->io = new SymfonyStyle($input, $output);
if (count($input->getOption('task')) > 0) {
$taskGroups = $this->taskRepository->getGroupedTasks()['taskGroupsWithTasks'];
$tasksToRun = $this->getTasksToRun($taskGroups, $input->getOption('task'));
$this->runTasks($tasksToRun, $taskGroups);
return Command::SUCCESS;
}
$this->askForTasksAndRun($input, $output);
return Command::SUCCESS;
}
private function askForTasksAndRun(InputInterface $input, OutputInterface $output): void
{
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelper('question');
$taskGroups = $this->taskRepository->getGroupedTasks()['taskGroupsWithTasks'];
$selectableTasks = $this->getSelectableTasks($taskGroups);
if ($selectableTasks === []) {
$this->io->note('No tasks available.');
return;
}
$tasksToRunQuestion = new ChoiceQuestion('Run tasks (comma-separated list): ', $selectableTasks);
$tasksToRunQuestion->setAutocompleterValues(array_keys($selectableTasks));
$tasksToRunQuestion->setMultiselect(true);
$tasksToRun = $questionHelper->ask($input, $output, $tasksToRunQuestion);
$this->runTasks($tasksToRun, $taskGroups);
}
private function runTasks($selectedTasks, $taskGroups): void
{
$taskUids = $this->getTaskUidsFromSelection($selectedTasks, $taskGroups);
ksort($taskUids);
$numLength = strlen((string)array_reverse($taskUids)[0]);
foreach ($taskUids as $taskUid) {
try {
$uid = (int)$taskUid;
$task = $this->taskRepository->findByUid($uid);
$additionalInformation = $task->getAdditionalInformation() === '' ? '' : ' (' . $task->getAdditionalInformation() . ')';
$taskDetails = $this->taskService->getTaskDetailsFromTask($task);
$space = str_repeat(' ', $numLength - strlen((string)$task->getTaskUid()));
$this->io->writeln('[ <fg=green>TASK:' . $task->getTaskUid() . $space . '</> ] Running "' . $taskDetails['title'] . $additionalInformation . '"');
$this->scheduler->executeTask($task);
} catch (\Throwable $exception) {
$this->io->writeln($exception->getMessage());
}
}
}
private function getTaskUidsFromSelection(array $list, array $groups): array
{
$taskUids = [];
foreach ($list as $uid) {
[$keyword, $group] = [...explode(':', (string)$uid), null];
if ($keyword === 'g') {
if (!array_key_exists($group, $groups)) {
throw new \InvalidArgumentException('Group with id "' . $group . '" does not exist.', 1679683415);
}
$taskUidsInGroup = array_column($groups[$group]['tasks'], 'uid');
$taskUids += $taskUidsInGroup;
} else {
$taskUids[] = $uid;
}
}
return $taskUids;
}
private function getLanguageService(): LanguageService
{
return GeneralUtility::makeInstance(LanguageServiceFactory::class)->create('en');
}
private function getTasksToRun(array $taskGroups, array $taskList): array
{
$taskUids = array_unique($this->getTaskUidsFromSelection($taskList, $taskGroups));
foreach ($taskUids as $taskUid) {
// This will throw an exception if the task uid was not found and print it to the console.
$this->taskRepository->findByUid((int)$taskUid);
}
return $taskUids;
}
protected function getSelectableTasks(mixed $taskGroups): array
{
$selectableTasks = [];
foreach ($taskGroups as $uid => $group) {
$groupLabel = ($group['groupName'] ?? $this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.noGroup'));
$selectableTasks['g:' . $uid] = '<fg=yellow>' . $groupLabel . '</>';
foreach ($group['tasks'] as $task) {
$additionalInformation = $task['additionalInformation'] === '' ? '' : ' (' . $task['additionalInformation'] . ')';
$selectableTasks[$task['uid']] = $task['fullTitle'] . $additionalInformation;
}
}
return $selectableTasks;
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableCell;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Task\TaskStatus;
/**
* CLI command for EXT:scheduler to list tasks
*/
#[AsCommand('scheduler:list', 'List all TYPO3 Scheduler tasks.')]
#[AsNonSchedulableCommand]
class SchedulerListCommand extends Command
{
protected SymfonyStyle $io;
public function __construct(
protected readonly SchedulerTaskRepository $taskRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'group',
'g',
InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
'Show only groups with given uid',
)
->addOption(
'watch',
'w',
InputOption::VALUE_OPTIONAL,
'Start watcher mode (polling)',
);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
if (!$output instanceof ConsoleOutputInterface) {
throw new \InvalidArgumentException('This command accepts only an instance of "ConsoleOutputInterface".', 1678645754);
}
// Make sure the _cli_ user is loaded
Bootstrap::initializeBackendAuthentication();
$this->io = new SymfonyStyle($input, $output);
$languageService = $this->getLanguageService();
$tableHeader = [
$languageService->sL('scheduler.messages:label.id'),
$languageService->sL('scheduler.messages:task'),
$languageService->sL('scheduler.messages:label.description'),
$languageService->sL('scheduler.messages:label.frequency'),
$languageService->sL('scheduler.messages:status'),
];
$tableSection = $output->section();
$tableBuffer = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL, true);
$table = new Table($tableBuffer);
$table->setHeaders($tableHeader);
$showGroups = $input->getOption('group');
$rows = $this->getTableRows($showGroups);
$table->setRows($rows);
$table->setColumnMaxWidth(1, 50);
$table->setColumnMaxWidth(2, 50);
$table->setColumnMaxWidth(4, 50);
$table->render();
$bufferedData = $tableBuffer->fetch();
$tableSection->overwrite($bufferedData);
$doWatch = $input->hasParameterOption('--watch') || $input->hasParameterOption('-w');
if ($doWatch) {
$infoSection = $output->section();
$interval = (int)($input->getOption('watch') ?: 1);
$infoSection->write('Watching tasks every ' . $interval . ' seconds, press CTRL+C to stop watching');
while (true) { // @phpstan-ignore while.alwaysTrue (intentional infinite loop for CLI watch mode, terminated by CTRL+C signal)
sleep($interval);
$this->updateTable($input, $table, $tableBuffer, $tableSection);
}
}
return Command::SUCCESS;
}
private function getTableRows(array $groups = []): array
{
$tasks = $this->taskRepository->getGroupedTasks();
$languageService = $this->getLanguageService();
$rows = [];
foreach ($tasks['taskGroupsWithTasks'] as $uid => $group) {
if (!in_array($uid, $groups) && count($groups) > 0) {
continue;
}
// Flag as disabled group
$groupDisabledLabel = $group['hidden'] ? '<fg=yellow>' . $this->getLanguageService()->sL('scheduler.messages:status.disabled') . '</>' : '';
$groupLabel = ($group['groupName'] ?? $languageService->sL('scheduler.messages:label.noGroup')) . ' (id:' . $uid . ') ' . $groupDisabledLabel;
$rows[] = [new TableSeparator(['colspan' => 5])];
$rows[] = [new TableCell('<options=bold>' . $groupLabel . '</>', ['colspan' => 5])];
$rows[] = [new TableSeparator(['colspan' => 5])];
foreach ($group['tasks'] as $task) {
$progress = $task['progress'] ?? false;
$taskStatus = [];
/** @var TaskStatus $status */
foreach ($task['statuses'] as $status) {
$color = match ($status->severity) {
ContextualFeedbackSeverity::OK => 'green',
ContextualFeedbackSeverity::INFO => 'blue',
ContextualFeedbackSeverity::WARNING => 'yellow',
ContextualFeedbackSeverity::ERROR => 'red',
ContextualFeedbackSeverity::NOTICE => 'gray',
};
$label = $languageService->sL($status->label);
if ($status->type === 'running' && $progress) {
$label .= ' (' . $progress . ')';
}
// The console has no tooltip, so the more detailed message
// (e.g. the failure reason) is shown inline instead.
if ($status->message !== '') {
$label .= ' (' . vsprintf($languageService->sL($status->message), $status->messageArguments) . ')';
}
$taskStatus[] = '<fg=' . $color . '>' . $label . '</>';
}
$taskTitle = $task['fullTitle'] . (empty($task['additionalInformation']) ? '' : ' (' . $task['additionalInformation'] . ')');
$rows[] = [
$task['uid'],
$taskTitle,
$task['description'],
$task['frequency'],
implode(', ', $taskStatus),
];
}
}
return $rows;
}
private function getLanguageService(): LanguageService
{
return GeneralUtility::makeInstance(LanguageServiceFactory::class)->create('en');
}
protected function updateTable(InputInterface $input, Table $table, BufferedOutput $buffer, ConsoleSectionOutput $tableSection): void
{
$tableSection->overwrite('');
$rows = $this->getTableRows($input->getOption('group'));
$table->setRows($rows)->render();
$bufferedData = $buffer->fetch();
$tableSection->overwrite($bufferedData);
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Event\ModifyNewSchedulerTaskWizardItemsEvent;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* New Scheduler Task wizard. This is the modal that pops up when clicking "New Task" in scheduler module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class NewSchedulerTaskController
{
protected string $returnUrl = '';
protected array $defaultValues = [];
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly TaskService $taskService,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->returnUrl = GeneralUtility::sanitizeLocalUrl((string)($request->getParsedBody()['returnUrl'] ?? $request->getQueryParams()['returnUrl'] ?? ''), $request);
$this->defaultValues = $request->getParsedBody()['defaultValues'] ?? $request->getQueryParams()['defaultValues'] ?? [];
$wizardItems = $this->eventDispatcher->dispatch(
new ModifyNewSchedulerTaskWizardItemsEvent(
$this->getWizardItemsFromTaskRegistry(),
$request,
)
)->getWizardItems();
$view = $this->backendViewFactory->create($request);
$view->assign('categoriesJson', GeneralUtility::jsonEncodeForHtmlAttribute($this->organizeWizardItems($wizardItems), false));
return new HtmlResponse($view->render('NewSchedulerTask/Wizard'));
}
/**
* Convert TaskService categorized tasks to wizard items
*/
protected function getWizardItemsFromTaskRegistry(): array
{
$wizardItems = [];
foreach ($this->taskService->getCategorizedTaskTypes() as $category => $tasks) {
// Add category header
$wizardItems[$category] = [
'header' => ucfirst($category),
];
// Add tasks for this category
foreach ($tasks as $taskType => $taskInfo) {
$wizardItems[$category . '_' . str_replace('\\', '_', $taskType)] = [
'title' => $taskInfo['title'],
'description' => $taskInfo['description'],
// @todo change once tx_scheduler_task get's icons
'icon' => $taskInfo['icon'] ?? 'mimetypes-x-tx_scheduler_task_group',
'iconOverlay' => $taskInfo['iconOverlay'] ?? '',
'taskType' => $taskType,
'taskClass' => $taskInfo['className'],
];
}
}
return $wizardItems;
}
/**
* Organize wizard items into the categories
*/
protected function organizeWizardItems(array $wizardItems): array
{
$categories = [];
$currentKey = '';
foreach ($wizardItems as $wizardKey => $wizardItem) {
if (isset($wizardItem['header'])) {
// This is a category
$currentKey = $wizardKey;
$categories[$currentKey] = [
'identifier' => $currentKey,
'label' => $wizardItem['header'],
'items' => [],
];
} else {
if (!($wizardItem['taskType'] ?? false)) {
continue;
}
// This is a task item
$item = [
'identifier' => $wizardKey,
'icon' => $wizardItem['icon'] ?? 'mimetypes-x-tx_scheduler_task_group',
'iconOverlay' => $wizardItem['iconOverlay'] ?? '',
'label' => $wizardItem['title'] ?? '',
'description' => $wizardItem['description'] ?? '',
'taskType' => $wizardItem['taskType'],
];
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'tx_scheduler_task' => [
0 => 'new',
],
],
'defVals' => [
'tx_scheduler_task' => array_replace_recursive($this->defaultValues, [
'pid' => 0,
'tasktype' => $wizardItem['taskType'],
]),
],
'returnUrl' => $this->returnUrl,
]);
if (!empty($currentKey)) {
$categories[$currentKey]['items'][] = $item;
}
}
}
// Remove empty categories
return array_filter($categories, static fn(array $category): bool => !empty($category['items']));
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,509 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Scheduler;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* Scheduler backend module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class SchedulerModuleController
{
public function __construct(
private Scheduler $scheduler,
private SchedulerTaskRepository $taskRepository,
private IconFactory $iconFactory,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private ComponentFactory $componentFactory,
private Context $context,
private TaskService $taskService,
private PageRenderer $pageRenderer,
private Registry $registry,
private ConnectionPool $connectionPool,
) {}
/**
* Entry dispatcher method.
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$view = $this->moduleTemplateFactory->create($request);
$view->assign('dateFormat', [
'day' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] ?? 'd-m-y',
'time' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'] ?? 'H:i',
]);
$moduleData = $request->getAttribute('moduleData');
// Simple actions from list view.
if (!empty($parsedBody['action']['toggleHidden'])) {
$this->toggleDisabledFlag($view, (int)$parsedBody['action']['toggleHidden']);
} elseif (!empty($parsedBody['action']['stop'])) {
$this->stopTask($view, (int)$parsedBody['action']['stop']);
} elseif (!empty($parsedBody['action']['execute'])) {
$this->executeTasks($view, (string)$parsedBody['action']['execute']);
} elseif (!empty($parsedBody['action']['scheduleCron'])) {
$this->scheduleCrons($view, (string)$parsedBody['action']['scheduleCron']);
} elseif (!empty($parsedBody['action']['group']['uid'])) {
$this->groupDisable((int)$parsedBody['action']['group']['uid'], (int)($parsedBody['action']['group']['hidden'] ?? 0));
} elseif (!empty($parsedBody['action']['delete'])) {
$this->deleteTask($view, (int)$parsedBody['action']['delete']);
} elseif (!empty($parsedBody['action']['groupRemove'])) {
$rows = $this->groupRemove((int)$parsedBody['action']['groupRemove']);
if ($rows > 0) {
$view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.group.deleted'));
} else {
$view->addFlashMessage($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.group.delete.failed'), '', ContextualFeedbackSeverity::WARNING);
}
}
return $this->renderListTasksView($view, $moduleData, $request);
}
/**
* AJAX endpoint for setup check modal content.
*/
public function setupCheckAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->assign('dateFormat', [
'day' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] ?? 'd-m-y',
'time' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'] ?? 'H:i',
]);
$this->addSetupCheckInformation($view);
return $view->renderResponse('CheckScreen');
}
/**
* Mark a task as deleted.
*/
private function deleteTask(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid', 1641670374);
}
try {
// Try to fetch the task and delete it
$task = $this->taskRepository->findByUid($taskUid);
if ($this->taskRepository->isTaskMarkedAsRunning($task)) {
// If the task is currently running, it may not be deleted
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.canNotDeleteRunningTask'), ContextualFeedbackSeverity::ERROR);
} else {
if ($this->taskRepository->remove($task)) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteError'));
}
}
} catch (\UnexpectedValueException) {
// The task could not be unserialized, simply update the database record setting it to deleted
$result = $this->taskRepository->remove($taskUid);
if ($result) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.deleteError'), ContextualFeedbackSeverity::ERROR);
}
} catch (\OutOfBoundsException) {
// The task was not found, for some reason
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Clears the registered running executions from the task.
* Note this doesn't actually stop the running script. It just unmarks execution.
* @todo find a way to really kill the running task.
*/
private function stopTask(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid', 1641670375);
}
try {
// Try to fetch the task and stop it
$task = $this->taskRepository->findByUid($taskUid);
if ($this->taskRepository->isTaskMarkedAsRunning($task)) {
// If the task is indeed currently running, clear marked executions
$result = $this->taskRepository->removeAllRegisteredExecutionsForTask($task);
if ($result) {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopSuccess'));
} else {
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopError'), ContextualFeedbackSeverity::ERROR);
}
} else {
// The task is not running, nothing to unmark
$this->addMessage($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.maynotStopNonRunningTask'), ContextualFeedbackSeverity::WARNING);
}
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.stopTaskFailed'), $taskUid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Toggle the disabled state of a task and register for next execution if a task is of type "single execution".
*/
private function toggleDisabledFlag(ModuleTemplate $view, int $taskUid): void
{
$languageService = $this->getLanguageService();
if ($taskUid <= 0) {
throw new \RuntimeException('Expecting a valid task uid to toggle disabled state', 1641670373);
}
try {
$task = $this->taskRepository->findByUid($taskUid);
// Toggle the task state and add a flash message
$taskName = $this->taskService->getHumanReadableTaskName($task);
$isTaskDisabled = $task->isDisabled();
// If a disabled single task is enabled again, register it for a single execution at next scheduler run.
if ($isTaskDisabled && $task->getExecution()->isSingleRun()) {
$task->setDisabled(false);
$task->setRunOnNextCronJob(true);
$execution = Execution::createSingleExecution($this->context->getAspect('date')->get('timestamp'));
$task->setExecution($execution);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabledAndQueuedForExecution'), $taskName, $taskUid));
} elseif ($isTaskDisabled) {
$task->setDisabled(false);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabled'), $taskName, $taskUid));
} else {
$task->setDisabled(true);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskDisabled'), $taskName, $taskUid));
}
$this->taskRepository->updateExecution($task);
} catch (\OutOfBoundsException) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $taskUid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.toggleDisableFailed'), $taskUid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
/**
* Execute a list of tasks.
*/
private function executeTasks(ModuleTemplate $view, string $taskUids): void
{
$taskUids = GeneralUtility::intExplode(',', $taskUids, true);
if (empty($taskUids)) {
throw new \RuntimeException('Expecting a list of task uids to execute', 1641715832);
}
// Loop selected tasks and execute.
$languageService = $this->getLanguageService();
foreach ($taskUids as $uid) {
try {
$task = $this->taskRepository->findByUid($uid);
$name = $this->taskService->getHumanReadableTaskName($task);
// Try to execute it and report result
$result = $this->scheduler->executeTask($task);
if ($result) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.executed'), $name, $uid));
} else {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.notExecuted'), $name, $uid), ContextualFeedbackSeverity::ERROR);
}
$this->scheduler->recordLastRun('manual');
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $uid), ContextualFeedbackSeverity::ERROR);
} catch (\Exception $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.executionFailed'), $uid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
}
/**
* Schedule selected tasks to be executed on next cron run
*/
private function scheduleCrons(ModuleTemplate $view, string $taskUids): void
{
$taskUids = GeneralUtility::intExplode(',', $taskUids, true);
if (empty($taskUids)) {
throw new \RuntimeException('Expecting a list of task uids to schedule', 1641715833);
}
// Loop selected tasks and register for next cron run.
$languageService = $this->getLanguageService();
foreach ($taskUids as $uid) {
try {
$task = $this->taskRepository->findByUid($uid);
$name = $this->taskService->getHumanReadableTaskName($task);
$task->setRunOnNextCronJob(true);
if ($task->isDisabled()) {
$task->setDisabled(false);
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskEnabledAndQueuedForExecution'), $name, $uid));
} else {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskQueuedForExecution'), $name, $uid));
}
$this->taskRepository->updateExecution($task);
} catch (\OutOfBoundsException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.taskNotFound'), $uid), ContextualFeedbackSeverity::ERROR);
} catch (\UnexpectedValueException $e) {
$this->addMessage($view, sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.schedulingFailed'), $uid, $e->getMessage()), ContextualFeedbackSeverity::ERROR);
}
}
}
/**
* Assemble a listing of scheduled tasks
*/
private function renderListTasksView(ModuleTemplate $view, ModuleData $moduleData, ServerRequestInterface $request): ResponseInterface
{
$languageService = $this->getLanguageService();
$data = $this->taskRepository->getGroupedTasks();
$hasAvailableTaskTypes = $this->taskService->getAllTaskTypes() !== [];
$groups = $data['taskGroupsWithTasks'] ?? [];
$groups = array_map(
static fn(int $key, array $group): array => array_merge($group, ['taskGroupCollapsed' => (bool)($moduleData->get('task-group-' . $key, false))]),
array_keys($groups),
$groups
);
// Move "not assigned to group" to the end
if (array_key_exists('uid', $groups[0] ?? []) && $groups[0]['uid'] === null) {
$groupWithoutTaskGroup = $groups[0];
unset($groups[0]);
$groups[0] = $groupWithoutTaskGroup;
}
$this->pageRenderer->loadJavaScriptModule('@typo3/scheduler/new-scheduler-task-wizard-button.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/scheduler/setup-check-button.js');
$view->assignMultiple([
'groups' => $groups,
'groupsWithoutTasks' => $this->getGroupsWithoutTasks($groups),
'hasAvailableTaskTypes' => $hasAvailableTaskTypes,
'errorClasses' => $data['errorClasses'],
'returnUrl' => $this->uriBuilder->buildUriFromRoute('scheduler'),
'errorClassesCollapsed' => (bool)($moduleData->get('task-group-missing', false)),
]);
$view->setTitle(
$languageService->translate('title', 'scheduler.module'),
$languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.scheduler')
);
$view->makeDocHeaderModuleMenu();
if ($hasAvailableTaskTypes) {
$addTaskUrl = (string)$this->uriBuilder->buildUriFromRoute('ajax_new_scheduler_task_wizard', [
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
]);
$view->assign('addTaskUrl', $addTaskUrl);
$this->addDocHeaderAddTaskButton($view, $addTaskUrl);
$this->addDocHeaderAddTaskGroupButton($view);
$this->addDocHeaderSetupCheckButton($view);
}
$this->addDocHeaderShortcutButton($view, $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.scheduler'));
return $view->renderResponse('ListTasks');
}
private function addDocHeaderAddTaskButton(ModuleTemplate $moduleTemplate, string $url): void
{
$languageService = $this->getLanguageService();
$addButton = $this->componentFactory->createGenericButton()
->setTag('typo3-scheduler-new-task-wizard-button')
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
->setLabel($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'))
->setShowLabelText(true)
->setAttributes([
'url' => $url,
'subject' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'),
]);
$moduleTemplate->addButtonToButtonBar($addButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
private function addDocHeaderAddTaskGroupButton(ModuleTemplate $moduleTemplate): void
{
$languageService = $this->getLanguageService();
$addButton = $this->componentFactory->createInputButton()
->setTitle($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.group.add'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
->setName('createSchedulerGroup')
->setValue('1')
->setClasses('t3js-create-group');
$moduleTemplate->addButtonToButtonBar($addButton, ButtonBar::BUTTON_POSITION_LEFT, 3);
}
private function addDocHeaderSetupCheckButton(ModuleTemplate $moduleTemplate): void
{
$languageService = $this->getLanguageService();
$setupCheckButton = $this->componentFactory->createGenericButton()
->setTag('typo3-scheduler-setup-check-button')
->setIcon($this->iconFactory->getIcon('actions-window-cog', IconSize::SMALL))
->setLabel($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.check'))
->setShowLabelText(true)
->setAttributes([
'url' => (string)$this->uriBuilder->buildUriFromRoute('ajax_scheduler_setup_check'),
'subject' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.check'),
]);
$moduleTemplate->addButtonToButtonBar($setupCheckButton, ButtonBar::BUTTON_POSITION_RIGHT, 0);
}
private function addDocHeaderShortcutButton(ModuleTemplate $moduleTemplate, string $name): void
{
$moduleTemplate->getDocHeaderComponent()->setShortcutContext(
'scheduler',
$name
);
}
/**
* Add a flash message to the flash message queue of this module.
*/
private function addMessage(ModuleTemplate $moduleTemplate, string $message, ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK): void
{
$moduleTemplate->addFlashMessage($message, '', $severity);
}
private function getGroupsWithoutTasks(array $taskGroupsWithTasks): array
{
$uidGroupsWithTasks = array_filter(array_column($taskGroupsWithTasks, 'uid'));
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
$queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
$resultEmptyGroups = $queryBuilder->select('*')
->from('tx_scheduler_task_group')
->orderBy('groupName');
// Only add where statement if we have taskGroups to consider.
if (!empty($uidGroupsWithTasks)) {
$resultEmptyGroups->where($queryBuilder->expr()->notIn('uid', $uidGroupsWithTasks));
}
return $resultEmptyGroups->executeQuery()->fetchAllAssociative();
}
private function groupRemove(int $groupId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
return $queryBuilder->update('tx_scheduler_task_group')
->where($queryBuilder->expr()->eq('uid', $groupId))
->set('deleted', 1)
->executeStatement();
}
private function groupDisable(int $groupId, int $hidden): void
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task_group');
$queryBuilder->update('tx_scheduler_task_group')
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($groupId)))
->set('hidden', $hidden)
->executeStatement();
}
private function addSetupCheckInformation(ViewInterface $view): void
{
$languageService = $this->getLanguageService();
// Display information about the last automated run, as stored in the system registry.
$lastRun = $this->registry->get('tx_scheduler', 'lastRun');
$lastRunMessageLabel = 'msg.noLastRun';
$lastRunMessageLabelArguments = [];
$lastRunSeverity = ContextualFeedbackSeverity::WARNING->value;
if (is_array($lastRun)) {
if (empty($lastRun['end']) || empty($lastRun['start']) || empty($lastRun['type'])) {
$lastRunMessageLabel = 'msg.incompleteLastRun';
$lastRunSeverity = ContextualFeedbackSeverity::WARNING->value;
} else {
$lastRunMessageLabelArguments = [
$lastRun['type'] === 'manual'
? $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.manually')
: $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.automatically'),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $lastRun['start']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $lastRun['start']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $lastRun['end']),
date($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $lastRun['end']),
];
$lastRunMessageLabel = 'msg.lastRun';
$lastRunSeverity = ContextualFeedbackSeverity::INFO->value;
}
}
// Information about cli script.
$script = $this->determineExecutablePath();
$isExecutableMessageLabel = 'msg.cliScriptNotExecutable';
$isExecutableSeverity = ContextualFeedbackSeverity::ERROR->value;
$composerMode = !$script && Environment::isComposerMode();
if (!$composerMode) {
// Check if CLI script is executable or not. Skip this check if running Windows since executable detection
// is not reliable on this platform, the script will always appear as *not* executable.
$isExecutable = Environment::isWindows() ? true : ($script && is_executable($script));
if ($isExecutable) {
$isExecutableMessageLabel = 'msg.cliScriptExecutable';
$isExecutableSeverity = ContextualFeedbackSeverity::OK->value;
}
}
$view->assignMultiple([
'composerMode' => $composerMode,
'script' => $script,
'lastRunMessageLabel' => $lastRunMessageLabel,
'lastRunMessageLabelArguments' => $lastRunMessageLabelArguments,
'lastRunSeverity' => $lastRunSeverity,
'isExecutableMessageLabel' => $isExecutableMessageLabel,
'isExecutableSeverity' => $isExecutableSeverity,
]);
}
private function determineExecutablePath(): ?string
{
if (!Environment::isComposerMode()) {
return GeneralUtility::getFileAbsFileName('EXT:core/bin/typo3');
}
$composerJsonFile = getenv('TYPO3_PATH_COMPOSER_ROOT') . '/composer.json';
if (!file_exists($composerJsonFile) || !($jsonContent = file_get_contents($composerJsonFile))) {
return null;
}
$jsonConfig = @json_decode($jsonContent, true);
if (empty($jsonConfig) || !is_array($jsonConfig)) {
return null;
}
$vendorDir = trim($jsonConfig['config']['vendor-dir'] ?? 'vendor', '/');
$binDir = trim($jsonConfig['config']['bin-dir'] ?? $vendorDir . '/bin', '/');
return sprintf('%s/%s/typo3', getenv('TYPO3_PATH_COMPOSER_ROOT'), $binDir);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\CronCommand;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class provides calculations for the cron command format.
*
* @internal not part of TYPO3 Public API
*/
class CronCommand
{
/**
* Normalized sections of the cron command.
* Comma separated lists of integers and the character '*' are allowed.
*
* field lower and upper bound
* ----- --------------
* minute 0-59
* hour 0-23
* day of month 1-31
* month 1-12
* day of week 1-7
*/
protected array $cronCommandSections;
/**
* Timestamp of next execution date.
* This value starts with 'now + 1 minute' if not set externally
* by unit tests. After a call to calculateNextValue() it holds the timestamp of
* the next execution date which matches the cron command restrictions.
*/
protected int $timestamp;
/**
* Constructor
*
* @param string $cronCommand The cron command can hold any combination documented as valid
* @param bool|int $timestamp Optional start time, used in unit tests
*/
public function __construct(string $cronCommand, bool|int $timestamp = false)
{
$cronCommand = NormalizeCommand::normalize($cronCommand);
// Explode cron command to sections
$this->cronCommandSections = GeneralUtility::trimExplode(' ', $cronCommand);
// Initialize the values with the starting time
// This takes care that the calculated time is always in the future
if ($timestamp === false) {
$timestamp = strtotime('+1 minute');
} else {
$timestamp += 60;
}
$this->timestamp = $this->roundTimestamp($timestamp);
}
/**
* Calculates the date of the next execution.
*
* @throws \RuntimeException
*/
public function calculateNextValue(): void
{
$newTimestamp = $this->getTimestamp();
// Calculate next minute and hour field
$loopCount = 0;
while (true) {
$loopCount++;
// If there was no match within two days, cron command is invalid.
// The second day is needed to catch the summertime leap in some countries.
if ($loopCount > 2880) {
throw new \RuntimeException('Unable to determine next execution timestamp: Hour and minute combination is invalid.', 1291494126);
}
if ($this->minuteAndHourMatchesCronCommand($newTimestamp)) {
break;
}
$newTimestamp += 60;
}
$loopCount = 0;
while (true) {
$loopCount++;
// A date must match within the next 4 years, this high number makes
// sure leap year cron command configuration are caught.
// If the loop runs longer than that, the cron command is invalid.
if ($loopCount > 1464) {
throw new \RuntimeException('Unable to determine next execution timestamp: Day of month, month and day of week combination is invalid.', 1291501280);
}
if ($this->dayMatchesCronCommand($newTimestamp)) {
break;
}
$newTimestamp += $this->numberOfSecondsInDay($newTimestamp);
}
$this->timestamp = $newTimestamp;
}
/**
* Get next timestamp
*/
public function getTimestamp(): int
{
return $this->timestamp;
}
/**
* Get cron command sections. Array of strings, each containing either
* a list of comma separated integers or *
*/
public function getCronCommandSections(): array
{
return $this->cronCommandSections;
}
/**
* Determine if current timestamp matches minute and hour cron command restriction.
*/
protected function minuteAndHourMatchesCronCommand(int $timestamp): bool
{
$minute = (int)date('i', $timestamp);
$hour = (int)date('G', $timestamp);
$commandMatch = false;
if ($this->isInCommandList($this->cronCommandSections[0], $minute) && $this->isInCommandList($this->cronCommandSections[1], $hour)) {
$commandMatch = true;
}
return $commandMatch;
}
/**
* Determine if current timestamp matches day of month, month and day of week
* cron command restriction
*/
protected function dayMatchesCronCommand(int $timestamp): bool
{
$dayOfMonth = (int)date('j', $timestamp);
$month = (int)date('n', $timestamp);
$dayOfWeek = (int)date('N', $timestamp);
$isInDayOfMonth = $this->isInCommandList($this->cronCommandSections[2], $dayOfMonth);
$isInMonth = $this->isInCommandList($this->cronCommandSections[3], $month);
$isInDayOfWeek = $this->isInCommandList($this->cronCommandSections[4], $dayOfWeek);
// Quote from vixiecron:
// Note: The day of a command's execution can be specified by two fields — day of month, and day of week.
// If both fields are restricted (i.e., aren't *), the command will be run when either field
// matches the current time. For example, `30 4 1,15 * 5' would cause
// a command to be run at 4:30 am on the 1st and 15th of each month, plus every Friday.
$isDayOfMonthRestricted = (string)$this->cronCommandSections[2] !== '*';
$isDayOfWeekRestricted = (string)$this->cronCommandSections[4] !== '*';
if (!$isInMonth) {
return false;
}
// If both day-of-month and day-of-week are unrestricted, month match is enough.
if (!$isDayOfMonthRestricted && !$isDayOfWeekRestricted) {
return true;
}
// Otherwise, at least one restriction must match.
return ($isInDayOfMonth && $isDayOfMonthRestricted) || ($isInDayOfWeek && $isDayOfWeekRestricted);
}
/**
* Determine if a given number validates a cron command section. The given cron
* command must be a 'normalized' list with only comma separated integers or '*'
*/
protected function isInCommandList(string $commandExpression, int $numberToMatch): bool
{
if ($commandExpression === '*') {
$inList = true;
} else {
$inList = GeneralUtility::inList($commandExpression, (string)$numberToMatch);
}
return $inList;
}
/**
* Helper method to calculate number of seconds in a day.
*
* This is not always 86400 (60*60*24) and depends on the timezone:
* Some countries like Germany have a summertime / wintertime switch,
* on every last sunday in march clocks are forwarded by one hour (set from 2:00 to 3:00),
* and on last sunday of october they are set back one hour (from 3:00 to 2:00).
* This shortens and lengthens the length of a day by one hour.
*/
protected function numberOfSecondsInDay(int $timestamp): int
{
$now = mktime(0, 0, 0, (int)date('n', $timestamp), (int)date('j', $timestamp), (int)date('Y', $timestamp));
// Make sure to be in next day, even if day has 25 hours
$nextDay = $now + 60 * 60 * 25;
$nextDay = mktime(0, 0, 0, (int)date('n', $nextDay), (int)date('j', $nextDay), (int)date('Y', $nextDay));
return $nextDay - $now;
}
/**
* Round a timestamp down to full minute.
*/
protected function roundTimestamp(int $timestamp): int
{
return (int)(floor($timestamp / 60) * 60);
}
}
+336
View File
@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\CronCommand;
use TYPO3\CMS\Core\Utility\MathUtility;
/**
* Validate and normalize a cron command.
*
* Special fields like three letter weekdays, ranges and steps are substituted
* to a comma separated list of integers. Example:
* '2-4 10-40/10 * mar * fri' will be normalized to '2,4 10,20,30,40 * * 3 1,2'
*
* @internal not part of TYPO3 Public API
*/
class NormalizeCommand
{
/**
* Main API method: Get the cron command and normalize it.
*
* If no exception is thrown, the resulting cron command is validated
* and consists of five whitespace separated fields, which are either
* the letter '*' or a sorted, unique comma separated list of integers.
*
* @throws \InvalidArgumentException cron command is invalid or out of bounds
*/
public static function normalize(string $cronCommand): string
{
$cronCommand = trim($cronCommand);
$cronCommand = self::convertKeywordsToCronCommand($cronCommand);
return self::normalizeFields($cronCommand);
}
/**
* Accept special cron command keywords and convert to standard cron syntax.
* Allowed keywords: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
*/
protected static function convertKeywordsToCronCommand(string $cronCommand): string
{
switch ($cronCommand) {
case '@yearly':
case '@annually':
$cronCommand = '0 0 1 1 *';
break;
case '@monthly':
$cronCommand = '0 0 1 * *';
break;
case '@weekly':
$cronCommand = '0 0 * * 0';
break;
case '@daily':
case '@midnight':
$cronCommand = '0 0 * * *';
break;
case '@hourly':
$cronCommand = '0 * * * *';
break;
}
return $cronCommand;
}
/**
* Normalize cron command field to list of integers or *
*/
protected static function normalizeFields(string $cronCommand): string
{
$fieldArray = self::splitFields($cronCommand);
$fieldArray[0] = self::normalizeIntegerField($fieldArray[0]);
$fieldArray[1] = self::normalizeIntegerField($fieldArray[1], 0, 23);
$fieldArray[2] = self::normalizeIntegerField($fieldArray[2], 1, 31);
$fieldArray[3] = self::normalizeMonthAndWeekdayField($fieldArray[3], true);
$fieldArray[4] = self::normalizeMonthAndWeekdayField($fieldArray[4], false);
return implode(' ', $fieldArray);
}
/**
* Split a given cron command like '23 * * * *' to an array with five fields.
*
* @throws \InvalidArgumentException If splitted array does not contain five entries
*/
protected static function splitFields(string $cronCommand): array
{
$fields = explode(' ', $cronCommand);
if (count($fields) !== 5) {
throw new \InvalidArgumentException('Unable to split given cron command to five fields.', 1291227373);
}
return $fields;
}
/**
* Normalize month field.
*/
protected static function normalizeMonthAndWeekdayField(string $expression, bool $isMonthField = true): string
{
if ((string)$expression === '*') {
$fieldValues = '*';
} else {
// Fragment expression by , / and - and substitute three letter code of month and weekday to numbers
$listOfCommaValues = explode(',', $expression);
$fieldArray = [];
foreach ($listOfCommaValues as $listElement) {
if (str_contains($listElement, '/')) {
[$left, $right] = explode('/', $listElement);
if (str_contains($left, '-')) {
[$leftBound, $rightBound] = explode('-', $left);
$leftBound = self::normalizeMonthAndWeekday($leftBound, $isMonthField);
$rightBound = self::normalizeMonthAndWeekday($rightBound, $isMonthField);
$left = $leftBound . '-' . $rightBound;
} else {
if ((string)$left !== '*') {
$left = self::normalizeMonthAndWeekday($left, $isMonthField);
}
}
$fieldArray[] = $left . '/' . $right;
} elseif (str_contains($listElement, '-')) {
[$left, $right] = explode('-', $listElement);
$left = self::normalizeMonthAndWeekday($left, $isMonthField);
$right = self::normalizeMonthAndWeekday($right, $isMonthField);
$fieldArray[] = $left . '-' . $right;
} else {
$fieldArray[] = self::normalizeMonthAndWeekday($listElement, $isMonthField);
}
}
$fieldValues = implode(',', $fieldArray);
}
return $isMonthField ? self::normalizeIntegerField($fieldValues, 1, 12) : self::normalizeIntegerField($fieldValues, 1, 7);
}
/**
* Normalize integer field.
*
* @throws \InvalidArgumentException If field is invalid or out of bounds
*/
protected static function normalizeIntegerField(string $expression, int $lowerBound = 0, int $upperBound = 59): string
{
if ($expression === '*') {
$fieldValues = '*';
} else {
$listOfCommaValues = explode(',', $expression);
$fieldArray = [];
foreach ($listOfCommaValues as $listElement) {
if (str_contains($listElement, '/')) {
[$left, $right] = explode('/', $listElement);
if ($left === '*') {
$leftList = self::convertRangeToListOfValues($lowerBound . '-' . $upperBound);
} else {
$leftList = self::convertRangeToListOfValues($left);
}
$fieldArray[] = self::reduceListOfValuesByStepValue($leftList . '/' . $right);
} elseif (str_contains($listElement, '-')) {
$fieldArray[] = self::convertRangeToListOfValues($listElement);
} elseif (MathUtility::canBeInterpretedAsInteger($listElement)) {
$fieldArray[] = $listElement;
} elseif (strlen($listElement) === 2 && $listElement[0] === '0') {
$fieldArray[] = (int)$listElement;
} else {
throw new \InvalidArgumentException('Unable to normalize integer field.', 1291429389);
}
}
$fieldValues = implode(',', $fieldArray);
}
if ($fieldValues !== '*') {
$fieldList = explode(',', $fieldValues);
sort($fieldList);
$fieldList = array_unique($fieldList);
if (current($fieldList) < $lowerBound) {
throw new \InvalidArgumentException('Lowest element in list is smaller than allowed.', 1291470084);
}
if (end($fieldList) > $upperBound) {
throw new \InvalidArgumentException('An element in the list is higher than allowed.', 1291470170);
}
$fieldValues = implode(',', $fieldList);
}
return $fieldValues;
}
/**
* Convert a range of integers to a list: 4-6 results in a string '4,5,6'
*
* @param string $range integer-integer
* @throws \InvalidArgumentException If range can not be converted to list
*/
protected static function convertRangeToListOfValues(string $range): string
{
if ($range === '') {
throw new \InvalidArgumentException('Unable to convert range to list of values with empty string.', 1291234985);
}
$rangeArray = explode('-', $range);
// Sanitize fields and cast to integer
foreach ($rangeArray as $fieldNumber => $fieldValue) {
if (!MathUtility::canBeInterpretedAsInteger($fieldValue)) {
throw new \InvalidArgumentException('Unable to convert value to integer.', 1291237668);
}
$rangeArray[$fieldNumber] = (int)$fieldValue;
}
$rangeArrayCount = count($rangeArray);
if ($rangeArrayCount === 1) {
$resultList = $rangeArray[0];
} elseif ($rangeArrayCount === 2) {
$left = $rangeArray[0];
$right = $rangeArray[1];
if ($left > $right) {
throw new \InvalidArgumentException('Unable to convert range to list: Left integer must not be greater than right integer.', 1291237145);
}
$resultListArray = [];
for ($i = $left; $i <= $right; $i++) {
$resultListArray[] = $i;
}
$resultList = implode(',', $resultListArray);
} else {
throw new \InvalidArgumentException('Unable to convert range to list of values.', 1291234986);
}
return (string)$resultList;
}
/**
* Reduce a given list of values by step value.
* Following a range with ``/<number>'' specifies skips of the number's value through the range.
* 1-5/2 -> 1,3,5
* 2-10/3 -> 2,5,8
*
* @return string comma-separated list of valid values
* @throws \InvalidArgumentException if step value is invalid or if resulting list is empty
*/
protected static function reduceListOfValuesByStepValue(string $stepExpression): string
{
if ($stepExpression === '') {
throw new \InvalidArgumentException('Unable to convert step values.', 1291234987);
}
$stepValuesAndStepArray = explode('/', $stepExpression);
$stepValuesAndStepArrayCount = count($stepValuesAndStepArray);
if ($stepValuesAndStepArrayCount > 2) {
throw new \InvalidArgumentException('Unable to convert step values: Multiple slashes found.', 1291242168);
}
$left = $stepValuesAndStepArray[0];
$right = $stepValuesAndStepArray[1] ?? '';
if ($left === '') {
throw new \InvalidArgumentException('Unable to convert step values: Left part of / is empty.', 1291414955);
}
if ($right === '') {
throw new \InvalidArgumentException('Unable to convert step values: Right part of / is empty.', 1291414956);
}
if (!MathUtility::canBeInterpretedAsInteger($right)) {
throw new \InvalidArgumentException('Unable to convert step values: Right part must be a single integer.', 1291414957);
}
$right = (int)$right;
$leftArray = explode(',', $left);
$validValues = [];
$currentStep = $right;
foreach ($leftArray as $leftValue) {
if (!MathUtility::canBeInterpretedAsInteger($leftValue)) {
throw new \InvalidArgumentException('Unable to convert step values: Left part must be a single integer or comma separated list of integers.', 1291414958);
}
if ($currentStep === 0) {
$currentStep = $right;
}
if ($currentStep === $right) {
$validValues[] = (int)$leftValue;
}
$currentStep--;
}
if (empty($validValues)) {
throw new \InvalidArgumentException('Unable to convert step values: Result value list is empty.', 1291414959);
}
return implode(',', $validValues);
}
/**
* Dispatcher method for normalizeMonth and normalizeWeekday
*/
protected static function normalizeMonthAndWeekday(string $expression, bool $isMonth = true): string
{
$expression = $isMonth ? self::normalizeMonth($expression) : self::normalizeWeekday($expression);
return (string)$expression;
}
/**
* Accept a string representation or integer number of a month like
* 'jan', 'February', 01, ... and convert to normalized integer value between 1 and 12
*
* @throws \InvalidArgumentException If month string can not be converted to integer
*/
protected static function normalizeMonth(string $month): int
{
$timestamp = strtotime('2010-' . $month . '-01');
// timestamp must be >= 2010-01-01 and <= 2010-12-01
if (!$timestamp || $timestamp < strtotime('2010-01-01') || $timestamp > strtotime('2010-12-01')) {
throw new \InvalidArgumentException('Unable to convert given month name.', 1291083486);
}
return (int)date('n', $timestamp);
}
/**
* Accept a string representation or integer number of a weekday like
* 'mon', 'Friday', 3, ... and convert to normalized integer value between 1 and 7
*
* @throws \InvalidArgumentException If weekday string can not be converted
*/
protected static function normalizeWeekday(string $weekday): int
{
$normalizedWeekday = false;
// 0 (sunday) -> 7
if ($weekday === '0') {
$weekday = 7;
}
if ($weekday >= 1 && $weekday <= 7) {
$normalizedWeekday = (int)$weekday;
}
if (!$normalizedWeekday) {
// Convert string representation like 'sun' to integer
$timestamp = strtotime('next ' . $weekday, (int)mktime(0, 0, 0, 1, 1, 2010));
if (!$timestamp || $timestamp < strtotime('2010-01-01') || $timestamp > strtotime('2010-01-08')) {
throw new \InvalidArgumentException('Unable to convert given weekday name.', 1291163589);
}
$normalizedWeekday = (int)date('N', $timestamp);
}
return $normalizedWeekday;
}
}
@@ -0,0 +1,682 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Domain\Repository;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\CommandLineUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\Field\StaticSelectFieldType;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\ProgressProviderInterface;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Task\TaskSerializer;
use TYPO3\CMS\Scheduler\Task\TaskStatus;
use TYPO3\CMS\Scheduler\Validation\Validator\TaskValidator;
/**
* Repository class to fetch tasks available in the systems ready to be executed
*
* @internal not part of public TYPO3 Core API
*/
#[Autoconfigure(public: true)]
readonly class SchedulerTaskRepository
{
protected const TABLE_NAME = 'tx_scheduler_task';
public function __construct(
protected TaskSerializer $taskSerializer,
protected TaskService $taskService,
protected TcaSchemaFactory $tcaSchemaFactory,
protected Context $context,
protected ConnectionPool $connectionPool,
) {}
/**
* Adds a task to the pool.
*
* @param AbstractTask $task The object representing the task to add
* @return bool TRUE if the task was successfully added, FALSE otherwise
*/
public function add(AbstractTask $task): bool
{
$taskUid = $task->getTaskUid();
if (!empty($taskUid)) {
return false;
}
$fields = $this->taskService->getFieldsForRecord($task);
$fields['pid'] = 0;
$newId = uniqid('NEW');
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
self::TABLE_NAME => [
$newId => $fields,
],
], []);
$dataHandler->process_datamap();
$taskUid = (int)$dataHandler->substNEWwithIDs[$newId];
if ($taskUid) {
$task->setTaskUid($taskUid);
return true;
}
return false;
}
/**
* Removes a task completely from the system.
*
* @param int|AbstractTask $task The object representing the task to delete
* @return bool TRUE if the task was successfully deleted, FALSE otherwise
*/
public function remove(int|AbstractTask $task): bool
{
$taskUid = is_int($task) ? $task : $task->getTaskUid();
if (empty($taskUid)) {
return false;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], [
self::TABLE_NAME => [
$taskUid => [
'delete' => 1,
],
],
]);
$dataHandler->process_cmdmap();
return $dataHandler->errorLog === [];
}
/**
* Update a task in the pool.
*/
public function update(AbstractTask $task, ?array $fields = null): bool
{
$taskUid = $task->getTaskUid();
if (empty($taskUid)) {
return false;
}
$fields = $fields ?? $this->taskService->getFieldsForRecord($task);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
self::TABLE_NAME => [
$taskUid => $fields,
],
], []);
$dataHandler->process_datamap();
return true;
}
/**
* Update a task in the pool but only the execution information.
*/
public function updateExecution(AbstractTask $task, bool $forceDisablingTask = false): void
{
$taskUid = $task->getTaskUid();
if (empty($taskUid)) {
return;
}
$fields = $this->taskService->getFieldsForRecord($task);
$fields = [
'nextexecution' => $fields['nextexecution'],
'disable' => $forceDisablingTask ? true : $fields['disable'],
'execution_details' => $fields['execution_details'],
];
$backendUser = $GLOBALS['BE_USER'] ?? null;
if ($backendUser === null && Environment::isCli()) {
/** @var CommandLineUserAuthentication $backendUser */
$backendUser = Bootstrap::initializeBackendUser(CommandLineUserAuthentication::class);
$backendUser->authenticate();
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
// We don't want every execution information logged
$dataHandler->enableLogging = false;
$dataHandler->start([
self::TABLE_NAME => [
$taskUid => $fields,
],
], [], $backendUser);
$dataHandler->process_datamap();
}
/**
* Fetches a task object from the db with the given $uid. The object representing
* the next due task is returned.
* If there are no tasks due, the method throws an exception.
*
* @param int $uid Primary key of a task
* @throws \OutOfBoundsException
* @throws \UnexpectedValueException
*/
public function findByUid(int $uid): AbstractTask
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $uid);
if (empty($row)) {
// Although an uid was passed, no task with given was found
throw new \OutOfBoundsException('No task with id ' . $uid . ' found', 1422044826);
}
return $this->createValidTaskObjectOrDisableTask($row);
}
/**
* Fetches the DB record for a given task UID.
*
* @param int $uid Primary key of the task to get
* @return array|null Database record for the task
* @see findByUid()
*/
public function findRecordByUid(int $uid): ?array
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $uid);
if (empty($row)) {
return null;
}
return $row;
}
/**
* Fetch and unserialize a task object from the db. Returns the object representing the
* next due task is returned. If there are no due tasks, the method throws an exception.
*
* @throws \UnexpectedValueException
*/
public function findNextExecutableTask(): ?AbstractTask
{
// If no uid is given, take any non-disabled task that has a next execution time in the past
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->select(
't.*'
)
->from(self::TABLE_NAME, 't')
->setMaxResults(1);
// Define where clause
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->leftJoin(
't',
'tx_scheduler_task_group',
'g',
$queryBuilder->expr()->eq('g.uid', $queryBuilder->expr()->castInt($queryBuilder->quoteIdentifier('t.task_group')))
);
$queryBuilder->where(
$queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->neq(
't.nextexecution',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
),
$queryBuilder->expr()->lte(
't.nextexecution',
$queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT)
),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->isNull('g.hidden')
),
$queryBuilder->expr()->eq('t.deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
);
$queryBuilder->orderBy('t.priority', 'DESC')->addOrderBy('t.nextexecution', 'ASC');
$row = $queryBuilder->executeQuery()->fetchAssociative();
if (empty($row)) {
return null;
}
return $this->createValidTaskObjectOrDisableTask($row);
}
/**
* @todo This will get split up into errored classes
*/
public function getGroupedTasks(): array
{
// Get all registered tasks
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()->removeAll();
$result = $queryBuilder->select('t.*')
->addSelect(
'g.groupName AS taskGroupName',
'g.description AS taskGroupDescription',
'g.uid AS taskGroupId',
'g.color AS taskGroupColor',
'g.deleted AS isTaskGroupDeleted',
'g.hidden AS isTaskGroupHidden',
)
->from(self::TABLE_NAME, 't')
->leftJoin(
't',
'tx_scheduler_task_group',
'g',
$queryBuilder->expr()->eq('g.uid', $queryBuilder->expr()->castInt($queryBuilder->quoteIdentifier('t.task_group')))
)
->where(
$queryBuilder->expr()->eq('t.deleted', 0)
)
->orderBy('g.sorting')
->executeQuery();
$taskGroupsWithTasks = [];
$errorClasses = [];
while ($row = $result->fetchAssociative()) {
$taskData = [
'uid' => (int)$row['uid'],
'lastExecutionTime' => (int)$row['lastexecution_time'],
'lastExecutionContext' => $row['lastexecution_context'],
'errorMessage' => '',
'description' => $row['description'],
];
try {
$taskObject = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException $e) {
$taskData['errorMessage'] = $e->getMessage();
$taskData['taskType'] = $row['tasktype'] ?: $this->taskSerializer->extractClassName($row['serialized_task_object']);
$errorClasses[] = $taskData;
continue;
}
$taskData['taskType'] = $taskObject->getTaskType();
if (!$this->isValidTaskObject($taskObject)) {
$taskData['errorMessage'] = 'The task "' . $taskObject->getTaskType() . ' is not a valid task';
$errorClasses[] = $taskData;
continue;
}
$taskInformation = $this->taskService->getTaskDetailsFromTask($taskObject);
if ($taskInformation === null) {
$taskData['errorMessage'] = 'The task ' . $taskObject->getTaskType() . ' is not a registered task';
$errorClasses[] = $taskData;
continue;
}
if ($taskObject instanceof ProgressProviderInterface) {
$taskData['progress'] = round((float)$taskObject->getProgress(), 2);
}
$taskData['fullTitle'] = $taskInformation['fullTitle'];
$taskData['additionalInformation'] = $taskObject->getAdditionalInformation();
$taskData['disabled'] = (bool)$row['disable'];
$taskData['isRunning'] = !empty($row['serialized_executions']);
$taskData['nextExecution'] = (int)$row['nextexecution'];
$taskData['runningType'] = 'single';
$taskData['frequency'] = '';
if ($taskObject->getExecution()->isRecurring()) {
$taskData['runningType'] = 'recurring';
$taskData['frequency'] = $taskObject->getExecution()->getCronCmd() ?: $taskObject->getExecution()->getInterval();
}
$taskData['multiple'] = (bool)$taskObject->getExecution()->isParallelExecutionAllowed();
$taskData['priority'] = (int)$row['priority'];
$taskData['priorityLabel'] = $this->resolvePriorityLabel((int)$row['priority']);
$taskData['lastExecutionFailure'] = false;
if (!empty($row['lastexecution_failure'])) {
$taskData['lastExecutionFailure'] = true;
// only scalars are serialized in \TYPO3\CMS\Scheduler\Scheduler::executeTask
$exceptionArray = @unserialize($row['lastexecution_failure'], ['allowed_classes' => false]);
$taskData['lastExecutionFailureCode'] = '';
$taskData['lastExecutionFailureMessage'] = '';
if (is_array($exceptionArray)) {
$taskData['lastExecutionFailureCode'] = $exceptionArray['code'];
$taskData['lastExecutionFailureMessage'] = $exceptionArray['message'];
}
}
$taskData['statuses'] = $this->buildTaskStatuses($taskData, (bool)$row['isTaskGroupHidden']);
// If a group is deleted or no group is set it needs to go into "not assigned groups"
$groupIndex = $row['isTaskGroupDeleted'] === 1 || $row['isTaskGroupDeleted'] === null ? 0 : (int)$row['task_group'];
if (!isset($taskGroupsWithTasks[$groupIndex])) {
$taskGroupsWithTasks[$groupIndex] = [
'uid' => $row['taskGroupId'],
'groupName' => $row['taskGroupName'],
'description' => $row['taskGroupDescription'],
'color' => $row['taskGroupColor'],
'hidden' => $row['isTaskGroupHidden'],
'tasks' => [],
];
}
$taskGroupsWithTasks[$groupIndex]['tasks'][] = $taskData;
}
return [
'taskGroupsWithTasks' => $taskGroupsWithTasks,
'errorClasses' => $errorClasses,
];
}
protected function createValidTaskObjectOrDisableTask(array $row): AbstractTask
{
$isInvalidTask = false;
$task = null;
try {
$task = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException) {
$isInvalidTask = true;
}
if ($isInvalidTask || !$this->isValidTaskObject($task)) {
$fieldName = $this->tcaSchemaFactory
->get(self::TABLE_NAME)
->getCapability(TcaSchemaCapability::RestrictionDisabledField)
->getFieldName();
if ((bool)$row[$fieldName] !== true) {
// Forcibly set the disabled flag to 1 in the database (if not already set), so that the
// task does not come up again and again for execution. Execute a simple update statement
// to avoid triggering any DH hook again, which would lead to an infinity loop.
$this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(self::TABLE_NAME, [$fieldName => 1], ['uid' => (int)$row['uid']]);
}
// Throw an exception to raise the problem
// @todo: This should most likely be changed to a specific exception.
throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
}
// The task is valid, return it
if ($task->getTaskGroup() === null) {
// Fix invalid task_group=NULL settings in order to avoid exceptions when saving on PostgreSQL
$task->setTaskGroup(0);
}
return $task;
}
/**
* Fetch and unserialize task objects selected with some (SQL) condition
*/
public function findNextExecutableTaskForUid(int $uid): ?AbstractTask
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(HiddenRestriction::class));
$queryBuilder
->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)),
$queryBuilder->expr()->neq('nextexecution', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->lte('nextexecution', $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT)),
);
$result = $queryBuilder->executeQuery();
while ($row = $result->fetchAssociative()) {
try {
$task = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException) {
continue;
}
// Add the task to the list only if it is valid
if ($this->isValidTaskObject($task)) {
return $task;
}
}
return null;
}
public function isTaskMarkedAsRunning(AbstractTask $task): bool
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid());
return !empty($row['serialized_executions'] ?? null);
}
/**
* This method adds current execution to the execution list.
* It also logs the execution time and mode
*
* The execution id is guaranteed to start from zero if the task has no
* currently running execution at the time of id allocation.
*
* @return int Execution id
*/
public function addExecutionToTask(AbstractTask $task): int
{
while (true) {
$row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid());
if ($row === null) {
throw new \InvalidArgumentException(
'Given task must have a persistence record associated with it',
1741257045
);
}
$previousExecutions = isset($row['serialized_executions'])
? (string)$row['serialized_executions']
: null;
$runningExecutions = $previousExecutions !== null
&& $previousExecutions !== ''
// serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>`
? unserialize($previousExecutions, ['allowed_classes' => false])
: [];
// Count the number of existing executions and use that number as a key
// (we need to know that number, because it is returned at the end of the method)
$numExecutions = count($runningExecutions);
$runningExecutions[$numExecutions] = time();
$updateCount = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
[
'serialized_executions' => serialize($runningExecutions),
'lastexecution_time' => time(),
// Define the context in which the script is running
'lastexecution_context' => Environment::isCli() ? 'CLI' : 'BE',
],
[
'uid' => $task->getTaskUid(),
'serialized_executions' => $previousExecutions,
],
[
'serialized_executions' => Connection::PARAM_LOB,
]
);
if ($updateCount === 1) {
return $numExecutions;
}
}
}
/**
* Removes a given execution from the list
*
* @param int $executionID Id of the execution to remove.
* @param string|array|null $failureReason Details of an exception to signal a failed execution.
*/
public function removeExecutionOfTask(AbstractTask $task, int $executionID, array|string|null $failureReason = null): void
{
while ($row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid())) {
$previousExecutions = (string)($row['serialized_executions'] ?? '');
if ($previousExecutions === '') {
break;
}
// serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>`
$runningExecutions = unserialize($previousExecutions, ['allowed_classes' => false]);
// Remove the selected execution
unset($runningExecutions[$executionID]);
if (!empty($runningExecutions)) {
// Re-serialize the updated executions list (if necessary)
$runningExecutionsSerialized = serialize($runningExecutions);
} else {
$runningExecutionsSerialized = '';
}
if (is_array($failureReason)) {
$failureReason = json_encode($failureReason);
}
// Save the updated executions list
$fieldUpdates = [
'serialized_executions' => $runningExecutionsSerialized,
];
if ($failureReason !== null) {
$fieldUpdates['lastexecution_failure'] = (string)$failureReason;
}
$updateCount = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
$fieldUpdates,
[
'uid' => $task->getTaskUid(),
'serialized_executions' => $previousExecutions,
],
[
'serialized_executions' => Connection::PARAM_LOB,
]
);
if ($updateCount === 1) {
break;
}
}
}
/**
* Clears all marked executions
*
* @return bool TRUE if the clearing succeeded, FALSE otherwise
*/
public function removeAllRegisteredExecutionsForTask(AbstractTask $task): bool
{
// Set the serialized executions field to empty
$result = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
['serialized_executions' => ''],
['uid' => $task->getTaskUid()],
['serialized_executions' => Connection::PARAM_LOB]
);
return (bool)$result;
}
/**
* See if there are any tasks configured at all.
*/
public function hasTasks(): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$queryBuilder
->count('*')
->from(self::TABLE_NAME);
return $queryBuilder->executeQuery()->fetchOne() > 0;
}
protected function isValidTaskObject($task): bool
{
return (new TaskValidator())->isValid($task);
}
/**
* Resolves the status flags of a single task into an ordered list, shared as a single
* source of truth by the backend module listing and the "scheduler:list" CLI command.
*
* @return list<TaskStatus>
*/
private function buildTaskStatuses(array $task, bool $groupHidden): array
{
$now = $this->context->getAspect('date')->get('timestamp');
$statuses = [];
if ($task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'running',
severity: ContextualFeedbackSeverity::INFO,
state: 'running',
label: 'scheduler.messages:status.running',
);
}
if ($task['nextExecution'] && $task['nextExecution'] < $now && !$groupHidden && !$task['disabled']) {
$statuses[] = new TaskStatus(
type: 'late',
severity: ContextualFeedbackSeverity::WARNING,
state: 'warning',
label: 'scheduler.messages:status.late',
);
}
if ($task['disabled'] && !$task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'disabled',
severity: ContextualFeedbackSeverity::NOTICE,
state: 'disabled',
label: 'scheduler.messages:status.disabled',
);
}
if ($groupHidden && !$task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'disabledByGroup',
severity: ContextualFeedbackSeverity::NOTICE,
state: 'disabled',
label: 'scheduler.messages:status.disabledByGroup',
);
}
if ($task['lastExecutionFailure'] ?? false) {
if (($task['lastExecutionFailureMessage'] ?? '') !== '') {
$statuses[] = new TaskStatus(
type: 'failure',
severity: ContextualFeedbackSeverity::ERROR,
state: 'danger',
label: 'scheduler.messages:status.failure',
message: 'scheduler.messages:msg.executionFailureReport',
messageArguments: [
$task['lastExecutionFailureCode'],
$task['lastExecutionFailureMessage'],
],
);
} else {
$statuses[] = new TaskStatus(
type: 'failure',
severity: ContextualFeedbackSeverity::ERROR,
state: 'default',
label: 'scheduler.messages:status.failure',
message: 'scheduler.messages:msg.executionFailureDefault',
);
}
}
return $statuses;
}
private function resolvePriorityLabel(int $priority): string
{
$field = $this->tcaSchemaFactory->get(self::TABLE_NAME)->getField('priority');
if ($field instanceof StaticSelectFieldType) {
foreach ($field->getItems() as $item) {
if ((int)$item->getValue() === $priority) {
return $item->getLabel();
}
}
}
return (string)$priority;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Event;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
/**
* Listeners to this event receive information about a completed scheduler task execution,
* including its success state and any thrown exception.
*/
final readonly class AfterTaskExecutionEvent
{
public function __construct(
private AbstractTask $task,
private bool $success,
private ?\Throwable $exception = null,
) {}
public function getTask(): AbstractTask
{
return $this->task;
}
public function isSuccess(): bool
{
return $this->success;
}
public function getException(): ?\Throwable
{
return $this->exception;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this event are able to modify the scheduler task items for the new task wizard
*/
final class ModifyNewSchedulerTaskWizardItemsEvent
{
public function __construct(
private array $wizardItems,
private readonly ServerRequestInterface $request,
) {}
public function getWizardItems(): array
{
return $this->wizardItems;
}
public function setWizardItems(array $wizardItems): void
{
$this->wizardItems = $wizardItems;
}
public function addWizardItem(string $key, array $wizardItem): void
{
$this->wizardItems[$key] = $wizardItem;
}
public function removeWizardItem(string $key): void
{
unset($this->wizardItems[$key]);
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Configuration\Event\BeforeTcaOverridesEvent;
use TYPO3\CMS\Core\Console\CommandRegistry;
use TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask;
final readonly class AddSchedulableCommandsAsNativeTaskTypes
{
public function __construct(
private CommandRegistry $commandRegistry,
) {}
#[AsEventListener]
public function __invoke(BeforeTcaOverridesEvent $event): void
{
$tca = $event->getTca();
foreach ($this->commandRegistry->getSchedulableCommandsConfiguration() as $commandIdentifier => $commandConfiguration) {
if (($commandConfiguration['aliasFor'] ?? '') !== '') {
// If an alias is set, we need to filter out the alias to prevent duplicate scheduler items.
continue;
}
$tca['tx_scheduler_task']['columns']['tasktype']['config']['items'][] = [
'label' => $commandConfiguration['name'],
'description' => $commandConfiguration['description'],
'value' => $commandIdentifier,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => explode(':', $commandIdentifier)[0],
];
$tca['tx_scheduler_task']['types'][$commandIdentifier] = [
'showitem' => '
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
parameters,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,
',
'columnsOverrides' => [
'parameters' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.schedulableCommand.command_configuration',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.schedulableCommand.command_configuration.description',
'config' => [
'renderType' => 'schedulableCommandConfiguration',
],
],
],
'taskOptions' => [
// @todo When introducing the AsRecordTypeHandler attribute we can get rid of this option again
'className' => ExecuteSchedulableCommandTask::class,
],
];
}
$event->setTca($tca);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\EventListener;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Listener replaces the "New" button of FormEngine with the button to open the scheduler task wizard
*/
final readonly class ReplaceAddNewButtonToFormEngine
{
public function __construct(
private IconFactory $iconFactory,
private PageRenderer $pageRenderer,
private UriBuilder $uriBuilder,
private ComponentFactory $componentFactory,
) {}
#[AsEventListener]
public function __invoke(ModifyButtonBarEvent $event): void
{
$request = $event->getRequest();
if (($request->getAttribute('routing')?->getRoute()?->getOptions()['_identifier'] ?? '') !== 'record_edit') {
return;
}
$editConfig = $request->getQueryParams()['edit'] ?? null;
if (!is_array($editConfig) || $editConfig === [] || count($editConfig) > 1 || key($editConfig) !== 'tx_scheduler_task') {
return;
}
$buttons = $event->getButtons();
$leftButtons = $buttons['left'] ?? [];
$this->pageRenderer->loadJavaScriptModule('@typo3/scheduler/new-scheduler-task-wizard-button.js');
$addTaskUrl = (string)$this->uriBuilder->buildUriFromRoute('ajax_new_scheduler_task_wizard', [
'returnUrl' => GeneralUtility::sanitizeLocalUrl($request->getQueryParams()['returnUrl'] ?? '', $request) ?: $request->getAttribute('normalizedParams')->getRequestUri(),
]);
$languageService = $this->getLanguageService();
$newButton = $this->componentFactory->createGenericButton()
->setTag('typo3-scheduler-new-task-wizard-button')
->setIcon($this->iconFactory->getIcon('actions-plus', IconSize::SMALL))
->setLabel($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'))
->setShowLabelText(true)
->setAttributes([
'url' => $addTaskUrl,
'subject' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add'),
]);
// Find and replace t3js-editform-new button
// By replacing the existing button we ensure to respect TSconfig and that user has necessary permissions
foreach ($leftButtons as $groupIndex => $buttonGroup) {
foreach ($buttonGroup as $buttonIndex => $button) {
if (method_exists($button, 'getClasses') && str_contains($button->getClasses(), 't3js-editform-new')) {
$leftButtons[$groupIndex][$buttonIndex] = $newButton;
}
}
}
$buttons['left'] = $leftButtons;
$event->setButtons($buttons);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler;
use TYPO3\CMS\Core\Exception as CoreException;
/**
* A generic scheduler exception
*/
class Exception extends CoreException {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Exception;
use TYPO3\CMS\Scheduler\Exception;
/**
* Thrown if a submitted date can not be converted to timestamp using the backend module.
*/
class InvalidDateException extends Exception {}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Exception;
use TYPO3\CMS\Scheduler\Exception;
/**
* Thrown if a Task could not be successfully unserialized, the unserialized
* Task is not an instance of AbstractTask or is not registered at all.
*/
class InvalidTaskException extends Exception {}
+371
View File
@@ -0,0 +1,371 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\CronCommand\CronCommand;
/**
* This class manages the logic of a particular execution of a task
* @internal
*/
class Execution
{
/**
* Start date of a task (timestamp)
*
* @var int
*/
protected $start;
/**
* End date of a task (timestamp)
*
* @var int
*/
protected $end;
/**
* Interval between executions (in seconds)
*
* @var int
*/
protected $interval;
/**
* Flag for concurrent executions: TRUE if allowed, FALSE otherwise (default)
*
* @var bool
*/
protected $multiple = false;
/**
* The cron command string of this task,
*
* @var string
*/
protected $cronCmd;
/**
* This flag is used to mark a new single execution
* See explanations in method setIsNewSingleExecution()
*
* @var bool
* @see \TYPO3\CMS\Scheduler\Execution::setIsNewSingleExecution()
*/
protected $isNewSingleExecution = false;
public static function createFromDetails(array $details): self
{
$obj = new self();
$obj->setStart((int)($details['start'] ?? 0));
$obj->setEnd((int)($details['end'] ?? 0));
$obj->setInterval((int)($details['interval'] ?? 0));
$obj->setMultiple((bool)($details['multiple'] ?? false));
$obj->setCronCmd((string)($details['cronCmd'] ?? ''));
$obj->setIsNewSingleExecution((bool)($details['isNewSingleExecution'] ?? false));
return $obj;
}
/**
* Registers a single execution of the task
*
* @param int $timestamp Timestamp of the next execution
*/
public static function createSingleExecution(int $timestamp): self
{
$obj = new self();
$obj->setStart($timestamp);
$obj->setInterval(0);
$obj->setEnd(0);
$obj->setCronCmd('');
$obj->setMultiple(false);
$obj->setIsNewSingleExecution(true);
return $obj;
}
/**
* Registers a recurring execution of the task
*
* @param int $start The first date/time when this execution should occur (timestamp)
* @param int $interval Execution interval in seconds
* @param int $end The last date/time when this execution should occur (timestamp)
* @param bool $multiple Set to FALSE if multiple executions of this task are not permitted in parallel
* @param string $cronCmd Used like in crontab (minute hour day month weekday)
*/
public static function createRecurringExecution(int $start, int $interval, int $end = 0, bool $multiple = false, string $cronCmd = ''): self
{
$obj = new self();
// Set general values
$obj->setStart($start);
$obj->setEnd($end);
$obj->setMultiple($multiple);
if (empty($cronCmd)) {
// Use interval
$obj->setInterval($interval);
$obj->setCronCmd('');
} else {
// Use cron syntax
$obj->setInterval(0);
$obj->setCronCmd($cronCmd);
}
return $obj;
}
/**********************************
* Setters and getters
**********************************/
/**
* This method is used to set the start date
*
* @param int $start Start date (timestamp)
*/
public function setStart($start)
{
$this->start = (int)$start;
}
/**
* This method is used to get the start date
*
* @return int Start date (timestamp)
*/
public function getStart()
{
return (int)$this->start;
}
/**
* This method is used to set the end date
*
* @param int $end End date (timestamp)
*/
public function setEnd($end)
{
$this->end = (int)$end;
}
/**
* This method is used to get the end date
*
* @return int End date (timestamp)
*/
public function getEnd()
{
return (int)$this->end;
}
/**
* This method is used to set the interval
*
* @param int $interval Interval (in seconds)
*/
public function setInterval($interval)
{
$this->interval = (int)$interval;
}
/**
* This method is used to get the interval
*
* @return int Interval (in seconds)
*/
public function getInterval()
{
return (int)$this->interval;
}
/**
* This method is used to set the multiple execution flag
*
* @param bool $multiple TRUE if concurrent executions are allowed, FALSE otherwise
*/
public function setMultiple($multiple)
{
$this->multiple = (bool)$multiple;
}
/**
* This method is used to get the multiple execution flag
*
* @return bool TRUE if concurrent executions are allowed, FALSE otherwise
*/
public function isParallelExecutionAllowed(): bool
{
return (bool)$this->multiple;
}
/**
* Set the value of the cron command
*
* @param string $cmd Cron command, using cron-like syntax
*/
public function setCronCmd($cmd)
{
$this->cronCmd = $cmd;
}
/**
* Get the value of the cron command
*
* @return string Cron command, using cron-like syntax
*/
public function getCronCmd()
{
return $this->cronCmd;
}
/**
* Set whether this is a newly created single execution.
* This is necessary for the following reason: if a new single-running task
* is created and its start date is in the past (even for only a few seconds),
* the next run time calculation (which happens upon saving) will disable
* that task, because it was meant to run only once and is in the past.
* Setting this flag to TRUE preserves this task for a single run.
* Upon next execution, this flag is set to FALSE.
*
* @param bool $isNewSingleExecution Is newly created single execution?
* @see \TYPO3\CMS\Scheduler\Execution::getNextExecution()
*/
public function setIsNewSingleExecution($isNewSingleExecution)
{
$this->isNewSingleExecution = (bool)$isNewSingleExecution;
}
/**
* Get whether this is a newly created single execution
*
* @return bool Is newly created single execution?
*/
public function getIsNewSingleExecution()
{
return (bool)$this->isNewSingleExecution;
}
/**********************************
* Execution calculations and logic
**********************************/
/**
* This method gets or calculates the next execution date
*
* @return int Timestamp of the next execution
* @throws \OutOfBoundsException
*/
public function getNextExecution()
{
if ($this->getIsNewSingleExecution()) {
$this->setIsNewSingleExecution(false);
return $this->getStart();
}
if (!$this->isEnded()) {
// If the schedule has not yet run out, find out the next date
if (!$this->isStarted()) {
// If the schedule hasn't started yet, next date is start date
$date = $this->getStart();
} else {
// If the schedule has already started, calculate next date
if ($this->cronCmd) {
// If it uses cron-like syntax, calculate next date
$date = $this->getNextCronExecution();
} elseif ($this->getInterval() == 0) {
// If not and there's no interval either, it's a singe execution: use start date
$date = $this->getStart();
} else {
// Otherwise calculate date based on interval
$now = time();
$date = $now + $this->getInterval() - ($now - $this->getStart()) % $this->getInterval();
}
// If date is in the future, throw an exception
if (!empty($this->getEnd()) && $date > $this->getEnd()) {
throw new \OutOfBoundsException('Next execution date is past end date.', 1250715528);
}
}
} else {
// The event has ended, throw an exception
throw new \OutOfBoundsException('Task is past end date.', 1250715544);
}
return $date;
}
/**
* Calculates the next execution from a cron command
*
* @return int Next execution (timestamp)
*/
public function getNextCronExecution()
{
$cronCmd = GeneralUtility::makeInstance(CronCommand::class, $this->getCronCmd());
$cronCmd->calculateNextValue();
return (int)$cronCmd->getTimestamp();
}
/**
* Checks if the schedule for a task is started or not
*
* @return bool TRUE if the schedule is already active, FALSE otherwise
*/
public function isStarted()
{
return $this->getStart() < time();
}
/**
* Checks if the schedule for a task is passed or not
*
* @return bool TRUE if the schedule is not active anymore, FALSE otherwise
*/
public function isEnded()
{
if ($this->getEnd() === 0) {
// If no end is defined, the schedule never ends
$result = false;
} else {
// Otherwise check if end is in the past
$result = $this->getEnd() < time();
}
return $result;
}
/**
* Guess recurring type from the existing information
* If an interval or a cron command is defined, it's a recurring task
*/
public function isRecurring(): bool
{
return !empty($this->getInterval()) || !empty($this->getCronCmd());
}
public function isSingleRun(): bool
{
return !$this->isRecurring();
}
public function toArray(): array
{
// The type cast is necessary as long as the DB migration (upgrade wizard) exists,
// Because this way, the serialization (from unserialize()) kicks in
// and cleans the values right away.
// @todo We can then strong-type-hint in TYPO3 v16.0.
return [
'start' => (int)$this->start,
'end' => (int)$this->end,
'interval' => (int)$this->interval,
'multiple' => (bool)$this->multiple,
'cronCmd' => (string)$this->cronCmd,
'isNewSingleExecution' => (bool)$this->isNewSingleExecution,
];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler;
/**
* Failed execution exception
*/
class FailedExecutionException extends \RuntimeException {}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Form\Element;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Core\Resource\Index\ExtractorInterface;
use TYPO3\CMS\Core\Resource\Index\ExtractorRegistry;
/**
* Renders registered extractors
*
* This is rendered for config type=none, renderType=registeredExtractors
*
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
final class RegisteredExtractors extends AbstractFormElement
{
public function __construct(
private readonly ExtractorRegistry $extractorRegistry
) {}
public function render(): array
{
$lang = $this->getLanguageService();
$extractors = $this->extractorRegistry->getExtractors();
if ($extractors !== []) {
$bullets = [];
foreach ($extractors as $extractor) {
$bullets[] = sprintf(
'<li class="list-group-item" title="%s">%s%s</li>',
get_class($extractor),
sprintf(
$lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors.extractor'),
$this->formatExtractorClassName($extractor),
$extractor->getPriority()
),
$this->getBackendUser()->shallDisplayDebugInformation() ? (' <code>[' . get_class($extractor) . ']</code>') : ''
);
}
$html = '
<div class="form-description">' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors.with_extractors')) . '</div>
<ul class="list-group mt-2">' . implode(LF, $bullets) . '</ul>
';
} else {
$html = '<div class="alert alert-warning">' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors.without_extractors')) . '/div>';
}
$resultArray['html'] = '
<fieldset>
<legend class="form-label t3js-formengine-label">
' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors')) . '
</legend>
' . $html . '
</fieldset>
';
return $resultArray;
}
/**
* Since the class name can be very long considering the namespace, only take the final
* part for better readability. The FQN of the class will be displayed as tooltip.
*/
private function formatExtractorClassName(ExtractorInterface $extractor): string
{
$extractorParts = explode('\\', get_class($extractor));
return (string)array_pop($extractorParts);
}
}
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Form\Element;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputDefinition;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Core\Console\CommandRegistry;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask;
/**
* Creates an element and shows the available configuration (arguments and options) for a schedulable commands.
*
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class SchedulableCommandConfigurationElement extends AbstractFormElement
{
public function __construct(
protected readonly TaskService $taskService,
protected readonly CommandRegistry $commandRegistry,
protected readonly SchedulerTaskRepository $taskRepository,
protected readonly ViewFactoryInterface $viewFactory,
) {}
public function render(): array
{
$resultArray = $this->initializeResultArray();
$selectedTaskType = $this->data['databaseRow']['tasktype'][0] ?? '';
if ($selectedTaskType === '') {
return $resultArray;
}
$parameterArray = $this->data['parameterArray'];
$itemName = $parameterArray['itemFormElName'];
try {
$taskObject = $this->taskRepository->findByUid((int)$this->data['databaseRow']['uid']);
} catch (\OutOfBoundsException) {
// This happens for new tasks when 'uid' is set to "0" because we have a Task Type from defVals
try {
$taskObject = $this->taskService->createNewTask($selectedTaskType);
} catch (InvalidTaskException) {
// Given task type is not registered - skip this element
return $resultArray;
}
}
if ($taskObject instanceof ExecuteSchedulableCommandTask === false) {
// Task is not an executable schedulable command task
return $resultArray;
}
try {
$command = $this->commandRegistry->get($selectedTaskType);
} catch (CommandNotFoundException) {
// Command not found
return $resultArray;
}
$argumentFields = $this->getCommandArgumentFields($command->getDefinition(), $taskObject);
$optionFields = $this->getCommandOptionFields($command->getDefinition(), $taskObject);
if ($argumentFields !== [] || $optionFields !== []) {
$fieldInformationResult = $this->renderFieldInformation();
$fieldInformationHtml = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$html = [];
$html[] = '<div class="formengine-field-item t3js-formengine-field-item">';
$html[] = $fieldInformationHtml;
$html[] = '<div class="form-wizards-wrap">';
$html[] = $this->renderCommandConfiguration(array_merge($argumentFields, $optionFields), $selectedTaskType, $itemName);
$html[] = '</div>';
if ($this->data['command'] === 'edit') {
$html[] = $this->getRunOnCliInfo($taskObject, $command);
}
$html[] = '</div>';
$resultArray['html'] = $this->wrapWithFieldsetAndLegend(implode(LF, $html));
}
return $resultArray;
}
protected function getCommandArgumentFields(InputDefinition $inputDefinition, ExecuteSchedulableCommandTask $task): array
{
$fields = [];
$argumentValues = $task->getArguments();
foreach ($inputDefinition->getArguments() as $argument) {
$name = $argument->getName();
$defaultValue = $argument->getDefault();
$task->addDefaultValue($name, $defaultValue);
$value = $argumentValues[$name] ?? $defaultValue;
if (is_array($value) && $argument->isArray()) {
$value = implode(',', $value);
}
$fields['arguments'][$name] = [
'label' => 'Argument "' . $argument->getName() . '"',
'description' => $argument->getDescription(),
'value' => $value,
'required' => $argument->isRequired(),
];
}
return $fields;
}
protected function getCommandOptionFields(InputDefinition $inputDefinition, ExecuteSchedulableCommandTask $task): array
{
$fields = [];
$enabledOptions = $task->getOptions();
$optionValues = $task->getOptionValues();
foreach ($inputDefinition->getOptions() as $option) {
$name = $option->getName();
$defaultValue = $option->getDefault();
$task->addDefaultValue($name, $defaultValue);
$enabled = $enabledOptions[$name] ?? false;
$value = $optionValues[$name] ?? $defaultValue;
if (is_array($value) && $option->isArray()) {
$value = implode(',', $value);
}
$fields['options'][$name] = [
'label' => 'Option "' . $option->getName() . '"',
'description' => $option->getDescription(),
'enabled' => $enabled,
'value' => $value,
'valueOption' => $option->isValueRequired() || $option->isValueOptional() || $option->isArray(),
];
}
return $fields;
}
protected function getRunOnCliInfo(ExecuteSchedulableCommandTask $taskObject, Command $command): string
{
$options = [];
foreach ($taskObject->getOptions() as $name => $enabled) {
if ($enabled) {
$value = $taskObject->getOptionValues()[$name] ?? null;
$options['--' . $name] = ($value === true) ? '' : $value;
}
}
$parameters = array_merge($taskObject->getArguments(), $options);
try {
$input = new ArrayInput($parameters, $command->getDefinition());
$arguments = $input->__toString();
$cliCommand = '<pre class="language-bash mt-2 mb-0"><code class="language-bash">' . $command->getName() . ' ' . $arguments . '</code></pre>';
} catch (RuntimeException|InvalidArgumentException $e) {
$cliCommand = '<div class="badge badge-warning mt-2">' . htmlspecialchars(sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingArguments'), $e->getMessage())) . '</div>';
} catch (InvalidOptionException $e) {
$cliCommand = '<div class="badge badge-warning mt-2">' . htmlspecialchars(sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingOptions'), $e->getMessage())) . '</div>';
}
return '
<div class="card mt-3 mb-0">
<div class="card-header">
<div class="card-header-body">
<h2 class="card-title">' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.runOnCli')) . '</h2>
' . $cliCommand . '
</div>
</div>
</div>
';
}
protected function renderCommandConfiguration(array $fields, string $taskType, string $itemName): string
{
return $this->viewFactory->create(
new ViewFactoryData(
templateRootPaths: ['EXT:scheduler/Resources/Private/Templates'],
partialRootPaths: ['EXT:scheduler/Resources/Private/Partials'],
layoutRootPaths: ['EXT:scheduler/Resources/Private/Layouts'],
request: $this->data['request'],
format: 'html',
)
)->assignMultiple([
'taskType' => $taskType,
'fields' => $fields,
'itemName' => $itemName,
'renderDebug' => $this->getBackendUser()->shallDisplayDebugInformation(),
])->render('CommandConfiguration');
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Form\Element;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class TaskTypeInfoElement extends AbstractFormElement
{
public function __construct(
private readonly TaskService $taskService,
private readonly IconFactory $iconFactory,
) {}
public function render(): array
{
$languageService = $this->getLanguageService();
$resultArray = $this->initializeResultArray();
$parameterArray = $this->data['parameterArray'];
$selectedValue = '';
if (!empty($parameterArray['itemFormElValue'])) {
if (is_array($parameterArray['itemFormElValue'])) {
$selectedValue = (string)$parameterArray['itemFormElValue'][0];
} else {
$selectedValue = (string)$parameterArray['itemFormElValue'];
}
}
$taskDetails = $this->taskService->getTaskDetailsFromTaskType($selectedValue);
if ($taskDetails) {
$resultArray['html'] = '
<div class="card mb-0">
<div class="card-header">
<div class="card-icon">
' . $this->iconFactory->getIcon(($taskDetails['icon'] ?? '') ?: 'mimetypes-x-tx_scheduler_task_group')->render() . '
</div>
<div class="card-header-body">
<h2 class="card-title">' . htmlspecialchars($taskDetails['title']) . '</h2>
<span class="card-subtitle">' . htmlspecialchars($taskDetails['description']) . '</span>
</div>
</div>
</div>
';
} else {
$resultArray['html'] = '<div class="alert alert-warning">' . htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidTaskType')) . ': <code>' . htmlspecialchars($selectedValue) . '</code></div>';
}
$resultArray['html'] .= '<input type="hidden" name="' . $parameterArray['itemFormElName'] . '" value="' . htmlspecialchars($selectedValue) . '" />';
return $resultArray;
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Form\Element;
use TYPO3\CMS\Backend\Form\Element\AbstractFormElement;
use TYPO3\CMS\Backend\Form\Element\CheckboxElement;
use TYPO3\CMS\Backend\Form\Element\DatetimeElement;
use TYPO3\CMS\Backend\Form\Element\InputTextElement;
use TYPO3\CMS\Backend\Form\Element\RadioElement;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewFactoryData;
use TYPO3\CMS\Core\View\ViewFactoryInterface;
use TYPO3\CMS\Scheduler\Execution;
/**
* Creates an element to show a lot of details.
*
* This is rendered for config type=json, renderType=schedulerTimingOptions
*
* @internal This is a specific hook implementation and is not considered part of the Public TYPO3 API.
*/
class TimingOptionsElement extends AbstractFormElement
{
public function __construct(
private readonly ViewFactoryInterface $viewFactory,
private readonly Context $context,
) {}
public function render(): array
{
$languageService = $this->getLanguageService();
$resultArray = $this->initializeResultArray();
$parameterArray = $this->data['parameterArray'];
$itemValue = $parameterArray['itemFormElValue'];
$itemName = $parameterArray['itemFormElName'];
if (is_array($itemValue) && $itemValue !== []) {
$executionDetails = Execution::createFromDetails($itemValue);
} else {
$executionDetails = new Execution();
// Set the default value to "in 5 minutes"
$executionDetails->setStart($this->context->getPropertyFromAspect('date', 'accessTime') + (5 * 60));
}
$fieldsHtml = '';
$runningType = GeneralUtility::makeInstance(RadioElement::class);
$runningType->data = $this->data;
$runningType->data['containerFieldName'] = 'runningType';
$runningType->data['parameterArray']['fieldConf']['label'] = htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:runningType'));
$runningType->data['parameterArray']['itemFormElName'] .= '[runningType]';
$runningType->data['parameterArray']['fieldConf']['config']['items'] = [
['label' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.type.single'), 'value' => 1],
['label' => $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.type.recurring'), 'value' => 2],
];
$runningType->data['parameterArray']['itemFormElValue'] = $executionDetails->isSingleRun() ? 1 : 2;
$runningType->data['parameterArray']['fieldChangeFunc'] = [];
$runningType->data['parameterArray']['fieldConf'] = array_replace_recursive($runningType->data['parameterArray']['fieldConf'], $parameterArray['fieldConf']['config']['overrideFieldTca']['runningType'] ?? []);
$subFieldResult = $runningType->render();
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $subFieldResult['javaScriptModules']);
$fieldsHtml .= '<div class="form-group col-sm-6 t3js-timing-options-runningType">' . str_replace('"form-check"', '"form-check form-inline me-2"', $subFieldResult['html']) . '</div>';
$multiple = GeneralUtility::makeInstance(CheckboxElement::class);
$multiple->data = $this->data;
$multiple->data['containerFieldName'] = 'multiple';
$multiple->data['parameterArray']['fieldConf']['label'] = htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.parallel.long'));
$multiple->data['parameterArray']['itemFormElName'] .= '[multiple]';
$multiple->data['parameterArray']['fieldConf']['config']['items'] = [];
$multiple->data['parameterArray']['fieldChangeFunc'] = [];
$multiple->data['parameterArray']['itemFormElValue'] = $executionDetails->isParallelExecutionAllowed();
$multiple->data['parameterArray']['fieldConf'] = array_replace_recursive($multiple->data['parameterArray']['fieldConf'], $parameterArray['fieldConf']['config']['overrideFieldTca']['multiple'] ?? []);
$subFieldResult = $multiple->render();
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $subFieldResult['javaScriptModules']);
$fieldsHtml .= '<div class="form-group col-sm-6 t3js-timing-options-parallel">' . $subFieldResult['html'] . '</div>';
$start = GeneralUtility::makeInstance(DatetimeElement::class);
$start->data = $this->data;
$start->data['containerFieldName'] = 'start';
$start->data['parameterArray']['fieldConf']['label'] = htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:scheduledFrom'));
$start->data['parameterArray']['itemFormElName'] .= '[start]';
$start->data['parameterArray']['itemFormElValue'] = DateTimeFactory::createFromTimestamp($executionDetails->getStart() ?: $this->context->getPropertyFromAspect('date', 'timestamp'));
$start->data['parameterArray']['fieldConf'] = array_replace_recursive($start->data['parameterArray']['fieldConf'], $parameterArray['fieldConf']['config']['overrideFieldTca']['start'] ?? []);
$subFieldResult = $start->render();
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $subFieldResult['javaScriptModules']);
$fieldsHtml .= '<div class="form-group col-sm-6 t3js-timing-options-start">' . $subFieldResult['html'] . '</div>';
$end = GeneralUtility::makeInstance(DatetimeElement::class);
$end->data = $this->data;
$end->data['containerFieldName'] = 'end';
$end->data['parameterArray']['fieldConf']['label'] = htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:scheduledUntil'));
$end->data['parameterArray']['itemFormElName'] .= '[end]';
$end->data['parameterArray']['itemFormElValue'] = $executionDetails->getEnd() ? DateTimeFactory::createFromTimestamp($executionDetails->getEnd()) : null;
$end->data['parameterArray']['fieldConf'] = array_replace_recursive($end->data['parameterArray']['fieldConf'], $parameterArray['fieldConf']['config']['overrideFieldTca']['end'] ?? []);
$subFieldResult = $end->render();
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $subFieldResult['javaScriptModules']);
$fieldsHtml .= '<div class="form-group col-sm-6 t3js-timing-options-end">' . $subFieldResult['html'] . '</div>';
$frequency = GeneralUtility::makeInstance(InputTextElement::class);
$frequency->data = $this->data;
$frequency->data['containerFieldName'] = 'frequency';
$frequency->data['parameterArray']['fieldConf']['label'] = htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.frequency.long'));
$frequency->data['parameterArray']['itemFormElName'] .= '[frequency]';
$frequency->data['parameterArray']['itemFormElValue'] = $executionDetails->getCronCmd() ?: $executionDetails->getInterval();
$frequency->data['parameterArray']['fieldChangeFunc'] = [];
$frequency->data['parameterArray']['fieldConf']['config']['size'] = 40;
$frequency->data['parameterArray']['fieldConf'] = array_replace_recursive($frequency->data['parameterArray']['fieldConf'], $parameterArray['fieldConf']['config']['overrideFieldTca']['frequency'] ?? []);
$subFieldResult = $frequency->render();
$resultArray['javaScriptModules'] = array_merge($resultArray['javaScriptModules'], $subFieldResult['javaScriptModules']);
$fieldsHtml .= '<div class="form-group t3js-timing-options-frequency">' . $subFieldResult['html'] . '</div>';
$fieldInformationResult = $this->renderFieldInformation();
$fieldInformationHtml = $fieldInformationResult['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$html = [];
$html[] = '<typo3-formengine-element-timing-options class="formengine-field-item t3js-formengine-field-item" fieldPrefix="' . htmlspecialchars($itemName) . '">';
$html[] = $fieldInformationHtml;
$html[] = '<div class="form-control-wrap" style="max-width: ' . $this->formMaxWidth((int)($this->defaultInputWidth * 1.5)) . 'px">';
$html[] = '<div class="form-wizards-wrap">';
$html[] = '<div class="form-wizards-item-element">';
$html[] = '<div class="row">' . $fieldsHtml . $this->renderServerTime() . '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</typo3-formengine-element-timing-options>';
$resultArray['html'] = $this->wrapWithFieldsetAndLegend(implode(LF, $html));
$resultArray['javaScriptModules'][] = JavaScriptModuleInstruction::create('@typo3/scheduler/form-engine/element/timing-options-element.js');
return $resultArray;
}
protected function renderServerTime(): string
{
$view = $this->viewFactory->create(
new ViewFactoryData(
templateRootPaths: ['EXT:scheduler/Resources/Private/Templates'],
partialRootPaths: ['EXT:scheduler/Resources/Private/Partials'],
layoutRootPaths: ['EXT:scheduler/Resources/Private/Layouts'],
request: $this->data['request'],
format: 'html',
)
);
$view->assignMultiple([
'dateFormat' => [
'day' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] ?? 'd-m-y',
'time' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'] ?? 'H:i',
],
]);
return $view->render('ServerTime');
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Form\FieldInformation;
use TYPO3\CMS\Backend\Form\AbstractNode;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask;
/**
* Renders "expiresPeriod" information for the selected table, which is used if nothing is specified for this field manually.
*
* @internal This is a specific scheduler implementation and is not considered part of the Public TYPO3 API.
*/
class ExpirePeriodInformation extends AbstractNode
{
public function render(): array
{
$resultArray = $this->initializeResultArray();
if ($this->data['command'] !== 'edit'
|| $this->data['tableName'] !== 'tx_scheduler_task'
|| (int)($this->data['parameterArray']['itemFormElValue'] ?? 0) > 0
) {
return $resultArray;
}
$refField = (string)($this->data['renderData']['fieldInformationOptions']['refField'] ?? '');
if (($this->data['databaseRow'][$refField] ?? false) === false) {
return $resultArray;
}
$selectedTable = (string)(is_array($this->data['databaseRow'][$refField]) ? $this->data['databaseRow'][$refField][0] : $this->data['databaseRow'][$refField]);
$tableConfiguration = GeneralUtility::makeInstance(TableGarbageCollectionTask::class)->getTableConfiguration()[$selectedTable] ?? [];
if (!isset($tableConfiguration['expirePeriod'])) {
return $resultArray;
}
$resultArray['html'] = '
<div class="badge badge-info mb-2">
' . sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.defaultExpirePeriod'), (int)$tableConfiguration['expirePeriod'], $selectedTable) . '
</div>';
return $resultArray;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Hooks;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Scheduler\CronCommand\NormalizeCommand;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Exception\InvalidDateException;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
/**
* DataHandler hook to validate incoming task parameters and execution_details
* when creating or updating a task.
*
* @internal This is an internal hook implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class SchedulerTaskPersistenceValidator
{
private FlashMessageQueue $flashMessageQueue;
public function __construct(
private TaskService $taskService,
private SchedulerTaskRepository $taskRepository,
FlashMessageService $flashMessageService,
) {
$this->flashMessageQueue = $flashMessageService->getMessageQueueByIdentifier();
}
/**
* If the task is not valid, this hook will create an error log message AND make the incomingFieldArray
* a non-array (e.g. false) to skip saving this record.
*/
public function processDatamap_preProcessFieldArray(&$incomingFieldArray, $table, $id, DataHandler $dataHandler): void
{
if ($table !== 'tx_scheduler_task') {
return;
}
$isNewTask = false;
if (MathUtility::canBeInterpretedAsInteger($id)) {
// Only execution is updated, do not validate anything else
if (count($incomingFieldArray) === 3 && isset($incomingFieldArray['nextexecution'], $incomingFieldArray['disable'], $incomingFieldArray['execution_details'])) {
return;
}
// Update process
$fullRecord = BackendUtility::getRecord($table, $id);
$changedTaskType = ($incomingFieldArray['tasktype'] ?? false) !== ($fullRecord['tasktype'] ?? false);
if (!isset($incomingFieldArray['tasktype'])) {
$taskType = $fullRecord['tasktype'];
} else {
$taskType = $incomingFieldArray['tasktype'];
}
if (!empty($fullRecord['serialized_executions'])) {
// If there's a registered execution, the task should not be edited. May happen if a cron started the task meanwhile.
$this->addErrorMessage($dataHandler, $id, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.maynotEditRunningTask');
}
$task = $this->taskRepository->findByUid((int)$id);
} else {
$isNewTask = true;
$changedTaskType = true;
$taskType = $incomingFieldArray['tasktype'];
try {
$task = $this->taskService->createNewTask($taskType);
} catch (InvalidTaskException $e) {
// Task can not be further processed since task type is not valid
$dataHandler->log('tx_scheduler_task', $id, 1, null, SystemLogErrorClassification::WARNING, 'Task can not be further processed since task type ' . $taskType . ' is not valid');
$incomingFieldArray = false;
return;
}
}
$decodedAndExtractedFieldArray = $this->decodeValues($incomingFieldArray);
if (!$this->isSubmittedTaskDataValid($dataHandler, $id, $decodedAndExtractedFieldArray, $task)) {
// Custom AdditionalFieldProvider may have added error messages via the FlashMessageQueue (as recommended)
// which is needed to render them properly in FormEngine via $dataHandler->printLogErrorMessages();
$this->convertErrorMessagesToDataHandlerLog($dataHandler, $id);
// Setting this to a "non-array" will skip further persistence chain
$incomingFieldArray = false;
return;
}
// Now let's transform our data
$this->setTaskDataFromRequest($task, $decodedAndExtractedFieldArray);
$incomingFieldArray = array_replace_recursive($incomingFieldArray, $this->taskService->getFieldsForRecord($task));
if ($isNewTask) {
$incomingFieldArray['parameters'] = $incomingFieldArray['parameters'] ?? [];
$incomingFieldArray['pid'] = 0;
} elseif ($changedTaskType) {
$incomingFieldArray['parameters'] = [];
$incomingFieldArray['tasktype'] = $taskType;
}
}
private function convertErrorMessagesToDataHandlerLog(DataHandler $dataHandler, string|int $taskId): void
{
$messages = $this->flashMessageQueue->getAllMessagesAndFlush();
foreach ($messages as $message) {
$messageError = match ($message->getSeverity()) {
ContextualFeedbackSeverity::WARNING => SystemLogErrorClassification::WARNING,
ContextualFeedbackSeverity::OK => SystemLogErrorClassification::MESSAGE,
default => SystemLogErrorClassification::USER_ERROR,
};
$dataHandler->log(
'tx_scheduler_task',
$taskId,
MathUtility::canBeInterpretedAsInteger($taskId) ? 2 : 1,
null,
$messageError,
$message->getMessage()
);
}
}
private function addErrorMessage(DataHandler $dataHandler, string|int $taskId, string $message, ...$args): void
{
$languageService = $this->getLanguageService();
$message = $languageService->sL($message);
$dataHandler->log(
'tx_scheduler_task',
$taskId,
MathUtility::canBeInterpretedAsInteger($taskId) ? 2 : 1,
null,
SystemLogErrorClassification::USER_ERROR,
$message,
null,
$args,
0
);
}
private function isSubmittedTaskDataValid(DataHandler $dataHandler, string|int $taskId, array $parsedBody, AbstractTask $task): bool
{
$startTime = $parsedBody['start'] ?? 0;
$endTime = $parsedBody['end'] ?? 0;
$frequency = $parsedBody['frequency'] ?? $parsedBody['cronCmd'] ?? '';
$runningType = (int)($parsedBody['runningType'] ?? ($frequency ? AbstractTask::TYPE_RECURRING : AbstractTask::TYPE_SINGLE));
$result = true;
if ($runningType !== AbstractTask::TYPE_SINGLE && $runningType !== AbstractTask::TYPE_RECURRING) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidRunningType');
}
if (empty($startTime)) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.noStartDate');
} else {
try {
$startTime = $this->getTimestampFromDateString($startTime);
} catch (InvalidDateException) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidStartDate');
}
}
if ($runningType === AbstractTask::TYPE_RECURRING && !empty($endTime)) {
try {
$endTime = $this->getTimestampFromDateString($endTime);
} catch (InvalidDateException) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidStartDate');
}
}
if ($runningType === AbstractTask::TYPE_RECURRING && $endTime > 0 && $endTime < $startTime) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.endDateSmallerThanStartDate');
}
if ($runningType === AbstractTask::TYPE_RECURRING) {
if (empty(trim($frequency))) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.noFrequency');
} elseif (!is_numeric(trim($frequency))) {
try {
NormalizeCommand::normalize(trim($frequency));
} catch (\InvalidArgumentException $e) {
$result = false;
$this->addErrorMessage($dataHandler, $taskId, 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.frequencyError', $e->getMessage(), $e->getCode());
}
}
}
return $result && (!method_exists($task, 'validateTaskParameters') || $task->validateTaskParameters($parsedBody));
}
/**
* Convert input to DateTime and retrieve timestamp.
*
* @throws InvalidDateException
*/
private function getTimestampFromDateString(int|string $input): int
{
if ($input === '' || $input === 0) {
return 0;
}
if (MathUtility::canBeInterpretedAsInteger($input)) {
// Already looks like a timestamp
return (int)$input;
}
try {
// Convert from ISO 8601 dates
$value = (new \DateTime($input))->getTimestamp();
} catch (\Exception $e) {
throw new InvalidDateException($e->getMessage(), 1747813335);
}
return $value;
}
private function decodeValues(array $fieldArray): array
{
foreach (['execution_details', 'parameters'] as $possibleEncodedValueKey) {
$value = $fieldArray[$possibleEncodedValueKey] ?? [];
if (is_string($value) && $value !== '') {
try {
$value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
$fieldArray[$possibleEncodedValueKey] = $value;
} catch (\JsonException) {
// Skip failed decoding
}
}
if (is_array($value) && $value !== []) {
// Extract "values" so additional field providers can directly
// access the values without going via the json field.
$fieldArray = array_merge($value, $fieldArray);
}
}
return $fieldArray;
}
private function setTaskDataFromRequest(AbstractTask $task, array $incomingData): void
{
$endTime = $incomingData['end'] ?? '';
$frequency = $incomingData['frequency'] ?? $incomingData['cronCmd'] ?? '';
$runningType = (int)($incomingData['runningType'] ?? ($frequency ? AbstractTask::TYPE_RECURRING : AbstractTask::TYPE_SINGLE));
if ($runningType === AbstractTask::TYPE_SINGLE) {
$execution = Execution::createSingleExecution($this->getTimestampFromDateString($incomingData['start']));
} else {
$execution = Execution::createRecurringExecution(
$this->getTimestampFromDateString($incomingData['start']),
is_numeric($frequency) ? (int)$frequency : 0,
!empty($endTime) ? $this->getTimestampFromDateString($endTime) : 0,
(bool)($incomingData['multiple'] ?? false),
!is_numeric($frequency) ? $frequency : '',
);
}
$task->setExecution($execution);
$task->setDisabled($incomingData['disable'] ?? false);
$task->setDescription($incomingData['description'] ?? '');
if (str_starts_with((string)($incomingData['task_group'] ?? ''), 'tx_scheduler_task_group_')) {
$incomingData['task_group'] = (int)substr($incomingData['task_group'], 24);
}
$task->setTaskGroup((int)($incomingData['task_group'] ?? 0));
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Migration;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Serializer\DenyListDeserializer;
use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite;
use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask;
use TYPO3\CMS\Scheduler\Task\TaskSerializer;
/**
* @since 14.0
* @internal This class is only meant to be used within EXT:scheduler and is not part of the TYPO3 Core API.
*/
#[UpgradeWizard('schedulerDatabaseStorageMigration')]
class SchedulerDatabaseStorageMigration implements UpgradeWizardInterface
{
protected const TABLE_NAME = 'tx_scheduler_task';
public function __construct(private readonly DenyListDeserializer $deserializer) {}
public function getTitle(): string
{
return 'Migrate the contents of the tx_scheduler_task database table into a more structured form.';
}
public function getDescription(): string
{
return 'Each Scheduler Task was previously stored in a serialized object format in the database. This update wizard migrates records of this type to a JSON-formatted storage in the database. If this wizard does not disappear, it means there are tasks that failed to be migrated and may need manual inspection or re-creation. When this happens, inspect all tasks of tx_scheduler_task where the "tasktype" column is empty.';
}
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
public function updateNecessary(): bool
{
return $this->hasRecordsToUpdate();
}
public function executeUpdate(): bool
{
$connection = $this->getConnectionPool()->getConnectionForTable(self::TABLE_NAME);
$table = $this->getConnectionPool()->getConnectionForTable(self::TABLE_NAME)->createSchemaManager()->introspectSchema()->getTable(self::TABLE_NAME);
$taskSerializer = GeneralUtility::makeInstance(TaskSerializer::class);
$taskService = GeneralUtility::makeInstance(TaskService::class);
$hasFailures = false;
foreach ($this->getRecordsToUpdate() as $record) {
try {
// Base migration was already done, but not the migration to additional fields, so we'll do this now
if (!empty($record['tasktype'])) {
$taskObject = $taskSerializer->deserialize($record);
} else {
// unserialize() will only give a E_NOTICE and false result, not throw an error. Silence this
// (for tests) and operate on the "false". If future PHP promotes this to an exception, the Throwable
// catch will kick in.
$taskObject = $this->deserializer->deserialize($record['serialized_task_object']);
}
if ($taskObject instanceof AbstractTask) {
$fieldsToUpdate = [
'tasktype' => $taskObject->getTaskType(),
'execution_details' => $taskObject->getExecution()?->toArray(),
];
$taskDetails = $taskService->getTaskDetailsFromTask($taskObject);
$taskParameters = $taskObject->getTaskParameters();
if (($taskDetails['isNativeTask'] ?? false) && $taskDetails['className'] !== ExecuteSchedulableCommandTask::class) {
// map native types to real fields, and do not use the parameters' value. Only
// exception to this are console commands, which are native types but use the
// parameters as well, because they have dynamic configuration (arguments, options).
if (is_array($taskDetails['additionalFields'] ?? false) && $taskDetails['additionalFields'] !== []) {
foreach ($taskDetails['additionalFields'] as $additionalFieldName) {
$fieldsToUpdate[$additionalFieldName] = $taskParameters[$additionalFieldName] ?? null;
}
}
$fieldsToUpdate['parameters'] = null;
} else {
$fieldsToUpdate['parameters'] = $taskParameters;
}
$connection->update(
self::TABLE_NAME,
array_filter($fieldsToUpdate, static fn($column) => $table->hasColumn($column), ARRAY_FILTER_USE_KEY),
['uid' => (int)$record['uid']]
);
} elseif ($taskObject instanceof \__PHP_Incomplete_Class) {
$objectVars = get_mangled_object_vars($taskObject);
$properties = [];
$executionDetails = null;
$taskType = null;
foreach ($objectVars as $key => $value) {
$key = trim($key);
$key = trim($key, "*\0");
$key = trim($key);
if ($key === '__PHP_Incomplete_Class_Name') {
$taskType = $value;
} else {
switch ($key) {
case '__PHP_Incomplete_Class_Name':
$taskType = $value;
break;
case 'execution':
$executionDetails = $value;
break;
case 'progress':
case 'scheduler':
case 'taskUid':
case 'disabled':
case 'runOnNextCronJob': // mapped to "task_group" in the database
case 'executionTime': // mapped to "next_execution" in the database
case 'taskGroup': // mapped to "task_group" in the database
case 'description':
break;
default:
if (is_scalar($value) || is_null($value)) {
$properties[$key] = $value;
}
}
}
}
$connection->update(
self::TABLE_NAME,
[
'tasktype' => $taskType,
'parameters' => $properties,
'execution_details' => $executionDetails?->toArray(),
],
['uid' => (int)$record['uid']]
);
} else {
// This happens if unserialize() failed (gracefully).
// Wizard shall not be marked as completed and show up again to let people know.
$hasFailures = true;
}
} catch (\Throwable) {
// Mark wizard as failed so the upgrade wizard will show up again, and people know there is a problem.
$hasFailures = true;
}
}
return !$hasFailures;
}
protected function hasRecordsToUpdate(): bool
{
// Check if table exists
if (!$this->getConnectionPool()->getConnectionForTable(self::TABLE_NAME)->createSchemaManager()->tableExists(self::TABLE_NAME)) {
return false;
}
return (bool)$this->getPreparedQueryBuilder()->count('uid')->executeQuery()->fetchOne();
}
protected function getRecordsToUpdate(): array
{
return $this->getPreparedQueryBuilder()->select('*')->executeQuery()->fetchAllAssociative();
}
protected function getPreparedQueryBuilder(): QueryBuilder
{
$nativeTaskTypesWithAdditionalFields = $this->getAllNativeTaskTypesWithAdditionalFields();
$queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable(self::TABLE_NAME);
// This is done by intention, so the upgrade wizard continues to work even if we introduce further TCA details for tx_scheduler_task
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER)),
$queryBuilder->expr()->or(
// Find all where the task type is empty (legacy serialized storage)
// OR where we have a native task type, that contains additional fields we can migrate
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq(
'tasktype',
$queryBuilder->createNamedParameter('')
),
$queryBuilder->expr()->isNull('tasktype')
),
$queryBuilder->expr()->and(
$queryBuilder->expr()->in(
'tasktype',
$queryBuilder->createNamedParameter(array_keys($nativeTaskTypesWithAdditionalFields), ArrayParameterType::STRING)
),
$queryBuilder->expr()->isNotNull('parameters'),
)
)
);
return $queryBuilder;
}
protected function getAllNativeTaskTypesWithAdditionalFields(): array
{
$taskService = GeneralUtility::makeInstance(TaskService::class);
$allTaskInformation = $taskService->getAllTaskTypes();
$nativeTaskTypesWithAdditionalFields = [];
foreach ($allTaskInformation as $taskType => $taskInformation) {
if (($taskInformation['isNativeTask'] ?? false) && $taskInformation['className'] !== ExecuteSchedulableCommandTask::class) {
// Native tasks can define "additionalFields". However, console commands, which are
// native tasks as well, do not define real fields but use the "parameters" feature.
$nativeTaskTypesWithAdditionalFields[$taskType] = $taskInformation['additionalFields'] ?? [];
}
}
return $nativeTaskTypesWithAdditionalFields;
}
protected function getConnectionPool(): ConnectionPool
{
return GeneralUtility::makeInstance(ConnectionPool::class);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler;
/**
* Interface for tasks who can provide their progress
*/
interface ProgressProviderInterface
{
/**
* Gets the progress of a task.
*
* @return float Progress of the task as a two decimal precision float. f.e. 44.87
*/
public function getProgress();
}
+225
View File
@@ -0,0 +1,225 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Event\AfterTaskExecutionEvent;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Task\TaskSerializer;
/**
* TYPO3 Scheduler. This class handles scheduling and execution of tasks.
*/
class Scheduler implements SingletonInterface
{
/**
* @var array $extConf Settings from the extension manager
*/
public array $extConf = [];
/**
* Constructor, makes sure all derived client classes are included
*/
public function __construct(
protected readonly LoggerInterface $logger,
protected readonly TaskSerializer $taskSerializer,
protected readonly SchedulerTaskRepository $schedulerTaskRepository,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly Registry $registry,
protected readonly ConnectionPool $connectionPool,
ExtensionConfiguration $extensionConfiguration,
) {
// Get configuration from the extension manager
$this->extConf = $extensionConfiguration->get('scheduler');
if (empty($this->extConf['maxLifetime'])) {
$this->extConf['maxLifetime'] = 1440;
}
// Clean up the serialized execution arrays
$this->cleanExecutionArrays();
}
/**
* Cleans the execution lists of the scheduled tasks, executions older than 24h are removed
* @todo find a way to actually kill the job
*/
protected function cleanExecutionArrays()
{
$tstamp = $GLOBALS['EXEC_TIME'];
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task');
// Select all tasks with executions
// NOTE: this cleanup is done for disabled tasks too,
// to avoid leaving old executions lying around
$result = $queryBuilder->select('*')
->from('tx_scheduler_task')
->where(
$queryBuilder->expr()->neq(
'serialized_executions',
$queryBuilder->createNamedParameter('')
),
$queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
)
->executeQuery();
$maxDuration = $this->extConf['maxLifetime'] * 60;
while ($row = $result->fetchAssociative()) {
$executions = [];
// serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>`
if ($serialized_executions = unserialize($row['serialized_executions'], ['allowed_classes' => false])) {
foreach ($serialized_executions as $task) {
if ($tstamp - $task < $maxDuration) {
$executions[] = $task;
} else {
try {
$schedulerTask = $this->taskSerializer->deserialize($row);
$taskType = $schedulerTask->getTaskType();
$executionTime = date('Y-m-d H:i:s', $schedulerTask->getExecutionTime());
} catch (InvalidTaskException $e) {
$taskType = 'unknown type';
$executionTime = 'unknown time';
}
$this->logger->info(
'Removing logged execution, assuming that the process is dead. Execution of \'{taskType} \' (UID: {taskId}) was started at {executionTime}',
[
'taskType' => $taskType,
'taskId' => $row['uid'],
'executionTime' => $executionTime,
]
);
}
}
}
$executionCount = count($executions);
if (!is_array($serialized_executions) || count($serialized_executions) !== $executionCount) {
if ($executionCount === 0) {
$value = '';
} else {
$value = serialize($executions);
}
$this->connectionPool->getConnectionForTable('tx_scheduler_task')->update(
'tx_scheduler_task',
['serialized_executions' => $value],
['uid' => (int)$row['uid']],
['serialized_executions' => Connection::PARAM_LOB]
);
}
}
}
/**
* This method executes the given task and properly marks and records that execution
* It is expected to return FALSE if the task was barred from running or if it was not saved properly
*
* @param Task\AbstractTask $task The task to execute
* @return bool Whether the task was saved successfully to the database or not
* @throws \Throwable
*/
public function executeTask(AbstractTask $task): bool
{
$task->setRunOnNextCronJob(false);
// Trigger the saving of the task, as this will calculate its next execution time
// This should be calculated all the time, even if the execution is skipped
// (in case it is skipped, this pushes back execution to the next possible date)
$this->schedulerTaskRepository->updateExecution($task, $task->getExecution()->isSingleRun());
// Reserve an id for the upcoming execution
$executionID = $this->schedulerTaskRepository->addExecutionToTask($task);
// Make sure we're the only one executing a single-execution-only task
if (!$task->getExecution()?->isParallelExecutionAllowed() && $executionID > 0) {
$this->schedulerTaskRepository->removeExecutionOfTask($task, $executionID);
$this->logger->info('Task is already running and multiple executions are not allowed, skipping! Task Type: {taskType}, UID: {uid}', [
'taskType' => $task->getTaskType(),
'uid' => $task->getTaskUid(),
]);
return false;
}
// Log scheduler invocation
$this->logger->info('Start execution. Task Type: {taskType}, UID: {uid}', [
'taskType' => $task->getTaskType(),
'uid' => $task->getTaskUid(),
]);
$failureString = '';
$success = false;
$e = null;
try {
// Execute task
$successfullyExecuted = $task->execute();
if (!$successfullyExecuted) {
throw new FailedExecutionException('Task failed to execute successfully. Task Type: ' . $task->getTaskType() . ', UID: ' . $task->getTaskUid(), 1250596541);
}
$success = true;
return true;
} catch (\Throwable $e) {
// Log failed execution
$this->logger->error('Task failed to execute successfully. Task Type: {taskType}, UID: {taskId}, Code: {code}, "{message}" in {exceptionFile} at line {exceptionLine}', [
'taskType' => $task->getTaskType(),
'taskId' => $task->getTaskUid(),
'exception' => $e,
'exceptionFile' => $e->getFile(),
'exceptionLine' => $e->getLine(),
'code' => $e->getCode(),
'message' => $e->getMessage(),
]);
// Store exception, so that it can be saved to database
// Do not serialize the complete exception or the trace, this can lead to huge strings > 50MB
$failureString = serialize([
'code' => $e->getCode(),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'traceString' => $e->getTraceAsString(),
]);
// Now that the result of the task execution has been handled,
// throw the exception again, if any
throw $e;
} finally {
// Un-register execution
$this->schedulerTaskRepository->removeExecutionOfTask($task, $executionID, $failureString);
// Log completion of execution
$this->logger->info('Task executed. Task Type: {taskType}, UID: {uid}', [
'taskType' => $task->getTaskType(),
'uid' => $task->getTaskUid(),
]);
$this->eventDispatcher->dispatch(
new AfterTaskExecutionEvent($task, $success, $e)
);
}
}
/**
* This method stores information about the last run of the Scheduler into the system registry
*
* @param string $type Type of run (manual or command-line (assumed to be cron))
*/
public function recordLastRun($type = 'cron')
{
// Validate input value
if ($type !== 'manual' && $type !== 'cli-by-id') {
$type = 'cron';
}
$runInformation = ['start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type];
$this->registry->set('tx_scheduler', 'lastRun', $runInformation);
}
}
+274
View File
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Service;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Console\CommandRegistry;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Field\NoneFieldType;
use TYPO3\CMS\Core\Schema\Struct\SelectItem;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Task\ExecuteSchedulableCommandTask;
/**
* Service class helping to retrieve data for EXT:scheduler
* @internal This is not a public API method, do not use in own extensions
*/
#[Autoconfigure(public: true)]
readonly class TaskService
{
public function __construct(
protected CommandRegistry $commandRegistry,
protected TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* This method fetches a list of all classes that have been registered with the Scheduler
* For each item the following information is provided, as an associative array:
*
* ['className'] => Name of the task PHP class
* ['extension'] => Key of the extension which provides the class
* ['filename'] => Path to the file containing the class
* ['title'] => String (possibly localized) containing a human-readable name for the class
*
* The name of the class itself is used as the key of the list array
*/
protected function getAvailableTaskTypes(bool $includeNativeTypes = true): array
{
$languageService = $this->getLanguageService();
$list = [];
// @deprecated will be removed in v16: SC_OPTIONS-based scheduler task registration
// is intentionally still read here in v15 to keep legacy (non-native)
// tasks listed and migratable. The per-task ['options']['tables'] fallbacks
// in IpAnonymizationTask and TableGarbageCollectionTask are kept for the
// same reason. Remove all of this together.
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'] ?? [] as $className => $registrationInformation) {
$title = isset($registrationInformation['title']) ? ($languageService?->sL($registrationInformation['title']) ?? $registrationInformation['title']) : '';
$description = isset($registrationInformation['description']) ? ($languageService?->sL($registrationInformation['description']) ?? $registrationInformation['description']) : '';
$list[$className] = [
'className' => $className,
'extension' => $registrationInformation['extension'] ?? '',
'icon' => $registrationInformation['icon'] ?? '',
'title' => $title,
'description' => $description,
'isNativeTask' => false,
'additionalFields' => [],
];
}
if ($includeNativeTypes) {
$schema = $this->tcaSchemaFactory->get('tx_scheduler_task');
$defaultFields = ['tasktype', 'task_group', 'description', 'parameters', 'execution_details', 'nextexecution', 'lastexecution_context', 'lastexecution_time', 'lastexecution_failure', 'disable', 'priority'];
// Loop over TCA items, and check if the task type is registered via TCA
foreach ($schema->getField('tasktype')->getConfiguration()['items'] ?? [] as $item) {
if (is_array($item) && $item['value'] !== 'div') {
$taskType = $className = $item['value'];
$additionalFields = [];
if ($schema->hasSubSchema($taskType)) {
$subSchema = $schema->getSubSchema($taskType);
if ($subSchema->getRawConfiguration()['taskOptions']['className'] ?? false) {
$className = $subSchema->getRawConfiguration()['taskOptions']['className'];
}
$additionalFields = $subSchema->getFields(fn($field) => !in_array($field->getName(), $defaultFields, true) && $field instanceof NoneFieldType === false);
$additionalFields = $additionalFields->getNames();
}
$list[$taskType] = [
'taskType' => $taskType,
'className' => $className,
'extension' => $item['group'] ?? '',
'icon' => $item['icon'] ?? '',
'iconOverlay' => $item['iconOverlay'] ?? '',
'title' => $languageService?->sL($item['label'] ?? '') ?? $item['label'] ?? '',
'description' => $languageService?->sL($item['description'] ?? '') ?? $item['description'] ?? '',
'isNativeTask' => true,
'additionalFields' => $additionalFields,
];
}
}
}
return $list;
}
public function getAllTaskTypes(bool $includeNativeTypes = true): array
{
$taskTypes = [];
foreach ($this->getAvailableTaskTypes($includeNativeTypes) as $taskType => $registrationInformation) {
$taskTypes[$taskType] = [
'className' => $registrationInformation['className'],
'taskType' => $taskType,
'category' => $registrationInformation['extension'],
'icon' => $registrationInformation['icon'],
// @todo Remove null coalescing once definition via $GLOBALS['TYPO3_CONF_VARS'] is removed
'iconOverlay' => $registrationInformation['iconOverlay'] ?? '',
'title' => $registrationInformation['title'],
'fullTitle' => $registrationInformation['title'] . ' [' . $registrationInformation['extension'] . ']',
'description' => $registrationInformation['description'],
'isNativeTask' => $registrationInformation['isNativeTask'],
'additionalFields' => $registrationInformation['additionalFields'],
];
}
ksort($taskTypes);
return $taskTypes;
}
public function getCategorizedTaskTypes(): array
{
$categorizedTaskTypes = [];
foreach ($this->getAllTaskTypes() as $taskType => $taskInformation) {
$categorizedTaskTypes[$taskInformation['category']][$taskType] = $taskInformation;
}
ksort($categorizedTaskTypes);
return $categorizedTaskTypes;
}
public function getTaskDetailsFromTask(AbstractTask $taskObject): ?array
{
$allTaskTypes = $this->getAllTaskTypes();
if (isset($allTaskTypes[$taskObject->getTaskType()])) {
return $allTaskTypes[$taskObject->getTaskType()];
}
if (isset($allTaskTypes[get_class($taskObject)])) {
return $allTaskTypes[get_class($taskObject)];
}
foreach ($allTaskTypes as $taskInformation) {
if ($taskInformation['className'] === get_class($taskObject)) {
return $taskInformation;
}
}
return null;
}
public function getTaskDetailsFromTaskType(string $taskType): ?array
{
$allTaskTypes = $this->getAllTaskTypes();
if (isset($allTaskTypes[$taskType])) {
return $allTaskTypes[$taskType];
}
foreach ($allTaskTypes as $taskInformation) {
if ($taskInformation['className'] === $taskType) {
return $taskInformation;
}
}
return null;
}
public function isTaskTypeRegistered(string $taskType): bool
{
$allTaskTypes = $this->getAllTaskTypes();
if (isset($allTaskTypes[$taskType])) {
return true;
}
foreach ($allTaskTypes as $taskInformation) {
if ($taskInformation['className'] === $taskType) {
return true;
}
}
return false;
}
/**
* Native fields are added / managed via FormEngine + dataHandler,
* so this only returns additional fields from the task object that are needed.
*/
public function getFieldsForRecord(AbstractTask $task): array
{
try {
if ($task->getRunOnNextCronJob()) {
$executionTime = time();
} else {
$executionTime = $task->getExecution()->getNextExecution();
}
$task->setExecutionTime($executionTime);
} catch (\Exception) {
$task->setDisabled(true);
$executionTime = 0;
}
$fields = [
'nextexecution' => $executionTime,
'disable' => (int)$task->isDisabled(),
'description' => $task->getDescription(),
'task_group' => $task->getTaskGroup(),
'tasktype' => $task->getTaskType(),
'execution_details' => $task->getExecution()->toArray(),
];
$taskDetails = $this->getTaskDetailsFromTask($task);
// Put the parameters in a separate field
if (!($taskDetails['isNativeTask'] ?? false)) {
$fields['parameters'] = $task->getTaskParameters();
}
return $fields;
}
public function createNewTask(string $taskType): AbstractTask
{
if (!$this->isTaskTypeRegistered($taskType)) {
throw new InvalidTaskException('Can not create task for unknown type ' . $taskType . '.', 1758885935);
}
/** @var AbstractTask $task */
$task = GeneralUtility::makeInstance($this->getTaskDetailsFromTaskType($taskType)['className']);
if ($task instanceof ExecuteSchedulableCommandTask) {
$task->setTaskType($taskType);
}
return $task;
}
public function getHumanReadableTaskName(AbstractTask $task): string
{
if (!$this->isTaskTypeRegistered($task->getTaskType())) {
throw new \RuntimeException('Task Type ' . $task->getTaskType() . ' not found in list of registered tasks', 1641658569);
}
return $this->getAllTaskTypes()[$task->getTaskType()]['fullTitle'];
}
/**
* Used in FormEngine. Actually, this is only needed to task types can be "validated" by form data providers.
* There is no possibility to "select" a task type in FormEngine. The field is a readonly information.
*/
public function getTaskTypesForTcaItems(array &$config, mixed $_ = null, bool $includeNativeItems = false): array
{
$taskTypes = $this->getAllTaskTypes($includeNativeItems);
foreach ($taskTypes as $taskType => $taskInformation) {
$config['items'][] = new SelectItem(
type: 'select',
label: $taskInformation['fullTitle'],
value: $taskType,
group: $taskInformation['category'],
description: $taskInformation['description'],
);
}
// Sort all items by group, and groups as well
usort($config['items'], static function (SelectItem $a, SelectItem $b): int {
$groupComparison = strnatcasecmp($a->getGroup(), $b->getGroup());
if ($groupComparison !== 0) {
return $groupComparison;
}
return strnatcasecmp($a->getLabel(), $b->getLabel());
});
return $config;
}
private function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\SystemInformation;
use TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Toolbar\InformationStatus;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Localization\DateFormatter;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
/**
* Event listener to display information about last automated run, as stored in the system registry.
*/
final class ToolbarItemProvider
{
/**
* Scheduler last run registry information
*/
private array $lastRunInformation = [];
/**
* Gather initial information
*/
public function __construct(
private readonly UriBuilder $uriBuilder,
Registry $registry,
) {
$this->lastRunInformation = $registry->get('tx_scheduler', 'lastRun', []);
}
#[AsEventListener('scheduler/show-latest-errors')]
public function getItem(SystemInformationToolbarCollectorEvent $event): void
{
$systemInformationToolbarItem = $event->getToolbarItem();
// No tasks configured, so nothing is shown at all
if (!$this->hasConfiguredTasks()) {
return;
}
$languageService = $this->getLanguageService();
if (!$this->schedulerWasExecuted()) {
// Display system message if the Scheduler has never yet run
$moduleIdentifier = 'scheduler';
$systemInformationToolbarItem->addSystemMessage(
sprintf(
$languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:systemmessage.noLastRun'),
(string)$this->uriBuilder->buildUriFromRoute($moduleIdentifier)
),
InformationStatus::WARNING,
1,
$moduleIdentifier,
);
} else {
// Display information about the last Scheduler execution
if (!$this->lastRunInfoExists()) {
// Show warning if the information of the last run is incomplete
$message = $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.incompleteLastRun');
$severity = InformationStatus::WARNING;
} else {
$start = DateTimeFactory::createFromTimestamp($this->lastRunInformation['start']);
$end = DateTimeFactory::createFromTimestamp($this->lastRunInformation['end']);
$startDate = $start->format($GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy']);
$startTime = $start->format($GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm']);
$duration = (new DateFormatter())->formatDateInterval(
$end->diff($start, true),
$languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.minutesHoursDaysYears')
);
$severity = InformationStatus::NOTICE;
$label = 'automatically';
if ($this->lastRunInformation['type'] === 'manual') {
$label = 'manually';
}
$type = $languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.' . $label);
$message = sprintf($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:systeminformation.lastRunValue'), $startDate, $startTime, $duration, $type);
}
$systemInformationToolbarItem->addSystemInformation(
'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:systeminformation.lastRunLabel',
$message,
'actions-play',
$severity
);
}
}
/**
* Check whether the scheduler was already executed
*/
private function schedulerWasExecuted(): bool
{
return !empty($this->lastRunInformation);
}
/**
* Check if the last scheduler run array contains all information
*/
private function lastRunInfoExists(): bool
{
return !empty($this->lastRunInformation['end'])
|| !empty($this->lastRunInformation['start'])
|| !empty($this->lastRunInformation['type']);
}
/**
* See if there are any tasks configured at all.
*/
private function hasConfiguredTasks(): bool
{
return GeneralUtility::makeInstance(SchedulerTaskRepository::class)->hasTasks();
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+326
View File
@@ -0,0 +1,326 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Scheduler\Execution;
/**
* This is the base class for all Scheduler tasks
* It's an abstract class, not designed to be instantiated directly
* All Scheduler tasks should inherit from this class
*/
abstract class AbstractTask implements LoggerAwareInterface
{
use LoggerAwareTrait;
public const TYPE_SINGLE = 1;
public const TYPE_RECURRING = 2;
/**
* The unique id of the task used to identify it in the database.
*/
protected int $taskUid = 0;
/**
* Disable flag, TRUE if task is disabled, FALSE otherwise
*
* @var bool
*/
protected $disabled = false;
/**
* Run on next cron job flag, TRUE if task should run on next cronjob, FALSE otherwise
*
* @var bool
*/
protected $runOnNextCronJob = false;
/**
* The execution object related to the task
*
* @var Execution
*/
protected $execution;
/**
* This variable contains the time of next execution of the task
*
* @var int
*/
protected $executionTime = 0;
/**
* Description for the task
*/
protected string $description = '';
/**
* Task group for this task
*
* @var int|null
*/
protected $taskGroup = 0;
public function __construct()
{
$this->execution = new Execution();
}
/**
* This is the main method that is called when a task is executed
* It MUST be implemented by all classes inheriting from this one
* Note that there is no error handling, errors and failures are expected
* to be handled and logged by the client implementations.
* Should return TRUE on successful execution, FALSE on error.
*
* @return bool Returns TRUE on successful execution, FALSE on error
*/
abstract public function execute();
/**
* This method is designed to return some additional information about the task,
* that may help to set it apart from other tasks from the same class
* This additional information is used - for example - in the Scheduler's BE module
* This method should be implemented in most task classes
*
* @return string Information to display
*/
public function getAdditionalInformation()
{
return '';
}
/**
* This method is used to set the unique id of the task
*
* @param int $id Primary key (from the database record) of the scheduled task
*/
public function setTaskUid($id): void
{
$this->taskUid = (int)$id;
}
/**
* This method returns the unique id of the task
*
* @return int The id of the task
*/
public function getTaskUid(): int
{
return $this->taskUid;
}
/**
* This method returns the disabled status of the task
*
* @return bool TRUE if task is disabled, FALSE otherwise
*/
public function isDisabled()
{
return $this->disabled;
}
/**
* This method is used to set the disabled status of the task
*
* @param bool $flag TRUE if task should be disabled, FALSE otherwise
*/
public function setDisabled($flag)
{
if ($flag) {
$this->disabled = true;
} else {
$this->disabled = false;
}
}
/**
* This method set the flag for next cron job execution
*
* @param bool $flag TRUE if task should run with the next cron job, FALSE otherwise
*/
public function setRunOnNextCronJob($flag)
{
$this->runOnNextCronJob = $flag;
}
/**
* This method returns the run on next cron job status of the task
*
* @return bool TRUE if task should run on next cron job, FALSE otherwise
*/
public function getRunOnNextCronJob()
{
return $this->runOnNextCronJob;
}
/**
* This method is used to set the timestamp corresponding to the next execution time of the task
*
* @param int $timestamp Timestamp of next execution
*/
public function setExecutionTime($timestamp)
{
$this->executionTime = (int)$timestamp;
}
/**
* This method returns the task group (uid) of the task
*
* @return int|null Uid of task group or null if it came back from the DB without the task group set.
*/
public function getTaskGroup()
{
return $this->taskGroup;
}
/**
* This method is used to set the task group (uid) of the task
*
* @param int $taskGroup Uid of task group
*/
public function setTaskGroup($taskGroup)
{
$this->taskGroup = (int)$taskGroup;
}
/**
* This method returns the timestamp corresponding to the next execution time of the task
*
* @return int Timestamp of next execution
*/
public function getExecutionTime()
{
return $this->executionTime;
}
/**
* This method is used to set the description of the task
*
* @param string $description Description
*/
public function setDescription($description): void
{
$this->description = (string)$description;
}
/**
* This method returns the description of the task
*
* @return string Description
*/
public function getDescription()
{
return $this->description;
}
/**
* Sets the internal execution object
*
* @param Execution $execution The execution to add
* @internal since TYPO3 v12.3, not part of TYPO3 Public API anymore.
*/
public function setExecution(Execution $execution): void
{
$this->execution = $execution;
}
/**
* Returns the execution object
*
* @return Execution|object|null The internal execution object - when an invalid task is being unserialized, the Execution object might not be available
* @internal since TYPO3 v12.3, not part of TYPO3 Public API anymore.
*/
public function getExecution()
{
return $this->execution;
}
/**
* Guess recurring type from the existing information
* If an interval or a cron command is defined, it's a recurring task
*/
public function getType(): int
{
if ($this->execution->isRecurring()) {
return self::TYPE_RECURRING;
}
return self::TYPE_SINGLE;
}
protected function logException(\Exception $e)
{
$this->logger?->error('A Task Exception was captured.', ['exception' => $e]);
}
protected function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
public function getTaskType(): string
{
return static::class;
}
/**
* It is recommended to implement this method in the respective task class.
*/
public function getTaskParameters(): array
{
$vars = get_object_vars($this);
$parameters = [];
foreach ($vars as $key => $value) {
$key = trim($key);
$key = trim($key, "*\0");
$key = trim($key);
$parameters[$key] = $value;
}
unset(
// Needs to be kept until TYPO3 v16.0 until the upgrade wizard was run through
$parameters['scheduler'],
$parameters['logger'],
$parameters['taskUid'],
$parameters['disabled'],
$parameters['runOnNextCronJob'],
$parameters['execution'],
$parameters['executionTime'],
$parameters['description'],
$parameters['taskGroup'],
);
return $parameters;
}
/**
* Used to fill fields of this class, e.g. also when instantiating this class when no parameters are
* given but native DB fields are coming in.
* @param array $parameters
*/
public function setTaskParameters(array $parameters): void
{
foreach ($parameters as $key => $value) {
// Ensure a member property exists; Task objects might have old configuration data changed with
// attributes that were removed meanwhile. This would otherwise trigger a PHP notice like
// "PHP Runtime Deprecation Notice: Creation of dynamic property TYPO3\CMS\Linkvalidator\Task\ValidatorTask::$fileConfiguration is deprecated"
if (property_exists($this, $key)) {
$this->{$key} = $value;
}
}
}
}
@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Garbage collection of caching framework cache backends.
*
* This task finds all configured caching framework caches and
* calls the garbage collection of a cache if the cache backend
* is configured to be cleaned.
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class CachingFrameworkGarbageCollectionTask extends AbstractTask
{
/**
* Backend types that should be cleaned up,
* set by additional field provider.
*
* @var array Selected backends to do garbage collection for
*/
public $selectedBackends = [];
/**
* Execute garbage collection, called by scheduler.
*
* @return bool
*/
public function execute()
{
// Global sub-array with all configured caches
$cacheConfigurations = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] ?? null;
if (is_array($cacheConfigurations)) {
// Iterate through configured caches and call garbage collection if
// backend is within selected backends in additional field of task
foreach ($cacheConfigurations as $cacheName => $cacheConfiguration) {
// The cache backend used for this cache
$usedCacheBackend = $cacheConfiguration['backend'] ?? Typo3DatabaseBackend::class;
if (in_array($usedCacheBackend, $this->selectedBackends, true)) {
GeneralUtility::makeInstance(CacheManager::class)->getCache($cacheName)->collectGarbage();
}
}
}
return true;
}
public function getTaskParameters(): array
{
return [
'cache_backends' => implode(',', $this->selectedBackends),
];
}
public function setTaskParameters(array $parameters): void
{
$selectedBackends = $parameters['selectedBackends'] ?? $parameters['cache_backends'] ?? [];
if (!is_array($selectedBackends)) {
$selectedBackends = GeneralUtility::trimExplode(',', $selectedBackends, true);
}
$this->selectedBackends = $selectedBackends;
}
/**
* Get all registered caching framework backends
*/
public function getRegisteredBackends(array &$config): void
{
$backends = [];
$cacheConfigurations = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'];
foreach ($cacheConfigurations ?? [] as $cacheConfiguration) {
$backend = (string)($cacheConfiguration['backend'] ?? Typo3DatabaseBackend::class);
if (!in_array($backend, $backends, true)) {
$backends[] = $backend;
}
}
foreach ($backends as $backend) {
$config['items'][] = ['value' => $backend, 'label' => $backend];
}
}
}
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use TYPO3\CMS\Core\Console\CommandRegistry;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class ExecuteSchedulableCommandTask extends AbstractTask
{
/**
* @var string
*/
protected $commandIdentifier = '';
/**
* @var array
*/
protected $arguments = [];
/**
* @var array
*/
protected $options = [];
/**
* @var array
*/
protected $optionValues = [];
/**
* @var array
*/
protected $defaults = [];
/**
* This is the main method that is called when a task is executed
* It MUST be implemented by all classes inheriting from this one
* Note that there is no error handling, errors and failures are expected
* to be handled and logged by the client implementations.
* Should return TRUE on successful execution, FALSE on error.
*
* @throws \Exception
*
* @return bool Returns TRUE on successful execution, FALSE on error
*/
public function execute(): bool
{
try {
$commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class);
$schedulableCommand = $commandRegistry->get($this->commandIdentifier);
} catch (CommandNotFoundException $e) {
throw new \RuntimeException(
sprintf(
$this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.unregisteredCommand'),
$this->commandIdentifier
),
1505055445,
$e
);
}
$input = new ArrayInput($this->getParameters(false));
$input->setInteractive(false);
$output = new NullOutput();
return $schedulableCommand->run($input, $output) === 0;
}
/**
* Return a text representation of the selected command and arguments
*
* @return string Information to display
*/
public function getAdditionalInformation(): string
{
try {
$commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class);
$schedulableCommand = $commandRegistry->get($this->commandIdentifier);
} catch (CommandNotFoundException $e) {
return sprintf(
$this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.unregisteredCommand'),
$this->commandIdentifier
);
}
try {
$input = new ArrayInput($this->getParameters(true), $schedulableCommand->getDefinition());
$arguments = $input->__toString();
} catch (\Symfony\Component\Console\Exception\RuntimeException|InvalidArgumentException $e) {
return $this->commandIdentifier . "\n"
. sprintf(
$this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingArguments'),
$e->getMessage()
);
} catch (InvalidOptionException $e) {
return $this->commandIdentifier . "\n"
. sprintf(
$this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingOptions'),
$e->getMessage()
);
}
if ($arguments !== '') {
return $this->commandIdentifier . ' ' . $arguments;
}
return '';
}
public function getArguments(): array
{
return $this->arguments;
}
public function getOptions(): array
{
return $this->options;
}
public function getOptionValues(): array
{
return $this->optionValues;
}
public function addDefaultValue(string $argumentName, mixed $argumentValue): void
{
if (is_bool($argumentValue)) {
$argumentValue = (int)$argumentValue;
}
$this->defaults[$argumentName] = $argumentValue;
}
private function getParameters(bool $forDisplay): array
{
$options = [];
foreach ($this->options as $name => $enabled) {
if ($enabled) {
$value = $this->optionValues[$name] ?? null;
$options['--' . $name] = ($forDisplay && $value === true) ? '' : $value;
}
}
return array_merge($this->arguments, $options);
}
public function getTaskType(): string
{
return $this->commandIdentifier;
}
public function setTaskType(string $taskType): void
{
$this->commandIdentifier = $taskType;
}
public function getTaskParameters(): array
{
return [
'commandIdentifier' => $this->commandIdentifier,
'arguments' => $this->arguments,
'options' => $this->options,
'optionValues' => $this->optionValues,
];
}
public function setTaskParameters(array $parameters): void
{
$this->commandIdentifier = $parameters['commandIdentifier'] ?? $this->commandIdentifier;
$this->arguments = $this->processArguments($parameters);
$processedOptions = $this->processOptions($parameters);
$this->options = $processedOptions['options'] ?? [];
$this->optionValues = $processedOptions['optionValues'] ?? [];
}
public function validateTaskParameters(array $parameters): bool
{
$result = true;
$commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class);
$flashMessageQueue = GeneralUtility::makeInstance(FlashMessageService::class)->getMessageQueueByIdentifier();
if ($commandRegistry->has($this->getTaskType())
&& (is_array($parameters['arguments'] ?? false) || is_array($parameters['options'] ?? false))
) {
// If this is a registered console command, validate given arguments / options
$command = $commandRegistry->get($this->getTaskType());
foreach ($command->getDefinition()->getArguments() as $argument) {
foreach (($parameters['arguments'] ?? []) as $argumentName => $argumentValue) {
if ($argument->getName() !== $argumentName) {
continue;
}
if ($argument->isRequired() && trim($argumentValue) === '') {
$flashMessageQueue->addMessage(
new FlashMessage(sprintf(
$this->getLanguageService()?->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.mandatoryArgumentMissing'),
$argumentName
), '', ContextualFeedbackSeverity::ERROR)
);
$result = false;
}
}
}
foreach ($command->getDefinition()->getOptions() as $optionDefinition) {
$optionEnabled = $parameters['options'][$optionDefinition->getName()] ?? false;
$optionValue = $parameters['optionValues'][$optionDefinition->getName()] ?? $optionDefinition->getDefault();
if ($optionEnabled && $optionDefinition->isValueRequired()) {
if ($optionDefinition->isArray()) {
$testValues = is_array($optionValue) ? $optionValue : GeneralUtility::trimExplode(',', $optionValue, false);
} else {
$testValues = [$optionValue];
}
foreach ($testValues as $testValue) {
if ($testValue === null || trim($testValue) === '') {
// An option that requires a value is used with an empty value
$flashMessageQueue->addMessage(
new FlashMessage(sprintf(
$this->getLanguageService()?->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.mandatoryArgumentMissing'),
$optionDefinition->getName()
), '', ContextualFeedbackSeverity::ERROR)
);
$result = false;
}
}
}
}
}
return $result;
}
protected function processArguments(array $paremeters): array
{
if (!is_array($paremeters['arguments'] ?? false)) {
return [];
}
try {
$commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class);
$command = $commandRegistry->get($this->commandIdentifier);
} catch (CommandNotFoundException) {
return [];
}
$arguments = [];
foreach ($paremeters['arguments'] as $argumentName => $argumentValue) {
try {
$argumentDefinition = $command->getDefinition()->getArgument($argumentName);
} catch (InvalidArgumentException) {
continue;
}
if ($argumentDefinition->isArray() && is_string($argumentValue)) {
$argumentValue = GeneralUtility::trimExplode(',', $argumentValue, true);
}
$arguments[$argumentName] = $argumentValue;
}
return $arguments;
}
protected function processOptions(array $parameters): array
{
if (!is_array($parameters['options'] ?? false)) {
return [];
}
try {
$commandRegistry = GeneralUtility::makeInstance(CommandRegistry::class);
$command = $commandRegistry->get($this->commandIdentifier);
} catch (CommandNotFoundException) {
return [];
}
$options = [];
$optionValues = [];
foreach ($command->getDefinition()->getOptions() as $optionDefinition) {
$optionEnabled = $parameters['options'][$optionDefinition->getName()] ?? false;
$options[$optionDefinition->getName()] = (bool)$optionEnabled;
if ($optionDefinition->isValueRequired() || $optionDefinition->isValueOptional() || $optionDefinition->isArray()) {
$optionValue = $parameters['optionValues'][$optionDefinition->getName()] ?? $optionDefinition->getDefault();
if ($optionDefinition->isArray() && is_string($optionValue)) {
// Do not remove empty array values.
// One empty array element indicates the existence of one occurrence of an array option (InputOption::VALUE_IS_ARRAY) without a value.
// Empty array elements are also required for command options like "-vvv" (can be entered as ",,").
$optionValue = GeneralUtility::trimExplode(',', $optionValue);
}
} else {
// boolean flag: option value must be true if option is added or false otherwise
$optionValue = (bool)$optionEnabled;
}
$optionValues[$optionDefinition->getName()] = $optionValue;
}
return ['options' => $options, 'optionValues' => $optionValues];
}
}
@@ -0,0 +1,88 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Resource\Index\Indexer;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This task which indexes files in storage
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class FileStorageExtractionTask extends AbstractTask
{
/**
* Storage Uid
*
* @var int
*/
public $storageUid = -1;
/**
* FileCount
*
* @var int
*/
public $maxFileCount = 100;
/**
* Function execute from the Scheduler
*
* @return bool TRUE on successful execution, FALSE on error
*/
public function execute()
{
$success = false;
if ((int)$this->storageUid > 0) {
$storage = GeneralUtility::makeInstance(StorageRepository::class)->findByUid($this->storageUid);
if ($storage === null) {
throw new \RuntimeException(self::class . ' misconfiguration: "Storage to index" must be an existing storage.', 1615020909);
}
$currentEvaluatePermissionsValue = $storage->getEvaluatePermissions();
$storage->setEvaluatePermissions(false);
$indexer = $this->getIndexer($storage);
try {
$indexer->runMetaDataExtraction((int)$this->maxFileCount);
$success = true;
} catch (\Exception $e) {
$success = false;
$this->logException($e);
}
$storage->setEvaluatePermissions($currentEvaluatePermissionsValue);
}
return $success;
}
protected function getIndexer(ResourceStorage $storage): Indexer
{
return GeneralUtility::makeInstance(Indexer::class, $storage);
}
public function getTaskParameters(): array
{
return [
'file_storage' => $this->storageUid,
'max_file_count' => $this->maxFileCount,
];
}
public function setTaskParameters(array $parameters): void
{
$this->storageUid = (int)($parameters['storageUid'] ?? $parameters['file_storage'] ?? -1);
$this->maxFileCount = (int)($parameters['maxFileCount'] ?? $parameters['max_file_count'] ?? 100);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Resource\Index\Indexer;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This task tries to find changes in storage and writes them back to DB
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class FileStorageIndexingTask extends AbstractTask
{
/**
* Storage Uid
*
* @var int
*/
public $storageUid = -1;
/**
* Function execute from the Scheduler
*
* @return bool TRUE on successful execution, FALSE on error
*/
public function execute()
{
if ((int)$this->storageUid > 0) {
$storage = GeneralUtility::makeInstance(StorageRepository::class)->findByUid($this->storageUid);
if ($storage === null) {
throw new \RuntimeException(self::class . ' misconfiguration: "Storage to index" must be an existing storage.', 1615020908);
}
$currentEvaluatePermissionsValue = $storage->getEvaluatePermissions();
$storage->setEvaluatePermissions(false);
$indexer = $this->getIndexer($storage);
$indexer->processChangesInStorages();
$storage->setEvaluatePermissions($currentEvaluatePermissionsValue);
}
return true;
}
protected function getIndexer(ResourceStorage $storage): Indexer
{
return GeneralUtility::makeInstance(Indexer::class, $storage);
}
public function getTaskParameters(): array
{
return [
'file_storage' => $this->storageUid,
];
}
public function setTaskParameters(array $parameters): void
{
$this->storageUid = $parameters['storageUid'] ?? $parameters['file_storage'] ?? 0;
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\IpAnonymizationUtility;
/**
* Anonymize IP addresses in records
*
* This task anonymizes IP addresses in tables older than the given number of days.
*
* Available tables must be registered in
* $GLOBALS['TCA']['tx_scheduler_task']['types'][\TYPO3\CMS\Scheduler\Task\IpAnonymizationTask::class]['taskOptions']['tables']
*
* See scheduler_ip_anonymization_task.php of scheduler extension for an example.
*
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class IpAnonymizationTask extends AbstractTask
{
/**
* @var int Number of days
*/
public $numberOfDays = 180;
/**
* @var int mask level see \TYPO3\CMS\Core\Utility\IpAnonymizationUtility::anonymizeIp
*/
public $mask = 2;
/**
* @var string Table to clean up
*/
public $table = '';
/**
* Execute garbage collection, called by scheduler.
*
* @throws \RuntimeException If configured table was not cleaned up
* @return bool TRUE if task run was successful
*/
public function execute()
{
$configuration = $this->getTableConfiguration()[$this->table] ?? [];
if (empty($configuration)) {
throw new \RuntimeException(self::class . ' misconfiguration: ' . $this->table . ' does not exist in configuration', 1524502548);
}
$this->handleTable($this->table, $configuration);
return true;
}
/**
* Execute clean up of a specific table
*
* @throws \RuntimeException If table configuration is broken
* @param string $table The table to handle
* @param array $configuration Clean up configuration
* @return bool TRUE if cleanup was successful
*/
protected function handleTable(string $table, array $configuration)
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->getRestrictions()->removeAll();
if (empty($configuration['dateField'])) {
throw new \RuntimeException(self::class . ' misconfiguration: "dateField" must be defined for table ' . $table, 1524502549);
}
if (empty($configuration['ipField'])) {
throw new \RuntimeException(self::class . ' misconfiguration: "ipField" must be defined for table ' . $table, 1524502666);
}
$deleteTimestamp = strtotime('-' . $this->numberOfDays . 'days');
if ($deleteTimestamp === false) {
throw new \RuntimeException(self::class . ' misconfiguration: number of days could not be calculated for table ' . $table, 1524526354);
}
if ($this->mask === 2) {
$notLikeMaskPattern = '%.0.0';
} else {
$notLikeMaskPattern = '%.0';
}
try {
$result = $queryBuilder
->select('uid', $configuration['ipField'])
->where(
$queryBuilder->expr()->lt(
$configuration['dateField'],
$queryBuilder->createNamedParameter($deleteTimestamp, Connection::PARAM_INT)
),
$queryBuilder->expr()->neq(
$configuration['ipField'],
$queryBuilder->createNamedParameter('')
),
$queryBuilder->expr()->isNotNull($configuration['ipField']),
$queryBuilder->expr()->notLike(
$configuration['ipField'],
$queryBuilder->createNamedParameter($notLikeMaskPattern)
),
$queryBuilder->expr()->notLike(
$configuration['ipField'],
$queryBuilder->createNamedParameter('%::')
)
)
->from($table)
->executeQuery();
while ($row = $result->fetchAssociative()) {
$ip = (string)$row[$configuration['ipField']];
$connection->update(
$table,
[
$configuration['ipField'] => IpAnonymizationUtility::anonymizeIp($ip, (int)$this->mask),
],
[
'uid' => $row['uid'],
]
);
}
} catch (\Exception $e) {
throw new \RuntimeException(self::class . ' failed for table ' . $this->table . ' with error: ' . $e->getMessage(), 1524502550);
}
return true;
}
public function getAdditionalInformation()
{
return sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.additionalInformationTable'), $this->table, $this->numberOfDays);
}
public function getTaskParameters(): array
{
return [
'number_of_days' => $this->numberOfDays,
'ip_mask' => $this->mask,
'selected_tables' => $this->table,
];
}
public function setTaskParameters(array $parameters): void
{
$this->table = (string)($parameters['table'] ?? $parameters['selected_tables'] ?? '');
$this->numberOfDays = (int)($parameters['numberOfDays'] ?? $parameters['number_of_days'] ?? 180);
$this->mask = (int)($parameters['mask'] ?? $parameters['ip_mask'] ?? 2);
}
public function getAnonymizableTables(array &$config): void
{
foreach ($this->getTableConfiguration() as $tableName => $tableConfiguration) {
$config['items'][] = [
'label' => $tableName . (($tableConfiguration['ipField'] ?? false) ? ' [ipField: ' . $tableConfiguration['ipField'] . ']' : '') . (($tableConfiguration['dateField'] ?? false) ? ' [dateField: ' . $tableConfiguration['dateField'] . ']' : ''),
'value' => $tableName,
];
}
}
public function getTableConfiguration(): array
{
$tableConfiguration = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('tx_scheduler_task.' . self::class)->getRawConfiguration()['taskOptions']['tables'] ?? [];
$tableConfigurationFromConfVars = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][self::class]['options']['tables'] ?? [];
if (!empty($tableConfigurationFromConfVars)) {
// @deprecated will be removed in v16: this SC_OPTIONS fallback is intentionally
// kept beyond v15 because SC_OPTIONS-based scheduler task registration
// is still read in TaskService::getAvailableTaskTypes() to keep legacy
// (non-native) tasks migratable. Remove together with that support.
trigger_error('Usage of $GLOBALS[\'TYPO3_CONF_VARS\'][\'SC_OPTIONS\'][\'scheduler\'][\'tasks\'][' . self::class . '][\'options\'][\'tables\'] to define table options is deprecated and will stop working in TYPO3 v16. Use $tca[\'tx_scheduler_task\'][\'types\'][' . self::class . '][\'taskOptions\'][\'tables\'] instead.', E_USER_DEPRECATED);
if (is_array($tableConfigurationFromConfVars)) {
$tableConfiguration = array_replace_recursive($tableConfiguration, $tableConfigurationFromConfVars);
}
}
return $tableConfiguration;
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Perform OPTIMIZE TABLE SQL statements
*
* This task reorganizes the physical storage of table data and associated index data,
* to reduce storage space and improve I/O efficiency when accessing the table. The
* exact changes made to each table depend on the storage engine used by that table.
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class OptimizeDatabaseTableTask extends AbstractTask
{
/**
* Database tables that should be cleaned up,
* set by additional field provider.
*
* @var array Selected tables to optimize
*/
public $selectedTables = [];
/**
* Execute table optimization, called by scheduler.
*
* @return bool
*/
public function execute()
{
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
foreach ($this->selectedTables as $tableName) {
$connection = $connectionPool->getConnectionForTable($tableName);
$platform = $connection->getDatabasePlatform();
if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) {
try {
// `OPTIMIZE TABLE` returns a result set and must be executed using `executeQuery()`,
// otherwise following database queries would fail with a database exception because of a
// not-consumed query buffer with `pdo_mysql` driver and the full result set is retrieved
// with `fetchAllAssociative()` and discarded as handling is not intended here.
$connection->executeQuery('OPTIMIZE TABLE ' . $connection->quoteIdentifier($tableName))->fetchAllAssociative();
} catch (DBALException $e) {
throw new \RuntimeException(
TableGarbageCollectionTask::class . ' failed for: ' . $tableName . ': '
. $e->getMessage(),
1441390263
);
}
}
}
return true;
}
/**
* Output the selected tables
*
* @return string
*/
public function getAdditionalInformation()
{
return implode(', ', $this->selectedTables);
}
public function getTaskParameters(): array
{
return [
'selected_tables' => implode(',', $this->selectedTables),
];
}
public function setTaskParameters(array $parameters): void
{
$selectedTables = $parameters['selected_tables'] ?? $parameters['tables'] ?? [];
if (!is_array($selectedTables)) {
$selectedTables = GeneralUtility::trimExplode(',', $selectedTables, true);
}
$this->selectedTables = $selectedTables;
}
/**
* TCA itemsProcFunc
* Get all tables that are capable of optimization
*/
public function getOptimizableTables(array &$config): array
{
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
$defaultConnection = $connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
// Retrieve all optimizable tables for the default connection
$optimizableTables = $this->getOptimizableTablesForConnection($defaultConnection);
// Retrieve additional optimizable tables that have been remapped to a different connection
$tableMap = $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'] ?? [];
if ($tableMap) {
// Remove all remapped tables from the list of optimizable tables
// These tables will be rechecked and possibly re-added to the list
// of optimizable tables. This ensures that no orphaned table from
// the default connection gets mistakenly labeled as optimizable.
$optimizableTables = array_diff($optimizableTables, array_keys($tableMap));
// Walk each connection and check all tables that have been
// remapped to it for optimization support.
$connectionNames = array_keys(array_flip($tableMap));
foreach ($connectionNames as $connectionName) {
$connection = $connectionPool->getConnectionByName($connectionName);
$tablesOnConnection = array_keys(array_filter(
$tableMap,
static function ($value) use ($connectionName) {
return $value === $connectionName;
}
));
$tables = $this->getOptimizableTablesForConnection($connection, $tablesOnConnection);
$optimizableTables = array_merge($optimizableTables, $tables);
}
}
sort($optimizableTables);
foreach ($optimizableTables as $tableName) {
$config['items'][] = [
'label' => $tableName,
'value' => $tableName,
];
}
return $optimizableTables;
}
/**
* Retrieve all optimizable tables for a connection, optionally restricted to the subset
* of table names in the $tableNames array.
*/
protected function getOptimizableTablesForConnection(Connection $connection, array $tableNames = []): array
{
// Return empty list if the database platform is not MySQL/MariaDB
$platform = $connection->getDatabasePlatform();
if (!($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform)) {
return [];
}
// Retrieve all tables from the MySQL information schema that have an engine type
// that supports the OPTIMIZE TABLE command.
$queryBuilder = $connection->createQueryBuilder();
$queryBuilder->select('TABLE_NAME AS Table', 'ENGINE AS Engine')
->from('information_schema.TABLES')
->where(
$queryBuilder->expr()->eq(
'TABLE_TYPE',
$queryBuilder->createNamedParameter('BASE TABLE')
),
$queryBuilder->expr()->in(
'ENGINE',
$queryBuilder->createNamedParameter(['InnoDB', 'MyISAM', 'ARCHIVE'], Connection::PARAM_STR_ARRAY)
),
$queryBuilder->expr()->eq(
'TABLE_SCHEMA',
$queryBuilder->createNamedParameter($connection->getDatabase())
)
);
if (!empty($tableNames)) {
$queryBuilder->andWhere(
$queryBuilder->expr()->in(
'TABLE_NAME',
$queryBuilder->createNamedParameter($tableNames, Connection::PARAM_STR_ARRAY)
)
);
}
$tables = $queryBuilder->executeQuery()->fetchAllAssociative();
return array_column($tables, 'Table');
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Recycler folder garbage collection task
*
* This task finds all "_recycler_" folders below all storages and
* deletes all files in them that have not changed for more than
* a given number of days.
*
* Compatible drivers should be implemented correctly for this. The shipped "local driver"
* does a "touch()" after the file is moved into the recycler folder.
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class RecyclerGarbageCollectionTask extends AbstractTask
{
/**
* Elapsed period since last modification before a file will
* be deleted in a recycler directory.
*
* @var int Number of days before cleaning up files
*/
public $numberOfDays = 0;
/**
* Cleanup recycled files, called by scheduler.
*
* @return bool TRUE if task run was successful
* @throws \BadMethodCallException
*/
public function execute()
{
$recyclerFolders = [];
$storageRepository = GeneralUtility::makeInstance(StorageRepository::class);
// takes only _recycler_ folder on the first level into account
foreach ($storageRepository->findAll() as $storage) {
$rootLevelFolder = $storage->getRootLevelFolder(false);
foreach ($rootLevelFolder->getSubfolders() as $subFolder) {
if ($subFolder->getRole() === $subFolder::ROLE_RECYCLER) {
$recyclerFolders[] = $subFolder;
break;
}
}
}
// Execute cleanup
$seconds = 60 * 60 * 24 * (int)$this->numberOfDays;
$timestamp = $GLOBALS['EXEC_TIME'] - $seconds;
foreach ($recyclerFolders as $recyclerFolder) {
$this->cleanupRecycledFiles($recyclerFolder, $timestamp);
}
return true;
}
/**
* Gets a list of all files in a directory recursively and removes
* old ones.
*
* @param Folder $folder the folder
* @param int $timestamp Timestamp of the last file modification
*/
protected function cleanupRecycledFiles(Folder $folder, $timestamp)
{
foreach ($folder->getFiles() as $file) {
if ($timestamp > $file->getModificationTime()) {
$file->delete();
}
}
foreach ($folder->getSubfolders() as $subFolder) {
$this->cleanupRecycledFiles($subFolder, $timestamp);
// if no more files and subdirectories are in the folder, remove the folder as well
if ($subFolder->getFileCount() === 0 && count($subFolder->getSubfolders()) === 0) {
$subFolder->delete(true);
}
}
}
public function getTaskParameters(): array
{
return [
'number_of_days' => $this->numberOfDays,
];
}
public function setTaskParameters(array $parameters): void
{
$this->numberOfDays = (int)($parameters['numberOfDays'] ?? $parameters['number_of_days'] ?? 0);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use Doctrine\DBAL\Exception as DBALException;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Remove old entries from tables.
*
* This task deletes rows from tables older than the given number of days.
*
* Available tables must be registered in
* $GLOBALS['TCA']['tx_scheduler_task']['types'][\TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask::class]['taskOptions']['tables']
*
* See scheduler_table_garbage_collection_task.php of scheduler extension for an example.
*
* @internal This class is a specific scheduler task implementation is not considered part of the Public TYPO3 API.
*/
class TableGarbageCollectionTask extends AbstractTask
{
/**
* @var bool True if all tables should be cleaned up
*/
public $allTables = false;
/**
* @var int Number of days
*/
public $numberOfDays = 0;
/**
* @var string Table to clean up
*/
public $table = '';
/**
* Execute garbage collection, called by scheduler.
*
* @throws \RuntimeException If configured table was not cleaned up
* @return bool TRUE if task run was successful
*/
public function execute()
{
$tableConfigurations = $this->getTableConfiguration();
$tableHandled = false;
foreach ($tableConfigurations as $tableName => $configuration) {
if ($this->allTables || $tableName === $this->table) {
$this->handleTable($tableName, $configuration);
$tableHandled = true;
}
}
if (!$tableHandled) {
throw new \RuntimeException(self::class . ' misconfiguration: ' . $this->table . ' does not exist in configuration', 1308354399);
}
return true;
}
/**
* Execute clean up of a specific table
*
* @throws \RuntimeException If table configuration is broken
* @param string $table The table to handle
* @param array $configuration Clean up configuration
* @return bool TRUE if cleanup was successful
*/
protected function handleTable(string $table, array $configuration): bool
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$queryBuilder->delete($table);
if (!empty($configuration['expireField'])) {
$field = $configuration['expireField'];
$dateLimit = $GLOBALS['EXEC_TIME'];
// If expire field value is 0, do not delete
// Expire field = 0 means no expiration
$queryBuilder->where(
$queryBuilder->expr()->lte($field, $queryBuilder->createNamedParameter($dateLimit, Connection::PARAM_INT)),
$queryBuilder->expr()->gt($field, $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
);
} elseif (!empty($configuration['dateField'])) {
if (!$this->allTables) {
$numberOfDays = $this->numberOfDays;
if (isset($configuration['expirePeriod']) && $numberOfDays <= 0) {
$numberOfDays = (int)$configuration['expirePeriod'];
}
$deleteTimestamp = strtotime('-' . $numberOfDays . 'days');
} else {
if (!isset($configuration['expirePeriod'])) {
throw new \RuntimeException(self::class . ' misconfiguration: No expirePeriod defined for table ' . $table, 1308355095);
}
$deleteTimestamp = strtotime('-' . $configuration['expirePeriod'] . 'days');
}
$queryBuilder->where(
$queryBuilder->expr()->lt(
$configuration['dateField'],
$queryBuilder->createNamedParameter($deleteTimestamp, Connection::PARAM_INT)
)
);
} else {
throw new \RuntimeException(self::class . ' misconfiguration: Either expireField or dateField must be defined for table ' . $table, 1308355268);
}
try {
$queryBuilder->executeStatement();
} catch (DBALException $e) {
throw new \RuntimeException(self::class . ' failed for table ' . $this->table . ' with error: ' . $e->getMessage(), 1308255491);
}
return true;
}
/**
* This method returns the selected table as additional information
*
* @return string Information to display
*/
public function getAdditionalInformation()
{
if ($this->allTables) {
$message = $this->getLanguageService()?->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.additionalInformationAllTables');
} else {
$message = sprintf($this->getLanguageService()?->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.additionalInformationTable'), $this->table);
}
return $message;
}
public function getTaskParameters(): array
{
return [
'all_tables' => $this->allTables,
'number_of_days' => $this->numberOfDays,
'selected_tables' => $this->table,
];
}
public function setTaskParameters(array $parameters): void
{
$this->allTables = (bool)($parameters['allTables'] ?? $parameters['all_tables'] ?? false);
$this->numberOfDays = (int)($parameters['numberOfDays'] ?? $parameters['number_of_days'] ?? 0);
$this->table = (string)($parameters['table'] ?? $parameters['selected_tables'] ?? '');
}
public function getCleanableTables(array &$config): void
{
foreach ($this->getTableConfiguration() as $tableName => $tableConfiguration) {
$config['items'][] = [
'label' => $tableName . (($tableConfiguration['expirePeriod'] ?? false) ? ' [expirePeriod: ' . $tableConfiguration['expirePeriod'] . ']' : '') . (($tableConfiguration['dateField'] ?? false) ? ' [dateField: ' . $tableConfiguration['dateField'] . ']' : ''),
'value' => $tableName,
];
}
}
public function getTableConfiguration(): array
{
$tableConfiguration = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('tx_scheduler_task.' . self::class)->getRawConfiguration()['taskOptions']['tables'] ?? [];
$tableConfigurationFromConfVars = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][self::class]['options']['tables'] ?? [];
if (!empty($tableConfigurationFromConfVars)) {
// @deprecated will be removed in v16: this SC_OPTIONS fallback is intentionally
// kept beyond v15 because SC_OPTIONS-based scheduler task registration
// is still read in TaskService::getAvailableTaskTypes() to keep legacy
// (non-native) tasks migratable. Remove together with that support.
trigger_error('Usage of $GLOBALS[\'TYPO3_CONF_VARS\'][\'SC_OPTIONS\'][\'scheduler\'][\'tasks\'][' . self::class . '][\'options\'][\'tables\'] to define table options is deprecated and will stop working in TYPO3 v16. Use $tca[\'tx_scheduler_task\'][\'types\'][' . self::class . '][\'taskOptions\'][\'tables\'] instead.', E_USER_DEPRECATED);
if (is_array($tableConfigurationFromConfVars)) {
$tableConfiguration = array_replace_recursive($tableConfiguration, $tableConfigurationFromConfVars);
}
}
return $tableConfiguration;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* Handles serialization of `AbstractTask` objects.
*
* @internal This is an internal API, avoid using it in custom implementations.
*/
#[Autoconfigure(public: true)]
readonly class TaskSerializer
{
public function __construct(
protected ContainerInterface $container,
protected TaskService $taskService,
) {}
/**
* This method takes care of safely deserializing tasks from the database
* and either returns a valid Task or throws an InvalidTaskException, which
* holds information about the broken task.
*
* First, find the task object, from the registry
* Second, recreate the execution object,
* Then fill all the data of the new task object from the rest of the row.
*
* @throws InvalidTaskException
*/
public function deserialize(array $row): AbstractTask
{
$taskType = $row['tasktype'] ?? '';
if (!empty($taskType)) {
if ($this->taskService->isTaskTypeRegistered($taskType)) {
$taskInformation = $this->taskService->getTaskDetailsFromTaskType($taskType);
$className = $taskInformation['className'];
try {
$taskObject = $this->container->get($className);
} catch (ServiceNotFoundException) {
$taskObject = GeneralUtility::makeInstance($className);
}
} else {
throw new InvalidTaskException('Task type ' . $taskType . ' not found. Probably not registered?', 1742584362);
}
if (!$taskObject instanceof AbstractTask) {
throw new InvalidTaskException('The deserialized task in not an instance of AbstractTask', 1642954501);
}
if ($taskObject instanceof ExecuteSchedulableCommandTask) {
$taskObject->setTaskType($taskType);
}
$taskObject->setTaskUid((int)$row['uid']);
$taskObject->setTaskGroup((int)$row['task_group']);
$taskParameters = json_decode($row['parameters'] ?? '', true) ?: [];
// Set additional fields from the row with the parameters stored
// in the parameters field for native types.
if ($taskInformation['isNativeTask'] ?? false) {
// If there are native registered fields, they take precedence over the values.
foreach ($taskInformation['additionalFields'] ?? [] as $additionalFieldName) {
$taskParameters[$additionalFieldName] = $taskParameters[$additionalFieldName] ?? $row[$additionalFieldName] ?? null;
}
}
$taskObject->setTaskParameters($taskParameters);
$taskObject->setDescription((string)$row['description']);
$taskObject->setExecutionTime((int)$row['nextexecution']);
$taskObject->setTaskGroup((int)$row['task_group']);
$taskObject->setDisabled((bool)$row['disable']);
$executionDetails = json_decode($row['execution_details'] ?? '', true);
if ($executionDetails !== null) {
$taskObject->setExecution(Execution::createFromDetails($executionDetails));
}
return $taskObject;
}
throw new InvalidTaskException('No task type given for task ID : ' . $row['uid'], 1740514192);
}
/**
* If the task class couldn't be figured out from the unserialization (because of uninstalled extensions or exceptions),
* try to find it in the serialized string with a simple preg match.
*/
public function extractClassName(string $serializedTask): ?string
{
if (preg_match('/^O:[0-9]+:"(?P<classname>[^"]+)"/', $serializedTask, $matches) === 1) {
return $matches['classname'];
}
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Task;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
/**
* Represents a single status flag of a scheduler task (running, late, disabled, …),
* shared by the scheduler backend module listing and the "scheduler:list" CLI command.
*
* @internal
*/
final readonly class TaskStatus
{
/**
* @param string $label domain:key reference of the badge label
* @param string $message domain:key reference of the optional, more detailed message (e.g. the failure reason)
* @param list<string|int> $messageArguments arguments for the message
*/
public function __construct(
public string $type,
public ContextualFeedbackSeverity $severity,
public string $state,
public string $label,
public string $message = '',
public array $messageArguments = [],
) {}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Validation\Validator;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
class TaskValidator
{
/**
* This method encapsulates a very simple test for the purpose of clarity.
* Registered tasks are stored in the database along with a serialized task object.
* When a registered task is fetched, its object is unserialized.
* At that point, if the class corresponding to the object is not available anymore
* (e.g. because the extension providing it has been uninstalled),
* the unserialization will produce an incomplete object.
* This test checks whether the unserialized object is of the right (parent) class or not.
*
* @param mixed $value The object to test
* @return bool TRUE if object is a task, FALSE otherwise
*/
public function isValid(mixed $value): bool
{
return $value instanceof AbstractTask
&& $value->getExecution() !== null
&& get_class($value->getExecution()) !== \__PHP_Incomplete_Class::class;
}
}