MDL-55528 core_files: Create new fileconverter plugintype
authorAndrew Nicols <andrew@nicols.co.uk>
Fri, 17 Feb 2017 01:34:55 +0000 (09:34 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Fri, 10 Mar 2017 01:31:14 +0000 (09:31 +0800)
19 files changed:
admin/settings/plugins.php
admin/templates/setting_manage_plugins.mustache
files/classes/conversion.php [new file with mode: 0644]
files/classes/converter.php [new file with mode: 0644]
files/classes/converter_interface.php [new file with mode: 0644]
files/classes/task/conversion_cleanup_task.php [new file with mode: 0644]
files/tests/conversion_test.php [new file with mode: 0644]
files/tests/converter_test.php [new file with mode: 0644]
lang/en/plugin.php
lib/adminlib.php
lib/classes/component.php
lib/classes/filetypes.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/fileconverter.php [new file with mode: 0644]
lib/db/install.xml
lib/db/tasks.php
lib/db/upgrade.php
lib/tests/component_test.php
version.php

index bd759a7..1dfdd6a 100644 (file)
@@ -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) {
index 65d941d..5c38e51 100644 (file)
                 {{info}}
             </td>
             {{/infocolumnname}}
-            <td>
+            <td class="text-nowrap">
                 {{#moveuplink}}
                     <a href="{{{moveuplink}}}">
-                        {{#moveupicon}}{{>core/pix_icon}}{{/moveupicon}}
+                        {{#pix}}t/up, moodle, {{#str}} up, moodle {{/str}}{{/pix}}
                     </a>
                 {{/moveuplink}}
                 {{^moveuplink}}
-                    {{#spacericon}}{{>core/pix_icon}}{{/spacericon}}
+                    {{#pix}}spacer, moodle{{/pix}}
                 {{/moveuplink}}
 
                 {{#movedownlink}}
                     <a href="{{{movedownlink}}}">
-                        {{#movedownicon}}{{>core/pix_icon}}{{/movedownicon}}
+                        {{#pix}}t/down, moodle, {{#str}} down, moodle {{/str}}{{/pix}}
                     </a>
                 {{/movedownlink}}
                 {{^movedownlink}}
-                    {{#spacericon}}{{>core/pix_icon}}{{/spacericon}}
+                    {{#pix}}spacer, moodle{{/pix}}
                 {{/movedownlink}}
             </td>
             <td>
                 <a href="{{{togglelink}}}">
-                    {{#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}}
                 </a>
             </td>
             <td>
diff --git a/files/classes/conversion.php b/files/classes/conversion.php
new file mode 100644 (file)
index 0000000..07872ce
--- /dev/null
@@ -0,0 +1,360 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Classes for converting files between different file formats.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..d39d84f
--- /dev/null
@@ -0,0 +1,247 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 (file)
index 0000000..52c1a94
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for converting files between different file formats.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..1353b5b
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A scheduled task to clear up old conversion records.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..662ccc3
--- /dev/null
@@ -0,0 +1,415 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+
+/**
+ * PHPUnit tests for conversion API.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..6f182c6
--- /dev/null
@@ -0,0 +1,926 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+
+/**
+ * PHPUnit tests for fileconverter API.
+ *
+ * @package    core_files
+ * @copyright  2017 Andrew nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+    }
+}
index 9ed6379..b865dff 100644 (file)
@@ -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';
index e1ce048..28aafd6 100644 (file)
@@ -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 <andrew@nicols.co.uk>
+* @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.
  *
index 735f386..009e8a3 100644 (file)
@@ -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();
index f1d9cf8..4a160b0 100644 (file)
@@ -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.
      *
index 8d7e376..6f9917d 100644 (file)
@@ -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 (file)
index 0000000..e7a5bae
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * 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";
+    }
+}
index 1c86d11..905a466 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170207" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170220" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="uq_external_file" UNIQUE="true" FIELDS="referencehash, repositoryid" COMMENT="The combination of repositoryid and reference field is supposed to be a unique identification of an external file. Because the reference is a TEXT field, we can't use to compose the index. So we use the referencehash instead and the file API is responsible to keep it up-to-date"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="file_conversion" COMMENT="Table to track file conversions.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="sourcefileid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="targetformat" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="status" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="statusmessage" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="converter" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="destfileid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="data" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="sourcefileid" TYPE="foreign" FIELDS="sourcefileid" REFTABLE="files" REFFIELDS="id"/>
+        <KEY NAME="destfileid" TYPE="foreign" FIELDS="destfileid" REFTABLE="files" REFFIELDS="id"/>
+      </KEYS>
+    </TABLE>
     <TABLE NAME="repository" COMMENT="This table contains one entry for every configured external repository instance.">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
index f7296c5..c820348 100644 (file)
@@ -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' => '*'
+    ),
 );
index 0dc1482..8e935d0 100644 (file)
@@ -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;
 }
index 51b53df..5ac5435 100644 (file)
@@ -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');
index 1d8ae47..90ddbe0 100644 (file)
@@ -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.