From d6a30b05f84bc4fc118a72c0900c0c3c6f829d8b Mon Sep 17 00:00:00 2001
From: Philipp Bergsmann <p.bergsmann@opendo.at>
Date: Mon, 13 Feb 2012 19:19:47 +0100
Subject: [PATCH] [FEATURE] Add scheduler task to remove deleted records

Scheduler task to remove deleted records from
content table(s) which are older than x days.

If a deleted record also contains an upload field,
then the file is also deleted.

Releases: master
Resolves: #32651
Change-Id: I58577c05a1a3b228579c05578cc8fdf2e3b393fa
Reviewed-on: http://review.typo3.org/9013
Reviewed-by: Markus Klein <klein.t3@reelworx.at>
Reviewed-by: Nicole Cordes <typo3@cordes.co>
Tested-by: Nicole Cordes <typo3@cordes.co>
Reviewed-by: Wouter Wolters <typo3@wouterwolters.nl>
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
---
 ...AddSchedulerTaskToRemoveDeletedRecords.rst |   9 +
 .../Classes/Task/CleanerFieldProvider.php     | 207 ++++++++++++++++
 .../recycler/Classes/Task/CleanerTask.php     | 232 ++++++++++++++++++
 .../Private/Language/locallang_tasks.xlf      |  35 +++
 .../Unit/Task/CleanerFieldProviderTest.php    | 152 ++++++++++++
 .../Tests/Unit/Task/CleanerTaskTest.php       | 111 +++++++++
 typo3/sysext/recycler/ext_localconf.php       |   8 +
 typo3/sysext/scheduler/Classes/Scheduler.php  |  98 ++++----
 8 files changed, 811 insertions(+), 41 deletions(-)
 create mode 100644 typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst
 create mode 100644 typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php
 create mode 100644 typo3/sysext/recycler/Classes/Task/CleanerTask.php
 create mode 100644 typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf
 create mode 100644 typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php
 create mode 100644 typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php

diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst
new file mode 100644
index 000000000000..acbea451c928
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-32651-AddSchedulerTaskToRemoveDeletedRecords.rst
@@ -0,0 +1,9 @@
+==============================================================
+Feature: #32651 - Add scheduler task to remove deleted records
+==============================================================
+
+Description
+===========
+
+A new scheduler task for removing deleted records has been added. The maximum age and
+the affected tables are configurable in the task's settings.
diff --git a/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php b/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php
new file mode 100644
index 000000000000..eeef26cbf0fb
--- /dev/null
+++ b/typo3/sysext/recycler/Classes/Task/CleanerFieldProvider.php
@@ -0,0 +1,207 @@
+<?php
+namespace TYPO3\CMS\Recycler\Task;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController;
+use TYPO3\CMS\Scheduler\Task\AbstractTask;
+
+/**
+ * A task that should be run regularly that deletes
+ * datasets flagged as "deleted" from the DB.
+ *
+ * @author Philipp Bergsmann <p.bergsmann@opendo.at>
+ */
+class CleanerFieldProvider implements \TYPO3\CMS\Scheduler\AdditionalFieldProviderInterface {
+
+	/**
+	 * Gets additional fields to render in the form to add/edit a task
+	 *
+	 * @param array $taskInfo Values of the fields from the add/edit task form
+	 * @param \TYPO3\CMS\Recycler\Task\CleanerTask $task The task object being edited. NULL when adding a task!
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return array A two dimensional array, array('Identifier' => array('fieldId' => array('code' => '', 'label' => '', 'cshKey' => '', 'cshLabel' => ''))
+	 */
+	public function getAdditionalFields(array &$taskInfo, $task, SchedulerModuleController $schedulerModule) {
+		if ($schedulerModule->CMD === 'edit') {
+			$taskInfo['RecyclerCleanerTCA'] = $task->getTcaTables();
+			$taskInfo['RecyclerCleanerPeriod'] = $task->getPeriod();
+		}
+
+		$additionalFields['period'] = array(
+			'code' => '<input type="text" class="form-control" name="tx_scheduler[RecyclerCleanerPeriod]" value="' . $taskInfo['RecyclerCleanerPeriod'] . '">',
+			'label' => 'LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskPeriod',
+			'cshKey' => '',
+			'cshLabel' => 'task_recyclerCleaner_selectedPeriod'
+		);
+
+		$additionalFields['tca'] = array(
+			'code' => $this->getTcaSelectHtml($taskInfo['RecyclerCleanerTCA']),
+			'label' => 'LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskTCA',
+			'cshKey' => '',
+			'cshLabel' => 'task_recyclerCleaner_selectedTables'
+		);
+
+		return $additionalFields;
+	}
+
+	/**
+	 * Gets the select-box from the TCA-fields
+	 *
+	 * @param array $selectedTables
+	 * @return string
+	 */
+	protected function getTcaSelectHtml($selectedTables = array()) {
+		if (!is_array($selectedTables)) {
+			$selectedTables = array();
+		}
+		$tcaSelectHtml = '<select name="tx_scheduler[RecyclerCleanerTCA][]" multiple="multiple" class="form-control" size="10">';
+
+		$options = array();
+		foreach ($GLOBALS['TCA'] as $table => $tableConf) {
+			if (!$tableConf['ctrl']['adminOnly'] && !empty($tableConf['ctrl']['delete'])) {
+				$selected = in_array($table, $selectedTables, TRUE) ? ' selected="selected"' : '';
+				$tableTitle = $this->getLanguageService()->sL($tableConf['ctrl']['title']);
+				$options[$tableTitle] = '<option' . $selected . ' value="' . $table . '">' . htmlspecialchars($tableTitle . ' (' . $table . ')') . '</option>';
+			}
+		}
+		ksort($options);
+
+		$tcaSelectHtml .= implode('', $options);
+		$tcaSelectHtml .= '</select>';
+
+		return $tcaSelectHtml;
+	}
+
+	/**
+	 * Validates the additional fields' values
+	 *
+	 * @param array $submittedData An array containing the data submitted by the add/edit task form
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return bool TRUE if validation was ok (or selected class is not relevant), FALSE otherwise
+	 */
+	public function validateAdditionalFields(array &$submittedData, SchedulerModuleController $schedulerModule) {
+		$validPeriod = $this->validateAdditionalFieldPeriod($submittedData['RecyclerCleanerPeriod'], $schedulerModule);
+		$validTca = $this->validateAdditionalFieldTca($submittedData['RecyclerCleanerTCA'], $schedulerModule);
+
+		return $validPeriod && $validTca;
+	}
+
+	/**
+	 * Validates the selected Tables
+	 *
+	 * @param array $tca The given TCA-tables as array
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return bool TRUE if validation was ok, FALSE otherwise
+	 */
+	protected function validateAdditionalFieldTca($tca, SchedulerModuleController $schedulerModule) {
+		return $this->checkTcaIsNotEmpty($tca, $schedulerModule) && $this->checkTcaIsValid($tca, $schedulerModule);
+	}
+
+	/**
+	 * Checks if the array is empty
+	 *
+	 * @param array $tca The given TCA-tables as array
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return bool TRUE if validation was ok, FALSE otherwise
+	 */
+	protected function checkTcaIsNotEmpty($tca, SchedulerModuleController $schedulerModule) {
+		if (is_array($tca) && !empty($tca)) {
+			$validTca = TRUE;
+		} else {
+			$schedulerModule->addMessage(
+				$this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorTCAempty', TRUE),
+				FlashMessage::ERROR
+			);
+			$validTca = FALSE;
+		}
+
+		return $validTca;
+	}
+
+	/**
+	 * Checks if the given tables are in the TCA
+	 *
+	 * @param array $tca The given TCA-tables as array
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return bool TRUE if validation was ok, FALSE otherwise
+	 */
+	protected function checkTcaIsValid(array $tca, SchedulerModuleController $schedulerModule) {
+		$checkTca = FALSE;
+		foreach ($tca as $tcaTable) {
+			if (!isset($GLOBALS['TCA'][$tcaTable])) {
+				$checkTca = FALSE;
+				$schedulerModule->addMessage(
+					sprintf($this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorTCANotSet', TRUE), $tcaTable),
+					FlashMessage::ERROR
+				);
+				break;
+			} else {
+				$checkTca = TRUE;
+			}
+		}
+
+		return $checkTca;
+	}
+
+	/**
+	 * Validates the input of period
+	 *
+	 * @param int $period The given period as integer
+	 * @param SchedulerModuleController $schedulerModule Reference to the scheduler backend module
+	 * @return bool TRUE if validation was ok, FALSE otherwise
+	 */
+	protected function validateAdditionalFieldPeriod($period, SchedulerModuleController $schedulerModule) {
+		if (!empty($period) && filter_var($period, FILTER_VALIDATE_INT) !== FALSE) {
+			$validPeriod = TRUE;
+		} else {
+			$schedulerModule->addMessage(
+				$this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskErrorPeriod', TRUE),
+				FlashMessage::ERROR
+			);
+			$validPeriod = FALSE;
+		}
+
+		return $validPeriod;
+	}
+
+	/**
+	 * Takes care of saving the additional fields' values in the task's object
+	 *
+	 * @param array $submittedData An array containing the data submitted by the add/edit task form
+	 * @param AbstractTask $task Reference to the scheduler backend module
+	 * @return void
+	 * @throws \InvalidArgumentException
+	 */
+	public function saveAdditionalFields(array $submittedData, AbstractTask $task) {
+		if (!$task instanceof CleanerTask) {
+			throw new \InvalidArgumentException(
+				'Expected a task of type \TYPO3\CMS\Recycler\Task\CleanerTask, but got ' . get_class($task),
+				1329219449
+			);
+		}
+
+		$task->setTcaTables($submittedData['RecyclerCleanerTCA']);
+		$task->setPeriod($submittedData['RecyclerCleanerPeriod']);
+	}
+
+	/**
+	 * @return \TYPO3\CMS\Lang\LanguageService
+	 */
+	protected function getLanguageService() {
+		return $GLOBALS['LANG'];
+	}
+
+}
diff --git a/typo3/sysext/recycler/Classes/Task/CleanerTask.php b/typo3/sysext/recycler/Classes/Task/CleanerTask.php
new file mode 100644
index 000000000000..a43621cee91f
--- /dev/null
+++ b/typo3/sysext/recycler/Classes/Task/CleanerTask.php
@@ -0,0 +1,232 @@
+<?php
+namespace TYPO3\CMS\Recycler\Task;
+
+/*
+ * 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!
+ */
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * A task that should be run regularly that deletes deleted
+ * datasets from the DB.
+ *
+ * @author Philipp Bergsmann <p.bergsmann@opendo.at>
+ */
+class CleanerTask extends \TYPO3\CMS\Scheduler\Task\AbstractTask {
+
+	/**
+	 * @var int The time period, after which the rows are deleted
+	 */
+	protected $period = 0;
+
+	/**
+	 * @var array The tables to clean
+	 */
+	protected $tcaTables = array();
+
+	/**
+	 * @var \TYPO3\CMS\Core\Database\DatabaseConnection
+	 */
+	protected $databaseConnection = NULL;
+
+	/**
+	 * The main method of the task. Iterates through
+	 * the tables and calls the cleaning function
+	 *
+	 * @return bool Returns TRUE on successful execution, FALSE on error
+	 */
+	public function execute() {
+		$success = TRUE;
+		$tables = $this->getTcaTables();
+		foreach ($tables as $table) {
+			if (!$this->cleanTable($table)) {
+				$success = FALSE;
+			}
+		}
+
+		return $success;
+	}
+
+	/**
+	 * Executes the delete-query for the given table
+	 *
+	 * @param string $tableName
+	 * @return bool
+	 */
+	protected function cleanTable($tableName) {
+		$queryParts = array();
+		if (isset($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) {
+			$queryParts[] = $GLOBALS['TCA'][$tableName]['ctrl']['delete'] . ' = 1';
+			if ($GLOBALS['TCA'][$tableName]['ctrl']['tstamp']) {
+				$dateBefore = $this->getPeriodAsTimestamp();
+				$queryParts[] = $GLOBALS['TCA'][$tableName]['ctrl']['tstamp'] . ' < ' . $dateBefore;
+			}
+			$where = implode(' AND ', $queryParts);
+
+			$this->checkFileResourceFieldsBeforeDeletion($tableName, $where);
+
+			$this->getDatabaseConnection()->exec_DELETEquery($tableName, $where);
+		}
+
+		return $this->getDatabaseConnection()->sql_error() === '';
+	}
+
+	/**
+	 * Returns the information shown in the task-list
+	 *
+	 * @return string Information-text fot the scheduler task-list
+	 */
+	public function getAdditionalInformation() {
+		$message = '';
+
+		$message .= sprintf(
+			$this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskDescriptionTables'),
+			implode(', ', $this->getTcaTables())
+		);
+
+		$message .= '; ';
+
+		$message .= sprintf(
+			$this->getLanguageService()->sL('LLL:EXT:recycler/locallang_tasks.xlf:cleanerTaskDescriptionDays'),
+			$this->getPeriod()
+		);
+
+		return $message;
+	}
+
+	/**
+	 * Sets the period after which a row is deleted
+	 *
+	 * @param int $period
+	 */
+	public function setPeriod($period) {
+		$this->period = (int)$period;
+	}
+
+	/**
+	 * Returns the period after which a row is deleted
+	 *
+	 * @return int
+	 */
+	public function getPeriod() {
+		return $this->period;
+	}
+
+	/**
+	 * @return int
+	 */
+	public function getPeriodAsTimestamp() {
+		return strtotime('-' . $this->getPeriod() . ' days');
+	}
+
+	/**
+	 * Sets the TCA-tables which are cleaned
+	 *
+	 * @param array $tcaTables
+	 */
+	public function setTcaTables($tcaTables = array()) {
+		$this->tcaTables = $tcaTables;
+	}
+
+	/**
+	 * Returns the TCA-tables which are cleaned
+	 *
+	 * @return array
+	 */
+	public function getTcaTables() {
+		return $this->tcaTables;
+	}
+
+	/**
+	 * @param \TYPO3\CMS\Core\Database\DatabaseConnection
+	 */
+	public function setDatabaseConnection($databaseConnection) {
+		$this->databaseConnection = $databaseConnection;
+	}
+
+	/**
+	 * Checks if the table has fields for uploaded files and removes those files.
+	 *
+	 * @param string $table
+	 * @param string $where
+	 * @return void
+	 */
+	protected function checkFileResourceFieldsBeforeDeletion($table, $where) {
+		$fieldList = $this->getFileResourceFields($table);
+		if (!empty($fieldList)) {
+			$this->deleteFilesForTable($table, $where, $fieldList);
+		}
+	}
+
+	/**
+	 * Removes all files from the given field list in the table.
+	 *
+	 * @param string $table
+	 * @param string $where
+	 * @param array $fieldList
+	 * @return void
+	 */
+	protected function deleteFilesForTable($table, $where, array $fieldList) {
+		$rows = $this->getDatabaseConnection()->exec_SELECTgetRows(
+			implode(',', $fieldList),
+			$table,
+			$where
+		);
+		foreach ($rows as $row) {
+			foreach ($fieldList as $fieldName) {
+				$uploadDir = PATH_site . $GLOBALS['TCA'][$table]['columns'][$fieldName]['config']['uploadfolder'] . '/';
+				$fileList = GeneralUtility::trimExplode(',', $row[$fieldName]);
+				foreach ($fileList as $fileName) {
+					@unlink($uploadDir . $fileName);
+				}
+			}
+		}
+	}
+
+	/**
+	 * Checks the $TCA for fields that can list file resources.
+	 *
+	 * @param string $table
+	 * @return array
+	 */
+	protected function getFileResourceFields($table) {
+		$result = array();
+		if (isset($GLOBALS['TCA'][$table]['columns'])) {
+			foreach ($GLOBALS['TCA'][$table]['columns'] as $fieldName => $fieldConfiguration) {
+				if ($fieldConfiguration['config']['type'] === 'group'
+					&& $fieldConfiguration['config']['internal_type'] === 'file'
+				) {
+					$result[] = $fieldName;
+					break;
+				}
+			}
+		}
+		return $result;
+	}
+
+	/**
+	 * @return \TYPO3\CMS\Core\Database\DatabaseConnection
+	 */
+	protected function getDatabaseConnection() {
+		if ($this->databaseConnection === NULL) {
+			$this->databaseConnection = $GLOBALS['TYPO3_DB'];
+		}
+		return $this->databaseConnection;
+	}
+
+	/**
+	 * @return \TYPO3\CMS\Lang\LanguageService
+	 */
+	protected function getLanguageService() {
+		return $GLOBALS['LANG'];
+	}
+}
diff --git a/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf b/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf
new file mode 100644
index 000000000000..5271f5c63de1
--- /dev/null
+++ b/typo3/sysext/recycler/Resources/Private/Language/locallang_tasks.xlf
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+	<file t3:id="1424776442" source-language="en" datatype="plaintext" original="messages" date="2011-10-17T20:22:35Z" product-name="recycler">
+		<header />
+		<body>
+			<trans-unit id="cleanerTaskTitle" xml:space="preserve">
+				<source>Remove deleted records</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskDescription" xml:space="preserve">
+				<source>Deletes rows from the database which are flagged "deleted"</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskPeriod" xml:space="preserve">
+				<source>Delete entries older than (in days)</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskTCA" xml:space="preserve">
+				<source>Tables</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskDescriptionTables" xml:space="preserve">
+				<source>Tables: %s</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskDescriptionDays" xml:space="preserve">
+				<source>Older than %d days(s)</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskErrorPeriod" xml:space="preserver">
+				<source>The period has to be an integer and greater than zero.</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskErrorTCAempty" xml:space="preserver">
+				<source>You have to select at least one table.</source>
+			</trans-unit>
+			<trans-unit id="cleanerTaskErrorTCANotSet" xml:space="preserver">
+				<source>The table "%s" is not in the TCA.</source>
+			</trans-unit>
+		</body>
+	</file>
+</xliff>
diff --git a/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php b/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php
new file mode 100644
index 000000000000..74284ac08c91
--- /dev/null
+++ b/typo3/sysext/recycler/Tests/Unit/Task/CleanerFieldProviderTest.php
@@ -0,0 +1,152 @@
+<?php
+namespace TYPO3\CMS\Recycler\Tests\Unit\Task;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Messaging\FlashMessage;
+use TYPO3\CMS\Lang\LanguageService;
+use TYPO3\CMS\Recycler\Task\CleanerFieldProvider;
+use TYPO3\CMS\Recycler\Task\CleanerTask;
+use TYPO3\CMS\Scheduler\Controller\SchedulerModuleController;
+
+/**
+ * Testcase
+ */
+class CleanerFieldProviderTest extends \TYPO3\CMS\Core\Tests\UnitTestCase {
+
+	/**
+	 * @var CleanerFieldProvider
+	 */
+	protected $subject = NULL;
+
+	/**
+	 * Sets up an instance of \TYPO3\CMS\Recycler\Task\CleanerFieldProvider
+	 */
+	public function setUp() {
+		$languageServiceMock = $this->getMock(LanguageService::class, array('sL'), array(), '', FALSE);
+		$languageServiceMock->expects($this->any())->method('sL')->will($this->returnValue('titleTest'));
+		$this->subject = $this->getMock(CleanerFieldProvider::class, array('getLanguageService'));
+		$this->subject->expects($this->any())->method('getLanguageService')->willReturn($languageServiceMock);
+	}
+
+	/**
+	 * @param array $mockedMethods
+	 * @return \PHPUnit_Framework_MockObject_MockObject|SchedulerModuleController
+	 */
+	protected function getScheduleModuleControllerMock($mockedMethods = array()) {
+		$languageServiceMock = $this->getMock(LanguageService::class, array('sL'), array(), '', FALSE);
+		$languageServiceMock->expects($this->any())->method('sL')->will($this->returnValue('titleTest'));
+
+		$mockedMethods = array_merge(array('getLanguageService'), $mockedMethods);
+		$scheduleModuleMock = $this->getMock(SchedulerModuleController::class, $mockedMethods, array(), '', FALSE);
+		$scheduleModuleMock->expects($this->any())->method('getLanguageService')->willReturn($languageServiceMock);
+
+		return $scheduleModuleMock;
+	}
+
+	/**
+	 * @return array
+	 */
+	public function validateAdditionalFieldsLogsPeriodErrorDataProvider() {
+		return array(
+			array('abc'),
+			array($this->getMockBuilder(CleanerTask::class)->disableOriginalConstructor()->getMock()),
+			array(NULL),
+			array(''),
+			array(0),
+			array('1234abc')
+		);
+	}
+
+	/**
+	 * @param mixed $period
+	 * @test
+	 * @dataProvider validateAdditionalFieldsLogsPeriodErrorDataProvider
+	 */
+	public function validateAdditionalFieldsLogsPeriodError($period) {
+		$submittedData = array(
+			'RecyclerCleanerPeriod' => $period,
+			'RecyclerCleanerTCA' => array('pages')
+		);
+
+		$scheduleModuleControllerMock = $this->getScheduleModuleControllerMock(array('addMessage'));
+		$scheduleModuleControllerMock->expects($this->atLeastOnce())
+			->method('addMessage')
+			->with($this->equalTo('titleTest'), FlashMessage::ERROR);
+
+		$this->subject->validateAdditionalFields($submittedData, $scheduleModuleControllerMock);
+	}
+
+	/**
+	 * @return array
+	 */
+	public function validateAdditionalFieldsDataProvider() {
+		return array(
+			array('abc'),
+			array($this->getMockBuilder(CleanerTask::class)->disableOriginalConstructor()->getMock()),
+			array(NULL),
+			array(123)
+		);
+	}
+
+	/**
+	 * @param mixed $table
+	 * @test
+	 * @dataProvider validateAdditionalFieldsDataProvider
+	 */
+	public function validateAdditionalFieldsLogsTableError($table) {
+		$submittedData = array(
+			'RecyclerCleanerPeriod' => 14,
+			'RecyclerCleanerTCA' => $table
+		);
+
+		$this->subject->validateAdditionalFields($submittedData, $this->getScheduleModuleControllerMock());
+	}
+
+	/**
+	 * @test
+	 */
+	public function validateAdditionalFieldsIsTrueIfValid() {
+		$submittedData = array(
+			'RecyclerCleanerPeriod' => 14,
+			'RecyclerCleanerTCA' => array('pages')
+		);
+
+		$scheduleModuleControllerMock = $this->getScheduleModuleControllerMock();
+		$GLOBALS['TCA']['pages'] = array('foo' => 'bar');
+		$this->assertTrue($this->subject->validateAdditionalFields($submittedData, $scheduleModuleControllerMock));
+	}
+
+	/**
+	 * @test
+	 */
+	public function saveAdditionalFieldsSavesFields() {
+		$submittedData = array(
+			'RecyclerCleanerPeriod' => 14,
+			'RecyclerCleanerTCA' => array('pages')
+		);
+
+		$taskMock = $this->getMock(CleanerTask::class);
+
+		$taskMock->expects($this->once())
+			->method('setTcaTables')
+			->with($this->equalTo(array('pages')));
+
+		$taskMock->expects($this->once())
+			->method('setPeriod')
+			->with($this->equalTo(14));
+
+		$this->subject->saveAdditionalFields($submittedData, $taskMock);
+	}
+}
diff --git a/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php b/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php
new file mode 100644
index 000000000000..f2f40961b741
--- /dev/null
+++ b/typo3/sysext/recycler/Tests/Unit/Task/CleanerTaskTest.php
@@ -0,0 +1,111 @@
+<?php
+namespace TYPO3\CMS\Recycler\Tests\Unit\Task;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Database\DatabaseConnection;
+use TYPO3\CMS\Recycler\Task\CleanerTask;
+
+/**
+ * Testcase
+ */
+class CleanerTaskTest extends \TYPO3\CMS\Core\Tests\UnitTestCase {
+
+	/**
+	 * @var \PHPUnit_Framework_MockObject_MockObject|CleanerTask
+	 */
+	protected $subject = NULL;
+
+	/**
+	 * sets up an instance of \TYPO3\CMS\Recycler\Task\CleanerTask
+	 */
+	public function setUp() {
+		$this->subject = $this->getMock(CleanerTask::class, array('dummy'), array(), '', FALSE);
+	}
+
+	/**
+	 * @test
+	 */
+	public function getPeriodCanBeSet() {
+		$period = 14;
+		$this->subject->setPeriod($period);
+
+		$this->assertEquals($period, $this->subject->getPeriod());
+	}
+
+	/**
+	 * @test
+	 */
+	public function getTcaTablesCanBeSet() {
+		$tables = array('pages', 'tt_content');
+		$this->subject->setTcaTables($tables);
+
+		$this->assertEquals($tables, $this->subject->getTcaTables());
+	}
+
+	/**
+	 * @test
+	 */
+	public function taskBuildsCorrectQuery() {
+		$GLOBALS['TCA']['pages']['ctrl']['delete'] = 'deleted';
+		$GLOBALS['TCA']['pages']['ctrl']['tstamp'] = 'tstamp';
+
+		/** @var \PHPUnit_Framework_MockObject_MockObject|CleanerTask $subject */
+		$subject = $this->getMock(CleanerTask::class, array('getPeriodAsTimestamp'), array(), '', FALSE);
+
+		$tables = array('pages');
+		$subject->setTcaTables($tables);
+
+		$period = 14;
+		$subject->setPeriod($period);
+		$periodAsTimestamp = strtotime('-' . $period . ' days');
+		$subject->expects($this->once())->method('getPeriodAsTimestamp')->willReturn($periodAsTimestamp);
+
+		$dbMock = $this->getMock(DatabaseConnection::class);
+		$dbMock->expects($this->once())
+			->method('exec_DELETEquery')
+			->with($this->equalTo('pages'), $this->equalTo('deleted = 1 AND tstamp < ' . $periodAsTimestamp));
+
+		$dbMock->expects($this->once())
+			->method('sql_error')
+			->will($this->returnValue(''));
+
+		$subject->setDatabaseConnection($dbMock);
+
+		$this->assertTrue($subject->execute());
+	}
+
+	/**
+	 * @test
+	 */
+	public function taskFailsOnError() {
+		$GLOBALS['TCA']['pages']['ctrl']['delete'] = 'deleted';
+		$GLOBALS['TCA']['pages']['ctrl']['tstamp'] = 'tstamp';
+
+		$tables = array('pages');
+		$this->subject->setTcaTables($tables);
+
+		$period = 14;
+		$this->subject->setPeriod($period);
+
+		$dbMock = $this->getMock(DatabaseConnection::class);
+		$dbMock->expects($this->once())
+			->method('sql_error')
+			->willReturn(1049);
+
+		$this->subject->setDatabaseConnection($dbMock);
+
+		$this->assertFalse($this->subject->execute());
+	}
+}
diff --git a/typo3/sysext/recycler/ext_localconf.php b/typo3/sysext/recycler/ext_localconf.php
index 7ec4801a7ee7..3fcbcc7d6aae 100644
--- a/typo3/sysext/recycler/ext_localconf.php
+++ b/typo3/sysext/recycler/ext_localconf.php
@@ -4,3 +4,11 @@ defined('TYPO3_MODE') or die();
 if (TYPO3_MODE === 'BE') {
 	\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::registerAjaxHandler('RecyclerAjaxController::dispatch', \TYPO3\CMS\Recycler\Controller\RecyclerAjaxController::class . '->dispatch');
 }
+$GLOBALS['TYPO3_CONF_VARS']['BE']['AJAX']['RecyclerAjaxController::init'] = \TYPO3\CMS\Recycler\Task\CleanerTask::class . '->init';
+
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['scheduler']['tasks'][\TYPO3\CMS\Recycler\Task\CleanerTask::class] = array(
+	'extension' => $_EXTKEY,
+	'title' => 'LLL:EXT:' . $_EXTKEY . '/locallang_tasks.xlf:cleanerTaskTitle',
+	'description' => 'LLL:EXT:' . $_EXTKEY . '/locallang_tasks.xlf:cleanerTaskDescription',
+	'additionalFields' => \TYPO3\CMS\Recycler\Task\CleanerFieldProvider::class
+);
\ No newline at end of file
diff --git a/typo3/sysext/scheduler/Classes/Scheduler.php b/typo3/sysext/scheduler/Classes/Scheduler.php
index 26dd67b0da81..34b8b428bcce 100644
--- a/typo3/sysext/scheduler/Classes/Scheduler.php
+++ b/typo3/sysext/scheduler/Classes/Scheduler.php
@@ -14,7 +14,10 @@ namespace TYPO3\CMS\Scheduler;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Registry;
 use TYPO3\CMS\Core\Utility\CommandUtility;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
 
 /**
  * TYPO3 Scheduler. This class handles scheduling and execution of tasks.
@@ -51,10 +54,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	/**
 	 * Adds a task to the pool
 	 *
-	 * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task The object representing the task to add
+	 * @param Task\AbstractTask $task The object representing the task to add
 	 * @return bool TRUE if the task was successfully added, FALSE otherwise
 	 */
-	public function addTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) {
+	public function addTask(Task\AbstractTask $task) {
 		$taskUid = $task->getTaskUid();
 		if (empty($taskUid)) {
 			$fields = array(
@@ -64,9 +67,9 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 				'task_group' => $task->getTaskGroup(),
 				'serialized_task_object' => 'RESERVED'
 			);
-			$result = $GLOBALS['TYPO3_DB']->exec_INSERTquery('tx_scheduler_task', $fields);
+			$result = $this->getDatabaseConnection()->exec_INSERTquery('tx_scheduler_task', $fields);
 			if ($result) {
-				$task->setTaskUid($GLOBALS['TYPO3_DB']->sql_insert_id());
+				$task->setTaskUid($this->getDatabaseConnection()->sql_insert_id());
 				$task->save();
 				$result = TRUE;
 			} else {
@@ -86,12 +89,13 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	 */
 	protected function cleanExecutionArrays() {
 		$tstamp = $GLOBALS['EXEC_TIME'];
+		$db = $this->getDatabaseConnection();
 		// Select all tasks with executions
 		// NOTE: this cleanup is done for disabled tasks too,
 		// to avoid leaving old executions lying around
-		$res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('uid, serialized_executions, serialized_task_object', 'tx_scheduler_task', 'serialized_executions <> \'\'');
+		$res = $db->exec_SELECTquery('uid, serialized_executions, serialized_task_object', 'tx_scheduler_task', 'serialized_executions <> \'\'');
 		$maxDuration = $this->extConf['maxLifetime'] * 60;
-		while ($row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res)) {
+		while ($row = $db->sql_fetch_assoc($res)) {
 			$executions = array();
 			if ($serialized_executions = unserialize($row['serialized_executions'])) {
 				foreach ($serialized_executions as $task) {
@@ -110,20 +114,22 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 				} else {
 					$value = serialize($executions);
 				}
-				$GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . (int)$row['uid'], array('serialized_executions' => $value));
+				$db->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . (int)$row['uid'], array('serialized_executions' => $value));
 			}
 		}
-		$GLOBALS['TYPO3_DB']->sql_free_result($res);
+		$db->sql_free_result($res);
 	}
 
 	/**
 	 * 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 \TYPO3\CMS\Scheduler\Task\AbstractTask $task The task to execute
+	 * @param Task\AbstractTask $task The task to execute
 	 * @return bool Whether the task was saved successfully to the database or not
+	 * @throws FailedExecutionException
+	 * @throws \Exception
 	 */
-	public function executeTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) {
+	public function executeTask(Task\AbstractTask $task) {
 		// 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)
@@ -149,7 +155,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 				// Execute task
 				$successfullyExecuted = $task->execute();
 				if (!$successfullyExecuted) {
-					throw new \TYPO3\CMS\Scheduler\FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
+					throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
 				}
 			} catch (\Exception $e) {
 				// Store exception, so that it can be saved to database
@@ -180,23 +186,24 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 		if ($type !== 'manual' && $type !== 'cli-by-id') {
 			$type = 'cron';
 		}
-		/** @var $registry \TYPO3\CMS\Core\Registry */
-		$registry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Registry::class);
+		/** @var Registry $registry */
+		$registry = GeneralUtility::makeInstance(Registry::class);
 		$runInformation = array('start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type);
 		$registry->set('tx_scheduler', 'lastRun', $runInformation);
 	}
 
 	/**
 	 * Removes a task completely from the system.
+	 *
 	 * @todo find a way to actually kill the existing jobs
 	 *
-	 * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task The object representing the task to delete
+	 * @param Task\AbstractTask $task The object representing the task to delete
 	 * @return bool TRUE if task was successfully deleted, FALSE otherwise
 	 */
-	public function removeTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) {
+	public function removeTask(Task\AbstractTask $task) {
 		$taskUid = $task->getTaskUid();
 		if (!empty($taskUid)) {
-			$result = $GLOBALS['TYPO3_DB']->exec_DELETEquery('tx_scheduler_task', 'uid = ' . $taskUid);
+			$result = $this->getDatabaseConnection()->exec_DELETEquery('tx_scheduler_task', 'uid = ' . $taskUid);
 		} else {
 			$result = FALSE;
 		}
@@ -209,10 +216,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	/**
 	 * Updates a task in the pool
 	 *
-	 * @param \TYPO3\CMS\Scheduler\Task\AbstractTask $task Scheduler task object
+	 * @param Task\AbstractTask $task Scheduler task object
 	 * @return bool False if submitted task was not of proper class
 	 */
-	public function saveTask(\TYPO3\CMS\Scheduler\Task\AbstractTask $task) {
+	public function saveTask(Task\AbstractTask $task) {
 		$taskUid = $task->getTaskUid();
 		if (!empty($taskUid)) {
 			try {
@@ -230,7 +237,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 				'task_group' => $task->getTaskGroup(),
 				'serialized_task_object' => serialize($task)
 			);
-			$result = $GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $taskUid, $fields);
+			$result = $this->getDatabaseConnection()->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $taskUid, $fields);
 		} else {
 			$result = FALSE;
 		}
@@ -246,7 +253,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	 * If there are no due tasks the method throws an exception.
 	 *
 	 * @param int $uid Primary key of a task
-	 * @return \TYPO3\CMS\Scheduler\Task\AbstractTask The fetched task object
+	 * @return Task\AbstractTask The fetched task object
 	 * @throws \OutOfBoundsException
 	 * @throws \UnexpectedValueException
 	 */
@@ -269,16 +276,17 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 			);
 		}
 
-		$res = $GLOBALS['TYPO3_DB']->exec_SELECT_queryArray($queryArray);
+		$db = $this->getDatabaseConnection();
+		$res = $db->exec_SELECT_queryArray($queryArray);
 		if ($res === FALSE) {
 			throw new \UnexpectedValueException('Query could not be executed. Possible defect in tables tx_scheduler_task or tx_scheduler_task_group', 1422044826);
 		}
 		// If there are no available tasks, thrown an exception
-		if ($GLOBALS['TYPO3_DB']->sql_num_rows($res) == 0) {
+		if ($db->sql_num_rows($res) == 0) {
 			throw new \OutOfBoundsException('No task', 1247827244);
 		} else {
-			$row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res);
-			/** @var $task \TYPO3\CMS\Scheduler\Task\AbstractTask */
+			$row = $db->sql_fetch_assoc($res);
+			/** @var $task Task\AbstractTask */
 			$task = unserialize($row['serialized_task_object']);
 			if ($this->isValidTaskObject($task)) {
 				// The task is valid, return it
@@ -286,11 +294,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 			} else {
 				// Forcibly set the disable flag to 1 in the database,
 				// so that the task does not come up again and again for execution
-				$GLOBALS['TYPO3_DB']->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $row['uid'], array('disable' => 1));
+				$db->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $row['uid'], array('disable' => 1));
 				// Throw an exception to raise the problem
 				throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
 			}
-			$GLOBALS['TYPO3_DB']->sql_free_result($res);
+			$db->sql_free_result($res);
 		}
 		return $task;
 	}
