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
+1
View File
@@ -0,0 +1 @@
/vendor/
+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;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
use TYPO3\CMS\Scheduler\Controller\NewSchedulerTaskController;
use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController;
/**
* Definitions for routes provided by EXT:scheduler
*/
return [
// Register new scheduler task wizard (used in a modal)
'new_scheduler_task_wizard' => [
'path' => '/scheduler/task/wizard/new',
'target' => NewSchedulerTaskController::class . '::handleRequest',
'methods' => ['GET'],
'inheritAccessFromModule' => 'scheduler',
],
// Register scheduler setup check (used in a modal)
'scheduler_setup_check' => [
'path' => '/scheduler/setup-check',
'target' => SchedulerModuleController::class . '::setupCheckAction',
'methods' => ['GET'],
'inheritAccessFromModule' => 'scheduler',
],
];
+23
View File
@@ -0,0 +1,23 @@
<?php
use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController;
/**
* Definitions for modules provided by EXT:scheduler
*/
return [
'scheduler' => [
'parent' => 'admin',
'position' => ['after' => 'backend_user_management', 'before' => 'permissions_pages'],
'access' => 'admin',
'path' => '/module/scheduler',
'workspaces' => 'live',
'iconIdentifier' => 'module-scheduler',
'labels' => 'scheduler.module',
'routes' => [
'_default' => [
'target' => SchedulerModuleController::class . '::handleRequest',
],
],
],
];
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'dependencies' => [
'backend',
'core',
],
'imports' => [
'@typo3/scheduler/' => 'EXT:scheduler/Resources/Public/JavaScript/',
],
];
+8
View File
@@ -0,0 +1,8 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
TYPO3\CMS\Scheduler\:
resource: '../Classes/*'
@@ -0,0 +1,48 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\CachingFrameworkGarbageCollectionTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addTCAcolumns(
'tx_scheduler_task',
[
'cache_backends' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.cachingFrameworkGarbageCollection.selectBackends',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'itemsProcFunc' => CachingFrameworkGarbageCollectionTask::class . '->getRegisteredBackends',
'size' => 10,
'minitems' => 0,
'maxitems' => 100,
'default' => '',
],
],
]
);
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:cachingFrameworkGarbageCollection.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:cachingFrameworkGarbageCollection.description',
'value' => CachingFrameworkGarbageCollectionTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
cache_backends;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.cachingFrameworkGarbageCollection.selectBackends,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,56 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\FileStorageExtractionTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addTCAcolumns(
'tx_scheduler_task',
[
'max_file_count' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.fileCount',
'config' => [
'type' => 'number',
'size' => 10,
'default' => 0,
'range' => [
'lower' => 1,
'upper' => 9999,
],
],
],
'registered_extractors' => [
'config' => [
'type' => 'none',
'renderType' => 'registeredExtractors',
],
],
]
);
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:fileStorageExtraction.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:fileStorageExtraction.description',
'value' => FileStorageExtractionTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
file_storage;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageIndexing.storage,
max_file_count,
registered_extractors,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,30 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\FileStorageIndexingTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:fileStorageIndexing.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:fileStorageIndexing.description',
'value' => FileStorageIndexingTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
file_storage;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageIndexing.storage,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,86 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\IpAnonymizationTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addTCAcolumns(
'tx_scheduler_task',
[
'ip_mask' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.mask',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 2,
'items' => [
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.mask.1', 'value' => 1],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.mask.2', 'value' => 2],
],
'minitems' => 1,
'maxitems' => 1,
],
],
]
);
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:ipAnonymization.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:ipAnonymization.description',
'value' => IpAnonymizationTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
ip_mask,
selected_tables;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.table,
number_of_days;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.numberOfDays,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[
'columnsOverrides' => [
'selected_tables' => [
'displayCond' => 'FIELD:all_tables:=:0',
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.table',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'size' => 1,
'minitems' => 1,
'maxitems' => 1,
'itemsProcFunc' => IpAnonymizationTask::class . '->getAnonymizableTables',
],
],
'number_of_days' => [
'displayCond' => 'FIELD:all_tables:=:0',
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.ipAnonymization.numberOfDays',
'config' => [
'type' => 'number',
'default' => 0,
'range' => [
'lower' => 0,
],
],
],
],
'taskOptions' => [
'tables' => [
'sys_log' => [
'dateField' => 'tstamp',
'ipField' => 'IP',
],
],
],
],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,44 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\OptimizeDatabaseTableTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:optimizeDatabaseTable.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:optimizeDatabaseTable.description',
'value' => OptimizeDatabaseTableTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
selected_tables;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.optimizeDatabaseTables.selectTables,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[
'columnsOverrides' => [
'selected_tables' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.optimizeDatabaseTables.selectTables',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'size' => 10,
'minitems' => 1,
'maxitems' => 100,
'itemsProcFunc' => OptimizeDatabaseTableTask::class . '->getOptimizableTables',
],
],
],
],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,43 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\RecyclerGarbageCollectionTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:recyclerGarbageCollection.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:recyclerGarbageCollection.description',
'value' => RecyclerGarbageCollectionTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
number_of_days;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.recyclerGarbageCollection.numberOfDays,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[
'columnsOverrides' => [
'number_of_days' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.recyclerGarbageCollection.numberOfDays',
'config' => [
'type' => 'number',
'default' => 30,
'range' => [
'lower' => 0,
],
],
],
],
],
'',
'tx_scheduler_task'
);
@@ -0,0 +1,97 @@
<?php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask;
defined('TYPO3') or die();
ExtensionManagementUtility::addTCAcolumns(
'tx_scheduler_task',
[
'all_tables' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.allTables',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.allTables.description',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'default' => 0,
],
],
]
);
ExtensionManagementUtility::addRecordType(
[
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:tableGarbageCollection.name',
'description' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:tableGarbageCollection.description',
'value' => TableGarbageCollectionTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'group' => 'scheduler',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
all_tables,
selected_tables;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.table,
number_of_days;LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.numberOfDays,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[
'columnsOverrides' => [
'selected_tables' => [
'displayCond' => 'FIELD:all_tables:=:0',
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.table',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'size' => 1,
'minitems' => 1,
'maxitems' => 1,
'itemsProcFunc' => TableGarbageCollectionTask::class . '->getCleanableTables',
],
],
'number_of_days' => [
'displayCond' => 'FIELD:all_tables:=:0',
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.tableGarbageCollection.numberOfDays',
'config' => [
'type' => 'number',
'default' => 0,
'range' => [
'lower' => 0,
],
'fieldInformation' => [
'expirePeriodInformation' => [
'renderType' => 'expirePeriodInformation',
'options' => [
'refField' => 'selected_tables',
],
],
],
],
],
],
'taskOptions' => [
'tables' => [
'sys_log' => [
'dateField' => 'tstamp',
'expirePeriod' => 180,
],
'sys_http_report' => [
'dateField' => 'changed',
'expirePeriod' => 30,
],
'sys_history' => [
'dateField' => 'tstamp',
'expirePeriod' => 30,
],
],
],
],
'',
'tx_scheduler_task'
);
+204
View File
@@ -0,0 +1,204 @@
<?php
use TYPO3\CMS\Scheduler\Service\TaskService;
return [
'ctrl' => [
'label' => 'tasktype',
'label_alt' => 'description',
'label_alt_force' => true,
'title' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task',
'crdate' => 'crdate',
'delete' => 'deleted',
'default_sortby' => 'nextexecution',
'typeicon_classes' => [
// @todo, TYPO3.icons needs to introduce tx_scheduler_task and use the current icon for "tx_scheduler_task_group"
'default' => 'mimetypes-x-tx_scheduler_task_group',
],
'type' => 'tasktype',
'hideTable' => true, // Disabled for now until sorting and grouping is usable in records module
'adminOnly' => true, // Only admin users can edit
'groupName' => 'system',
'rootLevel' => 1,
'enablecolumns' => [
'disabled' => 'disable',
],
],
'columns' => [
'tasktype' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.tasktype',
'config' => [
'type' => 'select',
'renderType' => 'taskTypeInfo',
'itemsProcFunc' => TaskService::class . '->getTaskTypesForTcaItems',
// Always select the first tasktype
'items' => [],
'default' => '',
'required' => true,
// relevant for migration
'nullable' => true,
],
],
'task_group' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.task_group',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'tx_scheduler_task_group',
'items' => [
['label' => '', 'value' => 0],
],
'size' => 1,
'default' => 0,
],
],
'priority' => [
'label' => 'scheduler.tca:tx_scheduler_task.priority',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['label' => 'scheduler.tca:tx_scheduler_task.priority.high', 'value' => 150],
['label' => 'scheduler.tca:tx_scheduler_task.priority.regular', 'value' => 100],
['label' => 'scheduler.tca:tx_scheduler_task.priority.low', 'value' => 50],
],
'default' => 100,
],
],
'description' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.description',
'config' => [
'type' => 'text',
],
],
'parameters' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.parameters',
'config' => [
'type' => 'json',
],
],
'execution_details' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.execution_details',
'config' => [
'type' => 'json',
'renderType' => 'schedulerTimingOptions',
'overrideFieldTca' => [
'frequency' => [
'config' => [
'valuePicker' => [
'items' => [
[ 'value' => '0 9,15 * * 1-5', 'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:command.example1' ],
[ 'value' => '0 */2 * * *', 'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:command.example2' ],
[ 'value' => '*/20 * * * *', 'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:command.example3' ],
[ 'value' => '0 7 * * 2', 'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:command.example4' ],
],
],
],
],
],
],
],
'nextexecution' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.nextexecution',
'config' => [
'type' => 'datetime',
'readOnly' => true,
],
],
'lastexecution_time' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.lastexecution_time',
'config' => [
'type' => 'datetime',
'readOnly' => true,
],
],
'lastexecution_failure' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.lastexecution_failure',
'config' => [
'type' => 'text',
'readOnly' => true,
],
],
'lastexecution_context' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task.lastexecution_context',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => [
['value' => 'CLI', 'label' => 'CLI'],
['value' => 'BE', 'label' => 'BE'],
['value' => '', 'label' => ''],
],
'readOnly' => true,
'dbFieldLength' => 3,
'default' => '',
],
],
'serialized_executions' => [
'config' => [
'type' => 'passthrough',
],
],
'number_of_days' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:tx_scheduler_task.number_of_days',
'config' => [
'type' => 'number',
'default' => 0,
],
],
'selected_tables' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:tx_scheduler_task.selected_tables',
'config' => [
'type' => 'select',
'renderType' => 'selectMultipleSideBySide',
'size' => 10,
'dbFieldLength' => 4000,
'minitems' => 1,
'maxitems' => 100,
'items' => [],
],
],
'file_storage' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:tx_scheduler_task.file_storage',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'foreign_table' => 'sys_file_storage',
'size' => 1,
'minitems' => 1,
'maxitems' => 1,
'items' => [],
],
],
],
'types' => [
'0' => [
'showitem' => '
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
--div--;core.form.tabs:timing,
--palette--;;execution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,
',
],
],
'palettes' => [
'execution' => [
'showitem' => '
execution_details,
--linebreak--,
nextexecution,
priority,
--linebreak--,
lastexecution_context,
lastexecution_time,
--linebreak--,
lastexecution_failure,
',
],
],
];
@@ -0,0 +1,72 @@
<?php
return [
'ctrl' => [
'label' => 'groupName',
'tstamp' => 'tstamp',
'title' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'typeicon_classes' => [
'default' => 'mimetypes-x-tx_scheduler_task_group',
],
'adminOnly' => true, // Only admin users can edit
'groupName' => 'system',
'rootLevel' => 1,
'enablecolumns' => [
'disabled' => 'hidden',
],
],
'columns' => [
'groupName' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.groupName',
'config' => [
'type' => 'input',
'size' => 35,
'max' => 80,
'required' => true,
'eval' => 'unique,trim',
'softref' => 'substitute',
],
],
'color' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color',
'config' => [
'type' => 'color',
'size' => 10,
'valuePicker' => [
'items' => [
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.typo3Orange', 'value' => '#FF8700'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.white', 'value' => '#ffffff'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.gray', 'value' => '#808080'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.black', 'value' => '#000000'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.blue', 'value' => '#2671d9'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.purple', 'value' => '#5e4db2'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.teal', 'value' => '#2da8d2'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.green', 'value' => '#3cc38c'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.magenta', 'value' => '#c6398f'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.yellow', 'value' => '#ffbf00'],
['label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.color.red', 'value' => '#d13a2e'],
],
],
],
],
'description' => [
'label' => 'LLL:EXT:scheduler/Resources/Private/Language/locallang_tca.xlf:tx_scheduler_task_group.description',
'config' => [
'type' => 'text',
],
],
],
'types' => [
'1' => [
'showitem' => '
--div--;core.form.tabs:general, groupName, color,
--div--;core.form.tabs:access, hidden,
--div--;core.form.tabs:notes, description,
--div--;core.form.tabs:extended,
',
],
],
];
+1
View File
@@ -0,0 +1 @@
options.showDuplicate.tx_scheduler_task = 0
@@ -0,0 +1,120 @@
:navigation-title: Backend Module
.. include:: /Includes.rst.txt
.. _backend-module:
==============================
The "Scheduler" backend module
==============================
.. contents:: Table of contents
.. _setup-check-screen:
The setup check screen
======================
This screen has already been mentioned in the :ref:`Installation chapter <setup-check>`.
It is mostly useful when setting up the Scheduler, as it indicates
whether the CLI script is executable or not. When everything is
running fine, it contains mostly one useful piece of information: when
the last run took place, when it ended and whether it was started
manually (i.e. from the BE module) or automatically (i.e. from the
command line).
.. _scheduled-tasks-screen:
The scheduled tasks screen
==========================
This is the main screen when administering tasks. At first it will be
empty and just offer a link to add a new task. When such registered
tasks exists, this screen will show a list with various pieces of
information.
.. figure:: /Images/BackendModuleMainView.png
:alt: Scheduler main screen
Main screen of the Scheduler BE module
Disabled tasks have a gray label sign near the task name. A disabled task is a
task that will not be run automatically by the command-line script,
but may still be executed from the BE module.
A late task will appear with an orange label sign near the task name:
.. figure:: /Images/LateTask.png
:alt: A late task in the Scheduler main screen
A late task in the main screen of the Scheduler BE module
The task list can be sorted by clicking the column label. With every click it
switches between ascending and descending order of the items of the associated column.
The table at the center of the above screenshot shows the
following:
- The first column contains checkboxes. Clicking on a checkbox will
select that particular scheduled task for immediate execution.
Clicking on the icon at the top of the column will toggle all
checkboxes. To execute the selected tasks, click on the "Execute
selected tasks" button. Read more in "Manually executing a task"
below.
- The second column simply displays the id of the task.
- The third column contains the name of the task, the extension it is
coming from and any additional information specific to the task. It also shows
a summary of the task's status with an icon.
- The fourth column shows whether the task is recurring or will run only
a single time.
- The fifth column shows the frequency.
- The sixth columns indicates whether parallel executions are allowed
or not.
- The seventh column shows the last execution time and indicates whether
the task was launched manually or was run via the command-line script
(cron).
- The eighth column shows the planned execution time. If the task is
overdue, the time will show up in bold, red numbers. A task may have
no future execution date if it has reached its end date, if it was
meant to run a single time and that execution is done, or if the task
is disabled. The next execution time is also hidden for running tasks,
as this information makes no sense at that point in time.
- The last column contains possible actions, mainly editing, disable or
deleting a task. There are also buttons for running the task on the
next cron job or run it directly.
The actions will be unavailable for a task that is currently running,
as it is unwise to edit or delete it a task in such a case. Instead a
running task will display a "stop" button (see "Stopping a task" below).
Note that all dates and times are displayed in the server's time zone.
The server time appears at the bottom of the screen.
At the top of the screen is a link to add a new task.
If there are a lot of tasks that appear late,
consider changing the frequency at which the cron job is running (see
"Choosing a frequency" above).
Occasionally the following display may appear:
.. figure:: ../../Images/MissingTaskClass.png
:alt: A broken task
A scheduled task missing its corresponding class
This will typically happen when a task provided by some
extension was registered, then the extension was uninstalled, but the
task was not deleted beforehand. In such a case, this task stays but
the Scheduler doesn't know how to handle it anymore. The solution is
either to install the related extension again or delete the registered
task.
@@ -0,0 +1,119 @@
:navigation-title: Console tools
.. include:: /Includes.rst.txt
.. _console-tools:
=======================================
Console tools to manage scheduler tasks
=======================================
Console commands to manage scheduler tasks include :command:`typo3 scheduler:list`,
:command:`typo3 scheduler:execute` and :command:`typo3 scheduler:run`.
You can display detailed help on these commands, by using the `--help` parameter to
display the help:
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
vendor/bin/typo3 scheduler:list --help
.. group-tab:: Classic mode
.. code-block:: bash
typo3/sysext/core/bin/typo3 scheduler:list --help
See also: `Command usage in terminal environments <https://docs.typo3.org/permalink/t3coreapi:how-to-run-a-command>`_.
.. contents:: Table of contents
.. toctree::
:glob:
:caption: Subpages
:titlesonly:
*
.. _console-run:
Running the scheduler
=====================
The command :command:`typo3 scheduler:run` is usually called by the
`cron job <https://docs.typo3.org/permalink/typo3/cms-scheduler:cron-job>`_.
It looks for tasks that are **due**, and runs them. You can
optionally target specific task IDs, force them even if not due, or stop them.
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
vendor/bin/typo3 scheduler:run
.. group-tab:: Classic mode
.. code-block:: bash
typo3/sysext/core/bin/typo3 scheduler:run
.. seealso::
`Running the scheduler: typo3 scheduler:run <https://docs.typo3.org/permalink/typo3/cms-scheduler:scheduler-shell-script>`_
.. _console-execute:
Executing scheduler tasks
=========================
The command :command:`typo3 scheduler:execute` is a "manual fire" runner. You pick
tasks (IDs or whole groups) and it **executes them on demand**, regardless of
whether they are due. It can also prompt you interactively to choose.
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
# Note the id of the task
vendor/bin/typo3 scheduler:list
vendor/bin/typo3 scheduler:execute --task=<taskUid>
.. group-tab:: Classic mode
.. code-block:: bash
# Find the id of the task
typo3/sysext/core/bin/typo3 scheduler:list
typo3/sysext/core/bin/typo3 scheduler:execute --task=<taskUid>
.. _console-list:
Listing all scheduler tasks
===========================
Command :command:`typo3 scheduler:list` can be used to list all available tasks.
This command basically displays the same information as the backend module
:guilabel:`Administration > Scheduler`.
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
vendor/bin/typo3 scheduler:list
.. group-tab:: Classic mode
.. code-block:: bash
typo3/sysext/core/bin/typo3 scheduler:list
@@ -0,0 +1,141 @@
:navigation-title: Run the scheduler
.. include:: /Includes.rst.txt
.. _scheduler-shell-script:
==========================================
Running the scheduler: typo3 scheduler:run
==========================================
The scheduler provides a PHP shell script designed to be run using
TYPO3's command-line dispatcher. To try and run that script a first
time, type the following command.
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 scheduler:run
.. group-tab:: Classic installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 scheduler:run
See also `TYPO3 Explained: Run a command from the command
line <https://docs.typo3.org/permalink/t3coreapi:symfony-console-commands-cli>`_.
.. contents:: Table of contents
.. _scheduler-shell-script-help:
Show help
=========
In order to show help:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
vendor/bin/typo3 scheduler:run --help
.. group-tab:: Classic installation
.. code-block:: bash
typo3/sysext/core/bin/typo3 scheduler:run --help
.. _scheduler-shell-script-options:
Providing options to the shell script
=====================================
The shell scripts accepts a number of options which can be provided in any
order.
.. _scheduler-shell-script-options-i:
`--task (-i)`
-------------
To run a specific scheduler task you need to provide the uid of the task:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
# Run task with uid 42
vendor/bin/typo3 scheduler:run --task=42
# Run tasks with uid 3 and 14
vendor/bin/typo3 scheduler:run --task=3 --task=14
.. group-tab:: Classic installation
.. code-block:: bash
# Run task with uid 42
typo3/sysext/core/bin/typo3 scheduler:run --task=42
# Run tasks with uid 3 and 14
typo3/sysext/core/bin/typo3 scheduler:run --task=3 --task=14
The tasks will be executed in the order in which the parameters are provided.
.. _scheduler-shell-script-options-f:
`--force (-f)`
--------------
To run a task even if it is disabled (or not scheduled to be run yet),
you need to provide the force option:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
# Run task with uid 42, even if disabled
vendor/bin/typo3 scheduler:run --task=42 --force
.. group-tab:: Classic installation
.. code-block:: bash
# Run task with uid 42, even if disabled
typo3/sysext/core/bin/typo3 scheduler:run --task=42 --force
This will also run the task with uid 42 if it is disabled.
.. _scheduler-shell-script-options-v:
`--verbose (-v)`
----------------
A single `-v` flag will output errors only. Two `-vv` flags will also output
additional information:
.. tabs::
.. group-tab:: Composer-based installation
.. code-block:: bash
# Run task with uid 42, with detailed stack traces
vendor/bin/typo3 scheduler:run --task=42 -vv
.. group-tab:: Classic installation
.. code-block:: bash
# Run task with uid 42, with detailed stack traces
typo3/sysext/core/bin/typo3 scheduler:run --task=42 -vv
@@ -0,0 +1,34 @@
.. include:: /Includes.rst.txt
.. _deleting-a-task:
===============
Deleting a task
===============
When choosing to delete a task, a pop-up window will appear requesting
confirmation.
Deleted tasks can be recovered in the module :guilabel:`Content > Recycler` if
installed or by unsetting the deleted flag in the database.
.. _deleting-a-task-restoration:
Restoring a deleted scheduler task
==================================
.. versionchanged:: 14.0
Previously removed tasks can be restored via recycler.
If the system extension :composer:`typo3/cms-recycler` is installed, go to
module :guilabel:`Content > Recycler`.
Choose the page root (page 0) in the page tree, all scheduler tasks are stored
here.
You can use the field "Type" to filter for "Scheduler task" only.
.. figure:: /Images/RestoreTask.png
:alt: Screenshot of the TYPO3 Backend Recycler module on the root page 0 with type "Scheduler task" selected
A task can be restored or permanently deleted in the recycler module
@@ -0,0 +1,104 @@
:navigation-title: Adding / Editing
.. include:: /Includes.rst.txt
.. _adding-editing-task:
========================
Adding or editing a task
========================
Administrators can add or edit scheduler tasks in the backend module
:guilabel:`Administration > Scheduler`.
When adding a new scheduler task a wizard will allow you to select a task
type from several categories.
.. seealso::
Developers can implement and register custom tasks:
`Creating a custom scheduler task <https://docs.typo3.org/permalink/typo3/cms-scheduler:creating-tasks>`_
.. contents:: Table of contents
.. _information-screen:
.. _adding-editing-task-wizard:
The scheduler task wizard
=========================
.. figure:: /Images/EmptySchedulerModule.png
:alt: Screenshot of an empty scheduler module, No tasks defined yet.
Click on "New task" to add a task
.. figure:: /Images/TaskCreationWizard.png
:alt: Screenshot of the "New task" wizard in the scheduler backend module
Choose the task to be created
.. seealso::
Developers can listen to event `ModifyNewSchedulerTaskWizardItemsEvent <https://docs.typo3.org/permalink/typo3/cms-scheduler:modifynewschedulertaskwizarditemsevent>`_
to influence the items displayed here.
.. _adding-editing-task-form:
The scheduler task form
=======================
When adding or editing a task, the following form will show up:
.. figure:: /Images/AddingATask.png
:alt: Screenshot of the form to Create new Scheduler task on root level
Adding a new scheduled task
Some fields require additional explanations (inline help is
available by moving the mouse over the field labels):
- A disabled task will be skipped by the command-line script. It may
still be launched manually, as described above.
.. versionadded:: 13.3
Similar to editing regular content elements, it is now possible to save
scheduler tasks being edited via keyboard shortcuts as well.
It is possible to invoke the :kbd:`Ctrl`/:kbd:`Cmd` + :kbd:`s` hotkey to save a
scheduler task, altogether with the hotkey :kbd:`Ctrl`/:kbd:`Cmd` + :kbd:`Shift` + :kbd:`S`
to save and close a scheduler task.
.. _adding-editing-task-form-settings:
Scheduler task settings
=======================
Some tasks allow additional settings to be made in the area :guilabel:`Settings`.
These fields differ from task to task
.. _adding-editing-task-form-timing:
Task executions timing details
==============================
.. figure:: /Images/TaskExecutionDetails.png
:alt: Screenshot of tab "Timing" the scheduler task form
Choosing a frequency for a recurring task
- A task must have a start date. It defaults to the time of creation.
The server's time appears at the bottom of the form.
- Task can be run a single time or recurring.
- The frequency needs be entered only for recurring tasks.
It can be either an integer number of seconds or a cron-like schedule expression.
Scheduler supports ranges, steps and keywords like ``@weekly``.
See `en.wikipedia.org <https://en.wikipedia.org/wiki/Cron#CRON_expression>`_ for more information.
See :php:`\TYPO3\CMS\Scheduler\CronCommand\CronCommand`
and :php:`\TYPO3\CMS\Scheduler\CronCommand\NormalizeCommand`
class references in the TYPO3 CMS source code for definitive rules.
- Parallel executions are denied for recurring tasks. They can be allowed by
checking "Allow Parallel Execution"
If an error occurs when validating a cron definition, the
Scheduler's built-in cron parser tries to provide an explanation about
what's wrong.
@@ -0,0 +1,69 @@
:navigation-title: Grouping
.. include:: /Includes.rst.txt
.. _grouping-tasks:
=======================================================
Grouping tasks together in the Scheduler backend module
=======================================================
In case of a high number of different tasks, it may be useful to visually
group similar tasks together:
.. figure:: /Images/GroupedTasks.png
:alt: Screenshot of the TYPO3 backend module scheduler with buttons regarding groups highlighted
Use button :guilabel:`New group` (1) to create a group, click on the title (2) to edit the group
You can sort and disable groups (3).
Unused groups are displayed at the bottom of the page. Only unused groups can
be deleted (4).
When editing a group you can change its title and color.
.. figure:: /Images/GroupEdit.png
:alt: Screenshot of the task group form
Color options can be customized by manipulating the
:php:`$GLOBAS['TCA']['tx_scheduler_task_group']['columns']['color']['config']['valuePicker']['items']`
array in your TCA overrides file.
.. code-block:: php
:caption: packages/my-sitepackage/Configuration/Overrides/tx_scheduler_task_group.php
$GLOBALS['TCA']['tx_scheduler_task_group']['columns']['color']['config']['valuePicker']['items'][] = [
'label' => 'My Color',
'value' => '#ABCDEF'
];
.. _grouping-tasks-edit:
Editing task groups
===================
Scheduler task groups can be created, edited and deleted from the module
:guilabel:`Administration > Scheduler`.
Technically they are records stored on the root page (pid=0). They can also be
created, edited and sorted with module :guilabel:`Content > Records`.
It is also possible to create a new task group from within the edit task form by
clicking on the `+` icon next to the task group select box.
.. _grouping-tasks-disable:
Disabling task groups
=====================
You can use button :guilabel:`Disable group` to disable all tasks in a group
at once.
.. figure:: /Images/GroupDisabled.png
:alt: Screenshot a disabled task group, all tasks are marked as disabled by group
Use button :guilabel:`Enable group` to enable all tasks that had not been manually disabled.
Tasks in a disabled group, just like disabled tasks in general are not executed
when the scheduler is called by the cron job. They can, however, be executed
manually by clicking the :guilabel:`Run task` button.
+25
View File
@@ -0,0 +1,25 @@
:navigation-title: Administration
.. include:: /Includes.rst.txt
.. _administration:
=================================================
Backend administration in the "Scheduler" module
=================================================
The Scheduler provides a BE module to manage tasks. It provides three
screens: a setup check, an information screen and the main (default)
one for actually managing the tasks.
.. toctree::
:maxdepth: 5
:titlesonly:
:glob:
BackendModule/Index
EditTask/Index
DeleteTask/Index
GroupTask/Index
StopTask/Index
ManualExecution/Index
ConsoleTools/Index
@@ -0,0 +1,56 @@
:navigation-title: Manual Execution
.. include:: /Includes.rst.txt
.. _manually-executing-a-task:
.. _executing-a-task-on-next-cronjob:
===========================================================
Manually executing a task from the Scheduler backend module
===========================================================
You can manually execute tasks from the BE module. After execution, each
task shows success or failure.
* If a task was overdue, a new execution date is calculated.
* If it was not overdue, the existing next execution date remains.
Running tasks:
* To run a single task, press the button in its row.
* To run multiple tasks, select their checkboxes and press the button below the list.
There are two options:
* Run the task immediately. (Button 2 in the screenshot)
* Schedule the task. (Button 1 in the screenshot) The selected tasks will
then run on the next cron job.
.. figure:: /Images/ManualExecution.png
:alt: Scheduler backend module with the buttons "Run task on next cron job" (1) and "Run task" (2) highlighted
Button 2 runs the task immediately, while button 1 schedules it fot the next cronjob run
.. _manually-executing-a-task-cli:
Manually executing a task from the console
==========================================
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
# Note the id of the task
vendor/bin/typo3 scheduler:list
vendor/bin/typo3 scheduler:execute --task=<taskUid>
.. group-tab:: Classic mode
.. code-block:: bash
# Find the id of the task
typo3/sysext/core/bin/typo3 scheduler:list
typo3/sysext/core/bin/typo3 scheduler:execute --task=<taskUid>
@@ -0,0 +1,92 @@
:navigation-title: Stopping a Task
.. include:: /Includes.rst.txt
.. _stopping-a-task:
===============================================
Stopping a task in the Scheduler backend module
===============================================
A task is marked as "running" while it runs. If the process crashes or
is killed, the task may remain marked as "running". This is usually
cleaned up automatically based on the maximum lifetime parameter,
but manual cleanup may sometimes be needed.
.. figure:: /Images/StoppingATask.png
:alt: Stopping a task
Stopping a running task from the main screen
Use the **stop** button to clear the execution mark for a task.
This allows the task to run again.
Note: This does **not** terminate an actual running or hanging process.
.. _stopping-a-task-cli:
Stopping a task via console command
===================================
You can also use a command to stop the task:
.. tabs::
.. group-tab:: Composer mode
.. code-block:: bash
# Note the id of the task
vendor/bin/typo3 scheduler:list
vendor/bin/typo3 scheduler:run --task=<taskUid> --stop
.. group-tab:: Classic mode
.. code-block:: bash
# Find the id of the task
typo3/sysext/core/bin/typo3 scheduler:list
typo3/sysext/core/bin/typo3 scheduler:run --task=<taskUid> --stop
.. _kill-task:
How to handle a truly "hung" task
=================================
If a task hangs or is stuck (for example due to an infinite loop or external I/O),
then stopping it via TYPO3 (either UI or CLI) will only clear TYPO3s internal flag.
The actual PHP process running the task will continue on the system
until it finishes or the OS/PHP process manager kills it.
If a task keeps running indefinitely:
#. **Identify the process PID** You can find the hanging PHP process using
tools like `top`, `ps aux | grep scheduler`, or via `systemctl status` if you run TYPO3 via a
systemd service.
#. **Manually terminate the PHP process** (for example, `kill <pid>`).
#. **Clear the TYPO3 execution flag** by running:
:command:`vendor/bin/typo3 scheduler:run --task=<taskUid> --stop`
.. warning::
Manually terminating a scheduler process using `sudo kill <pid>` should only
be used as a *last resort*.
Killing a running PHP process may interrupt database or file operations and
leave the system in an inconsistent state.
Always analyze why the task is hanging before killing it.
.. code-block:: bash
ps aux | grep scheduler
# you might find something like this:
www-data 12345 99.0 5.2 php vendor/bin/typo3 scheduler:run
# To force-stop that OS-level process:
sudo kill 12345
# Then clear the mark again via:
vendor/bin/typo3 scheduler:run --task=13 --stop
@@ -0,0 +1,94 @@
:navigation-title: Table Garbage Collection
.. include:: /Includes.rst.txt
.. _table-garbage-collection-task:
=============================
Table garbage collection task
=============================
The table garbage collection task can take a more elaborate
configuration which is detailed below.
.. contents:: Table of contents
.. _table-garbage-collection-task-usage:
Using the garbage collection task
=================================
The task can be registered to clean up a particular table, in which
case you simply choose the table and the minimum age of the records to
delete from the task configuration screen.
.. figure:: /Images/TableGarbageCollectionTaskConfiguration.png
:alt: Table Garbage Collection task configuration
Configuring the table garbage collection task
In case no minimum age is choosen, the configured :php:`expirePeriod` is used.
.. figure:: /Images/TableGarbageCollectionTaskConfiguration-2.png
:alt: Table Garbage Collection task configuration default expire period
Configuring the table garbage collection task with default expire period
It is also possible to clean up all configured table by
checking the "Clean all available tables" box.
The configuration for
the tables to clean up is stored in the TCA of table `tx_scheduler_task`, in
field `tables`.
This configuration is an array with the table names as fields and the following
entries:
- option :php:`expireField` can be used to point to a table field
containing an expiry timestamp. This timestamp will then be used to
decide whether a record has expired or not. If its timestamp is in the
past, the record will be deleted.
- if a table has no expiry field, one can use a combination of a date
field and an expiry period to decide which records should be deleted.
The corresponding options are :php:`dateField` and :php:`expirePeriod`.
The expiry period is expressed in days.
.. _table-garbage-collection-task-example:
Example: Configure additional tables for the "Garbage Collection" task
======================================================================
.. deprecated:: 14.0
The previous configuration method using
:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask::class]['options']['tables']`
has been deprecated and will be removed in TYPO3 v15.
See also: `Changelog Deprecation: #107550 - Table Garbage Collection Task configuration via $GLOBALS <https://docs.typo3.org/permalink/changelog:deprecation-107550-1736193200>`_
.. literalinclude:: _codesnippets/_tx_scheduler_garbage_collection.php.inc
:language: php
:caption: packages/my_extension/Configuration/TCA/Overrides/tx_scheduler_garbage_collection.php
.. include:: /_Includes/_ExtendingSchedulerTca.rst.txt
The first part of the configuration indicates that records older than
180 days should be removed from table :code:`tx_myextension_my_table` ,
based on the timestamp field called "tstamp". The second part
indicates that old records should be removed from table
:code:`tx_myextension_my_other_table` directly based on the field `expire`
which contains expiration dates for each record.
.. _table-garbage-collection-task-migration:
Migration: Supporting custom tables for garbage collection for both TYPO3 13 and 14
===================================================================================
If your extension supports both TYPO3 13 (or below) and 14 keep the registration
of additional tables in the extensions :file:`ext_localconf.php` until support
for TYPO3 13 is removed:
.. literalinclude:: _codesnippets/_additional.php.inc
:language: php
:caption: packages/my_extension/ext_localconf.php
+42
View File
@@ -0,0 +1,42 @@
:navigation-title: Basic tasks
.. include:: /Includes.rst.txt
.. _base-tasks:
==========================================
The basic tasks provided by the TYPO3 Core
==========================================
The Scheduler comes by default with several tasks:
- **Caching framework garbage collection** : some cache backends do not
have an automatic garbage collection process. For these it is useful
to run this Scheduler task to regularly free some space.
- **Fileadmin garbage collection** : empties :file:`_recycler_` folders in
the fileadmin.
- **Table garbage collection** : cleans up old records from any table in
the database. See related section below for more information on
configuration.
Most TYPO3 console command can also be executed via scheduler.
The following tasks provide configuration options that need dedicated chapters:
.. toctree::
:glob:
:titlesonly:
*
.. _other-tasks:
Providing custom tasks from your extension
==========================================
More tasks are provided by system extensions, such as the Extension
Manager, which defines one for updating the available extensions list.
The base tasks are also there to serve as examples for task developers
(see :ref:`developer-guide`).
@@ -0,0 +1,53 @@
:navigation-title: IP Anonymization task
.. include:: /Includes.rst.txt
.. _ip-anonymization-task:
=====================
IP anonymization task
=====================
The IP Anonymization task can take a more elaborate
configuration which is detailed below.
The task anonymizes the IP addresses to enforce the privacy of the persisted data.
.. contents:: Table of contents
.. _ip-anonymization-task-example:
Example: Configure additional tables for the "IP Anonymization" task
====================================================================
.. deprecated:: 14.0
The previous configuration method using
:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Scheduler\Task\IpAnonymizationTask::class]['options']['tables']`
has been deprecated and will be removed in TYPO3 v15.
See also: `Changelog Deprecation: #107562 - Ip Anonymization Task configuration via $GLOBALS <https://docs.typo3.org/permalink/changelog:deprecation-107562-1736193200>`_
.. literalinclude:: _codesnippets/_tx_scheduler_ip_anonymization.php.inc
:language: php
:caption: packages/my_extension/Configuration/TCA/Overrides/tx_scheduler_ip_anonymization.php
.. include:: /_Includes/_ExtendingSchedulerTca.rst.txt
This entry configures that the field `private_ip` of table
`tx_myextension_my_table` can be anonymized after a chosen number of days.
The field `tstamp` will be taken into account to determine when the database
record was last changed.
.. _ip-anonymization-task-migration:
Migration: Supporting custom tables for "IP Anonymization" tasks for both TYPO3 13 and 14
=========================================================================================
If your extension supports both TYPO3 13 (or below) and 14 keep the registration
of additional tables in the extensions :file:`ext_localconf.php` until support
for TYPO3 13 is removed:
.. literalinclude:: _codesnippets/_ext_localconf_ip_anonymization.php.inc
:language: php
:caption: packages/my_extension/ext_localconf.php
@@ -0,0 +1,16 @@
<?php
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask;
if ((new Typo3Version())->getMajorVersion() < 14) {
// TODO: Remove once TYPO3 13 support is dropped
// TYPO3 14 configuration can be found in Configuration/TCA/Overrides/tx_scheduler_garbage_collection.php
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][TableGarbageCollectionTask::class]['options']['tables']['tx_myextension_errorlog'] = [
'dateField' => 'tstamp',
'expirePeriod' => '180',
];
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][TableGarbageCollectionTask::class]['options']['tables']['tx_myextension_uniqalias'] = [
'expireField' => 'expire',
];
}
@@ -0,0 +1,17 @@
<?php
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Scheduler\Task\IpAnonymizationTask;
if ((new Typo3Version())->getMajorVersion() < 14) {
// TODO: Remove once TYPO3 13 support is dropped
// TYPO3 14 configuration can be found in Configuration/TCA/Overrides/tx_scheduler_ip_anonymization.php
$garbageCollectionTables =& $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][IpAnonymizationTask::class]['options']['tables'];
$garbageCollectionTables = array_replace($garbageCollectionTables ?? [], [
'tx_myextension_my_table' => [
'dateField' => 'tstamp',
'ipField' => 'private_ip',
],
]);
}
@@ -0,0 +1,17 @@
<?php
use TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask;
if (isset($GLOBALS['TCA']['tx_scheduler_task'])) {
$garbageCollectionTables =& $GLOBALS['TCA']['tx_scheduler_task']['types'][TableGarbageCollectionTask::class]['taskOptions']['tables'];
$garbageCollectionTables = array_replace($garbageCollectionTables ?? [], [
'tx_myextension_my_table' => [
'dateField' => 'tstamp',
'expirePeriod' => 180,
],
'tx_myextension_my_other_table' => [
'expireField' => 'expire',
],
]);
}
@@ -0,0 +1,14 @@
<?php
use TYPO3\CMS\Scheduler\Task\IpAnonymizationTask;
if (isset($GLOBALS['TCA']['tx_scheduler_task'])) {
$garbageCollectionTables =& $GLOBALS['TCA']['tx_scheduler_task']['types'][IpAnonymizationTask::class]['taskOptions']['tables'];
$garbageCollectionTables = array_replace($garbageCollectionTables ?? [], [
'tx_myextension_my_table' => [
'dateField' => 'tstamp',
'ipField' => 'private_ip',
],
]);
}
@@ -0,0 +1,129 @@
:navigation-title: Task development
.. include:: /Includes.rst.txt
.. _creating-tasks:
================================
Creating a custom scheduler task
================================
.. important::
.. versionchanged:: 14.0
Custom scheduler tasks can be registered as TCA types in table
`tx_scheduler_task`.
See also: `Changelog Feature: #107526 - Custom TCA types for scheduler tasks <https://docs.typo3.org/permalink/changelog:feature-107526-1747816234>`_.
.. contents:: Table of contents
.. toctree::
:glob:
:titlesonly:
*
.. seealso::
Symfony console commands can also be executed as scheduler task:
See :ref:`Create and use Symfony commands in TYPO3 <t3coreapi:symfony-console-commands>`.
.. _creating-tasks-implementation:
Implementation of a custom scheduler task
=========================================
All scheduler task implementations **must** extend
:php:`\TYPO3\CMS\Scheduler\Task\AbstractTask`.
.. literalinclude:: _codesnippets/_MyTask.php.inc
:language: php
:caption: packages/my_extension/Classes/MyTask.php
A custom task implementation **must** override the method `execute(): bool`.
It is the main method that is called when a task is executed.
This method Should return `true` on successful execution, `false` on error.
.. note::
There is no error handling by default, errors and failures are expected
to be handled and logged by the client implementation.
Method `getAdditionalInformation()` **should** be implemented to provide
additional information in the schedulers backend module.
Scheduler task implementations that provide `additional fields <https://docs.typo3.org/permalink/typo3/cms-scheduler:additional-fields>`_
**should** implement additional methods, expecially `getTaskParameters()`.
.. _creating-tasks-registration:
Scheduler task registration and configuration
=============================================
.. deprecated:: 14.0
Registering tasks and additional field providers via
:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks']` has
been deprecated.
Custom scheduler tasks can be registered via TCA overrides, for example in
:file:`EXT:my_extension/Configuration/TCA/Overrides/tx_scheduler_my_task.php`
.. literalinclude:: _codesnippets/_tx_scheduler_my_task.php.inc
:language: php
:caption: EXT:my_extension/Configuration/TCA/Overrides/tx_scheduler_my_task.php
.. tip::
Using the :php:`iconOverlay` option on task type registration, an icon
overlay can be added, which is then displayed in the wizard. This can
be useful for similar task types that use the same "base" `icon`, but
still have to be differentiated.
.. include:: /_Includes/_ExtendingSchedulerTca.rst.txt
.. _additional-fields:
Providing additional fields for scheduler task
==============================================
.. deprecated:: 14.0
Registering tasks and additional field providers via
:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks']` has
been deprecated.
The :php-short:`\TYPO3\CMS\Scheduler\AdditionalFieldProviderInterface` and
:php-short:`\TYPO3\CMS\Scheduler\AbstractAdditionalFieldProvider` have also
been deprecated.
Tasks in general and additional fields for tasks are registered via TCA
instead.
See also: `Migrating tasks with AdditionalFieldProviders to TCA registration <https://docs.typo3.org/permalink/typo3/cms-scheduler:additional-fields-migration>`_
Additional fields for scheduler tasks are handled via FormEngine and can be
configured via TCA.
If the task should provide additional fields for configuration options in
the backend module, you need to implement a second class, extending
:php-short:`\TYPO3\CMS\Scheduler\AbstractAdditionalFieldProvider`.
The task needs to be registered via TCA override:
.. literalinclude:: _codesnippets/_scheduler_my_task_type-additional.php.inc
:language: php
:caption: EXT:my_extension/Configuration/TCA/Overrides/scheduler_my_task_type.php
And implemented the following methods in your scheduler task if needed:
.. literalinclude:: _codesnippets/_MyTaskWithAdditionalFields.php.inc
:language: php
:caption: packages/my_extension/Classes/MyTask.php
.. note::
Method `getTaskParameters()` should be implemented when
`migrating tasks <https://docs.typo3.org/permalink/typo3/cms-scheduler:additional-fields-migration>`_
For native TCA tasks, this method is typically no longer needed in custom
tasks after the migration has been done, since field values are then stored
directly in database columns.
.. seealso::
There are additional examples in described in the
`Changelog Feature: #107526 - Custom TCA types for scheduler tasks <https://docs.typo3.org/permalink/changelog:feature-107526-1747816234>`_.
@@ -0,0 +1,110 @@
:navigation-title: Migration
.. include:: /Includes.rst.txt
.. _task-migration:
=====================================================
Migration to the TCA registration for scheduler tasks
=====================================================
.. deprecated:: 14.0
Registering tasks and additional field providers via
:php:`$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks']` has
been deprecated.
The :php-short:`\TYPO3\CMS\Scheduler\AdditionalFieldProviderInterface` and
:php-short:`\TYPO3\CMS\Scheduler\AbstractAdditionalFieldProvider` have also
been deprecated.
.. contents:: Table of contents
Tasks in general and additional fields for tasks are registered via TCA
instead.
.. _additional-fields-migration:
Migrating tasks with AdditionalFieldProviders to TCA registration
=================================================================
Scheduler tasks should now be registered as native task types using TCA.
This provides a more integrated and maintainable approach to task configuration.
.. _additional-fields-migration-steps:
Migration steps:
----------------
1. Remove the registration from :file:`ext_localconf.php`
2. Create a TCA override file in :file:`Configuration/TCA/Overrides/scheduler_my_task_type.php`
3. Update your task class to implement the new parameter methods
4. Remove the :php:`AdditionalFieldProvider` class if it exists
.. note::
The new TCA-based approach automatically migrates existing task data.
When upgrading, existing task configurations are preserved through the
:php:`getTaskParameters()` and :php:`setTaskParameters()` methods.
.. _additional-fields-migration-example:
Example migration: Scheduler task with additional fields suppporting TYPO3 13 and 14
------------------------------------------------------------------------------------
Remove the registration from :file:`ext_localconf.php` once TYPO3 13 support is
dropped:
.. literalinclude:: _codesnippets/_ext_localconf_deprecated.php.inc
:language: php
:caption: packages/my_extension/ext_localconf.php
And also remove the :php:`MyTaskAdditionalFieldProvider` class once
TYPO3 13 support is dropped.
Create a TCA override file in :file:`Configuration/TCA/Overrides/scheduler_my_task_type.php`:
.. literalinclude:: _codesnippets/_scheduler_my_task_type-additional.php.inc
:language: php
:caption: EXT:my_extension/Configuration/TCA/Overrides/scheduler_my_task_type.php
Update your (existing) task class to implement the new methods:
.. literalinclude:: _codesnippets/_MyTaskWithAdditionalFieldsMigration.php.inc
:language: php
:caption: packages/my_extension/Classes/MyTask.php
The new TCA-based approach uses three key methods for parameter handling:
**getTaskParameters(): array**
This method is already implemented in ``AbstractTask`` to handle task class
properties automatically, but can be overridden in task classes for custom
behavior.
The method is primarily used:
* For migration from old serialized task format to new TCA structure
* For non-native (deprecated) task types to store their values in the legacy ``parameters`` field
For native TCA tasks, this method is typically no longer needed in custom
tasks after the migration has been done, since field values are then stored
directly in database columns.
**setTaskParameters(array $parameters): void**
Sets field values from an associative array. This method handles:
* Migration from old AdditionalFieldProvider field names to new TCA field names
* Loading saved task configurations when editing or executing tasks
* Parameter mapping during task creation and updates
* The method should always be implemented, especially for native tasks
The migration pattern is: :php:`$this->myField = $parameters['oldName'] ?? $parameters['new_tca_field_name'] ?? '';`
**validateTaskParameters(array $parameters): bool**
*Optional method.* Only implement this for validation that cannot be handled by FormEngine.
* Basic validation (required, trim, etc.) should be done via TCA configuration (``required`` property and ``eval`` options)
* Use this method for complex business logic validation (e.g., email format validation, external API checks)
* Return ``false`` and add FlashMessage for validation errors
* FormEngine automatically handles standard TCA validation rules
For a complete working example, see :php:`\TYPO3\CMS\Reports\Task\SystemStatusUpdateTask`
and its corresponding TCA configuration in
:file:`EXT:reports/Configuration/TCA/Overrides/scheduler_system_status_update_task.php`.
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Task;
use MyVendor\MyExtension\BusinessLogic;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
final class MyTask extends AbstractTask
{
/**
* MUST be implemented by all tasks
*/
public function execute(): bool
{
# Dependency injection cannot be used in scheduler tasks
$businessLogic = GeneralUtility::makeInstance(BusinessLogic::class);
return $businessLogic->run('arg1', 'arg2', '…');
}
public function getAdditionalInformation()
{
$this->getLanguageService()->sL('LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:myTaskInformation');
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Task;
use MyVendor\MyExtension\BusinessLogic;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
final class MyTask extends AbstractTask
{
protected string $myField = '';
protected string $emailList = '';
public function execute(): bool
{
# Dependency injection cannot be used in scheduler tasks
$businessLogic = GeneralUtility::makeInstance(BusinessLogic::class);
return $businessLogic->run($this->myField, $this->emailList, '…');
}
/**
* Set field values from associative array.
*
* @param array $parameters Values from TCA fields
*/
public function setTaskParameters(array $parameters): void
{
$this->myField = $parameters['my_extension_field'] ?? '';
$this->emailList = $parameters['my_extension_email_list'] ?? '';
}
/**
* Validate task parameters.
* Only implement this method for validation that cannot be handled by FormEngine.
* Basic validation like 'required' should be done via TCA 'eval' configuration.
*/
public function validateTaskParameters(array $parameters): bool
{
$isValid = true;
// Example: Custom email validation (beyond basic 'required' check)
$emailList = $parameters['my_extension_email_list'] ?? '';
if (!empty($emailList)) {
$emails = GeneralUtility::trimExplode(',', $emailList, true);
foreach ($emails as $email) {
if (!GeneralUtility::validEmail($email)) {
GeneralUtility::makeInstance(FlashMessageService::class)
->getMessageQueueByIdentifier()
->addMessage(
GeneralUtility::makeInstance(
FlashMessage::class,
'Invalid email address: ' . $email,
'',
ContextualFeedbackSeverity::ERROR
)
);
$isValid = false;
}
}
}
return $isValid;
}
public function getAdditionalInformation(): string
{
return sprintf(
'Field: %s, Emails: %s',
$this->myField,
$this->emailList
);
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Task;
use MyVendor\MyExtension\BusinessLogic;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
final class MyTask extends AbstractTask
{
protected string $myField = '';
protected string $emailList = '';
public function execute(): bool
{
# Dependency injection cannot be used in scheduler tasks
$businessLogic = GeneralUtility::makeInstance(BusinessLogic::class);
return $businessLogic->run($this->myField, $this->emailList, '…');
}
/**
* Return current field values as associative array.
* This method is called during migration from old serialized tasks
* and when displaying task information.
*/
public function getTaskParameters(): array
{
return [
'my_extension_field' => $this->myField,
'my_extension_email_list' => $this->emailList,
];
}
/**
* Set field values from associative array.
* This method handles both old and new parameter formats for migration.
*
* @param array $parameters Values from either old AdditionalFieldProvider or new TCA fields
*/
public function setTaskParameters(array $parameters): void
{
// Handle migration: check old parameter names first, then new TCA field names
$this->myField = $parameters['myField'] ?? $parameters['my_extension_field'] ?? '';
$this->emailList = $parameters['emailList'] ?? $parameters['my_extension_email_list'] ?? '';
}
/**
* Validate task parameters.
* Only implement this method for validation that cannot be handled by FormEngine.
* Basic validation like 'required' should be done via TCA 'eval' configuration.
*/
public function validateTaskParameters(array $parameters): bool
{
$isValid = true;
// Example: Custom email validation (beyond basic 'required' check)
$emailList = $parameters['my_extension_email_list'] ?? '';
if (!empty($emailList)) {
$emails = GeneralUtility::trimExplode(',', $emailList, true);
foreach ($emails as $email) {
if (!GeneralUtility::validEmail($email)) {
GeneralUtility::makeInstance(FlashMessageService::class)
->getMessageQueueByIdentifier()
->addMessage(
GeneralUtility::makeInstance(
FlashMessage::class,
'Invalid email address: ' . $email,
'',
ContextualFeedbackSeverity::ERROR
)
);
$isValid = false;
}
}
}
return $isValid;
}
public function getAdditionalInformation(): string
{
$info = [];
if ($this->myField !== '') {
$info[] = 'Field: ' . $this->myField;
}
if ($this->emailList !== '') {
$info[] = 'Emails: ' . $this->emailList;
}
return implode(', ', $info);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use MyVendor\MyExtension\Task\MyTask;
if ((new \TYPO3\CMS\Core\Information\Typo3Version())->getMajorVersion() < 14) {
// Todo: Remove when TYPO3 13 support is dropped
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][MyTask::class] = [
'extension' => 'my_extension',
'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:myTask.title',
'description' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:myTask.description',
'additionalFields' => \MyVendor\MyExtension\Task\MyTaskAdditionalFieldProvider::class,
];
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use MyVendor\MyExtension\Task\MyTask;
defined('TYPO3') or die();
if (isset($GLOBALS['TCA']['tx_scheduler_task'])) {
// Add custom fields to the tx_scheduler_task table
ExtensionManagementUtility::addTCAcolumns(
'tx_scheduler_task',
[
'my_extension_field' => [
'label' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:field.label',
'config' => [
'type' => 'input',
'size' => 30,
'required' => true,
'eval' => 'trim',
'placeholder' => 'Enter value here...',
],
],
'my_extension_email_list' => [
'label' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:emailList.label',
'config' => [
'type' => 'text',
'rows' => 3,
'required' => true,
'placeholder' => 'admin@example.com',
],
],
]
);
// Register the task type
ExtensionManagementUtility::addRecordType(
[
'label' => 'Some title or LLL:EXT reference',
'description' => 'Some description or LLL:EXT reference',
'value' => MyTask::class,
'icon' => 'mimetypes-x-tx_scheduler_task_group',
'iconOverlay' => 'content-clock',
'group' => 'my_extension',
],
'
--div--;core.form.tabs:general,
tasktype,
task_group,
description,
my_extension_field,
my_extension_email_list,
--div--;core.form.tabs:timing,
execution_details,
nextexecution,
--palette--;;lastexecution,
--div--;core.form.tabs:access,
disable,
--div--;core.form.tabs:extended,',
[],
'',
'tx_scheduler_task'
);
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use MyVendor\MyExtension\Task\MyTask;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
defined('TYPO3') or die();
if (isset($GLOBALS['TCA']['tx_scheduler_task'])) {
ExtensionManagementUtility::addRecordType(
[
'label' => 'My Custom Task',
'description' => 'Description of what this task does',
'value' => MyTask::class,
'icon' => 'my-custom-icon',
'iconOverlay' => 'content-clock',
'group' => 'my_extension',
],
$GLOBALS['TCA']['tx_scheduler_task']['types']['0']['showitem'],
[],
'',
'tx_scheduler_task'
);
}
@@ -0,0 +1,54 @@
:navigation-title: Events
.. include:: /Includes.rst.txt
.. _scheduler-events:
==========================================
Events provided by the scheduler extension
==========================================
The system extension :composer:`typo3/cms-scheduler` provides the following
events:
.. contents:: Table of contents
.. _ModifyNewSchedulerTaskWizardItemsEvent:
ModifyNewSchedulerTaskWizardItemsEvent
======================================
The PSR-14 event :php:`\TYPO3\CMS\Scheduler\Event\ModifyNewSchedulerTaskWizardItemsEvent`
allows extensions to modify the items in the
`scheduler task wizard <https://docs.typo3.org/permalink/typo3/cms-scheduler:adding-editing-task-wizard>`_.
.. seealso::
`ModifyNewSchedulerTaskWizardItemsEvent (TYPO3 explained) <https://docs.typo3.org/permalink/t3coreapi:ModifyNewSchedulerTaskWizardItemsEvent>`_
for examples and the api information.
.. _AfterTaskExecutionEvent:
AfterTaskExecutionEvent
=======================
The PSR-14 event :php:`\TYPO3\CMS\Scheduler\Event\AfterTaskExecutionEvent`
is dispatched after a scheduled task (including command tasks) has been executed.
It provides the following information:
* :php:`getTask(): AbstractTask` — the executed task object
* :php:`isSuccess(): bool` — whether the task completed without exception
* :php:`getException(): ?\Throwable` — the thrown exception on failure, or :php:`null` on success
Example listener::
use TYPO3\CMS\Scheduler\Event\AfterTaskExecutionEvent;
final class SchedulerTaskResultListener
{
public function __invoke(AfterTaskExecutionEvent $event): void
{
$task = $event->getTask();
$status = $event->isSuccess() ? 'success' : 'failure';
// e.g. send a notification, write to a custom log, etc.
}
}
+19
View File
@@ -0,0 +1,19 @@
:navigation-title: Development
.. include:: /Includes.rst.txt
.. _developer-guide:
==========================
Scheduler task development
==========================
The Scheduler makes it very easy to create a new task class.
Furthermore the tasks packaged with this extension provide
a good basis to learn by example.
.. toctree::
:maxdepth: 5
:titlesonly:
:glob:
*/Index
@@ -0,0 +1,30 @@
.. include:: /Includes.rst.txt
.. _scheduler-api:
=============
Scheduler API
=============
It is possible to refer to the Scheduler from other extensions. Once a
:php:`\TYPO3\CMS\Scheduler\Scheduler` object has been instantiated all of its
public methods can be used. The PHPdoc of the methods should be enough to
understand what each is to be used for.
The extension ships with a
:php:`\TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository` class,
which provides some helpful methods, for example:
* :php:`findByUid(int $uid)`: this method is used to fetch a registered task
from the database given an ID.
* :php:`findNextExecutableTask()`: this method returns the next due task. The
return value is the unserialized task object.
* :php:`findRecordByUid(int $uid)`: is also used to retrieve a registered task
from the database, but it returns the record corresponding to the task
registration and not the task object itself.
These are the main methods that will be used from outside the
Scheduler as they can retrieve registered tasks from the database.
When a task has been fetched, all public methods from the
:php:`\TYPO3\CMS\Scheduler\Task\AbstractTask` class can be used.
@@ -0,0 +1,58 @@
.. include:: /Includes.rst.txt
.. _technical-background:
.. _scheduler-task-storage:
======================
Scheduler task storage
======================
.. versionchanged:: 14.0
With TYPO3 v14.0 the storage of scheduler tasks switched from the
PHP-serialized storage format in the database to a JSON-based format.
The scheduler tasks displayed in backend module :guilabel:`Administration > Scheduler`
are stored in the **database** table :sql:`tx_scheduler_task`. Task groups are
stored in table :sql:`tx_scheduler_task_group`.
.. contents:: Table of contents
.. _scheduler-task-storage-fields:
tx_scheduler_task fields
========================
The database table :sql:`tx_scheduler_task` contains the following fields:
`tasktype`
Typically contains the fully qualified class name of the task, for example
:php:`\TYPO3\CMS\Scheduler\Task\CachingFrameworkGarbageCollectionTask` or
the command name, for example `language:update` if the task is implemented as
`Symfony console command <https://docs.typo3.org/permalink/t3coreapi:symfony-console-commands>`_.
`parameters`
The options configuring the task as JSON-encoded value.
`execution_details`
Contains all details on execution like the type (Recurring / Single),
start and end dates and the frequency in which recurring tasks should be
executed.
Additionally it stores some information on the last and next execution of the
task, and fields for a description and the group.
.. _serialized-objects:
.. _save-task-state:
.. _serialized-objects_migration:
Migration from serialized task objects
======================================
.. attention::
.. versionchanged:: 14.0
The storage format was changed with TYPO3 v14. If the database field
:sql:`tx_scheduler_task:tasktype` is empty this hints at a missing or failed
migration by the upgrade wizard. See also:
`Important: #106532 - Changed database storage format for Scheduler Tasks <https://docs.typo3.org/permalink/changelog:important-106532-1744207039>`_
A manual migration is necessary. You can also delete the task and
create it newly.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More