commit 4392dbe2cea51e46f402bf9f991db5bbd8d73c4f Author: Sven Wappler Date: Mon Aug 10 22:31:38 2026 +0200 TYPO3 v15 dev-main snapshot () diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Command/SchedulerCommand.php b/Classes/Command/SchedulerCommand.php new file mode 100644 index 0000000..dc34345 --- /dev/null +++ b/Classes/Command/SchedulerCommand.php @@ -0,0 +1,259 @@ +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())); + } + } +} diff --git a/Classes/Command/SchedulerExecuteCommand.php b/Classes/Command/SchedulerExecuteCommand.php new file mode 100644 index 0000000..a2425da --- /dev/null +++ b/Classes/Command/SchedulerExecuteCommand.php @@ -0,0 +1,173 @@ +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('[ 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] = '' . $groupLabel . ''; + + foreach ($group['tasks'] as $task) { + $additionalInformation = $task['additionalInformation'] === '' ? '' : ' (' . $task['additionalInformation'] . ')'; + $selectableTasks[$task['uid']] = $task['fullTitle'] . $additionalInformation; + } + } + return $selectableTasks; + } +} diff --git a/Classes/Command/SchedulerListCommand.php b/Classes/Command/SchedulerListCommand.php new file mode 100644 index 0000000..d39b439 --- /dev/null +++ b/Classes/Command/SchedulerListCommand.php @@ -0,0 +1,193 @@ +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'] ? '' . $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('' . $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[] = '' . $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); + } +} diff --git a/Classes/Controller/NewSchedulerTaskController.php b/Classes/Controller/NewSchedulerTaskController.php new file mode 100644 index 0000000..ef7fbd3 --- /dev/null +++ b/Classes/Controller/NewSchedulerTaskController.php @@ -0,0 +1,156 @@ +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']; + } +} diff --git a/Classes/Controller/SchedulerModuleController.php b/Classes/Controller/SchedulerModuleController.php new file mode 100644 index 0000000..5343d6d --- /dev/null +++ b/Classes/Controller/SchedulerModuleController.php @@ -0,0 +1,509 @@ +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']; + } +} diff --git a/Classes/CronCommand/CronCommand.php b/Classes/CronCommand/CronCommand.php new file mode 100644 index 0000000..20e0101 --- /dev/null +++ b/Classes/CronCommand/CronCommand.php @@ -0,0 +1,213 @@ +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); + } +} diff --git a/Classes/CronCommand/NormalizeCommand.php b/Classes/CronCommand/NormalizeCommand.php new file mode 100644 index 0000000..69f1b7d --- /dev/null +++ b/Classes/CronCommand/NormalizeCommand.php @@ -0,0 +1,336 @@ + $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 ``/'' 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; + } +} diff --git a/Classes/Domain/Repository/SchedulerTaskRepository.php b/Classes/Domain/Repository/SchedulerTaskRepository.php new file mode 100644 index 0000000..0f42af3 --- /dev/null +++ b/Classes/Domain/Repository/SchedulerTaskRepository.php @@ -0,0 +1,682 @@ +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` + ? 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` + $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 + */ + 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; + } +} diff --git a/Classes/Event/AfterTaskExecutionEvent.php b/Classes/Event/AfterTaskExecutionEvent.php new file mode 100644 index 0000000..92e818a --- /dev/null +++ b/Classes/Event/AfterTaskExecutionEvent.php @@ -0,0 +1,48 @@ +task; + } + + public function isSuccess(): bool + { + return $this->success; + } + + public function getException(): ?\Throwable + { + return $this->exception; + } +} diff --git a/Classes/Event/ModifyNewSchedulerTaskWizardItemsEvent.php b/Classes/Event/ModifyNewSchedulerTaskWizardItemsEvent.php new file mode 100644 index 0000000..a55388b --- /dev/null +++ b/Classes/Event/ModifyNewSchedulerTaskWizardItemsEvent.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/Classes/EventListener/AddSchedulableCommandsAsNativeTaskTypes.php b/Classes/EventListener/AddSchedulableCommandsAsNativeTaskTypes.php new file mode 100644 index 0000000..bf8a044 --- /dev/null +++ b/Classes/EventListener/AddSchedulableCommandsAsNativeTaskTypes.php @@ -0,0 +1,77 @@ +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); + } +} diff --git a/Classes/EventListener/ReplaceAddNewButtonToFormEngine.php b/Classes/EventListener/ReplaceAddNewButtonToFormEngine.php new file mode 100644 index 0000000..a56e4a1 --- /dev/null +++ b/Classes/EventListener/ReplaceAddNewButtonToFormEngine.php @@ -0,0 +1,94 @@ +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']; + } +} diff --git a/Classes/Exception.php b/Classes/Exception.php new file mode 100644 index 0000000..1c8c9ed --- /dev/null +++ b/Classes/Exception.php @@ -0,0 +1,25 @@ +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, + ]; + } +} diff --git a/Classes/FailedExecutionException.php b/Classes/FailedExecutionException.php new file mode 100644 index 0000000..5e52fe0 --- /dev/null +++ b/Classes/FailedExecutionException.php @@ -0,0 +1,21 @@ +getLanguageService(); + $extractors = $this->extractorRegistry->getExtractors(); + + if ($extractors !== []) { + $bullets = []; + foreach ($extractors as $extractor) { + $bullets[] = sprintf( + '
  • %s%s
  • ', + 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() ? (' [' . get_class($extractor) . ']') : '' + ); + } + $html = ' +
    ' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors.with_extractors')) . '
    +
      ' . implode(LF, $bullets) . '
    + '; + } else { + $html = '
    ' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors.without_extractors')) . '/div>'; + } + + $resultArray['html'] = ' +
    + + ' . htmlspecialchars($lang->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.fileStorageExtraction.registeredExtractors')) . ' + + ' . $html . ' +
    + '; + + 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); + } +} diff --git a/Classes/Form/Element/SchedulableCommandConfigurationElement.php b/Classes/Form/Element/SchedulableCommandConfigurationElement.php new file mode 100644 index 0000000..d8c4beb --- /dev/null +++ b/Classes/Form/Element/SchedulableCommandConfigurationElement.php @@ -0,0 +1,214 @@ +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[] = '
    '; + $html[] = $fieldInformationHtml; + $html[] = '
    '; + $html[] = $this->renderCommandConfiguration(array_merge($argumentFields, $optionFields), $selectedTaskType, $itemName); + $html[] = '
    '; + if ($this->data['command'] === 'edit') { + $html[] = $this->getRunOnCliInfo($taskObject, $command); + } + $html[] = '
    '; + + $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 = '
    ' . $command->getName() . ' ' . $arguments . '
    '; + } catch (RuntimeException|InvalidArgumentException $e) { + $cliCommand = '
    ' . htmlspecialchars(sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingArguments'), $e->getMessage())) . '
    '; + } catch (InvalidOptionException $e) { + $cliCommand = '
    ' . htmlspecialchars(sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.errorParsingOptions'), $e->getMessage())) . '
    '; + } + + return ' +
    +
    +
    +

    ' . htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.runOnCli')) . '

    + ' . $cliCommand . ' +
    +
    +
    + '; + } + + 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'); + } +} diff --git a/Classes/Form/Element/TaskTypeInfoElement.php b/Classes/Form/Element/TaskTypeInfoElement.php new file mode 100644 index 0000000..a0eee40 --- /dev/null +++ b/Classes/Form/Element/TaskTypeInfoElement.php @@ -0,0 +1,70 @@ +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'] = ' +
    +
    +
    + ' . $this->iconFactory->getIcon(($taskDetails['icon'] ?? '') ?: 'mimetypes-x-tx_scheduler_task_group')->render() . ' +
    +
    +

    ' . htmlspecialchars($taskDetails['title']) . '

    + ' . htmlspecialchars($taskDetails['description']) . ' +
    +
    +
    + '; + } else { + $resultArray['html'] = '
    ' . htmlspecialchars($languageService->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidTaskType')) . ': ' . htmlspecialchars($selectedValue) . '
    '; + } + + $resultArray['html'] .= ''; + return $resultArray; + } +} diff --git a/Classes/Form/Element/TimingOptionsElement.php b/Classes/Form/Element/TimingOptionsElement.php new file mode 100644 index 0000000..31eec01 --- /dev/null +++ b/Classes/Form/Element/TimingOptionsElement.php @@ -0,0 +1,170 @@ +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 .= '
    ' . str_replace('"form-check"', '"form-check form-inline me-2"', $subFieldResult['html']) . '
    '; + + $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 .= '
    ' . $subFieldResult['html'] . '
    '; + + $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 .= '
    ' . $subFieldResult['html'] . '
    '; + + $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 .= '
    ' . $subFieldResult['html'] . '
    '; + + $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 .= '
    ' . $subFieldResult['html'] . '
    '; + + $fieldInformationResult = $this->renderFieldInformation(); + $fieldInformationHtml = $fieldInformationResult['html']; + $resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false); + + $html = []; + $html[] = ''; + $html[] = $fieldInformationHtml; + $html[] = '
    '; + $html[] = '
    '; + $html[] = '
    '; + $html[] = '
    ' . $fieldsHtml . $this->renderServerTime() . '
    '; + $html[] = '
    '; + $html[] = '
    '; + $html[] = '
    '; + $html[] = '
    '; + + $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'); + } +} diff --git a/Classes/Form/FieldInformation/ExpirePeriodInformation.php b/Classes/Form/FieldInformation/ExpirePeriodInformation.php new file mode 100644 index 0000000..e851b70 --- /dev/null +++ b/Classes/Form/FieldInformation/ExpirePeriodInformation.php @@ -0,0 +1,66 @@ +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'] = ' +
    + ' . sprintf($this->getLanguageService()->sL('LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.defaultExpirePeriod'), (int)$tableConfiguration['expirePeriod'], $selectedTable) . ' +
    '; + + return $resultArray; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } +} diff --git a/Classes/Hooks/SchedulerTaskPersistenceValidator.php b/Classes/Hooks/SchedulerTaskPersistenceValidator.php new file mode 100644 index 0000000..7618957 --- /dev/null +++ b/Classes/Hooks/SchedulerTaskPersistenceValidator.php @@ -0,0 +1,280 @@ +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']; + } +} diff --git a/Classes/Migration/SchedulerDatabaseStorageMigration.php b/Classes/Migration/SchedulerDatabaseStorageMigration.php new file mode 100644 index 0000000..37c8d64 --- /dev/null +++ b/Classes/Migration/SchedulerDatabaseStorageMigration.php @@ -0,0 +1,235 @@ +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); + } +} diff --git a/Classes/ProgressProviderInterface.php b/Classes/ProgressProviderInterface.php new file mode 100644 index 0000000..d8d6ea4 --- /dev/null +++ b/Classes/ProgressProviderInterface.php @@ -0,0 +1,29 @@ +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` + 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); + } +} diff --git a/Classes/Service/TaskService.php b/Classes/Service/TaskService.php new file mode 100644 index 0000000..5cf550f --- /dev/null +++ b/Classes/Service/TaskService.php @@ -0,0 +1,274 @@ + 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; + } +} diff --git a/Classes/SystemInformation/ToolbarItemProvider.php b/Classes/SystemInformation/ToolbarItemProvider.php new file mode 100644 index 0000000..83757e3 --- /dev/null +++ b/Classes/SystemInformation/ToolbarItemProvider.php @@ -0,0 +1,135 @@ +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']; + } +} diff --git a/Classes/Task/AbstractTask.php b/Classes/Task/AbstractTask.php new file mode 100644 index 0000000..b462699 --- /dev/null +++ b/Classes/Task/AbstractTask.php @@ -0,0 +1,326 @@ +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; + } + } + } +} diff --git a/Classes/Task/CachingFrameworkGarbageCollectionTask.php b/Classes/Task/CachingFrameworkGarbageCollectionTask.php new file mode 100644 index 0000000..832537a --- /dev/null +++ b/Classes/Task/CachingFrameworkGarbageCollectionTask.php @@ -0,0 +1,96 @@ + $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]; + } + } +} diff --git a/Classes/Task/ExecuteSchedulableCommandTask.php b/Classes/Task/ExecuteSchedulableCommandTask.php new file mode 100644 index 0000000..8848c86 --- /dev/null +++ b/Classes/Task/ExecuteSchedulableCommandTask.php @@ -0,0 +1,310 @@ +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]; + } +} diff --git a/Classes/Task/FileStorageExtractionTask.php b/Classes/Task/FileStorageExtractionTask.php new file mode 100644 index 0000000..68f391c --- /dev/null +++ b/Classes/Task/FileStorageExtractionTask.php @@ -0,0 +1,88 @@ +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); + } +} diff --git a/Classes/Task/FileStorageIndexingTask.php b/Classes/Task/FileStorageIndexingTask.php new file mode 100644 index 0000000..e21f1ab --- /dev/null +++ b/Classes/Task/FileStorageIndexingTask.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/Classes/Task/IpAnonymizationTask.php b/Classes/Task/IpAnonymizationTask.php new file mode 100644 index 0000000..5b41634 --- /dev/null +++ b/Classes/Task/IpAnonymizationTask.php @@ -0,0 +1,193 @@ +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; + } +} diff --git a/Classes/Task/OptimizeDatabaseTableTask.php b/Classes/Task/OptimizeDatabaseTableTask.php new file mode 100644 index 0000000..d868d6f --- /dev/null +++ b/Classes/Task/OptimizeDatabaseTableTask.php @@ -0,0 +1,193 @@ +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'); + } +} diff --git a/Classes/Task/RecyclerGarbageCollectionTask.php b/Classes/Task/RecyclerGarbageCollectionTask.php new file mode 100644 index 0000000..1f9cca7 --- /dev/null +++ b/Classes/Task/RecyclerGarbageCollectionTask.php @@ -0,0 +1,109 @@ +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); + } +} diff --git a/Classes/Task/TableGarbageCollectionTask.php b/Classes/Task/TableGarbageCollectionTask.php new file mode 100644 index 0000000..f1ef800 --- /dev/null +++ b/Classes/Task/TableGarbageCollectionTask.php @@ -0,0 +1,188 @@ +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; + } +} diff --git a/Classes/Task/TaskSerializer.php b/Classes/Task/TaskSerializer.php new file mode 100644 index 0000000..b04da01 --- /dev/null +++ b/Classes/Task/TaskSerializer.php @@ -0,0 +1,110 @@ +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[^"]+)"/', $serializedTask, $matches) === 1) { + return $matches['classname']; + } + return null; + } +} diff --git a/Classes/Task/TaskStatus.php b/Classes/Task/TaskStatus.php new file mode 100644 index 0000000..6f9c4eb --- /dev/null +++ b/Classes/Task/TaskStatus.php @@ -0,0 +1,43 @@ + $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 = [], + ) {} +} diff --git a/Classes/Validation/Validator/TaskValidator.php b/Classes/Validation/Validator/TaskValidator.php new file mode 100644 index 0000000..30cc7fb --- /dev/null +++ b/Classes/Validation/Validator/TaskValidator.php @@ -0,0 +1,42 @@ +getExecution() !== null + && get_class($value->getExecution()) !== \__PHP_Incomplete_Class::class; + } +} diff --git a/Configuration/Backend/AjaxRoutes.php b/Configuration/Backend/AjaxRoutes.php new file mode 100644 index 0000000..8d086d0 --- /dev/null +++ b/Configuration/Backend/AjaxRoutes.php @@ -0,0 +1,24 @@ + [ + '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', + ], +]; diff --git a/Configuration/Backend/Modules.php b/Configuration/Backend/Modules.php new file mode 100644 index 0000000..9ed4f5c --- /dev/null +++ b/Configuration/Backend/Modules.php @@ -0,0 +1,23 @@ + [ + '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', + ], + ], + ], +]; diff --git a/Configuration/JavaScriptModules.php b/Configuration/JavaScriptModules.php new file mode 100644 index 0000000..c6be8a4 --- /dev/null +++ b/Configuration/JavaScriptModules.php @@ -0,0 +1,11 @@ + [ + 'backend', + 'core', + ], + 'imports' => [ + '@typo3/scheduler/' => 'EXT:scheduler/Resources/Public/JavaScript/', + ], +]; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..e854733 --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,8 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Scheduler\: + resource: '../Classes/*' diff --git a/Configuration/TCA/Overrides/scheduler_caching_framework_garbage_collection_task.php b/Configuration/TCA/Overrides/scheduler_caching_framework_garbage_collection_task.php new file mode 100644 index 0000000..9f06fc8 --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_caching_framework_garbage_collection_task.php @@ -0,0 +1,48 @@ + [ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_file_storage_extraction_task.php b/Configuration/TCA/Overrides/scheduler_file_storage_extraction_task.php new file mode 100644 index 0000000..7b866c5 --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_file_storage_extraction_task.php @@ -0,0 +1,56 @@ + [ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_file_storage_indexing_task.php b/Configuration/TCA/Overrides/scheduler_file_storage_indexing_task.php new file mode 100644 index 0000000..1b9db35 --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_file_storage_indexing_task.php @@ -0,0 +1,30 @@ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_ip_anonymization_task.php b/Configuration/TCA/Overrides/scheduler_ip_anonymization_task.php new file mode 100644 index 0000000..296b8a8 --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_ip_anonymization_task.php @@ -0,0 +1,86 @@ + [ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_optimize_database_table_task.php b/Configuration/TCA/Overrides/scheduler_optimize_database_table_task.php new file mode 100644 index 0000000..66b031c --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_optimize_database_table_task.php @@ -0,0 +1,44 @@ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_recycler_garbage_collection_task.php b/Configuration/TCA/Overrides/scheduler_recycler_garbage_collection_task.php new file mode 100644 index 0000000..1823857 --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_recycler_garbage_collection_task.php @@ -0,0 +1,43 @@ + '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' +); diff --git a/Configuration/TCA/Overrides/scheduler_table_garbage_collection_task.php b/Configuration/TCA/Overrides/scheduler_table_garbage_collection_task.php new file mode 100644 index 0000000..8c2808c --- /dev/null +++ b/Configuration/TCA/Overrides/scheduler_table_garbage_collection_task.php @@ -0,0 +1,97 @@ + [ + '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' +); diff --git a/Configuration/TCA/tx_scheduler_task.php b/Configuration/TCA/tx_scheduler_task.php new file mode 100644 index 0000000..b17b454 --- /dev/null +++ b/Configuration/TCA/tx_scheduler_task.php @@ -0,0 +1,204 @@ + [ + '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, + ', + ], + ], +]; diff --git a/Configuration/TCA/tx_scheduler_task_group.php b/Configuration/TCA/tx_scheduler_task_group.php new file mode 100644 index 0000000..6faf604 --- /dev/null +++ b/Configuration/TCA/tx_scheduler_task_group.php @@ -0,0 +1,72 @@ + [ + '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, + ', + ], + ], +]; diff --git a/Configuration/user.tsconfig b/Configuration/user.tsconfig new file mode 100644 index 0000000..e04bc11 --- /dev/null +++ b/Configuration/user.tsconfig @@ -0,0 +1 @@ +options.showDuplicate.tx_scheduler_task = 0 diff --git a/Documentation/Administration/BackendModule/Index.rst b/Documentation/Administration/BackendModule/Index.rst new file mode 100644 index 0000000..98bfd81 --- /dev/null +++ b/Documentation/Administration/BackendModule/Index.rst @@ -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 `. +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. diff --git a/Documentation/Administration/ConsoleTools/Index.rst b/Documentation/Administration/ConsoleTools/Index.rst new file mode 100644 index 0000000..24cf078 --- /dev/null +++ b/Documentation/Administration/ConsoleTools/Index.rst @@ -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 `_. + +.. 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 `_. + +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 `_ + +.. _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= + + .. 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= + +.. _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 diff --git a/Documentation/Administration/ConsoleTools/Running.rst b/Documentation/Administration/ConsoleTools/Running.rst new file mode 100644 index 0000000..31023c3 --- /dev/null +++ b/Documentation/Administration/ConsoleTools/Running.rst @@ -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 `_. + +.. 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 diff --git a/Documentation/Administration/DeleteTask/Index.rst b/Documentation/Administration/DeleteTask/Index.rst new file mode 100644 index 0000000..ed33fe0 --- /dev/null +++ b/Documentation/Administration/DeleteTask/Index.rst @@ -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 diff --git a/Documentation/Administration/EditTask/Index.rst b/Documentation/Administration/EditTask/Index.rst new file mode 100644 index 0000000..88f50cc --- /dev/null +++ b/Documentation/Administration/EditTask/Index.rst @@ -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 `_ + +.. 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 `_ + 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 `_ 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. diff --git a/Documentation/Administration/GroupTask/Index.rst b/Documentation/Administration/GroupTask/Index.rst new file mode 100644 index 0000000..24c08d6 --- /dev/null +++ b/Documentation/Administration/GroupTask/Index.rst @@ -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. diff --git a/Documentation/Administration/Index.rst b/Documentation/Administration/Index.rst new file mode 100644 index 0000000..3c1a754 --- /dev/null +++ b/Documentation/Administration/Index.rst @@ -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 diff --git a/Documentation/Administration/ManualExecution/Index.rst b/Documentation/Administration/ManualExecution/Index.rst new file mode 100644 index 0000000..37fd662 --- /dev/null +++ b/Documentation/Administration/ManualExecution/Index.rst @@ -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= + + .. 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= diff --git a/Documentation/Administration/StopTask/Index.rst b/Documentation/Administration/StopTask/Index.rst new file mode 100644 index 0000000..0ec4bb9 --- /dev/null +++ b/Documentation/Administration/StopTask/Index.rst @@ -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= --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= --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 TYPO3’s 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 `). +#. **Clear the TYPO3 execution flag** by running: +:command:`vendor/bin/typo3 scheduler:run --task= --stop` + +.. warning:: + Manually terminating a scheduler process using `sudo kill ` 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 diff --git a/Documentation/BasicTasks/GarbageCollection.rst b/Documentation/BasicTasks/GarbageCollection.rst new file mode 100644 index 0000000..651b0a1 --- /dev/null +++ b/Documentation/BasicTasks/GarbageCollection.rst @@ -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 `_ + +.. 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 diff --git a/Documentation/BasicTasks/Index.rst b/Documentation/BasicTasks/Index.rst new file mode 100644 index 0000000..bc0d26c --- /dev/null +++ b/Documentation/BasicTasks/Index.rst @@ -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`). diff --git a/Documentation/BasicTasks/IpAnonymization.rst b/Documentation/BasicTasks/IpAnonymization.rst new file mode 100644 index 0000000..26db9c5 --- /dev/null +++ b/Documentation/BasicTasks/IpAnonymization.rst @@ -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 `_ + +.. 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 diff --git a/Documentation/BasicTasks/_codesnippets/_additional.php.inc b/Documentation/BasicTasks/_codesnippets/_additional.php.inc new file mode 100644 index 0000000..a35136a --- /dev/null +++ b/Documentation/BasicTasks/_codesnippets/_additional.php.inc @@ -0,0 +1,16 @@ +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', + ]; +} diff --git a/Documentation/BasicTasks/_codesnippets/_ext_localconf_ip_anonymization.php.inc b/Documentation/BasicTasks/_codesnippets/_ext_localconf_ip_anonymization.php.inc new file mode 100644 index 0000000..ef61263 --- /dev/null +++ b/Documentation/BasicTasks/_codesnippets/_ext_localconf_ip_anonymization.php.inc @@ -0,0 +1,17 @@ +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', + ], + ]); +} diff --git a/Documentation/BasicTasks/_codesnippets/_tx_scheduler_garbage_collection.php.inc b/Documentation/BasicTasks/_codesnippets/_tx_scheduler_garbage_collection.php.inc new file mode 100644 index 0000000..9f3b878 --- /dev/null +++ b/Documentation/BasicTasks/_codesnippets/_tx_scheduler_garbage_collection.php.inc @@ -0,0 +1,17 @@ + [ + 'dateField' => 'tstamp', + 'expirePeriod' => 180, + ], + 'tx_myextension_my_other_table' => [ + 'expireField' => 'expire', + ], + ]); +} diff --git a/Documentation/BasicTasks/_codesnippets/_tx_scheduler_ip_anonymization.php.inc b/Documentation/BasicTasks/_codesnippets/_tx_scheduler_ip_anonymization.php.inc new file mode 100644 index 0000000..8101ebd --- /dev/null +++ b/Documentation/BasicTasks/_codesnippets/_tx_scheduler_ip_anonymization.php.inc @@ -0,0 +1,14 @@ + [ + 'dateField' => 'tstamp', + 'ipField' => 'private_ip', + ], + ]); +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/Index.rst b/Documentation/DevelopersGuide/CreatingTasks/Index.rst new file mode 100644 index 0000000..8be9381 --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/Index.rst @@ -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 `_. + +.. 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 `. + +.. _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 `_ +**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 `_ + +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 `_ + + 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 `_. diff --git a/Documentation/DevelopersGuide/CreatingTasks/Migration.rst b/Documentation/DevelopersGuide/CreatingTasks/Migration.rst new file mode 100644 index 0000000..2e92f7e --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/Migration.rst @@ -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`. diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTask.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTask.php.inc new file mode 100644 index 0000000..1c63387 --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTask.php.inc @@ -0,0 +1,27 @@ +run('arg1', 'arg2', '…'); + } + + public function getAdditionalInformation() + { + $this->getLanguageService()->sL('LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:myTaskInformation'); + } +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFields.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFields.php.inc new file mode 100644 index 0000000..2ec92cd --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFields.php.inc @@ -0,0 +1,77 @@ +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 + ); + } +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFieldsMigration.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFieldsMigration.php.inc new file mode 100644 index 0000000..1e4c6f0 --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_MyTaskWithAdditionalFieldsMigration.php.inc @@ -0,0 +1,96 @@ +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); + } + +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_ext_localconf_deprecated.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_ext_localconf_deprecated.php.inc new file mode 100644 index 0000000..4e2293a --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_ext_localconf_deprecated.php.inc @@ -0,0 +1,15 @@ +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, + ]; +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_scheduler_my_task_type-additional.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_scheduler_my_task_type-additional.php.inc new file mode 100644 index 0000000..aa55820 --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_scheduler_my_task_type-additional.php.inc @@ -0,0 +1,65 @@ + [ + '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' + ); +} diff --git a/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_tx_scheduler_my_task.php.inc b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_tx_scheduler_my_task.php.inc new file mode 100644 index 0000000..5f7a663 --- /dev/null +++ b/Documentation/DevelopersGuide/CreatingTasks/_codesnippets/_tx_scheduler_my_task.php.inc @@ -0,0 +1,25 @@ + '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' + ); +} diff --git a/Documentation/DevelopersGuide/Events/Index.rst b/Documentation/DevelopersGuide/Events/Index.rst new file mode 100644 index 0000000..72668f8 --- /dev/null +++ b/Documentation/DevelopersGuide/Events/Index.rst @@ -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 `_. + +.. seealso:: + + `ModifyNewSchedulerTaskWizardItemsEvent (TYPO3 explained) `_ + 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. + } + } diff --git a/Documentation/DevelopersGuide/Index.rst b/Documentation/DevelopersGuide/Index.rst new file mode 100644 index 0000000..fd88bc4 --- /dev/null +++ b/Documentation/DevelopersGuide/Index.rst @@ -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 diff --git a/Documentation/DevelopersGuide/SchedulerApi/Index.rst b/Documentation/DevelopersGuide/SchedulerApi/Index.rst new file mode 100644 index 0000000..0259142 --- /dev/null +++ b/Documentation/DevelopersGuide/SchedulerApi/Index.rst @@ -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. diff --git a/Documentation/DevelopersGuide/TaskStorage/Index.rst b/Documentation/DevelopersGuide/TaskStorage/Index.rst new file mode 100644 index 0000000..f9502f2 --- /dev/null +++ b/Documentation/DevelopersGuide/TaskStorage/Index.rst @@ -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 `_. +`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 `_ + + A manual migration is necessary. You can also delete the task and + create it newly. diff --git a/Documentation/Images/AddingATask.png b/Documentation/Images/AddingATask.png new file mode 100644 index 0000000..07d7ddd Binary files /dev/null and b/Documentation/Images/AddingATask.png differ diff --git a/Documentation/Images/BackendModuleMainView.png b/Documentation/Images/BackendModuleMainView.png new file mode 100644 index 0000000..48415eb Binary files /dev/null and b/Documentation/Images/BackendModuleMainView.png differ diff --git a/Documentation/Images/EmptySchedulerModule.png b/Documentation/Images/EmptySchedulerModule.png new file mode 100644 index 0000000..52d7872 Binary files /dev/null and b/Documentation/Images/EmptySchedulerModule.png differ diff --git a/Documentation/Images/ExtensionConfiguration.png b/Documentation/Images/ExtensionConfiguration.png new file mode 100644 index 0000000..32f112d Binary files /dev/null and b/Documentation/Images/ExtensionConfiguration.png differ diff --git a/Documentation/Images/GroupDisabled.png b/Documentation/Images/GroupDisabled.png new file mode 100644 index 0000000..fb6706d Binary files /dev/null and b/Documentation/Images/GroupDisabled.png differ diff --git a/Documentation/Images/GroupEdit.png b/Documentation/Images/GroupEdit.png new file mode 100644 index 0000000..26120b8 Binary files /dev/null and b/Documentation/Images/GroupEdit.png differ diff --git a/Documentation/Images/GroupedTasks.png b/Documentation/Images/GroupedTasks.png new file mode 100644 index 0000000..695aad3 Binary files /dev/null and b/Documentation/Images/GroupedTasks.png differ diff --git a/Documentation/Images/InstallActivate.png b/Documentation/Images/InstallActivate.png new file mode 100644 index 0000000..5b41829 Binary files /dev/null and b/Documentation/Images/InstallActivate.png differ diff --git a/Documentation/Images/LateTask.png b/Documentation/Images/LateTask.png new file mode 100644 index 0000000..85e0fdb Binary files /dev/null and b/Documentation/Images/LateTask.png differ diff --git a/Documentation/Images/ManualExecution.png b/Documentation/Images/ManualExecution.png new file mode 100644 index 0000000..62fb7f7 Binary files /dev/null and b/Documentation/Images/ManualExecution.png differ diff --git a/Documentation/Images/MissingTaskClass.png b/Documentation/Images/MissingTaskClass.png new file mode 100644 index 0000000..45e93d6 Binary files /dev/null and b/Documentation/Images/MissingTaskClass.png differ diff --git a/Documentation/Images/RestoreTask.png b/Documentation/Images/RestoreTask.png new file mode 100644 index 0000000..ea7a019 Binary files /dev/null and b/Documentation/Images/RestoreTask.png differ diff --git a/Documentation/Images/SetupCheck.png b/Documentation/Images/SetupCheck.png new file mode 100644 index 0000000..ff9342f Binary files /dev/null and b/Documentation/Images/SetupCheck.png differ diff --git a/Documentation/Images/SetupCheckButton.png b/Documentation/Images/SetupCheckButton.png new file mode 100644 index 0000000..4156ad9 Binary files /dev/null and b/Documentation/Images/SetupCheckButton.png differ diff --git a/Documentation/Images/StoppingATask.png b/Documentation/Images/StoppingATask.png new file mode 100644 index 0000000..1b17ae6 Binary files /dev/null and b/Documentation/Images/StoppingATask.png differ diff --git a/Documentation/Images/TableGarbageCollectionTaskConfiguration-2.png b/Documentation/Images/TableGarbageCollectionTaskConfiguration-2.png new file mode 100644 index 0000000..ed93243 Binary files /dev/null and b/Documentation/Images/TableGarbageCollectionTaskConfiguration-2.png differ diff --git a/Documentation/Images/TableGarbageCollectionTaskConfiguration.png b/Documentation/Images/TableGarbageCollectionTaskConfiguration.png new file mode 100644 index 0000000..e4bff33 Binary files /dev/null and b/Documentation/Images/TableGarbageCollectionTaskConfiguration.png differ diff --git a/Documentation/Images/TaskCreationWizard.png b/Documentation/Images/TaskCreationWizard.png new file mode 100644 index 0000000..3c47a00 Binary files /dev/null and b/Documentation/Images/TaskCreationWizard.png differ diff --git a/Documentation/Images/TaskExecutionDetails.png b/Documentation/Images/TaskExecutionDetails.png new file mode 100644 index 0000000..13a24a6 Binary files /dev/null and b/Documentation/Images/TaskExecutionDetails.png differ diff --git a/Documentation/Includes.rst.txt b/Documentation/Includes.rst.txt new file mode 100644 index 0000000..2362507 --- /dev/null +++ b/Documentation/Includes.rst.txt @@ -0,0 +1 @@ +.. You can put central messages to display on all pages here diff --git a/Documentation/Index.rst b/Documentation/Index.rst new file mode 100644 index 0000000..3831a37 --- /dev/null +++ b/Documentation/Index.rst @@ -0,0 +1,55 @@ +.. include:: /Includes.rst.txt +.. _start: + +=============== +TYPO3 Scheduler +=============== + +:Extension key: + scheduler + +:Package name: + typo3/cms-scheduler + +:Version: + |release| + +:Language: + en + +:Author: + TYPO3 contributors + +:License: + This document is published under the + `Open Content License `__. + +:Rendered: + |today| + +---- + +The Scheduler supports one-time or periodic execution of tasks that can be +delivered by any extension. + +---- + +**Table of Contents:** + +.. toctree:: + :maxdepth: 2 + :titlesonly: + + Introduction/Index + Installation/Index + BasicTasks/Index + Administration/Index + DevelopersGuide/Index + KnownProblems/Index + +.. Meta Menu + +.. toctree:: + :hidden: + + Sitemap diff --git a/Documentation/Installation/CronJob/Index.rst b/Documentation/Installation/CronJob/Index.rst new file mode 100644 index 0000000..6718333 --- /dev/null +++ b/Documentation/Installation/CronJob/Index.rst @@ -0,0 +1,133 @@ +:navigation-title: Cron Job Set up + +.. include:: /Includes.rst.txt +.. _cron-job: + +================================================== +Setting up the cron job to run the scheduler tasks +================================================== + +Tasks registered with the Scheduler can be run manually from the backend +module. However this is of limited use. To really benefit from the +Scheduler, it must be set up on the server to run regularly. The +following chapters describe how to set this up on Unix or Unix-like +system (including Mac OS X) and on Windows. + +.. _frequency: + +Choosing a frequency +==================== + +Whatever system the Scheduler will run on, the first step is to define +the frequency at which it should run. The Scheduler script should set +up to run pretty often, but not unnecessarily often either. The +frequency should be that of the most often running task or some +frequency that fits all tasks. + +For example, if you have some tasks running every quarter of an hour +and some others running every hour, it is useless to have the +Scheduler run every 5 minutes. On the other hand, if you have tasks +scheduled to run every 10 minutes and others every 15 minutes, you +will want to run the Scheduler every 5 minutes. Indeed, if you run it +only at 10-minute intervals, it will run – assuming it is 8 o'clock – +at 08:10, 08:20, 08:30, etc. So the tasks that should run at 08:15 +will actually run 5 minutes late. + + +.. _unix-mac: + +On Unix and Mac OS X +==================== + +On such systems the Scheduler must be set up as a cron job. There are +several ways to achieve this, although the simplest is probably to add +it to some user's crontab. Edit that user's crontab using: + +.. code-block:: bash + + crontab -e + +and add a line like + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + */15 * * * * /usr/local/bin/php /home/user/www/vendor/bin/typo3 scheduler:run + + .. group-tab:: Legacy installation + + .. code-block:: bash + + */15 * * * * /usr/local/bin/php /home/user/www/typo3/sysext/core/bin/typo3 scheduler:run + +Save the modified crontab. Obviously, the paths have to be adapted to +your system. The above command will call up the Scheduler every 15 +minutes. + +.. seealso:: + + See :ref:`scheduler-shell-script` for more information about + calling the scheduler from the command line. + +If you are editing system crontabs (for example :file:`/etc/crontab` +and :file:`/etc/cron.d/*` ), there will be one additional parameter +to enter, i.e. the user with which the job should run. Example: + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + */15 * * * * www /usr/local/bin/php /home/user/www/vendor/bin/typo3 scheduler:run + + .. group-tab:: Classic installation + + .. code-block:: bash + + */15 * * * * www /usr/local/bin/php /home/user/www/typo3/sysext/core/bin/typo3 scheduler:run + +This will run the job as user "www". + +If you are not familiar with cron syntax, refer to some Unix +administration book or start with the Wikipedia page about it +(https://en.wikipedia.org/wiki/Cron). + +.. _windows: + +On Windows +========== + +On Windows, cron jobs are called "Scheduled tasks" and run with the +:file:`schtasks` utility. :file:`SchTasks.exe` performs operations +similar to those provided by Scheduled Tasks in the Control Panel. You +can use either tool to create, delete, configure, or simply display +scheduled tasks. + +Assuming you want to run the TYPO3 Scheduler every 15 minutes, use the +following command line to create a new task: + +.. tabs:: + + .. group-tab:: Composer-based installation + + .. code-block:: bash + + schtasks /create /sc minute /mo 15 /tn "T3scheduler" /tr "c:\winstaller\php\php.exe c:\winstaller\htdocs\quickstart\vendor\bin\typo3 scheduler:run" + + .. group-tab:: Classic installation + + .. code-block:: bash + + schtasks /create /sc minute /mo 15 /tn "T3scheduler" /tr "c:\winstaller\php\php.exe c:\winstaller\htdocs\quickstart\typo3/sysext/core/bin/typo3 scheduler:run" + +At task creation you will be prompted to give a password or you can +use the :code:`/u` and :code:`/p` switches to provide user and +password information. Note that the user must be a member of the +Administrators group on the computer where the command will run. + +The full reference for :file:`schtasks` is available at: +https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks diff --git a/Documentation/Installation/Index.rst b/Documentation/Installation/Index.rst new file mode 100644 index 0000000..d3bbebe --- /dev/null +++ b/Documentation/Installation/Index.rst @@ -0,0 +1,15 @@ +.. include:: /Includes.rst.txt +.. _installation: + +==================== +Installation & Setup +==================== + +.. toctree:: + :maxdepth: 5 + :titlesonly: + :glob: + + Installing/Index + SetupCheck/Index + CronJob/Index diff --git a/Documentation/Installation/Installing/Index.rst b/Documentation/Installation/Installing/Index.rst new file mode 100644 index 0000000..83001ca --- /dev/null +++ b/Documentation/Installation/Installing/Index.rst @@ -0,0 +1,75 @@ +.. include:: /Includes.rst.txt +.. _installing: + +============ +Installation +============ + +This extension is part of the TYPO3 Core, but not installed by default. + +.. contents:: Table of contents + :local: + +.. _installing-composer: + +Installation with Composer +========================== + +Check whether you are already using the extension with: + +.. code-block:: bash + + composer show | grep scheduler + +This should either give you no result or something similar to: + +.. code-block:: none + + typo3/cms-scheduler v12.4.11 + +If it is not installed yet, use the ``composer require`` command to install +the extension: + +.. code-block:: bash + + composer require typo3/cms-scheduler + +The given version depends on the version of the TYPO3 Core you are using. + + +.. _installing-classic: + +Classic installation without Composer +===================================== + +In an installation without Composer, the extension is already shipped but might +not be activated yet. Activate it as follows: + +#. In the backend, navigate to the :guilabel:`System > Extensions` + module. +#. Click the :guilabel:`Activate` icon for the Scheduler extension. + +.. figure:: /Images/InstallActivate.png + :class: with-border + :alt: Extension manager showing Scheduler extension + + Extension manager showing Scheduler extension + +.. _installing-cnext: + +Next steps +========== + +Once the extension is installed, the following setting is available: + +- **Maximum lifetime** : it may happen that a task crashes while + executing. In this case it will stay in a state marked as "running". + That may prevent it from being executed again, if parallel executions + are denied (see "Tasks execution" above). The maximum lifetime + parameter ensures that old executions are removed after a while. The + lifetime is expressed in **minutes** . The default is 15 minutes. + +.. figure:: /Images/ExtensionConfiguration.png + :alt: Extension configuration + + Configuring the extension settings diff --git a/Documentation/Installation/SetupCheck/Index.rst b/Documentation/Installation/SetupCheck/Index.rst new file mode 100644 index 0000000..524d197 --- /dev/null +++ b/Documentation/Installation/SetupCheck/Index.rst @@ -0,0 +1,26 @@ +:navigation-title: Setup Check + +.. include:: /Includes.rst.txt +.. _setup-check: + +============================================= +Checking the setup of the scheduler extension +============================================= + +The scheduler check provides useful information for setting up cronjobs. + +.. figure:: /Images/SetupCheckButton.png + :alt: The TYPO3 Backend module "Scheduler" with Button "Setup check" highlighted + + Click on the button :guilabel:`Setup check` to open the popup + +.. figure:: /Images/SetupCheck.png + :alt: The "Setup check" modal popup in module "Scheduler" + +The first message shows when the scheduler was last run. If it was never run +there will be a warning displayed. + +The second messages tells you which command (with absolute paths) must be +executed by the cron job. + +The third message shows information about the current server time. diff --git a/Documentation/Introduction/Index.rst b/Documentation/Introduction/Index.rst new file mode 100644 index 0000000..f5697d0 --- /dev/null +++ b/Documentation/Introduction/Index.rst @@ -0,0 +1,103 @@ +.. include:: /Includes.rst.txt +.. _introduction: + +============ +Introduction +============ + +The Scheduler is a system extension that provides a simple interface to automate +the running of tasks in TYPO3. It provides an easier alternative to having to set +up command-line scripts in cron jobs. + +.. _task-management: + +Task management +=============== + +A task is created by extending the Scheduler base class and then registering it so +that it appears in the Scheduler backend module. + +.. _screenshots: + +Screenshots +=========== + +Below is the Scheduler backend module, showing a task that has been registered. +The module shows details about the tasks, i.e. status, type and whether it failed during the +last execution, and individual tasks can be rerun manually by clicking on the +'play' button. + +.. figure:: ../Images/BackendModuleMainView.png + :alt: Scheduler main screen + + Main screen of the Scheduler BE module + +.. _tasks-execution: + +Task execution +============== + +The Scheduler includes a command-line script that needs to be registered once +in the server's crontab to set it up. It is run by the TYPO3 command-line +dispatcher. Every time the Scheduler is launched by the cron daemon it looks for +tasks that are due (or overdue) and executes them. + +When a task is executed it is marked as being +executed in the Scheduler database record (in the +`serialized_executions` field). When the task has finished running, the +execution status is removed from the database record. This makes it +clear whether a task is currently running and also +prevents multiple executions. If a task requires +more time to run than the frequency it is set up for, a +new run will start (which is not always desirable). It is possible +to prevent such parallel (or multiple) executions. + +.. _follow-up: + +Follow-up +========= + +Log messages are written out to the TYPO3 system :guilabel:`Administration > Log` +when a task starts and ends and when parallel execution has been blocked. This +provides a trace of events of the tasks. + +A task that fails may also raise an exception reporting the reasons for failure. +The exception message will be logged in the Scheduler database table and +displayed in the backend module. + +Tasks have no command-line output as they are designed to run in the background. + +Symfony Console commands can also be run as tasks (see +`https://docs.typo3.org/permalink/t3coreapi:symfony-console-commands`__). +These tasks can specify all commandline arguments that are available to Symfony +Console commands. + +.. _glossary: + +Glossary +======== + +Task + Specifically, a piece of code that does a precise task and can be registered + with the Scheduler in order to execute that piece of code at a precise time, + either once or recurrently. + +Task class + A type of task, for example, the "IP Anonymization" task is one + particular task class. Its function is to anonymize IP addresses to enforce + the privacy of persisted data. The "Optimize MySQL database tables" + task executes "OPTIMIZE TABLE" statements on selected database tables. + +Registered task + An instance of a task class that has been + registered with the Scheduler. A given task class may be registered + several times, for example if it needs to be executed with different + parameters. + +.. _credits: + +Credits +======= + +The Scheduler is a derivation of the Gabriel extension originally developed by +Christian Jul Jensen and further developed by Markus Friedrich. diff --git a/Documentation/KnownProblems/Index.rst b/Documentation/KnownProblems/Index.rst new file mode 100644 index 0000000..1a891ea --- /dev/null +++ b/Documentation/KnownProblems/Index.rst @@ -0,0 +1,15 @@ +.. include:: /Includes.rst.txt +.. _known-problems: + +============== +Known problems +============== + +The main problem currently is that a running task cannot be killed, +because no relation exists to the (cron) process that is running the +Scheduler. The process pid could be retrieved, but that may not work +on all platforms. And can the process be killed afterwards? Anyway it +may not be safe to do that. + +.. seealso:: + `How to handle a truly “hung” task `_ diff --git a/Documentation/Sitemap.rst b/Documentation/Sitemap.rst new file mode 100644 index 0000000..cd06f7d --- /dev/null +++ b/Documentation/Sitemap.rst @@ -0,0 +1,9 @@ +:template: sitemap.html + +.. include:: /Includes.rst.txt + +======= +Sitemap +======= + +.. The sitemap.html template will insert here the page tree automatically. diff --git a/Documentation/_Includes/_ExtendingSchedulerTca.rst.txt b/Documentation/_Includes/_ExtendingSchedulerTca.rst.txt new file mode 100644 index 0000000..a34425c --- /dev/null +++ b/Documentation/_Includes/_ExtendingSchedulerTca.rst.txt @@ -0,0 +1,6 @@ +.. warning:: + If your extension overrides the TCA of the scheduler extension, it **must** + be loaded **after** :composer:`typo3/cms-scheduler`, otherwise the + configuration might take no effect. + + See `Extension loading order `_ diff --git a/Documentation/guides.xml b/Documentation/guides.xml new file mode 100644 index 0000000..b179688 --- /dev/null +++ b/Documentation/guides.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a5a5bc4 --- /dev/null +++ b/README.rst @@ -0,0 +1,11 @@ +============================= +TYPO3 extension ``scheduler`` +============================= + +The Scheduler supports one-time or periodic execution of tasks that can be +delivered by any extension. + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/c/typo3/cms-scheduler/main/en-us/ +:Packagist: https://packagist.org/packages/typo3/cms-scheduler diff --git a/Resources/Private/Language/label.xlf b/Resources/Private/Language/label.xlf new file mode 100644 index 0000000..69eef81 --- /dev/null +++ b/Resources/Private/Language/label.xlf @@ -0,0 +1,15 @@ + + + + + + Delete task group + + + + + Delete task + + + + diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf new file mode 100644 index 0000000..a6d017b --- /dev/null +++ b/Resources/Private/Language/locallang.xlf @@ -0,0 +1,579 @@ + + + +
    + + + Cancel + + + Create group + + + Scheduled tasks + + + Setup check + + + New task + + + New group + + + Group name + + + CLI script + + + System is running in Composer Mode + + + + The system is running in composer mode, the path to the `typo3` executable could not be reliably determined. + By default, the command to run in composer mode is `vendor/bin/typo3 scheduler:run`. + + + + Last run + + + automatically + + + Backend types + + + Number of days until removing files + + + Check/uncheck all + + + Task + + + Cron + + + Definition type + + + Interval + + + Cron command + + + Description + + + Email + + + End (HH:MM DD-MM-YYYY) + + + Start at + + + Run periodically until + + + Execute + + + Execute on next cron job + + + Frequency + + + Frequency (seconds or cron command) + + + ID + + + Last Execution + + + Manual + + + manually + + + Next Execution + + + Parallel Execution + + + Allow Parallel Execution + + + Server time + + + Sleep time + + + Start (HH:MM DD-MM-YYYY) + + + Storage to index + + + Number of files per run + + + Registered extractors + + + %1$s with priority %2$d + + + Following extractors have been registered and will be used when running this task + + + There currently is no extractor registered. This task will have no effect. + + + Clean all available tables + + + Note: Entries from all available tables are deleted based on the table-specific "expirePeriod" option. + + + Table to clean up + + + Table: %s + + + All tables + + + Delete entries older than given number of days + + + Database tables + + + Status + + + Type + + + Run task multiple times? + + + Recurring + + + Single + + + Task group + + + Not assigned to any task group + + + disabled + + + Unused task groups + + + Group + + + Enable group + + + Disable group + + + Edit group + + + Broken tasks + + + Save and create new task + + + Add option + + + Set option + + + Run on CLI + + + The script to execute the Scheduler from the command line is: <strong>"%s scheduler:run"</strong>. + + + The web server user is allowed execute this script. + + + The web server user is not allowed to execute this script. + + + Are you sure you want to delete this task? + + + The task could not be deleted. + + + The task was successfully deleted. + + + The end date is before the start date. + + + Task "%s" with uid "%s" has been executed. + + + Invalid frequency. Please enter either a number of seconds or a valid cron command. The cron parser said: %1$s [code: %2$d]. + + + The information about the last execution of the Scheduler is incomplete. + + + Invalid frequency. Please enter either a number of seconds or a valid cron command. + + + Please enter a sleep time greater or equal to 0 (zero). + + + The Scheduler was last started (%1$s) on %2$s at %3$s and ended on %4$s at %5$s. + + + A running task may not be deleted. + + + A running task may not be edited. + + + The task is not running. There are no executions to unmark. + + + Please enter an email address. + + + No frequency was defined, either as an interval or as a cron command. + + + The Scheduler has never yet run or the information about the last run has been lost. + + + The selected task type must be either "single execution" or "recurring execution". + + + No tasks found + + + There are currently no configured tasks found. You can create a new one. + + + There are currently no tasks available in the system to be configured. + + + Task "%s" with uid "%s" has not been executed. + + + Execution of task "%1$s" failed with the following message: %2$s + + + Scheduling of task "%1$s" failed with the following message: %2$s + + + Toggling disabled state of task "%1$s" failed with the following message: %2$s + + + Stopping task "%1$s" failed with the following message: %2$s + + + The execution failed, but the error message could not be retrieved (it was probably too large). Sorry. + + + Execution failed: %1$d, %2$s + + + Please define a start date. + + + Start date is invalid. + + + End date is invalid. + + + Number of days is invalid. + + + Some of the selected cache backends do not exist. Please select valid backends only. + + + Please select at least one cache backend. + + + The following tasks are broken. If your task doesn't have a registered class, you should consider reinstalling the extension that provided them or simply deleting the task. In case the error is "Cannot unserialize [class]", the announced class must be removed from your task class, as it is not allowed to be used due to security reasons. Please check also if any of the listed tasks have to be recreated manually (which can be necessary after an extension update due to breaking changes or missing migration scripts).]]> + + + Error message + + + %s registered scheduler tasks can not be executed + + + Please check the erroneous tasks.]]> + + + This screen checks if the requisites for running the Scheduler as a cron job are fulfilled. It also displays information about the last run of the Scheduler. + + + Current server time is %s. + + + All dates and times in the Scheduler are measured according to the server's time, as the Scheduler is run purely on the server-side. + + + Are you sure you want to mark this task as not running? Note that this will not stop the actual script (if unsure please refer to the manual). + + + The task could not be marked as non-running. + + + The task was successfully marked as non-running. + + + The requested task (UID: %d) was not found. + + + The task could not be updated. + + + Some of the selected database tables do not exist. Please select only valid tables. + + + Please select at least one database table. + + + Command with identifier "%s" has not been registered. + + + Error parsing current set of arguments: "%s". + + + Error parsing current set of options: "%s". + + + Argument "%s" is mandatory. + + + Task "%s" with uid "%s" has been enabled. + + + Task "%s" with uid "%s" has been disabled. + + + Task "%s" with uid "%s" has been enabled and queued for execution at next scheduler CLI run. + + + Task "%s" with uid "%s" has been queued for execution at next scheduler CLI run. + + + Successfully deleted scheduler group. + + + Failed to delete scheduler group. + + + Invalid Task type + + + Default expire period of %d days, configured for selected table "%s", will be used. + + + None + + + Status + + + Progress + + + disabled + + + disabled by group + + + failure + + + late + + + running + + + scheduled + + + Task + + + TYPO3 Scheduler administration module + + + Run task + + + Run task on next cron job + + + Caching framework garbage collection + + + This task calls the garbage collection of configured caching framework caches which use one of the selected backends. This will free some space in cache backends which do not have an internal garbage collection. In case of the default database backend it is advisable to run this task once a day when the database is mostly idle. + + + [OBSOLETE] File Abstraction Layer: Indexing job + + + Runs indexing tasks based on an indexing configuration and a storage/folder information. + + + File Abstraction Layer: Update storage index + + + Updates the Index/Cache Data of a Storage; only needed if changes to the storage are possible outside the backend (FTP, RemoteStorages). + + + File Abstraction Layer: Extract metadata in storage + + + Extracts metadata for all files in storage which have been changed since last run. + + + Table garbage collection + + + Task to delete old entries from specific tables like sys_log. + + + Fileadmin garbage collection + + + This task empties all "_recycler_" folders below fileadmin. This helps free some space in the file system. + + + Execute console commands + + + Allows regular console commands to be configured and executed through the scheduler framework. + + + Optimize MySQL database tables + + + This task executes "OPTIMIZE TABLE" statements on the selected database tables. This helps to reduce storage space and improve I/O efficiency. Warning: tables will be locked during the optimization process. + + + Anonymize IP addresses in database tables + + + This task anonymizes the IP addresses from specific tables like sys_log to enforce the privacy of the persisted data. + + + Table to anonymize + + + Table: %s after %s days + + + All tables + + + Handle entries older than given number of days + + + Mask level + + + Last byte for IPv4 / Interface ID for IPv6 + + + Last 2 bytes for IPv4 / Interface & SLA ID for IPv6 + + + Command configuration + + + Configure available arguments and options for the command. + + + Every day-of-week from Monday through Friday at 09:00 and 15:00 + + + Every 2 hours + + + Every 20 minutes + + + Every Tuesday at 07:00 + + + configuration.]]> + + + Last Scheduler run + + + %1$s at %2$s, Duration %3$s, (started %4$s) + + + Error + + + Could not create group + + + Enter a group name + + + Edit group name + + + Search for any kind of tasks + + + Unfortunately no scheduler task matches your query, please try a different one. + + + + Delete this task group? You can restore it in the module "Recycler". + + + Delete this task? You can restore it in the module "Recycler". + + + {count, plural, one {# task} other {# tasks}} + + + {count, plural, one {# unused task group} other {# unused task groups}} + + + + diff --git a/Resources/Private/Language/locallang_em.xlf b/Resources/Private/Language/locallang_em.xlf new file mode 100644 index 0000000..b1c35d6 --- /dev/null +++ b/Resources/Private/Language/locallang_em.xlf @@ -0,0 +1,11 @@ + + + +
    + + + Maximum lifetime: Set the maximum runtime (in minutes) for a scheduler task. Tasks still running after this time are removed from the execution list but are not stopped. + + + + diff --git a/Resources/Private/Language/locallang_tca.xlf b/Resources/Private/Language/locallang_tca.xlf new file mode 100644 index 0000000..2eec90b --- /dev/null +++ b/Resources/Private/Language/locallang_tca.xlf @@ -0,0 +1,104 @@ + + + +
    + + + Scheduler task + + + Task type + + + Belongs to task group + + + Priority + + + High + + + Regular + + + Low + + + Description + + + Settings + + + Schedule + + + Next run planned at + + + Last run started at + + + Error details from last run + + + Last run via + + + Handle entries older than given number of days + + + Database tables + + + File storage + + + Scheduler task group + + + Group name + + + Color + + + TYPO3 Orange + + + White + + + Gray + + + Black + + + Blue + + + Purple + + + Teal + + + Green + + + Magenta + + + Yellow + + + Red + + + Description + + + + diff --git a/Resources/Private/Language/module.xlf b/Resources/Private/Language/module.xlf new file mode 100644 index 0000000..9897bf4 --- /dev/null +++ b/Resources/Private/Language/module.xlf @@ -0,0 +1,17 @@ + + + +
    + + + TYPO3 Scheduler + + + Scheduler administration module. Check all registered tasks. Add, modify or delete tasks. + + + Scheduler + + + + diff --git a/Resources/Private/Partials/GroupUnusedList.fluid.html b/Resources/Private/Partials/GroupUnusedList.fluid.html new file mode 100644 index 0000000..0c6f440 --- /dev/null +++ b/Resources/Private/Partials/GroupUnusedList.fluid.html @@ -0,0 +1,90 @@ + + +
    + + + + + + + + + + + + + + + + + +
    + + {group.groupName} + + +

    {group.description}

    +
    +
    +
    + +
    + + + + +
    +
    + + + + + + + + + +
    +
    +
    + +
    +
    + +
    +
    + + diff --git a/Resources/Private/Partials/MultiRecordSelectionActions.fluid.html b/Resources/Private/Partials/MultiRecordSelectionActions.fluid.html new file mode 100644 index 0000000..22c642f --- /dev/null +++ b/Resources/Private/Partials/MultiRecordSelectionActions.fluid.html @@ -0,0 +1,25 @@ + + + + + diff --git a/Resources/Private/Partials/ServerTime.fluid.html b/Resources/Private/Partials/ServerTime.fluid.html new file mode 100644 index 0000000..5d915f5 --- /dev/null +++ b/Resources/Private/Partials/ServerTime.fluid.html @@ -0,0 +1,20 @@ + + + +

    +

    + + +

    +
    + + diff --git a/Resources/Private/Partials/TaskList.fluid.html b/Resources/Private/Partials/TaskList.fluid.html new file mode 100644 index 0000000..947e984 --- /dev/null +++ b/Resources/Private/Partials/TaskList.fluid.html @@ -0,0 +1,491 @@ + + + + + + + + + + +
    + + + +
    + + +
    +
    +
    + +
    +
    +
    +
    + + + + +
    {f:translate(key:'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.invalidTaskClass') -> f:format.raw()}
    + + + + + + + + + + + + + + + + + + + + + + + + +
    {errorClass.uid}{errorClass.class}{errorClass.errorMessage} +
    +
    + +
    + +
    +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    + +
    +
    +
    + + + {f:translate(key: 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:label.noGroup')} + + + + {group.groupName} + + + + +

    {group.description}

    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + +
    +
    +
    + +
    + + + +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + + {task.uid} +
    + + {task.fullTitle} + + + + +
    {task.additionalInformation}
    +
    +
    +
    + +
    {task.description}
    +
    +
    + + + + + + + + + {label.text} + + + + + + + + + + + + + - + {task.frequency} + +
    {f:translate(key: task.priorityLabel, default: task.priorityLabel)}
    +
    + + + + + + + + + + + +
    + + + + + + + + +
    + - +
    +
    + + - + + + + + + + + + + + + + + +
    + +
    +
    + +
    + + + + + + + + + + + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + diff --git a/Resources/Private/Templates/CheckScreen.fluid.html b/Resources/Private/Templates/CheckScreen.fluid.html new file mode 100644 index 0000000..9699858 --- /dev/null +++ b/Resources/Private/Templates/CheckScreen.fluid.html @@ -0,0 +1,30 @@ + + +

    + + + + + + +

    {f:translate(key: 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.composerMode')}

    +
    +
    + + +

    {f:translate(key: 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:msg.cliScript', arguments: '{0: script}') -> f:format.raw()}

    + +
    +
    +
    + + + + diff --git a/Resources/Private/Templates/CommandConfiguration.fluid.html b/Resources/Private/Templates/CommandConfiguration.fluid.html new file mode 100644 index 0000000..9822e96 --- /dev/null +++ b/Resources/Private/Templates/CommandConfiguration.fluid.html @@ -0,0 +1,67 @@ + + + +
    +
    + +
    + +
    {field.description}
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    + +
    {field.description}
    +
    + + +
    + + +
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + + + diff --git a/Resources/Private/Templates/Email/TestTask.fluid.html b/Resources/Private/Templates/Email/TestTask.fluid.html new file mode 100644 index 0000000..6605b57 --- /dev/null +++ b/Resources/Private/Templates/Email/TestTask.fluid.html @@ -0,0 +1,14 @@ + +Scheduler Test Task + + UID: {data.uid}
    + Sitename: {typo3.sitename}
    + Called by: {data.calledBy}
    + Tstamp: {f:format.date(format:'Y-m-d H:i:s',date:data.tstamp)} [{data.tstamp}]
    + maxLifetime: {data.maxLifetime}
    + Start: {f:format.date(format:'Y-m-d H:i:s', date:exec.start)} [{exec.start}]
    + End: {f:format.date(format:'Y-m-d H:i:s', date:exec.end)} [{exec.end}]-
    + Interval: {exec.interval}
    + multiple: {f:if(condition:exec.multiple,then:'yes',else:'no')}
    + cronCmd: {f:if(condition:exec.cronCmd,then:cronCmd,else:'not used')} +
    diff --git a/Resources/Private/Templates/Email/TestTask.fluid.txt b/Resources/Private/Templates/Email/TestTask.fluid.txt new file mode 100644 index 0000000..8578534 --- /dev/null +++ b/Resources/Private/Templates/Email/TestTask.fluid.txt @@ -0,0 +1,15 @@ + +Scheduler Test Task + +UID: {data.uid} +Sitename: {typo3.sitename} +Called by: {data.calledBy} +Tstamp: {f:format.date(format:'Y-m-d H:i:s',date:data.tstamp)} [{data.tstamp}] +maxLifetime: {data.maxLifetime} +start: {f:format.date(format:'Y-m-d H:i:s', date:exec.start)} [{exec.start}] +end: {f:format.date(format:'Y-m-d H:i:s', date:exec.end)} [{exec.end}]- +interval: {exec.interval} +multiple: {f:if(condition:exec.multiple,then:'yes',else:'no')} +cronCmd: {f:if(condition:exec.cronCmd,then:cronCmd,else:'not used')} + + diff --git a/Resources/Private/Templates/ListTasks.fluid.html b/Resources/Private/Templates/ListTasks.fluid.html new file mode 100644 index 0000000..3079a3e --- /dev/null +++ b/Resources/Private/Templates/ListTasks.fluid.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + + +

    + + + + + + + + +

    + + {f:translate(key: 'LLL:EXT:scheduler/Resources/Private/Language/locallang.xlf:function.add')} + +
    + +

    +
    +
    +
    +
    +
    + + +

    + +
    + + +
    + + diff --git a/Resources/Private/Templates/NewSchedulerTask/Wizard.fluid.html b/Resources/Private/Templates/NewSchedulerTask/Wizard.fluid.html new file mode 100644 index 0000000..7e009be --- /dev/null +++ b/Resources/Private/Templates/NewSchedulerTask/Wizard.fluid.html @@ -0,0 +1,11 @@ + + + + + + diff --git a/Resources/Private/Templates/ServerTime.fluid.html b/Resources/Private/Templates/ServerTime.fluid.html new file mode 100644 index 0000000..e8f2fdc --- /dev/null +++ b/Resources/Private/Templates/ServerTime.fluid.html @@ -0,0 +1,10 @@ + + +
    + +
    + + diff --git a/Resources/Public/Icons/Extension.png b/Resources/Public/Icons/Extension.png new file mode 100644 index 0000000..3196735 Binary files /dev/null and b/Resources/Public/Icons/Extension.png differ diff --git a/Resources/Public/JavaScript/form-engine/element/timing-options-element.js b/Resources/Public/JavaScript/form-engine/element/timing-options-element.js new file mode 100644 index 0000000..678e5e7 --- /dev/null +++ b/Resources/Public/JavaScript/form-engine/element/timing-options-element.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import s from"@typo3/backend/form-engine.js";import{selector as a}from"@typo3/core/literals.js";import g from"@typo3/core/event/regular-event.js";var i;(function(t){t.single="1",t.recurring="2"})(i||(i={}));class u extends HTMLElement{async connectedCallback(){await s.ready(),this.fieldPrefix=this.getAttribute("fieldPrefix"),this.registerEventHandler()}registerEventHandler(){new g("change",()=>{this.toggleRunningType()}).delegateTo(this,a`input[name='${this.fieldPrefix}[runningType]']`),this.toggleRunningType()}toggleRunningType(){const l=this.querySelector(a`input[name='${this.fieldPrefix}[runningType]']:checked`).value;this.querySelectorAll(".t3js-timing-options-end, .t3js-timing-options-parallel, .t3js-timing-options-frequency").forEach(n=>{if(n.style.display=l===i.recurring?"block":"none",n.classList.contains("t3js-timing-options-frequency")){const e=n.querySelector("input[data-formengine-validation-rules]");let r,o;l===i.recurring?(r=e.getAttribute("data-formengine-validation-rules")==="[]",o='[{"type":"required"}]'):(r=e.getAttribute("data-formengine-validation-rules")!=="[]",o="[]"),r&&(e.setAttribute("data-formengine-validation-rules",o),TYPO3.FormEngine.Validation.initializeInputField(e.dataset.formengineInputName),TYPO3.FormEngine.Validation.validate())}})}}window.customElements.define("typo3-formengine-element-timing-options",u); diff --git a/Resources/Public/JavaScript/new-scheduler-task-wizard-button.js b/Resources/Public/JavaScript/new-scheduler-task-wizard-button.js new file mode 100644 index 0000000..88106e8 --- /dev/null +++ b/Resources/Public/JavaScript/new-scheduler-task-wizard-button.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import{property as f,customElement as a}from"lit/decorators.js";import{PseudoButtonLitElement as d}from"@typo3/backend/element/pseudo-button.js";import c from"@typo3/backend/modal.js";import{SeverityEnum as m}from"@typo3/backend/enum/severity.js";import"@typo3/backend/new-record-wizard.js";var s=function(i,e,r,n){var p=arguments.length,t=p<3?e:n===null?n=Object.getOwnPropertyDescriptor(e,r):n,u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")t=Reflect.decorate(i,e,r,n);else for(var l=i.length-1;l>=0;l--)(u=i[l])&&(t=(p<3?u(t):p>3?u(e,r,t):u(e,r))||t);return p>3&&t&&Object.defineProperty(e,r,t),t};let o=class extends d{buttonActivated(){this.url&&c.advanced({content:this.url,title:this.subject,severity:m.notice,size:c.sizes.large,type:c.types.ajax})}};s([f({type:String})],o.prototype,"url",void 0),s([f({type:String})],o.prototype,"subject",void 0),o=s([a("typo3-scheduler-new-task-wizard-button")],o);export{o as NewSchedulerTaskWizardButton}; diff --git a/Resources/Public/JavaScript/scheduler-add-group.js b/Resources/Public/JavaScript/scheduler-add-group.js new file mode 100644 index 0000000..fbe8a1a --- /dev/null +++ b/Resources/Public/JavaScript/scheduler-add-group.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import r from"@typo3/backend/modal.js";import{html as d}from"lit";import m from"@typo3/core/ajax/ajax-request.js";import l from"@typo3/backend/notification.js";import n from"~labels/scheduler.messages";class p{constructor(){this.selector=".t3js-create-group",this.initialize()}initialize(){const o=document.querySelector(this.selector);o&&o.addEventListener("click",s=>{s.preventDefault();const e=d`
    the whole record to add a custom color and description.
    `;r.advanced({content:e,title:n.get("function.group.add"),size:r.sizes.small,buttons:[{trigger:()=>r.dismiss(),text:n.get("button.cancel"),btnClass:"btn-default",name:"cancel"},{trigger:()=>{r.currentModal.querySelector('form[name="scheduler-create-group"]').requestSubmit()},text:n.get("button.group.modalOk"),btnClass:"btn-primary",name:"ok"}]}).addEventListener("typo3-modal-shown",()=>{r.currentModal.querySelector('input[name="action[createGroup]"]').focus()})})}createGroup(o){o.preventDefault();const e=new FormData(o.target).get("action[createGroup]").toString(),t="NEW"+Math.random().toString(36).slice(2,7),c="&data[tx_scheduler_task_group]["+t+"][pid]=0&data[tx_scheduler_task_group]["+t+"][groupName]="+encodeURIComponent(e);return new m(TYPO3.settings.ajaxUrls.record_process).post(c,{headers:{"Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"}}).then(async a=>await a.resolve()).then(a=>(a.hasErrors&&l.error(n.get("msg.group.notification.error.title"),n.get("msg.group.notification.error.message")+' "'+e+'"!'),a.messages.forEach(i=>{l.info(i.title,i.message)}),a)).catch(()=>{l.error(n.get("msg.group.notification.error.title"),n.get("msg.group.notification.error.message")+' "'+e+'"!')}).finally(()=>{if(document.querySelector("#task_group")){const i=document.forms[0],u=i.querySelector('[name="tx_scheduler[select_latest_group]"]');u.value="1",i.submit()}else window.location.reload();r.dismiss()})}editWholeRecord(o){o.preventDefault();const s=r.currentModal.querySelector('input[name="action[createGroup]"]'),e=s?s.value.trim():"",t=new URL(top.TYPO3.settings.FormEngine.moduleUrl,window.location.origin);t.searchParams.set("edit[tx_scheduler_task_group][0]","new"),e&&t.searchParams.set("defVals[tx_scheduler_task_group][groupName]",e),t.searchParams.set("returnUrl",window.location.href),r.dismiss(),window.location.href=t.toString()}}var g=new p;export{g as default}; diff --git a/Resources/Public/JavaScript/scheduler-sortable-groups.js b/Resources/Public/JavaScript/scheduler-sortable-groups.js new file mode 100644 index 0000000..d8d9ef8 --- /dev/null +++ b/Resources/Public/JavaScript/scheduler-sortable-groups.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import n from"sortablejs";import l from"@typo3/backend/ajax-data-handler.js";class i{constructor(){this.container=".t3js-group-draggable-container",this.dragHandle=".t3js-group-draggable-handle",this.initialize()}initialize(){const r=document.querySelector(this.container);r&&(new n(r,{handle:this.dragHandle,onMove:e=>"taskGroupId"in e.related.dataset&&Number(e.related.dataset.taskGroupId)!==0,onSort:e=>{const o=e.target.children[e.newDraggableIndex-1];let d=0;o&&(d=+("-"+o.dataset.taskGroupId));const t=Number(e.item.dataset.taskGroupId),a="tx_scheduler_task_group",s={component:"contextmenu",action:"delete",table:a,uid:t};l.process("cmd["+a+"]["+t+"][move][action]=paste&cmd["+a+"]["+t+"][move][target]="+d+"&cmd["+a+"]["+t+"][move][update][colPos]=0&cmd["+a+"]["+t+"][move][update][sys_language_uid]=0",s)}}),document.querySelectorAll(this.dragHandle).forEach(e=>{e.disabled=!1}))}}var c=new i;export{c as default}; diff --git a/Resources/Public/JavaScript/scheduler.js b/Resources/Public/JavaScript/scheduler.js new file mode 100644 index 0000000..8c41a32 --- /dev/null +++ b/Resources/Public/JavaScript/scheduler.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import r from"@typo3/backend/sortable-table.js";import i from"@typo3/core/event/regular-event.js";import c from"@typo3/backend/icons.js";import l from"@typo3/backend/storage/persistent.js";import{MultiRecordSelectionSelectors as d}from"@typo3/backend/multi-record-selection.js";import u from"@typo3/core/document-service.js";class a{constructor(){u.ready().then(()=>{this.initializeEvents()})}static storeCollapseState(o,s){let t={};l.isset("moduleData.scheduler")&&(t=l.get("moduleData.scheduler"));const e={};e[o]=s?1:0,t={...t,...e},l.set("moduleData.scheduler",t)}initializeEvents(){document.querySelectorAll("[data-scheduler-table]").forEach(o=>{new r(o)}),new i("show.bs.collapse",this.toggleCollapseIcon.bind(this)).bindTo(document),new i("hide.bs.collapse",this.toggleCollapseIcon.bind(this)).bindTo(document),new i("multiRecordSelection:action:go",this.executeTasks.bind(this)).bindTo(document),new i("multiRecordSelection:action:go_cron",this.executeTasks.bind(this)).bindTo(document)}toggleCollapseIcon(o){const s=o.type==="hide.bs.collapse",t=document.querySelector('.t3js-toggle-table[data-bs-target="#'+o.target.id+'"] .t3js-icon');t!==null&&c.getIcon(s?"actions-view-list-expand":"actions-view-list-collapse",c.sizes.small).then(e=>{t.replaceWith(document.createRange().createContextualFragment(e))}),a.storeCollapseState(o.target.dataset.table,s)}executeTasks(o){const s=document.querySelector('[data-multi-record-selection-form="'+o.detail.identifier+'"]');if(s===null)return;const t=[];if(o.detail.checkboxes.forEach(e=>{const n=e.closest(d.elementSelector);n!==null&&n.dataset.taskId&&t.push(n.dataset.taskId)}),t.length){if(o.type==="multiRecordSelection:action:go_cron"){const e=document.createElement("input");e.setAttribute("type","hidden"),e.setAttribute("name","action[scheduleCron]"),e.setAttribute("value",t.join(",")),s.append(e)}else{const e=document.createElement("input");e.setAttribute("type","hidden"),e.setAttribute("name","action[execute]"),e.setAttribute("value",t.join(",")),s.append(e)}s.submit()}}}var m=new a;export{m as default}; diff --git a/Resources/Public/JavaScript/setup-check-button.js b/Resources/Public/JavaScript/setup-check-button.js new file mode 100644 index 0000000..f991030 --- /dev/null +++ b/Resources/Public/JavaScript/setup-check-button.js @@ -0,0 +1,13 @@ +/* + * 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! + */ +import{property as s,customElement as m}from"lit/decorators.js";import{PseudoButtonLitElement as y}from"@typo3/backend/element/pseudo-button.js";import l from"@typo3/backend/modal.js";import{SeverityEnum as d}from"@typo3/backend/enum/severity.js";var f=function(n,e,o,i){var p=arguments.length,t=p<3?e:i===null?i=Object.getOwnPropertyDescriptor(e,o):i,u;if(typeof Reflect=="object"&&typeof Reflect.decorate=="function")t=Reflect.decorate(n,e,o,i);else for(var c=n.length-1;c>=0;c--)(u=n[c])&&(t=(p<3?u(t):p>3?u(e,o,t):u(e,o))||t);return p>3&&t&&Object.defineProperty(e,o,t),t};let r=class extends y{buttonActivated(){this.url&&l.advanced({content:this.url,title:this.subject,severity:d.notice,size:l.sizes.large,type:l.types.ajax})}};f([s({type:String})],r.prototype,"url",void 0),f([s({type:String})],r.prototype,"subject",void 0),r=f([m("typo3-scheduler-setup-check-button")],r);export{r as SetupCheckButton}; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f7e26b1 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "typo3/cms-scheduler", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Scheduler - Schedule tasks to run once or periodically at a specific time.", + "homepage": "https://typo3.community/", + "funding": [ + { + "type": "membership", + "url": "https://typo3.org/membership" + } + ], + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + } + ], + "support": { + "issues": "https://forge.typo3.org/issues/", + "forum": "https://talk.typo3.org/", + "source": "https://github.com/TYPO3/typo3/", + "docs": "https://docs.typo3.org/c/typo3/cms-scheduler/main/en-us/", + "rss": "https://news.typo3.com/rss/", + "chat": "https://typo3.community/meet/slack/", + "security": "https://typo3.org/security/" + }, + "config": { + "sort-packages": true + }, + "require": { + "typo3/cms-core": "15.0.*@dev" + }, + "conflict": { + "typo3/cms": "*" + }, + "extra": { + "branch-alias": { + "dev-main": "15.0.x-dev" + }, + "typo3/cms": { + "extension-key": "scheduler" + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Scheduler\\": "Classes/" + } + } +} diff --git a/ext_conf_template.txt b/ext_conf_template.txt new file mode 100644 index 0000000..3535108 --- /dev/null +++ b/ext_conf_template.txt @@ -0,0 +1,2 @@ +# cat=basic//; type=string; label=LLL:EXT:scheduler/Resources/Private/Language/locallang_em.xlf:scheduler.config.maxLifetime +maxLifetime = 1440 diff --git a/ext_localconf.php b/ext_localconf.php new file mode 100644 index 0000000..412efd8 --- /dev/null +++ b/ext_localconf.php @@ -0,0 +1,45 @@ + 'schedulerTimingOptions', + 'priority' => 40, + 'class' => TimingOptionsElement::class, +]; + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1758189546] = [ + 'nodeName' => 'taskTypeInfo', + 'priority' => 40, + 'class' => TaskTypeInfoElement::class, +]; + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1758791054] = [ + 'nodeName' => 'registeredExtractors', + 'priority' => 40, + 'class' => RegisteredExtractors::class, +]; + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1758906785] = [ + 'nodeName' => 'expirePeriodInformation', + 'priority' => 40, + 'class' => ExpirePeriodInformation::class, +]; + +$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1759164368] = [ + 'nodeName' => 'schedulableCommandConfiguration', + 'priority' => 40, + 'class' => SchedulableCommandConfigurationElement::class, +]; + +// Register hook for datamap +$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'][] = SchedulerTaskPersistenceValidator::class; diff --git a/ext_tables.sql b/ext_tables.sql new file mode 100644 index 0000000..a8805c4 --- /dev/null +++ b/ext_tables.sql @@ -0,0 +1,6 @@ +CREATE TABLE tx_scheduler_task ( + # @deprecated will be removed in TYPO3 v16 when the upgrade wizard is going to be removed + serialized_task_object mediumblob, + serialized_executions mediumblob, + KEY index_nextexecution (nextexecution) +);