@@ -305,13 +313,14 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	 * @throws \OutOfBoundsException
 	 */
 	public function fetchTaskRecord($uid) {
-		$res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tx_scheduler_task', 'uid = ' . (int)$uid);
+		$db = $this->getDatabaseConnection();
+		$res = $db->exec_SELECTquery('*', 'tx_scheduler_task', 'uid = ' . (int)$uid);
 		// If the task is not found, throw an exception
-		if ($GLOBALS['TYPO3_DB']->sql_num_rows($res) == 0) {
+		if ($db->sql_num_rows($res) == 0) {
 			throw new \OutOfBoundsException('No task', 1247827245);
 		} else {
-			$row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res);
-			$GLOBALS['TYPO3_DB']->sql_free_result($res);
+			$row = $db->sql_fetch_assoc($res);
+			$db->sql_free_result($res);
 		}
 		return $row;
 	}
@@ -336,10 +345,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 			}
 			$whereClause .= 'disable = 0';
 		}
-		$res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('serialized_task_object', 'tx_scheduler_task', $whereClause);
+		$db = $this->getDatabaseConnection();
+		$res = $db->exec_SELECTquery('serialized_task_object', 'tx_scheduler_task', $whereClause);
 		if ($res) {
-			while ($row = $GLOBALS['TYPO3_DB']->sql_fetch_assoc($res)) {
-				/** @var $task Task */
+			while ($row = $db->sql_fetch_assoc($res)) {
+				/** @var Task\AbstractTask $task */
 				$task = unserialize($row['serialized_task_object']);
 				// Add the task to the list only if it is valid
 				if ($this->isValidTaskObject($task)) {
@@ -347,7 +357,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 					$tasks[] = $task;
 				}
 			}
-			$GLOBALS['TYPO3_DB']->sql_free_result($res);
+			$db->sql_free_result($res);
 		}
 		return $tasks;
 	}
