From 34df779a9597ae30bc7091c660176d8fa351a2c8 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Fri, 17 Feb 2017 09:34:55 +0800 Subject: [PATCH] MDL-55528 core_files: Create new fileconverter plugintype --- admin/settings/plugins.php | 13 + .../templates/setting_manage_plugins.mustache | 17 +- files/classes/conversion.php | 360 +++++++ files/classes/converter.php | 247 +++++ files/classes/converter_interface.php | 81 ++ .../classes/task/conversion_cleanup_task.php | 54 + files/tests/conversion_test.php | 415 ++++++++ files/tests/converter_test.php | 926 ++++++++++++++++++ lang/en/plugin.php | 3 + lib/adminlib.php | 43 +- lib/classes/component.php | 2 + lib/classes/filetypes.php | 17 + lib/classes/plugin_manager.php | 3 + lib/classes/plugininfo/fileconverter.php | 183 ++++ lib/db/install.xml | 22 +- lib/db/tasks.php | 9 + lib/db/upgrade.php | 32 + lib/tests/component_test.php | 2 +- version.php | 2 +- 19 files changed, 2411 insertions(+), 20 deletions(-) create mode 100644 files/classes/conversion.php create mode 100644 files/classes/converter.php create mode 100644 files/classes/converter_interface.php create mode 100644 files/classes/task/conversion_cleanup_task.php create mode 100644 files/tests/conversion_test.php create mode 100644 files/tests/converter_test.php create mode 100644 lib/classes/plugininfo/fileconverter.php diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index bd759a706cd..1dfdd6a8df1 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -230,6 +230,19 @@ if ($hassiteconfig) { 300, PARAM_INT, 10)); $ADMIN->add('mediaplayers', $temp); + // Convert plugins. + $ADMIN->add('modules', new admin_category('fileconverterplugins', new lang_string('type_fileconverter_plural', 'plugin'))); + $temp = new admin_settingpage('managefileconverterplugins', new lang_string('type_fileconverter', 'plugin')); + $temp->add(new admin_setting_manage_fileconverter_plugins()); + $ADMIN->add('fileconverterplugins', $temp); + + $plugins = core_plugin_manager::instance()->get_plugins_of_type('fileconverter'); + core_collator::asort_objects_by_property($plugins, 'displayname'); + foreach ($plugins as $plugin) { + /** @var \core\plugininfo\media $plugin */ + $plugin->load_settings($ADMIN, 'fileconverterplugins', $hassiteconfig); + } + $plugins = core_plugin_manager::instance()->get_plugins_of_type('media'); core_collator::asort_objects_by_property($plugins, 'displayname'); foreach ($plugins as $plugin) { diff --git a/admin/templates/setting_manage_plugins.mustache b/admin/templates/setting_manage_plugins.mustache index 65d941d86a9..5c38e51513f 100644 --- a/admin/templates/setting_manage_plugins.mustache +++ b/admin/templates/setting_manage_plugins.mustache @@ -20,28 +20,33 @@ {{info}} {{/infocolumnname}} - + {{#moveuplink}} - {{#moveupicon}}{{>core/pix_icon}}{{/moveupicon}} + {{#pix}}t/up, moodle, {{#str}} up, moodle {{/str}}{{/pix}} {{/moveuplink}} {{^moveuplink}} - {{#spacericon}}{{>core/pix_icon}}{{/spacericon}} + {{#pix}}spacer, moodle{{/pix}} {{/moveuplink}} {{#movedownlink}} - {{#movedownicon}}{{>core/pix_icon}}{{/movedownicon}} + {{#pix}}t/down, moodle, {{#str}} down, moodle {{/str}}{{/pix}} {{/movedownlink}} {{^movedownlink}} - {{#spacericon}}{{>core/pix_icon}}{{/spacericon}} + {{#pix}}spacer, moodle{{/pix}} {{/movedownlink}} - {{#toggleicon}}{{>core/pix_icon}}{{/toggleicon}} + {{#toggletarget}} + {{#pix}}i/show, moodle, {{#str}} enable, moodle {{/str}}{{/pix}} + {{/toggletarget}} + {{^toggletarget}} + {{#pix}}i/hide, moodle, {{#str}} disable, moodle {{/str}}{{/pix}} + {{/toggletarget}} diff --git a/files/classes/conversion.php b/files/classes/conversion.php new file mode 100644 index 00000000000..07872ce40a2 --- /dev/null +++ b/files/classes/conversion.php @@ -0,0 +1,360 @@ +. + +/** + * Classes for converting files between different file formats. + * + * @package core_files + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_files; + +defined('MOODLE_INTERNAL') || die(); + +use stored_file; + +/** + * Class representing a conversion currently in progress. + * + * @package core_files + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class conversion extends \core\persistent { + + /** + * Status value representing a conversion waiting to start. + */ + const STATUS_PENDING = 0; + + /** + * Status value representing a conversion in progress. + */ + const STATUS_IN_PROGRESS = 1; + + /** + * Status value representing a successful conversion. + */ + const STATUS_COMPLETE = 2; + + /** + * Status value representing a failed conversion. + */ + const STATUS_FAILED = -1; + + /** + * Table name for this persistent. + */ + const TABLE = 'file_conversion'; + + protected static function define_properties() { + return array( + 'sourcefileid' => [ + 'type' => PARAM_INT, + ], + 'targetformat' => [ + 'type' => PARAM_ALPHANUMEXT, + ], + 'status' => [ + 'type' => PARAM_INT, + 'choices' => [ + self::STATUS_PENDING, + self::STATUS_IN_PROGRESS, + self::STATUS_COMPLETE, + self::STATUS_FAILED, + ], + 'default' => self::STATUS_PENDING, + ], + 'statusmessage' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'converter' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'destfileid' => [ + 'type' => PARAM_INT, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + 'data' => [ + 'type' => PARAM_RAW, + 'null' => NULL_ALLOWED, + 'default' => null, + ], + ); + } + + /** + * Fetch all conversions relating to the specified file. + * + * Only conversions which have a valid file are returned. + * + * @param stored_file $file The source file being converted + * @param string $format The targetforamt to filter to + * @return conversion[] + */ + public static function get_conversions_for_file(stored_file $file, $format) { + global $DB; + $instances = []; + + // Conversion records are intended for tracking a conversion in progress or recently completed. + // The record is removed periodically, but the destination file is not. + // We need to fetch all conversion records which match the source file and target, and also all source and + // destination files which do not have a conversion record. + $sqlfields = self::get_sql_fields('c', 'conversion'); + + // Fetch actual conversions which relate to the specified source file, and have a matching conversion record, + // and either have a valid destination file which still exists, or do not have a destination file at all. + $sql = "SELECT {$sqlfields} + FROM {" . self::TABLE . "} c + INNER JOIN {files} conversionsourcefile ON conversionsourcefile.id = c.sourcefileid + LEFT JOIN {files} conversiondestfile ON conversiondestfile.id = c.destfileid + WHERE + conversionsourcefile.contenthash = :ccontenthash + AND c.targetformat = :cformat + AND ( + c.destfileid IS NULL OR conversiondestfile.id IS NOT NULL + )"; + + // Fetch a empty conversion record for each source/destination combination that we find to match where the + // destination file is in the correct filearea/filepath/filename combination to meet the requirements. + // This ensures that existing conversions are used where possible, even if there is no 'conversion' record for + // them. + $sql .= " + UNION ALL + SELECT + NULL AS conversionid, + orphanedsourcefile.id AS conversionsourcefileid, + :oformat AS conversiontargetformat, + 2 AS conversionstatus, + NULL AS conversionstatusmessage, + NULL AS conversionconverter, + orphaneddestfile.id AS conversiondestfileid, + NULL AS conversiondata, + 0 AS conversiontimecreated, + 0 AS conversiontimemodified, + 0 AS conversionusermodified + FROM {files} orphanedsourcefile + INNER JOIN {files} orphaneddestfile ON ( + orphaneddestfile.filename = orphanedsourcefile.contenthash + AND orphaneddestfile.component = 'core' + AND orphaneddestfile.filearea = 'documentconversion' + AND orphaneddestfile.filepath = :ofilepath + ) + LEFT JOIN {" . self::TABLE . "} orphanedconversion ON orphanedconversion.destfileid = orphaneddestfile.id + WHERE + orphanedconversion.id IS NULL + AND + orphanedsourcefile.id = :osourcefileid + "; + $records = $DB->get_records_sql($sql, [ + 'ccontenthash' => $file->get_contenthash(), + 'osourcefileid' => $file->get_id(), + 'cfilepath' => "/{$format}/", + 'ofilepath' => "/{$format}/", + 'cformat' => $format, + 'oformat' => $format, + ]); + + foreach ($records as $record) { + $data = self::extract_record($record, 'conversion'); + $newrecord = new static(0, $data); + $instances[] = $newrecord; + } + + return $instances; + } + + /** + * Remove all old conversion records. + */ + public static function remove_old_conversion_records() { + global $DB; + + $DB->delete_records_select(self::TABLE, 'timemodified <= :weekagosecs', [ + 'weekagosecs' => time() - WEEKSECS, + ]); + } + + /** + * Set the source file id for the conversion. + * + * @param stored_file $file The file to convert + * @return $this + */ + public function set_sourcefile(stored_file $file) { + $this->raw_set('sourcefileid', $file->get_id()); + + return $this; + } + + /** + * Fetch the source file. + * + * @return stored_file|false + */ + public function get_sourcefile() { + $fs = get_file_storage(); + + return $fs->get_file_by_id($this->get('sourcefileid')); + } + + /** + * Set the destination file for this conversion. + * + * @param string $filepath The path to the converted file + * @return $this + */ + public function store_destfile_from_path($filepath) { + if ($record = $this->get_file_record()) { + $fs = get_file_storage(); + $existing = $fs->get_file( + $record['contextid'], + $record['component'], + $record['filearea'], + $record['itemid'], + $record['filepath'], + $record['filename'] + ); + if ($existing) { + $existing->delete(); + } + $file = $fs->create_file_from_pathname($record, $filepath); + + $this->raw_set('destfileid', $file->get_id()); + } + + return $this; + } + + /** + * Set the destination file for this conversion. + * + * @param string $content The content of the converted file + * @return $this + */ + public function store_destfile_from_string($content) { + if ($record = $this->get_file_record()) { + $fs = get_file_storage(); + $existing = $fs->get_file( + $record['contextid'], + $record['component'], + $record['filearea'], + $record['itemid'], + $record['filepath'], + $record['filename'] + ); + if ($existing) { + $existing->delete(); + } + $file = $fs->create_file_from_string($record, $content); + + $this->raw_set('destfileid', $file->get_id()); + } + + return $this; + } + + /** + * Get the destination file. + * + * @return stored_file|this + */ + public function get_destfile() { + $fs = get_file_storage(); + + return $fs->get_file_by_id($this->get('destfileid')); + } + + /** + * Helper to ensure that the returned status is always an int. + * + * @return int + */ + protected function get_status() { + return (int) $this->raw_get('status'); + } + + /** + * Get an instance of the current converter. + * + * @return converter_interface|false + */ + public function get_converter_instance() { + $currentconverter = $this->get('converter'); + + if ($currentconverter && class_exists($currentconverter)) { + return new $currentconverter(); + } else { + return false; + } + } + + /** + * Transform data into a storable format. + * + * @param stdClass $data The data to be stored + * @return $this + */ + protected function set_data($data) { + $this->raw_set('data', json_encode($data)); + + return $this; + } + + /** + * Transform data into a storable format. + * + * @return stdClass The stored data + */ + protected function get_data() { + $data = $this->raw_get('data'); + + if (!empty($data)) { + return json_decode($data); + } + + return (object) []; + } + + /** + * Return the file record base for use in the files table. + * + * @return array + */ + protected function get_file_record() { + $file = $this->get_sourcefile(); + + if (!$file) { + // If the source file was removed before we completed, we must return early. + return false; + } + + return [ + 'contextid' => \context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'documentconversion', + 'itemid' => 0, + 'filepath' => "/" . $this->get('targetformat') . "/", + 'filename' => $file->get_contenthash(), + ]; + } +} diff --git a/files/classes/converter.php b/files/classes/converter.php new file mode 100644 index 00000000000..d39d84f0733 --- /dev/null +++ b/files/classes/converter.php @@ -0,0 +1,247 @@ +. + +/** + * Class for converting files between different file formats using unoconv. + * + * @package core_files + * @copyright 2017 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_files; + +defined('MOODLE_INTERNAL') || die(); + +use stored_file; + +/** + * Class for converting files between different formats using unoconv. + * + * @package core_files + * @copyright 2017 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class converter { + + /** + * Get a list of enabled plugins and classes. + * + * @return array + */ + protected function get_enabled_plugins() { + $plugins = \core\plugininfo\fileconverter::get_enabled_plugins(); + + $pluginclasses = []; + foreach ($plugins as $plugin) { + $pluginclasses[$plugin] = \core\plugininfo\fileconverter::get_classname($plugin); + } + + return $pluginclasses; + } + + /** + * Return the file_storage API. + * + * This allows for mocking of the file_storage API. + * + * @return file_storage + */ + protected function get_file_storage() { + return get_file_storage(); + } + + /** + * Start the conversion for a stored_file into a new format. + * + * @param stored_file $file The file to convert + * @param string $format The desired target file format (file extension) + * @param boolean $forcerefresh If true, the file will be converted every time (not cached). + * @return conversion + */ + public function start_conversion(stored_file $file, $format, $forcerefresh = false) { + $conversions = conversion::get_conversions_for_file($file, $format); + + if ($forcerefresh || count($conversions) > 1) { + while ($conversion = array_shift($conversions)) { + if ($conversion->get('id')) { + $conversion->delete(); + } + } + } + + if (empty($conversions)) { + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $file->get_id(), + 'targetformat' => $format, + ]); + $conversion->create(); + } else { + $conversion = array_shift($conversions); + } + + if ($conversion->get('status') !== conversion::STATUS_COMPLETE) { + $this->poll_conversion($conversion); + } + + return $conversion; + } + + /** + * Poll for updates to the supplied conversion. + * + * @param conversion $conversion The conversion in progress + * @return $this + */ + public function poll_conversion(conversion $conversion) { + $format = $conversion->get('targetformat'); + $file = $conversion->get_sourcefile(); + + if ($conversion->get('status') == conversion::STATUS_IN_PROGRESS) { + // The current conversion is in progress. + // Check for updates. + if ($instance = $conversion->get_converter_instance()) { + $instance->poll_conversion_status($conversion); + } else { + // Unable to fetch the converter instance. + // Reset the status back to PENDING so that it may be picked up again. + $conversion->set('status', conversion::STATUS_PENDING); + $conversion->update(); + } + } + + // Refresh the status. + $status = $conversion->get('status'); + if ($status === conversion::STATUS_PENDING || $status === conversion::STATUS_FAILED) { + // The current status is either pending or failed. + // Attempt to pick up a new converter and convert the document. + $from = \core_filetypes::get_file_extension($file->get_mimetype()); + $converters = $this->get_document_converter_classes($from, $format); + $currentconverter = $this->get_next_converter($converters, $conversion->get('converter')); + + if (!$currentconverter) { + // No more converters available. + $conversion->set('status', conversion::STATUS_FAILED); + return $this; + } + + do { + $conversion + ->set('converter', $currentconverter) + ->set('status', conversion::STATUS_IN_PROGRESS) + ->update(); + + $instance = $conversion->get_converter_instance(); + $instance->start_document_conversion($conversion); + $failed = $conversion->get('status') === conversion::STATUS_FAILED; + $currentconverter = $this->get_next_converter($converters, $currentconverter); + } while ($failed && $currentconverter); + + $conversion->update(); + } + + return $this; + } + + /** + * Fetch the next converter to try. + * + * @param array $converters The list of converters to try + * @param string|null $currentconverter The converter currently in use + * @return string|false + */ + protected function get_next_converter($converters, $currentconverter = null) { + if ($currentconverter) { + $keys = array_keys($converters, $currentconverter); + $key = $keys[0]; + if (isset($converters[$key + 1])) { + return $converters[$key + 1]; + } else { + return false; + } + } else if (!empty($converters)) { + return $converters[0]; + } else { + return false; + } + } + + /** + * Fetch the class for the preferred document converter. + * + * @param string $from The source target file (file extension) + * @param string $to The desired target file format (file extension) + * @return string The class for document conversion + */ + protected function get_document_converter_classes($from, $to) { + $classes = []; + + $converters = $this->get_enabled_plugins(); + foreach ($converters as $plugin => $classname) { + if (!class_exists($classname)) { + continue; + } + + if (!$classname::are_requirements_met()) { + continue; + } + + if ($classname::supports($from, $to)) { + $classes[] = $classname; + } + } + + return $classes; + } + + /** + * Check whether document conversion is supported for this file and target format. + * + * @param stored_file $file The file to convert + * @param string $to The desired target file format (file extension) + * @return bool Whether the target type can be converted + */ + public function can_convert_storedfile_to(stored_file $file, $to) { + if ($file->is_directory()) { + // Directories cannot be converted. + return false; + } + + if (!$file->get_filesize()) { + // Empty files cannot be converted. + return false; + } + + $from = \core_filetypes::get_file_extension($file->get_mimetype()); + if (!$from) { + // No mimetype could be found. Unable to determine converter. + return false; + } + + return $this->can_convert_format_to($from, $to); + } + + /** + * Check whether document conversion is supported for this file and target format. + * + * @param string $from The source target file (file extension) + * @param string $to The desired target file format (file extension) + * @return bool Whether the target type can be converted + */ + public function can_convert_format_to($from, $to) { + return !empty($this->get_document_converter_classes($from, $to)); + } + +} diff --git a/files/classes/converter_interface.php b/files/classes/converter_interface.php new file mode 100644 index 00000000000..52c1a946f3b --- /dev/null +++ b/files/classes/converter_interface.php @@ -0,0 +1,81 @@ +. + +/** + * Class for converting files between different file formats. + * + * @package core_files + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_files; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class for converting files between different file formats. + * + * @package docconvert_unoconv + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface converter_interface { + + /** + * Whether the plugin is configured and requirements are met. + * + * Note: This function may be called frequently and caching is advisable. + * + * @return bool + */ + public static function are_requirements_met(); + + /** + * Convert a document to a new format and return a conversion object relating to the conversion in progress. + * + * @param conversion $conversion The file to be converted + * @return $this + */ + public function start_document_conversion(conversion $conversion); + + /** + * Poll an existing conversion for status update. + * + * @param conversion $conversion The file to be converted + * @return $this + */ + public function poll_conversion_status(conversion $conversion); + + /** + * Determine whether a conversion between the two supplied formats is achievable. + * + * Note: This function may be called frequently and caching is advisable. + * + * @param string $from The source type + * @param string $to The destination type + * @return bool + */ + public static function supports($from, $to); + + /** + * A list of the supported conversions. + * + * Note: This information is only displayed to administrators. + * + * @return string + */ + public function get_supported_conversions(); +} diff --git a/files/classes/task/conversion_cleanup_task.php b/files/classes/task/conversion_cleanup_task.php new file mode 100644 index 00000000000..1353b5b0967 --- /dev/null +++ b/files/classes/task/conversion_cleanup_task.php @@ -0,0 +1,54 @@ +. + +/** + * A scheduled task to clear up old conversion records. + * + * @package core_files + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_files\task; + +defined('MOODLE_INTERNAL') || die(); + +/** + * A scheduled task to clear up old conversion records. + * + * @package core_files + * @copyright 2017 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class conversion_cleanup_task extends \core\task\scheduled_task { + + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('fileconversioncleanuptask', 'admin'); + } + + /** + * Run task. + */ + public function execute() { + \core_files\conversion::remove_old_conversion_records(); + } + +} diff --git a/files/tests/conversion_test.php b/files/tests/conversion_test.php new file mode 100644 index 00000000000..662ccc3003f --- /dev/null +++ b/files/tests/conversion_test.php @@ -0,0 +1,415 @@ +. + + +/** + * PHPUnit tests for conversion API. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +use core_files\conversion; + +/** + * PHPUnit tests for conversion persistent. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_files_conversion_testcase extends advanced_testcase { + + /** + * Helper to create a stored file object with the given supplied content. + * + * @param string $filecontent The content of the mocked file + * @param string $filename The file name to use in the stored_file + * @param string $filerecord Any overrides to the filerecord + * @return stored_file + */ + protected function create_stored_file($filecontent = 'content', $filename = 'testfile.txt', $filerecord = []) { + $filerecord = array_merge([ + 'contextid' => context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'unittest', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ], $filerecord); + + $fs = get_file_storage(); + $file = $fs->create_file_from_string($filerecord, $filecontent); + + return $file; + } + + /** + * Ensure that get_conversions_for_file returns an existing conversion + * record with matching sourcefileid and targetformat. + */ + public function test_get_conversions_for_file_existing_conversion_incomplete() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + + $existing = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $existing->create(); + + $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf'); + + $this->assertCount(1, $conversions); + + $conversion = array_shift($conversions); + $conversionfile = $conversion->get_sourcefile(); + + $this->assertEquals($sourcefile->get_id(), $conversionfile->get_id()); + $this->assertFalse($conversion->get_destfile()); + } + + /** + * Ensure that get_conversions_for_file returns an existing conversion + * record with matching sourcefileid and targetformat when a second + * conversion to a different format exists. + */ + public function test_get_conversions_for_file_existing_conversion_multiple_formats_incomplete() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + + $existing = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $existing->create(); + + $second = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'doc', + ]); + $second->create(); + + $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf'); + + $this->assertCount(1, $conversions); + + $conversion = array_shift($conversions); + $conversionfile = $conversion->get_sourcefile(); + + $this->assertEquals($sourcefile->get_id(), $conversionfile->get_id()); + $this->assertFalse($conversion->get_destfile()); + } + + /** + * Ensure that get_conversions_for_file returns an existing conversion + * record with matching sourcefileid and targetformat. + */ + public function test_get_conversions_for_file_existing_conversion_complete() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $destfile = $this->create_stored_file( + 'example content', + $sourcefile->get_contenthash(), + [ + 'component' => 'core', + 'filearea' => 'documentconversion', + 'filepath' => '/pdf/', + ]); + + $existing = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + 'destfileid' => $destfile->get_id(), + ]); + $existing->create(); + + $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf'); + + // Only one file should be returned. + $this->assertCount(1, $conversions); + + $conversion = array_shift($conversions); + + $this->assertEquals($sourcefile->get_id(), $conversion->get_sourcefile()->get_id()); + $this->assertEquals($destfile->get_id(), $conversion->get_destfile()->get_id()); + } + + /** + * Ensure that get_conversions_for_file returns an existing conversion + * record with matching sourcefileid and targetformat. + */ + public function test_get_conversions_for_file_existing_conversion_multiple_formats_complete() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $destfile = $this->create_stored_file( + 'example content', + $sourcefile->get_contenthash(), + [ + 'component' => 'core', + 'filearea' => 'documentconversion', + 'filepath' => '/pdf/', + ]); + + $existing = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + 'destfileid' => $destfile->get_id(), + ]); + $existing->create(); + + $second = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'doc', + ]); + $second->create(); + + $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf'); + + // Only one file should be returned. + $this->assertCount(1, $conversions); + + $conversion = array_shift($conversions); + + $this->assertEquals($sourcefile->get_id(), $conversion->get_sourcefile()->get_id()); + $this->assertEquals($destfile->get_id(), $conversion->get_destfile()->get_id()); + } + + /** + * Ensure that get_conversions_for_file returns an existing conversion + * record does not exist, but the file has previously been converted. + */ + public function test_get_conversions_for_file_existing_target() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $destfile = $this->create_stored_file( + 'example content', + $sourcefile->get_contenthash(), + [ + 'component' => 'core', + 'filearea' => 'documentconversion', + 'filepath' => '/pdf/', + ]); + + $conversions = conversion::get_conversions_for_file($sourcefile, 'pdf'); + + $this->assertCount(1, $conversions); + + $conversion = array_shift($conversions); + $conversionsource = $conversion->get_sourcefile(); + $this->assertEquals($sourcefile->get_id(), $conversionsource->get_id()); + $conversiondest = $conversion->get_destfile(); + $this->assertEquals($destfile->get_id(), $conversiondest->get_id()); + } + + /** + * Ensure that set_sourcefile sets the correct fileid. + */ + public function test_set_sourcefile() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) []); + + $conversion->set_sourcefile($sourcefile); + + $this->assertEquals($sourcefile->get_id(), $conversion->get('sourcefileid')); + $this->assertNull($conversion->get('destfileid')); + } + + /** + * Ensure that store_destfile_from_path stores the file as expected. + */ + public function test_store_destfile_from_path() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + + $fixture = __FILE__; + $conversion->store_destfile_from_path($fixture); + + $destfile = $conversion->get_destfile(); + $this->assertEquals(file_get_contents($fixture), $destfile->get_content()); + } + + /** + * Ensure that store_destfile_from_path stores the file as expected. + */ + public function test_store_destfile_from_path_delete_existing() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + + $record = [ + 'contextid' => \context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'documentconversion', + 'itemid' => 0, + 'filepath' => '/pdf/', + ]; + $existingfile = $this->create_stored_file('foo', $sourcefile->get_contenthash(), $record); + + $fixture = __FILE__; + $conversion->store_destfile_from_path($fixture); + + $destfile = $conversion->get_destfile(); + $this->assertEquals(file_get_contents($fixture), $destfile->get_content()); + } + + /** + * Ensure that store_destfile_from_path stores the file as expected. + */ + public function test_store_destfile_from_string() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + + $fixture = 'Example content'; + $conversion->store_destfile_from_string($fixture); + + $destfile = $conversion->get_destfile(); + $this->assertEquals($fixture, $destfile->get_content()); + } + + /** + * Ensure that store_destfile_from_string stores the file as expected when + * an existing destfile is found. + */ + public function test_store_destfile_from_string_delete_existing() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + + $record = [ + 'contextid' => \context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'documentconversion', + 'itemid' => 0, + 'filepath' => '/pdf/', + ]; + $existingfile = $this->create_stored_file('foo', $sourcefile->get_contenthash(), $record); + + $fixture = 'Example content'; + $conversion->store_destfile_from_string($fixture); + + $destfile = $conversion->get_destfile(); + $this->assertEquals($fixture, $destfile->get_content()); + } + + /** + * Ensure that the get_status functions cast the status to integer correctly. + */ + public function test_get_status() { + $conversion = new conversion(0, (object) [ + 'status' => (string) 1, + ]); + + $this->assertInternalType('integer', $conversion->get('status')); + } + + /** + * Ensure that get_converter_instance returns false when no converter is set. + */ + public function test_get_converter_instance_none_set() { + $conversion = new conversion(0, (object) []); + $this->assertFalse($conversion->get_converter_instance()); + } + + /** + * Ensure that get_converter_instance returns false when no valid converter is set. + */ + public function test_get_converter_instance_invalid_set() { + $conversion = new conversion(0, (object) [ + 'converter' => '\\fileconverter_not_a_valid_converter\\converter', + ]); + $this->assertFalse($conversion->get_converter_instance()); + } + + /** + * Ensure that get_converter_instance returns an instance when a valid converter is set. + */ + public function test_get_converter_instance_valid_set() { + $conversion = new conversion(0, (object) [ + 'converter' => \fileconverter_unoconv\converter::class, + ]); + $this->assertInstanceOf(\fileconverter_unoconv\converter::class, $conversion->get_converter_instance()); + } + + /** + * Test that all old conversion records are removed periodically. + */ + public function test_remove_old_conversion_records_old() { + $this->resetAfterTest(); + global $DB; + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $conversion->create(); + $DB->set_field(conversion::TABLE, 'timemodified', time() - YEARSECS); + + conversion::remove_old_conversion_records(); + + $this->assertEquals(0, $DB->count_records(conversion::TABLE)); + } + + /** + * Test that all old conversion records are removed periodically. + */ + public function test_remove_old_conversion_records_young() { + $this->resetAfterTest(); + global $DB; + + $sourcefile = $this->create_stored_file(); + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $conversion->create(); + $DB->set_field(conversion::TABLE, 'timemodified', time() - DAYSECS); + + conversion::remove_old_conversion_records(); + + $this->assertEquals(1, $DB->count_records(conversion::TABLE)); + } +} diff --git a/files/tests/converter_test.php b/files/tests/converter_test.php new file mode 100644 index 00000000000..6f182c6777d --- /dev/null +++ b/files/tests/converter_test.php @@ -0,0 +1,926 @@ +. + + +/** + * PHPUnit tests for fileconverter API. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +use core_files\conversion; +use core_files\converter; + +/** + * PHPUnit tests for fileconverter API. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_files_converter_testcase extends advanced_testcase { + + /** + * Get a testable mock of the abstract files_converter class. + * + * @param array $mockedmethods A list of methods you intend to override + * If no methods are specified, only abstract functions are mocked. + * @return \core_files\converter + */ + protected function get_testable_mock($mockedmethods = []) { + $converter = $this->getMockBuilder(\core_files\converter::class) + ->setMethods($mockedmethods) + ->getMockForAbstractClass(); + + return $converter; + } + + /** + * Get a testable mock of the conversion. + * + * @param array $mockedmethods A list of methods you intend to override + * @return \core_files\conversion + */ + protected function get_testable_conversion($mockedmethods = []) { + $conversion = $this->getMockBuilder(\core_files\conversion::class) + ->setMethods($mockedmethods) + ->setConstructorArgs([0, (object) []]) + ->getMock(); + + return $conversion; + } + + /** + * Get a testable mock of the abstract files_converter class. + * + * @param array $mockedmethods A list of methods you intend to override + * If no methods are specified, only abstract functions are mocked. + * @return \core_files\converter_interface + */ + protected function get_mocked_converter($mockedmethods = []) { + $converter = $this->getMockBuilder(\core_files\converter_interface::class) + ->setMethods($mockedmethods) + ->getMockForAbstractClass(); + + return $converter; + } + + /** + * Helper to create a stored file objectw with the given supplied content. + * + * @param string $filecontent The content of the mocked file + * @param string $filename The file name to use in the stored_file + * @param array $mockedmethods A list of methods you intend to override + * If no methods are specified, only abstract functions are mocked. + * @return stored_file + */ + protected function get_stored_file($filecontent = 'content', $filename = null, $filerecord = [], $mockedmethods = null) { + global $CFG; + + $contenthash = sha1($filecontent); + if (empty($filename)) { + $filename = $contenthash; + } + + $filerecord['contenthash'] = $contenthash; + $filerecord['filesize'] = strlen($filecontent); + $filerecord['filename'] = $filename; + $filerecord['id'] = 42; + + $file = $this->getMockBuilder(stored_file::class) + ->setMethods($mockedmethods) + ->setConstructorArgs([get_file_storage(), (object) $filerecord]) + ->getMock(); + + return $file; + } + + /** + * Helper to create a stored file object with the given supplied content. + * + * @param string $filecontent The content of the mocked file + * @param string $filename The file name to use in the stored_file + * @param string $filerecord Any overrides to the filerecord + * @return stored_file + */ + protected function create_stored_file($filecontent = 'content', $filename = 'testfile.txt', $filerecord = []) { + $filerecord = array_merge([ + 'contextid' => context_system::instance()->id, + 'component' => 'core', + 'filearea' => 'unittest', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ], $filerecord); + + $fs = get_file_storage(); + $file = $fs->create_file_from_string($filerecord, $filecontent); + + return $file; + } + + /** + * Get a mock of the file_storage API. + * + * @param array $mockedmethods A list of methods you intend to override + * @return file_storage + */ + protected function get_file_storage_mock($mockedmethods = []) { + $fs = $this->getMockBuilder(\file_storage::class) + ->setMethods($mockedmethods) + ->disableOriginalConstructor() + ->getMock(); + + return $fs; + } + + /** + * Test the start_conversion function. + */ + public function test_start_conversion_existing_single() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + + $first = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $first->create(); + + $converter = $this->get_testable_mock(['poll_conversion']); + $conversion = $converter->start_conversion($sourcefile, 'pdf', false); + + // The old conversions should still be present and match the one returned. + $this->assertEquals($first->get('id'), $conversion->get('id')); + } + + /** + * Test the start_conversion function. + */ + public function test_start_conversion_existing_multiple() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + + $first = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $first->create(); + + $second = new conversion(0, (object) [ + 'sourcefileid' => $sourcefile->get_id(), + 'targetformat' => 'pdf', + ]); + $second->create(); + + $converter = $this->get_testable_mock(['poll_conversion']); + $conversion = $converter->start_conversion($sourcefile, 'pdf', false); + + // The old conversions should have been removed. + $this->assertFalse(conversion::get_record(['id' => $first->get('id')])); + $this->assertFalse(conversion::get_record(['id' => $second->get('id')])); + } + + /** + * Test the start_conversion function. + */ + public function test_start_conversion_no_existing() { + $this->resetAfterTest(); + + $sourcefile = $this->create_stored_file(); + + $converter = $this->get_testable_mock(['poll_conversion']); + $conversion = $converter->start_conversion($sourcefile, 'pdf', false); + + $this->assertInstanceOf(\core_files\conversion::class, $conversion); + } + + /** + * Test the get_document_converter_classes function with no enabled plugins. + */ + public function test_get_document_converter_classes_no_plugins() { + $converter = $this->get_testable_mock(['get_enabled_plugins']); + $converter->method('get_enabled_plugins')->willReturn([]); + + $method = new ReflectionMethod(\core_files\converter::class, 'get_document_converter_classes'); + $method->setAccessible(true); + $result = $method->invokeArgs($converter, ['docx', 'pdf']); + $this->assertEmpty($result); + } + + /** + * Test the get_document_converter_classes function when no class was found. + */ + public function test_get_document_converter_classes_plugin_class_not_found() { + $converter = $this->get_testable_mock(['get_enabled_plugins']); + $converter->method('get_enabled_plugins')->willReturn([ + 'noplugin' => '\not\a\real\plugin', + ]); + + $method = new ReflectionMethod(\core_files\converter::class, 'get_document_converter_classes'); + $method->setAccessible(true); + $result = $method->invokeArgs($converter, ['docx', 'pdf']); + $this->assertEmpty($result); + } + + /** + * Test the get_document_converter_classes function when the returned classes do not meet requirements. + */ + public function test_get_document_converter_classes_plugin_class_requirements_not_met() { + $plugin = $this->getMockBuilder(\core_file_converter_requirements_not_met_test::class) + ->setMethods() + ->getMock(); + + $converter = $this->get_testable_mock(['get_enabled_plugins']); + $converter->method('get_enabled_plugins')->willReturn([ + 'test_plugin' => get_class($plugin), + ]); + + $method = new ReflectionMethod(\core_files\converter::class, 'get_document_converter_classes'); + $method->setAccessible(true); + $result = $method->invokeArgs($converter, ['docx', 'pdf']); + $this->assertEmpty($result); + } + + /** + * Test the get_document_converter_classes function when the returned classes do not meet requirements. + */ + public function test_get_document_converter_classes_plugin_class_met_not_supported() { + $plugin = $this->getMockBuilder(\core_file_converter_type_not_supported_test::class) + ->setMethods() + ->getMock(); + + $converter = $this->get_testable_mock(['get_enabled_plugins']); + $converter->method('get_enabled_plugins')->willReturn([ + 'test_plugin' => get_class($plugin), + ]); + + $method = new ReflectionMethod(\core_files\converter::class, 'get_document_converter_classes'); + $method->setAccessible(true); + $result = $method->invokeArgs($converter, ['docx', 'pdf']); + $this->assertEmpty($result); + } + + /** + * Test the get_document_converter_classes function when the returned classes do not meet requirements. + */ + public function test_get_document_converter_classes_plugin_class_met_and_supported() { + $plugin = $this->getMockBuilder(\core_file_converter_type_supported_test::class) + ->setMethods() + ->getMock(); + $classname = get_class($plugin); + + $converter = $this->get_testable_mock(['get_enabled_plugins']); + $converter->method('get_enabled_plugins')->willReturn([ + 'test_plugin' => $classname, + ]); + + $method = new ReflectionMethod(\core_files\converter::class, 'get_document_converter_classes'); + $method->setAccessible(true); + $result = $method->invokeArgs($converter, ['docx', 'pdf']); + $this->assertCount(1, $result); + $this->assertNotFalse(array_search($classname, $result)); + } + + /** + * Test the can_convert_storedfile_to function with a directory. + */ + public function test_can_convert_storedfile_to_directory() { + $converter = $this->get_testable_mock(); + + // A file with filename '.' is a directory. + $file = $this->get_stored_file('', '.'); + + $this->assertFalse($converter->can_convert_storedfile_to($file, 'target')); + } + + /** + * Test the can_convert_storedfile_to function with an empty file. + */ + public function test_can_convert_storedfile_to_emptyfile() { + $converter = $this->get_testable_mock(); + + // A file with filename '.' is a directory. + $file = $this->get_stored_file(''); + + $this->assertFalse($converter->can_convert_storedfile_to($file, 'target')); + } + + /** + * Test the can_convert_storedfile_to function with a file with indistinguished mimetype. + */ + public function test_can_convert_storedfile_to_no_mimetype() { + $converter = $this->get_testable_mock(); + + // A file with filename '.' is a directory. + $file = $this->get_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $this->assertFalse($converter->can_convert_storedfile_to($file, 'target')); + } + + /** + * Test the can_convert_storedfile_to function with a file with indistinguished mimetype. + */ + public function test_can_convert_storedfile_to_docx() { + $returnvalue = (object) []; + + $converter = $this->get_testable_mock([ + 'can_convert_format_to' + ]); + + $types = \core_filetypes::get_types(); + + // A file with filename '.' is a directory. + $file = $this->get_stored_file('example content', 'example', [ + 'mimetype' => $types['docx']['type'], + ]); + + $converter->expects($this->once()) + ->method('can_convert_format_to') + ->willReturn($returnvalue); + + $result = $converter->can_convert_storedfile_to($file, 'target'); + $this->assertEquals($returnvalue, $result); + } + + + /** + * Test the can_convert_format_to function. + */ + public function test_can_convert_format_to_found() { + $converter = $this->get_testable_mock(['get_document_converter_classes']); + + $mock = $this->get_mocked_converter(); + + $converter->method('get_document_converter_classes') + ->willReturn([$mock]); + + $result = $converter->can_convert_format_to('from', 'to'); + $this->assertTrue($result); + } + + /** + * Test the can_convert_format_to function. + */ + public function test_can_convert_format_to_not_found() { + $converter = $this->get_testable_mock(['get_document_converter_classes']); + + $converter->method('get_document_converter_classes') + ->willReturn([]); + + $result = $converter->can_convert_format_to('from', 'to'); + $this->assertFalse($result); + } + + /** + * Test the can_convert_storedfile_to function with an empty file. + */ + public function test_poll_conversion_in_progress() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + 'get_next_converter', + ]); + + $converter->method('get_document_converter_classes')->willReturn([]); + $converter->method('get_next_converter')->willReturn(false); + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $this->get_testable_conversion([ + 'get_converter_instance', + ]); + $conversion->set_sourcefile($file); + $conversion->set('targetformat', 'target'); + $conversion->set('status', conversion::STATUS_IN_PROGRESS); + + $converterinstance = $this->get_mocked_converter([ + 'poll_conversion_status', + ]); + $converterinstance->expects($this->once()) + ->method('poll_conversion_status'); + $conversion->method('get_converter_instance')->willReturn($converterinstance); + + $converter->poll_conversion($conversion); + + $this->assertEquals(conversion::STATUS_IN_PROGRESS, $conversion->get('status')); + } + + /** + * Test poll_conversion with an in-progress conversion where we are + * unable to instantiate the converter instance. + */ + public function test_poll_conversion_in_progress_fail() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + 'get_next_converter', + ]); + + $converter->method('get_document_converter_classes')->willReturn([]); + $converter->method('get_next_converter')->willReturn(false); + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $this->get_testable_conversion([ + 'get_converter_instance', + ]); + $conversion->set_sourcefile($file); + $conversion->set('targetformat', 'target'); + $conversion->set('status', conversion::STATUS_IN_PROGRESS); + $conversion->create(); + + $conversion->method('get_converter_instance')->willReturn(false); + + $converter->poll_conversion($conversion); + + $this->assertEquals(conversion::STATUS_FAILED, $conversion->get('status')); + } + + /** + * Test the can_convert_storedfile_to function with an empty file. + */ + public function test_poll_conversion_none_supported() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + 'get_next_converter', + ]); + + $converter->method('get_document_converter_classes')->willReturn([]); + $converter->method('get_next_converter')->willReturn(false); + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = new conversion(0, (object) [ + 'sourcefileid' => $file->get_id(), + 'targetformat' => 'target', + ]); + + $converter->poll_conversion($conversion); + + $this->assertEquals(conversion::STATUS_FAILED, $conversion->get('status')); + } + + /** + * Test the can_convert_storedfile_to function with an empty file. + */ + public function test_poll_conversion_pick_first() { + $this->resetAfterTest(); + + $converterinstance = $this->get_mocked_converter([ + 'start_document_conversion', + 'poll_conversion_status', + ]); + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + 'get_next_converter', + ]); + + $converter->method('get_document_converter_classes')->willReturn([]); + $converter->method('get_next_converter')->willReturn(get_class($converterinstance)); + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $this->get_testable_conversion([ + 'get_converter_instance', + ]); + $conversion->set_sourcefile($file); + $conversion->set('targetformat', 'target'); + $conversion->set('status', conversion::STATUS_PENDING); + $conversion->create(); + + $conversion->method('get_converter_instance')->willReturn($converterinstance); + + $converterinstance->expects($this->once()) + ->method('start_document_conversion'); + $converterinstance->expects($this->never()) + ->method('poll_conversion_status'); + + $converter->poll_conversion($conversion); + + $this->assertEquals(conversion::STATUS_IN_PROGRESS, $conversion->get('status')); + } + + /** + * Test the can_convert_storedfile_to function with an empty file. + */ + public function test_poll_conversion_pick_subsequent() { + $this->resetAfterTest(); + + $converterinstance = $this->get_mocked_converter([ + 'start_document_conversion', + 'poll_conversion_status', + ]); + $converterinstance2 = $this->get_mocked_converter([ + 'start_document_conversion', + 'poll_conversion_status', + ]); + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + 'get_next_converter', + ]); + + $converter->method('get_document_converter_classes')->willReturn([]); + $converter->method('get_next_converter') + ->will($this->onConsecutiveCalls( + get_class($converterinstance), + get_class($converterinstance2) + )); + + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $this->get_testable_conversion([ + 'get_converter_instance', + 'get_status', + ]); + $conversion->set_sourcefile($file); + $conversion->set('targetformat', 'target'); + $conversion->set('status', conversion::STATUS_PENDING); + $conversion->create(); + + $conversion->method('get_status') + ->will($this->onConsecutiveCalls( + // Initial status check. + conversion::STATUS_PENDING, + // Second check to make sure it's still pending after polling. + conversion::STATUS_PENDING, + // First one fails. + conversion::STATUS_FAILED, + // Second one succeeds. + conversion::STATUS_COMPLETE, + // And the final result checked in this unit test. + conversion::STATUS_COMPLETE + )); + + $conversion->method('get_converter_instance') + ->will($this->onConsecutiveCalls( + $converterinstance, + $converterinstance2 + )); + + $converterinstance->expects($this->once()) + ->method('start_document_conversion'); + $converterinstance->expects($this->never()) + ->method('poll_conversion_status'); + $converterinstance2->expects($this->once()) + ->method('start_document_conversion'); + $converterinstance2->expects($this->never()) + ->method('poll_conversion_status'); + + $converter->poll_conversion($conversion); + + $this->assertEquals(conversion::STATUS_COMPLETE, $conversion->get('status')); + } + + /** + * Test the start_conversion with a single converter which succeeds. + */ + public function test_start_conversion_one_supported_success() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + ]); + + $converter->method('get_document_converter_classes') + ->willReturn([\core_file_converter_type_successful::class]); + + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $converter->start_conversion($file, 'target'); + + $this->assertEquals(conversion::STATUS_COMPLETE, $conversion->get('status')); + } + + /** + * Test the start_conversion with a single converter which failes. + */ + public function test_start_conversion_one_supported_failure() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + ]); + + $mock = $this->get_mocked_converter(['start_document_conversion']); + $converter->method('get_document_converter_classes') + ->willReturn([\core_file_converter_type_failed::class]); + + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $converter->start_conversion($file, 'target'); + + $this->assertEquals(conversion::STATUS_FAILED, $conversion->get('status')); + } + + /** + * Test the start_conversion with two converters - fail, then succeed. + */ + public function test_start_conversion_two_supported() { + $this->resetAfterTest(); + + $converter = $this->get_testable_mock([ + 'get_document_converter_classes', + ]); + + $mock = $this->get_mocked_converter(['start_document_conversion']); + $converter->method('get_document_converter_classes') + ->willReturn([ + \core_file_converter_type_failed::class, + \core_file_converter_type_successful::class, + ]); + + $file = $this->create_stored_file('example content', 'example', [ + 'mimetype' => null, + ]); + + $conversion = $converter->start_conversion($file, 'target'); + + $this->assertEquals(conversion::STATUS_COMPLETE, $conversion->get('status')); + } + + /** + * Ensure that get_next_converter returns false when no converters are available. + */ + public function test_get_next_converter_no_converters() { + $rcm = new \ReflectionMethod(converter::class, 'get_next_converter'); + $rcm->setAccessible(true); + + $converter = new \core_files\converter(); + $result = $rcm->invoke($converter, [], null); + $this->assertFalse($result); + } + + /** + * Ensure that get_next_converter returns false when already on the + * only converter. + */ + public function test_get_next_converter_only_converters() { + $rcm = new \ReflectionMethod(converter::class, 'get_next_converter'); + $rcm->setAccessible(true); + + $converter = new converter(); + $result = $rcm->invoke($converter, ['example'], 'example'); + $this->assertFalse($result); + } + + /** + * Ensure that get_next_converter returns false when already on the + * last converter. + */ + public function test_get_next_converter_last_converters() { + $rcm = new \ReflectionMethod(converter::class, 'get_next_converter'); + $rcm->setAccessible(true); + + $converter = new converter(); + $result = $rcm->invoke($converter, ['foo', 'example'], 'example'); + $this->assertFalse($result); + } + + /** + * Ensure that get_next_converter returns the next vlaue when in a + * current converter. + */ + public function test_get_next_converter_middle_converters() { + $rcm = new \ReflectionMethod(converter::class, 'get_next_converter'); + $rcm->setAccessible(true); + + $converter = new converter(); + $result = $rcm->invoke($converter, ['foo', 'bar', 'baz', 'example'], 'bar'); + $this->assertEquals('baz', $result); + } + /** + * + * Ensure that get_next_converter returns the next vlaue when in a + * current converter. + */ + public function test_get_next_converter_first() { + $rcm = new \ReflectionMethod(converter::class, 'get_next_converter'); + $rcm->setAccessible(true); + + $converter = new converter(); + $result = $rcm->invoke($converter, ['foo', 'bar', 'baz', 'example']); + $this->assertEquals('foo', $result); + } +} + +class core_file_converter_requirements_test_base implements \core_files\converter_interface { + + /** + * Whether the plugin is configured and requirements are met. + * + * @return bool + */ + public static function are_requirements_met() { + return false; + } + + /** + * Convert a document to a new format and return a conversion object relating to the conversion in progress. + * + * @param conversion $conversion The file to be converted + * @return conversion + */ + public function start_document_conversion(conversion $conversion) { + } + + /** + * Poll an existing conversion for status update. + * + * @param conversion $conversion The file to be converted + * @return conversion + */ + public function poll_conversion_status(conversion $conversion) { + } + + /** + * Whether a file conversion can be completed using this converter. + * + * @param string $from The source type + * @param string $to The destination type + * @return bool + */ + public static function supports($from, $to) { + return false; + } + + /** + * A list of the supported conversions. + * + * @return string + */ + public function get_supported_conversions() { + return []; + } + +} + +/** + * Test class for converter support with requirements are not met. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_file_converter_requirements_not_met_test extends core_file_converter_requirements_test_base { +} + +/** + * Test class for converter support with requirements met and conversion not supported. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_file_converter_type_not_supported_test extends core_file_converter_requirements_test_base { + + /** + * Whether the plugin is configured and requirements are met. + * + * @return bool + */ + public static function are_requirements_met() { + return true; + } +} + +/** + * Test class for converter support with requirements met and conversion supported. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_file_converter_type_supported_test extends core_file_converter_requirements_test_base { + + /** + * Whether the plugin is configured and requirements are met. + * + * @return bool + */ + public static function are_requirements_met() { + return true; + } + + /** + * Whether a file conversion can be completed using this converter. + * + * @param string $from The source type + * @param string $to The destination type + * @return bool + */ + public static function supports($from, $to) { + return true; + } +} + +/** + * Test class for converter support with requirements met and successful conversion. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_file_converter_type_successful extends core_file_converter_requirements_test_base { + + /** + * Convert a document to a new format and return a conversion object relating to the conversion in progress. + * + * @param conversion $conversion The file to be converted + * @return conversion + */ + public function start_document_conversion(conversion $conversion) { + $conversion->set('status', conversion::STATUS_COMPLETE); + + return $conversion; + } + + /** + * Whether a file conversion can be completed using this converter. + * + * @param string $from The source type + * @param string $to The destination type + * @return bool + */ + public static function supports($from, $to) { + return true; + } +} + +/** + * Test class for converter support with requirements met and failed conversion. + * + * @package core_files + * @copyright 2017 Andrew nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_file_converter_type_failed extends core_file_converter_requirements_test_base { + + /** + * Whether the plugin is configured and requirements are met. + * + * @return bool + */ + public static function are_requirements_met() { + return true; + } + + /** + * Convert a document to a new format and return a conversion object relating to the conversion in progress. + * + * @param conversion $conversion The file to be converted + * @return conversion + */ + public function start_document_conversion(conversion $conversion) { + $conversion->set('status', conversion::STATUS_FAILED); + + return $conversion; + } + + /** + * Whether a file conversion can be completed using this converter. + * + * @param string $from The source type + * @param string $to The destination type + * @return bool + */ + public static function supports($from, $to) { + return true; + } +} diff --git a/lang/en/plugin.php b/lang/en/plugin.php index 9ed63798bf0..b865dff531e 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -105,6 +105,7 @@ $string['status_nodb'] = 'No database'; $string['status_upgrade'] = 'To be upgraded'; $string['status_uptodate'] = 'Installed'; $string['supportedmoodleversions'] = 'Supported Moodle versions'; +$string['supportedconversions'] = 'Supported document conversions'; $string['systemname'] = 'Identifier'; $string['type_antivirus'] = 'Antivirus plugin'; $string['type_antivirus_plural'] = 'Antivirus plugins'; @@ -128,6 +129,8 @@ $string['type_editor'] = 'Editor'; $string['type_editor_plural'] = 'Editors'; $string['type_enrol'] = 'Enrolment method'; $string['type_enrol_plural'] = 'Enrolment methods'; +$string['type_fileconverter'] = 'Document converter'; +$string['type_fileconverter_plural'] = 'Document converters'; $string['type_filter'] = 'Text filter'; $string['type_filter_plural'] = 'Text filters'; $string['type_format'] = 'Course format'; diff --git a/lib/adminlib.php b/lib/adminlib.php index e1ce048cf68..28aafd6ce96 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -7399,6 +7399,7 @@ abstract class admin_setting_manage_plugins extends admin_setting { /** * Get the admin settings section name (just a unique string) + * * @return string */ public function get_section_name() { @@ -7407,18 +7408,21 @@ abstract class admin_setting_manage_plugins extends admin_setting { /** * Get the admin settings section title (use get_string). + * * @return string */ abstract public function get_section_title(); /** * Get the type of plugin to manage. + * * @return string */ abstract public function get_plugin_type(); /** * Get the name of the second column. + * * @return string */ public function get_info_column_name() { @@ -7514,19 +7518,12 @@ abstract class admin_setting_manage_plugins extends admin_setting { public function output_html($data, $query = '') { global $CFG, $OUTPUT, $DB, $PAGE; - $spacer = new pix_icon('spacer', '', 'moodle'); - $moveup = new pix_icon('t/up', get_string('up'), 'moodle'); - $movedown = new pix_icon('t/down', get_string('down'), 'moodle'); - $context = (object) [ 'manageurl' => new moodle_url($this->get_manage_url(), [ 'type' => $this->get_plugin_type(), 'sesskey' => sesskey(), ]), 'infocolumnname' => $this->get_info_column_name(), - 'spacericon' => $spacer->export_for_template($OUTPUT), - 'moveupicon' => $moveup->export_for_template($OUTPUT), - 'movedownicon' => $movedown->export_for_template($OUTPUT), 'plugins' => [], ]; @@ -7541,7 +7538,6 @@ abstract class admin_setting_manage_plugins extends admin_setting { 'plugin' => $plugin->displayname, 'enabled' => $plugin->is_enabled(), 'togglelink' => '', - 'toggleicon' => '', 'moveuplink' => '', 'movedownlink' => '', 'settingslink' => $plugin->get_settings_url(), @@ -7552,7 +7548,7 @@ abstract class admin_setting_manage_plugins extends admin_setting { // Enable/Disable link. $togglelink = new moodle_url($pluginlink); if ($plugin->is_enabled()) { - $toggleicon = new pix_icon('i/hide', get_string('disable', 'moodle'), 'moodle'); + $toggletarget = false; $togglelink->param('action', 'disable'); if (count($context->plugins)) { @@ -7567,11 +7563,11 @@ abstract class admin_setting_manage_plugins extends admin_setting { $pluginkey->info = $this->get_info_column($plugin); } else { - $toggleicon = new pix_icon('i/show', get_string('enable', 'moodle'), 'moodle'); + $toggletarget = true; $togglelink->param('action', 'enable'); } - $pluginkey->toggleicon = $toggleicon->export_for_template($OUTPUT); + $pluginkey->toggletarget = $toggletarget; $pluginkey->togglelink = $togglelink; $frankenstyle = $plugin->type . '_' . $plugin->name; @@ -7593,6 +7589,31 @@ abstract class admin_setting_manage_plugins extends admin_setting { } } +/** + * Generic class for managing plugins in a table that allows re-ordering and enable/disable of each plugin. + * Requires a get_rank method on the plugininfo class for sorting. + * + * @copyright 2017 Andrew Nicols +* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class admin_setting_manage_fileconverter_plugins extends admin_setting_manage_plugins { + public function get_section_title() { + return get_string('type_fileconverter_plural', 'plugin'); + } + + public function get_plugin_type() { + return 'fileconverter'; + } + + public function get_info_column_name() { + return get_string('supportedconversions', 'plugin'); + } + + public function get_info_column($plugininfo) { + return $plugininfo->get_supported_conversions(); + } +} + /** * Special class for media player plugins management. * diff --git a/lib/classes/component.php b/lib/classes/component.php index 735f3863c58..009e8a34b08 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -439,6 +439,7 @@ $cache = '.var_export($cache, true).'; 'enrol' => $CFG->dirroot.'/enrol', 'error' => null, 'filepicker' => null, + 'fileconverter' => $CFG->dirroot.'/files/converter', 'files' => $CFG->dirroot.'/files', 'filters' => null, //'fonts' => null, // Bogus. @@ -525,6 +526,7 @@ $cache = '.var_export($cache, true).'; 'tool' => $CFG->dirroot.'/'.$CFG->admin.'/tool', 'cachestore' => $CFG->dirroot.'/cache/stores', 'cachelock' => $CFG->dirroot.'/cache/locks', + 'fileconverter' => $CFG->dirroot.'/files/converter', ); $parents = array(); $subplugins = array(); diff --git a/lib/classes/filetypes.php b/lib/classes/filetypes.php index f1d9cf8d4a8..4a160b0c80e 100644 --- a/lib/classes/filetypes.php +++ b/lib/classes/filetypes.php @@ -291,6 +291,23 @@ abstract class core_filetypes { ); } + /** + * Given a mimetype - return a valid file extension for it. + * + * @param $mimetype string + * @return string|bool False if the mimetype was not known, a string indicating a valid file extension otherwise. It may not + * be the only valid file extension - just the first one found. + */ + public static function get_file_extension($mimetype) { + $types = self::get_types(); + foreach ($types as $extension => $info) { + if ($info['type'] == $mimetype) { + return $extension; + } + } + return false; + } + /** * Gets all the current types. * diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 8d7e37670a9..6f9917d6d4c 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1768,6 +1768,9 @@ class core_plugin_manager { 'imagegallery' ), + 'fileconverter' => array( + ), + 'editor' => array( 'atto', 'textarea', 'tinymce' ), diff --git a/lib/classes/plugininfo/fileconverter.php b/lib/classes/plugininfo/fileconverter.php new file mode 100644 index 00000000000..e7a5baeb30a --- /dev/null +++ b/lib/classes/plugininfo/fileconverter.php @@ -0,0 +1,183 @@ +. + +/** + * Defines classes used for plugin info. + * + * @package core + * @copyright 2017 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core\plugininfo; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class for document converter plugins + * + * @package core + * @copyright 2017 Damyon Wiese + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fileconverter extends base { + + /** + * Should there be a way to uninstall the plugin via the administration UI. + * + * Uninstallation is allowed for fileconverter plugins. + * + * @return bool + */ + public function is_uninstall_allowed() { + return true; + } + + /** + * Get the name for the settings section. + * + * @return string + */ + public function get_settings_section_name() { + return 'fileconverter' . $this->name; + } + + /** + * Load the global settings for a particular availability plugin (if there are any) + * + * @param \part_of_admin_tree $adminroot + * @param string $parentnodename + * @param bool $hassiteconfig + */ + public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) { + global $CFG, $USER, $DB, $OUTPUT, $PAGE; // In case settings.php wants to refer to them. + $ADMIN = $adminroot; // May be used in settings.php. + $plugininfo = $this; // Also can be used inside settings.php. + + if (!$this->is_installed_and_upgraded()) { + return; + } + + if (!$hassiteconfig) { + return; + } + + $section = $this->get_settings_section_name(); + + $settings = null; + if (file_exists($this->full_path('settings.php'))) { + $settings = new \admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false); + include($this->full_path('settings.php')); // This may also set $settings to null. + } + if ($settings) { + $ADMIN->add($parentnodename, $settings); + } + } + + /** + * Return URL used for management of plugins of this type. + * @return \moodle_url + */ + public static function get_manage_url() { + return new \moodle_url('/admin/settings.php', array('section' => 'managefileconverterplugins')); + } + + /** + * Finds all enabled plugins, the result may include missing plugins. + * + * @return array|null of enabled plugins $pluginname=>$pluginname, null means unknown + */ + public static function get_enabled_plugins() { + global $CFG; + + $order = (!empty($CFG->converter_plugins_sortorder)) ? explode(',', $CFG->converter_plugins_sortorder) : []; + if ($order) { + $plugins = \core_plugin_manager::instance()->get_installed_plugins('fileconverter'); + $order = array_intersect($order, array_keys($plugins)); + } + + return array_combine($order, $order); + } + + /** + * Sets the current plugin as enabled or disabled + * When enabling tries to guess the sortorder based on default rank returned by the plugin. + * @param bool $newstate + */ + public function set_enabled($newstate = true) { + $enabled = self::get_enabled_plugins(); + if (array_key_exists($this->name, $enabled) == $newstate) { + // Nothing to do. + return; + } + if ($newstate) { + // Enable converter plugin. + $plugins = \core_plugin_manager::instance()->get_plugins_of_type('fileconverter'); + if (!array_key_exists($this->name, $plugins)) { + // Can not be enabled. + return; + } + $enabled[$this->name] = $this->name; + self::set_enabled_plugins($enabled); + } else { + // Disable converter plugin. + unset($enabled[$this->name]); + self::set_enabled_plugins($enabled); + } + } + + /** + * Set the list of enabled converter players in the specified sort order + * To be used when changing settings or in unit tests + * @param string|array $list list of plugin names without frankenstyle prefix - comma-separated string or an array + */ + public static function set_enabled_plugins($list) { + if (empty($list)) { + $list = []; + } else if (!is_array($list)) { + $list = explode(',', $list); + } + if ($list) { + $plugins = \core_plugin_manager::instance()->get_installed_plugins('fileconverter'); + $list = array_intersect($list, array_keys($plugins)); + } + set_config('converter_plugins_sortorder', join(',', $list)); + \core_plugin_manager::reset_caches(); + } + + /** + * Returns a string describing the formats this engine can converter from / to. + * + * @return string + */ + public function get_supported_conversions() { + $classname = self::get_classname($this->name); + if (class_exists($classname)) { + $object = new $classname(); + return $object->get_supported_conversions(); + } + return ''; + } + + /** + * Return the class name for the plugin. + * + * @param string $plugin + * @return string + */ + public static function get_classname($plugin) { + return "\\fileconverter_{$plugin}\\converter"; + } +} diff --git a/lib/db/install.xml b/lib/db/install.xml index 1c86d11c497..905a4660048 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -2388,6 +2388,26 @@ + + + + + + + + + + + + + + + + + + + +
diff --git a/lib/db/tasks.php b/lib/db/tasks.php index f7296c544fe..c820348d6c2 100644 --- a/lib/db/tasks.php +++ b/lib/db/tasks.php @@ -338,4 +338,13 @@ $tasks = array( 'dayofweek' => '*', 'month' => '*' ), + array( + 'classname' => 'core_files\task\conversion_cleanup_task', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => '2', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ), ); diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 0dc1482fcf2..8e935d0fe8a 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2555,5 +2555,37 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2017021400.00); } + if ($oldversion < 2017030700.00) { + + // Define table file_conversion to be created. + $table = new xmldb_table('file_conversion'); + + // Adding fields to table file_conversion. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('sourcefileid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('targetformat', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('status', XMLDB_TYPE_INTEGER, '10', null, null, null, '0'); + $table->add_field('statusmessage', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('converter', XMLDB_TYPE_CHAR, '255', null, null, null, null); + $table->add_field('destfileid', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + $table->add_field('data', XMLDB_TYPE_TEXT, null, null, null, null, null); + + // Adding keys to table file_conversion. + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); + $table->add_key('sourcefileid', XMLDB_KEY_FOREIGN, array('sourcefileid'), 'files', array('id')); + $table->add_key('destfileid', XMLDB_KEY_FOREIGN, array('destfileid'), 'files', array('id')); + + // Conditionally launch create table for file_conversion. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2017030700.00); + } + return true; } diff --git a/lib/tests/component_test.php b/lib/tests/component_test.php index 51b53df52b0..5ac54351d4c 100644 --- a/lib/tests/component_test.php +++ b/lib/tests/component_test.php @@ -34,7 +34,7 @@ class core_component_testcase extends advanced_testcase { // To be changed if number of subsystems increases/decreases, // this is defined here to annoy devs that try to add more without any thinking, // always verify that it does not collide with any existing add-on modules and subplugins!!! - const SUBSYSTEMCOUNT = 65; + const SUBSYSTEMCOUNT = 66; public function setUp() { $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces'); diff --git a/version.php b/version.php index 1d8ae473617..90ddbe0c5c0 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2017030300.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2017030700.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.