@@ -365,7 +375,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 	 * @return bool TRUE if object is a task, FALSE otherwise
 	 */
 	public function isValidTaskObject($task) {
-		return $task instanceof \TYPO3\CMS\Scheduler\Task\AbstractTask;
+		return $task instanceof Task\AbstractTask;
 	}
 
 	/**
@@ -395,11 +405,11 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 		if ((int)$this->extConf['useAtdaemon'] !== 1) {
 			return FALSE;
 		}
-		/** @var $registry \TYPO3\CMS\Core\Registry */
-		$registry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Registry::class);
+		/** @var $registry Registry */
+		$registry = GeneralUtility::makeInstance(Registry::class);
 		// Get at job id from registry and remove at job
 		$atJobId = $registry->get('tx_scheduler', 'atJobId');
-		if (\TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($atJobId)) {
+		if (MathUtility::canBeInterpretedAsInteger($atJobId)) {
 			shell_exec('atrm ' . (int)$atJobId . ' 2>&1');
 		}
 		// Can not use fetchTask() here because if tasks have just executed
@@ -408,7 +418,7 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 		$nextExecution = FALSE;
 		foreach ($tasks as $task) {
 			try {
-				/** @var $task \TYPO3\CMS\Scheduler\Task\AbstractTask */
+				/** @var $task Task\AbstractTask */
 				$tempNextExecution = $task->getNextDueExecution();
 				if ($nextExecution === FALSE || $tempNextExecution < $nextExecution) {
 					$nextExecution = $tempNextExecution;
@@ -431,12 +441,12 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 			$output = shell_exec($cmd);
 			$outputParts = '';
 			foreach (explode(LF, $output) as $outputLine) {
-				if (\TYPO3\CMS\Core\Utility\GeneralUtility::isFirstPartOfStr($outputLine, 'job')) {
+				if (GeneralUtility::isFirstPartOfStr($outputLine, 'job')) {
 					$outputParts = explode(' ', $outputLine, 3);
 					break;
 				}
 			}
-			if ($outputParts[0] === 'job' && \TYPO3\CMS\Core\Utility\MathUtility::canBeInterpretedAsInteger($outputParts[1])) {
+			if ($outputParts[0] === 'job' && MathUtility::canBeInterpretedAsInteger($outputParts[1])) {
 				$atJobId = (int)$outputParts[1];
 				$registry->set('tx_scheduler', 'atJobId', $atJobId);
 			}
@@ -444,4 +454,10 @@ class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {
 		return TRUE;
 	}
 
+	/**
+	 * @return \TYPO3\CMS\Core\Database\DatabaseConnection
+	 */
+	protected function getDatabaseConnection() {
+		return $GLOBALS['TYPO3_DB'];
+	}
 }
-- 
GitLab