diff --git a/contentbank/classes/contentbank.php b/contentbank/classes/contentbank.php index 8393f6a184a..326e3049d8a 100644 --- a/contentbank/classes/contentbank.php +++ b/contentbank/classes/contentbank.php @@ -24,6 +24,7 @@ namespace core_contentbank; +use core_plugin_manager; use stored_file; use context; @@ -35,6 +36,8 @@ use context; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class contentbank { + /** @var array Enabled content types. */ + private $enabledcontenttypes = null; /** * Obtains the list of core_contentbank_content objects currently active. @@ -44,16 +47,20 @@ class contentbank { * @return string[] Array of contentbank contenttypes. */ public function get_enabled_content_types(): array { + if (!is_null($this->enabledcontenttypes)) { + return $this->enabledcontenttypes; + } + $enabledtypes = \core\plugininfo\contenttype::get_enabled_plugins(); $types = []; foreach ($enabledtypes as $name) { $contenttypeclassname = "\\contenttype_$name\\contenttype"; $contentclassname = "\\contenttype_$name\\content"; if (class_exists($contenttypeclassname) && class_exists($contentclassname)) { - $types[] = $name; + $types[$contenttypeclassname] = $name; } } - return $types; + return $this->enabledcontenttypes = $types; } /** @@ -292,4 +299,37 @@ class contentbank { } return $result; } + + /** + * Get the list of content types that have the requested feature. + * + * @param string $feature Feature code e.g CAN_UPLOAD. + * @param null|\context $context Optional context to check the permission to use the feature. + * @param bool $enabled Whether check only the enabled content types or all of them. + * + * @return string[] List of content types where the user has permission to access the feature. + */ + public function get_contenttypes_with_capability_feature(string $feature, \context $context = null, bool $enabled = true): array { + $contenttypes = []; + // Check enabled content types or all of them. + if ($enabled) { + $contenttypestocheck = $this->get_enabled_content_types(); + } else { + $plugins = core_plugin_manager::instance()->get_plugins_of_type('contenttype'); + foreach ($plugins as $plugin) { + $contenttypeclassname = "\\{$plugin->type}_{$plugin->name}\\contenttype"; + $contenttypestocheck[$contenttypeclassname] = $plugin->name; + } + } + + foreach ($contenttypestocheck as $classname => $name) { + $contenttype = new $classname($context); + // The method names that check the features permissions must follow the pattern can_feature. + if ($contenttype->{"can_$feature"}()) { + $contenttypes[$classname] = $name; + } + } + + return $contenttypes; + } } diff --git a/contentbank/classes/contenttype.php b/contentbank/classes/contenttype.php index e2c1940683c..ba9442be89c 100644 --- a/contentbank/classes/contenttype.php +++ b/contentbank/classes/contenttype.php @@ -41,7 +41,10 @@ abstract class contenttype { /** Plugin implements uploading feature */ const CAN_UPLOAD = 'upload'; - /** @var context This contenttype's context. **/ + /** Plugin implements edition feature */ + const CAN_EDIT = 'edit'; + + /** @var \context This contenttype's context. **/ protected $context = null; /** @@ -59,7 +62,7 @@ abstract class contenttype { /** * Fills content_bank table with appropiate information. * - * @param stdClass $record An optional content record compatible object (default null) + * @param \stdClass $record An optional content record compatible object (default null) * @return content Object with content bank information. */ public function create_content(\stdClass $record = null): ?content { @@ -127,7 +130,7 @@ abstract class contenttype { * This method can be overwritten by the plugins if they need to change some other specific information. * * @param content $content The content to rename. - * @param string $name The name of the content. + * @param string $name The name of the content. * @return boolean true if the content has been renamed; false otherwise. */ public function rename_content(content $content, string $name): bool { @@ -139,7 +142,7 @@ abstract class contenttype { * This method can be overwritten by the plugins if they need to change some other specific information. * * @param content $content The content to rename. - * @param context $context The new context. + * @param \context $context The new context. * @return boolean true if the content has been renamed; false otherwise. */ public function move_content(content $content, \context $context): bool { @@ -325,6 +328,37 @@ abstract class contenttype { return true; } + /** + * Returns whether or not the user has permission to use the editor. + * + * @return bool True if the user can edit content. False otherwise. + */ + final public function can_edit(): bool { + if (!$this->is_feature_supported(self::CAN_EDIT)) { + return false; + } + + if (!$this->can_access()) { + return false; + } + + $classname = 'contenttype/'.$this->get_plugin_name(); + + $editioncap = $classname.':useeditor'; + $hascapabilities = has_all_capabilities(['moodle/contentbank:useeditor', $editioncap], $this->context); + return $hascapabilities && $this->is_edit_allowed(); + } + + /** + * Returns plugin allows edition. + * + * @return bool True if plugin allows edition. False otherwise. + */ + protected function is_edit_allowed(): bool { + // Plugins can overwrite this function to add any check they need. + return true; + } + /** * Returns the plugin supports the feature. * @@ -348,4 +382,17 @@ abstract class contenttype { * @return array */ abstract public function get_manageable_extensions(): array; + + /** + * Returns the list of different types of the given content type. + * + * A content type can have one or more options for creating content. This method will report all of them or only the content + * type itself if it has no other options. + * + * @return array An object for each type: + * - string typename: descriptive name of the type. + * - string typeeditorparams: params required by this content type editor. + * - url typeicon: this type icon. + */ + abstract public function get_contenttype_types(): array; } diff --git a/contentbank/classes/form/edit_content.php b/contentbank/classes/form/edit_content.php new file mode 100644 index 00000000000..e75b7316fa2 --- /dev/null +++ b/contentbank/classes/form/edit_content.php @@ -0,0 +1,90 @@ +. + +/** + * Provides {@see \core_contentbank\form\edit_content} class. + * + * @package core_contentbank + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_contentbank\form; + +use moodleform; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir.'/formslib.php'); + +/** + * Defines the form for editing a content. + * + * @package core_contentbank + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class edit_content extends moodleform { + + /** @var int Context the content belongs to. */ + protected $contextid; + + /** @var string Content type plugin name. */ + protected $plugin; + + /** @var int Content id in the content bank. */ + protected $id; + + /** + * Constructor. + * + * @param string $action The action attribute for the form. + * @param array $customdata Data to set during instance creation. + * @param string $method Form method. + */ + public function __construct(string $action = null, array $customdata = null, string $method = 'post') { + parent::__construct($action, $customdata, $method); + $this->contextid = $customdata['contextid']; + $this->plugin = $customdata['plugin']; + $this->id = $customdata['id']; + + $mform =& $this->_form; + $mform->addElement('hidden', 'contextid', $this->contextid); + $this->_form->setType('contextid', PARAM_INT); + + $mform->addElement('hidden', 'plugin', $this->plugin); + $this->_form->setType('plugin', PARAM_PLUGIN); + + $mform->addElement('hidden', 'id', $this->id); + $this->_form->setType('id', PARAM_INT); + } + + /** + * Overrides formslib's add_action_buttons() method. + * + * + * @param bool $cancel + * @param string|null $submitlabel + * + * @return void + */ + public function add_action_buttons($cancel = true, $submitlabel = null): void { + if (is_null($submitlabel)) { + $submitlabel = get_string('save'); + } + parent::add_action_buttons($cancel, $submitlabel); + } +} diff --git a/contentbank/classes/output/bankcontent.php b/contentbank/classes/output/bankcontent.php index 6574b0409c7..b851222803f 100644 --- a/contentbank/classes/output/bankcontent.php +++ b/contentbank/classes/output/bankcontent.php @@ -98,7 +98,56 @@ class bankcontent implements renderable, templatable { ); } $data->contents = $contentdata; - $data->tools = $this->toolbar; + // The tools are displayed in the action bar on the index page. + foreach ($this->toolbar as $tool) { + // Customize the output of a tool, like dropdowns. + $method = 'export_tool_'.$tool['name']; + if (method_exists($this, $method)) { + $this->$method($tool); + } + $data->tools[] = $tool; + } + return $data; } + + /** + * Adds the content type items to display to the Add dropdown. + * + * Each content type is represented as an object with the properties: + * - name: the name of the content type. + * - baseurl: the base content type editor URL. + * - types: different types of the content type to display as dropdown items. + * + * @param array $tool Data for rendering the Add dropdown, including the editable content types. + */ + private function export_tool_add(array &$tool) { + $editabletypes = $tool['contenttypes']; + + $addoptions = []; + foreach ($editabletypes as $class => $type) { + $contentype = new $class($this->context); + // Get the creation options of each content type. + $types = $contentype->get_contenttype_types(); + if ($types) { + // Add a text describing the content type as first option. This will be displayed in the drop down to + // separate the options for the different content types. + $contentdesc = new stdClass(); + $contentdesc->typename = get_string('description', $contentype->get_contenttype_name()); + array_unshift($types, $contentdesc); + // Context data for the template. + $addcontenttype = new stdClass(); + // Content type name. + $addcontenttype->name = $type; + // Content type editor base URL. + $tool['link']->param('plugin', $type); + $addcontenttype->baseurl = $tool['link']->out(); + // Different types of the content type. + $addcontenttype->types = $types; + $addoptions[] = $addcontenttype; + } + } + + $tool['contenttypes'] = $addoptions; + } } diff --git a/contentbank/classes/output/viewcontent.php b/contentbank/classes/output/viewcontent.php new file mode 100644 index 00000000000..efb403ed6ad --- /dev/null +++ b/contentbank/classes/output/viewcontent.php @@ -0,0 +1,94 @@ +. + +/** + * Class containing data for a content view. + * + * @package core_contentbank + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_contentbank\output; + +use core_contentbank\content; +use core_contentbank\contenttype; +use moodle_url; +use renderable; +use renderer_base; +use stdClass; +use templatable; + +/** + * Class containing data for the content view. + * + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class viewcontent implements renderable, templatable { + /** + * @var contenttype Content bank content type. + */ + private $contenttype; + + /** + * @var stdClass Record of the contentbank_content table. + */ + private $content; + + /** + * Construct this renderable. + * + * @param contenttype $contenttype Content bank content type. + * @param content $content Record of the contentbank_content table. + */ + public function __construct(contenttype $contenttype, content $content) { + $this->contenttype = $contenttype; + $this->content = $content; + } + + /** + * Export this data so it can be used as the context for a mustache template. + * + * @param renderer_base $output + * + * @return stdClass + */ + public function export_for_template(renderer_base $output): stdClass { + $data = new stdClass(); + + // Get the content type html. + $contenthtml = $this->contenttype->get_view_content($this->content); + $data->contenthtml = $contenthtml; + + // Check if the user can edit this content type. + if ($this->contenttype->can_edit()) { + $data->usercanedit = true; + $urlparams = [ + 'contextid' => $this->content->get_contextid(), + 'plugin' => $this->contenttype->get_plugin_name(), + 'id' => $this->content->get_id() + ]; + $editcontenturl = new moodle_url('/contentbank/edit.php', $urlparams); + $data->editcontenturl = $editcontenturl->out(false); + } + + $closeurl = new moodle_url('/contentbank/index.php', ['contextid' => $this->content->get_contextid()]); + $data->closeurl = $closeurl->out(false); + + return $data; + } +} diff --git a/contentbank/contenttype/h5p/classes/contenttype.php b/contentbank/contenttype/h5p/classes/contenttype.php index d48941dfc70..1c1f2ea1df9 100644 --- a/contentbank/contenttype/h5p/classes/contenttype.php +++ b/contentbank/contenttype/h5p/classes/contenttype.php @@ -25,7 +25,11 @@ namespace contenttype_h5p; use core\event\contentbank_content_viewed; -use html_writer; +use stdClass; +use core_h5p\editor_ajax; +use core_h5p\file_storage; +use core_h5p\local\library\autoloader; +use H5PCore; /** * H5P content bank manager class @@ -65,8 +69,7 @@ class contenttype extends \core_contentbank\contenttype { $event->trigger(); $fileurl = $content->get_file_url(); - $html = html_writer::tag('h2', $content->get_name()); - $html .= \core_h5p\player::display($fileurl, new \stdClass(), true); + $html = \core_h5p\player::display($fileurl, new \stdClass(), true); return $html; } @@ -107,7 +110,7 @@ class contenttype extends \core_contentbank\contenttype { * @return array */ protected function get_implemented_features(): array { - return [self::CAN_UPLOAD]; + return [self::CAN_UPLOAD, self::CAN_EDIT]; } /** @@ -127,4 +130,42 @@ class contenttype extends \core_contentbank\contenttype { protected function is_access_allowed(): bool { return true; } + + /** + * Returns the list of different H5P content types the user can create. + * + * @return array An object for each H5P content type: + * - string typename: descriptive name of the H5P content type. + * - string typeeditorparams: params required by the H5P editor. + * - url typeicon: H5P content type icon. + */ + public function get_contenttype_types(): array { + // Get the H5P content types available. + autoloader::register(); + $editorajax = new editor_ajax(); + $h5pcontenttypes = $editorajax->getLatestLibraryVersions(); + + $types = []; + $h5pfilestorage = new file_storage(); + foreach ($h5pcontenttypes as $h5pcontenttype) { + $library = [ + 'name' => $h5pcontenttype->machine_name, + 'majorVersion' => $h5pcontenttype->major_version, + 'minorVersion' => $h5pcontenttype->minor_version, + ]; + $key = H5PCore::libraryToString($library); + $type = new stdClass(); + $type->key = $key; + $type->typename = $h5pcontenttype->title; + $type->typeeditorparams = 'library=' . $key; + $type->typeicon = $h5pfilestorage->get_icon_url( + $h5pcontenttype->id, + $h5pcontenttype->machine_name, + $h5pcontenttype->major_version, + $h5pcontenttype->minor_version); + $types[] = $type; + } + + return $types; + } } diff --git a/contentbank/contenttype/h5p/classes/form/editor.php b/contentbank/contenttype/h5p/classes/form/editor.php new file mode 100644 index 00000000000..b7229b9e50e --- /dev/null +++ b/contentbank/contenttype/h5p/classes/form/editor.php @@ -0,0 +1,152 @@ +. + +/** + * Provides the class that defines the form for the H5P authoring tool. + * + * @package contenttype_h5p + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace contenttype_h5p\form; + +use contenttype_h5p\content; +use contenttype_h5p\contenttype; +use core_contentbank\form\edit_content; +use core_h5p\api; +use core_h5p\editor as h5peditor; +use core_h5p\factory; +use stdClass; + +/** + * Defines the form for editing an H5P content. + * + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class editor extends edit_content { + + /** @var $h5peditor H5P editor object */ + private $h5peditor; + + /** @var $content The content being edited */ + private $content; + + /** + * Defines the form fields. + */ + protected function definition() { + global $DB; + + $mform = $this->_form; + + // Id of the content to edit. + $id = $this->_customdata['id']; + // H5P content type to create. + $library = optional_param('library', null, PARAM_TEXT); + + if (empty($id) && empty($library)) { + $returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $this->_customdata['contextid']]); + print_error('invalidcontentid', 'error', $returnurl); + } + + $this->h5peditor = new h5peditor(); + + if ($id) { + // The H5P editor needs the H5P content id (h5p table). + $record = $DB->get_record('contentbank_content', ['id' => $id]); + $this->content = new content($record); + $file = $this->content->get_file(); + + $h5p = api::get_content_from_pathnamehash($file->get_pathnamehash()); + $mform->addElement('hidden', 'h5pid', $h5p->id); + $mform->setType('h5pid', PARAM_INT); + $this->h5peditor->set_content($h5p->id); + } else { + // The H5P editor needs the H5P content type library name for a new content. + $mform->addElement('hidden', 'library', $library); + $mform->setType('library', PARAM_TEXT); + $this->h5peditor->set_library($library, $this->_customdata['contextid'], 'contentbank', 'public'); + } + + $mformid = 'coolh5peditor'; + $mform->setAttributes(array('id' => $mformid) + $mform->getAttributes()); + + $this->add_action_buttons(); + + $this->h5peditor->add_editor_to_form($mform); + + $this->add_action_buttons(); + } + + /** + * Modify or create an H5P content from the form data. + * + * @param stdClass $data Form data to create or modify an H5P content. + * + * @return int The id of the edited or created content. + */ + public function save_content(stdClass $data): int { + global $DB; + + // The H5P libraries expect data->id as the H5P content id. + // The method \H5PCore::saveContent throws an error if id is set but empty. + if (empty($data->id)) { + unset($data->id); + } else { + // The H5P libraries save in $data->id the H5P content id (h5p table), so the content id is saved in another var. + $contentid = $data->id; + } + + $h5pcontentid = $this->h5peditor->save_content($data); + + $factory = new factory(); + $h5pfs = $factory->get_framework(); + + // Needs the H5P file id to create or update the content bank record. + $h5pcontent = $h5pfs->loadContent($h5pcontentid); + $fs = get_file_storage(); + $file = $fs->get_file_by_hash($h5pcontent['pathnamehash']); + // Creating new content. + if (!isset($data->h5pid)) { + // The initial name of the content is the title of the H5P content. + $cbrecord = new stdClass(); + $cbrecord->name = json_decode($data->h5pparams)->metadata->title; + $context = \context::instance_by_id($data->contextid, MUST_EXIST); + // Create entry in content bank. + $contenttype = new contenttype($context); + $newcontent = $contenttype->create_content($cbrecord); + if ($file && $newcontent) { + $updatedfilerecord = new stdClass(); + $updatedfilerecord->id = $file->get_id(); + $updatedfilerecord->itemid = $newcontent->get_id(); + // As itemid changed, the pathnamehash has to be updated in the file table. + $pathnamehash = \file_storage::get_pathname_hash($file->get_contextid(), $file->get_component(), + $file->get_filearea(), $updatedfilerecord->itemid, $file->get_filepath(), $file->get_filename()); + $updatedfilerecord->pathnamehash = $pathnamehash; + $DB->update_record('files', $updatedfilerecord); + // The pathnamehash in the h5p table must match the file pathnamehash. + $h5pfs->updateContentFields($h5pcontentid, ['pathnamehash' => $pathnamehash]); + } + } else { + // Update content. + $this->content->update_content(); + } + + return $contentid ?? $newcontent->get_id(); + } +} diff --git a/contentbank/contenttype/h5p/db/access.php b/contentbank/contenttype/h5p/db/access.php index 95db4aad8a2..97076ae751c 100644 --- a/contentbank/contenttype/h5p/db/access.php +++ b/contentbank/contenttype/h5p/db/access.php @@ -44,4 +44,14 @@ $capabilities = [ 'editingteacher' => CAP_ALLOW, ] ], + 'contenttype/h5p:useeditor' => [ + 'riskbitmask' => RISK_SPAM, + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'manager' => CAP_ALLOW, + 'coursecreator' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + ] + ], ]; diff --git a/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php b/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php index 179e80f2612..7e2e49e78ff 100644 --- a/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php +++ b/contentbank/contenttype/h5p/lang/en/contenttype_h5p.php @@ -22,8 +22,10 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['description'] = 'H5P Interactive Content'; $string['pluginname'] = 'H5P'; $string['pluginname_help'] = 'Content bank to upload and share H5P content'; $string['privacy:metadata'] = 'The H5P content bank plugin does not store any personal data.'; $string['h5p:access'] = 'Access H5P content in the content bank'; $string['h5p:upload'] = 'Upload new H5P content'; +$string['h5p:useeditor'] = 'Create or edit content using the H5P editor'; diff --git a/contentbank/contenttype/h5p/version.php b/contentbank/contenttype/h5p/version.php index 548ef1345c1..fe0e68be844 100644 --- a/contentbank/contenttype/h5p/version.php +++ b/contentbank/contenttype/h5p/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2020041500.00; // The current plugin version (Date: YYYYMMDDXX) +$plugin->version = 2020051500.01; // The current plugin version (Date: YYYYMMDDXX) $plugin->requires = 2020041500.00; // Requires this Moodle version $plugin->component = 'contenttype_h5p'; // Full name of the plugin (used for diagnostics). diff --git a/contentbank/edit.php b/contentbank/edit.php new file mode 100644 index 00000000000..832f1c2148d --- /dev/null +++ b/contentbank/edit.php @@ -0,0 +1,110 @@ +. + +/** + * Create or update contents through the specific content type editor + * + * @package core_contentbank + * @copyright 2020 Victor Deniz + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require('../config.php'); + +require_login(); + +$contextid = required_param('contextid', PARAM_INT); +$pluginname = required_param('plugin', PARAM_PLUGIN); +$id = optional_param('id', null, PARAM_INT); +$context = context::instance_by_id($contextid, MUST_EXIST); +require_capability('moodle/contentbank:access', $context); + +$returnurl = new \moodle_url('/contentbank/view.php', ['id' => $id]); + +if (!empty($id)) { + $record = $DB->get_record('contentbank_content', ['id' => $id], '*', MUST_EXIST); + $contentclass = "$record->contenttype\\content"; + $content = new $contentclass($record); + // Set the heading title. + $heading = $content->get_name(); + // The content type of the content overwrites the pluginname param value. + $contenttypename = $content->get_content_type(); +} else { + $contenttypename = "contenttype_$pluginname"; + $heading = get_string('addinganew', 'moodle', get_string('description', $contenttypename)); +} + +// Check plugin is enabled. +$plugin = core_plugin_manager::instance()->get_plugin_info($contenttypename); +if (!$plugin || !$plugin->is_enabled()) { + print_error('unsupported', 'core_contentbank', $returnurl); +} + +// Create content type instance. +$contenttypeclass = "$contenttypename\\contenttype"; +if (class_exists($contenttypeclass)) { + $contenttype = new $contenttypeclass($context); +} else { + print_error('unsupported', 'core_contentbank', $returnurl); +} + +// Checks the user can edit this content type. +if (!$contenttype->can_edit()) { + print_error('contenttypenoedit', 'core_contentbank', $returnurl, $contenttype->get_plugin_name()); +} + +$values = [ + 'contextid' => $contextid, + 'plugin' => $pluginname, + 'id' => $id +]; + +$title = get_string('contentbank'); +\core_contentbank\helper::get_page_ready($context, $title, true); +if ($PAGE->course) { + require_login($PAGE->course->id); +} + +$PAGE->set_url(new \moodle_url('/contentbank/edit.php', $values)); +$PAGE->set_context($context); +$PAGE->navbar->add(get_string('edit')); +$PAGE->set_title($title); + +$PAGE->set_heading($heading); + +// Instantiate the content type form. +$editorclass = "$contenttypename\\form\\editor"; +if (!class_exists($editorclass)) { + print_error('noformdesc'); +} + +$editorform = new $editorclass(null, $values); + +if ($editorform->is_cancelled()) { + if (empty($id)) { + $returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $context->id]); + } + redirect($returnurl); +} else if ($data = $editorform->get_data()) { + $id = $editorform->save_content($data); + // Just in case we've created a new content. + $returnurl->param('id', $id); + redirect($returnurl); +} + +echo $OUTPUT->header(); +$editorform->display(); +echo $OUTPUT->footer(); diff --git a/contentbank/index.php b/contentbank/index.php index 3b15b1e9699..e4bb6d63515 100644 --- a/contentbank/index.php +++ b/contentbank/index.php @@ -62,6 +62,19 @@ $foldercontents = $cb->search_contents($search, $contextid, $contenttypes); // Get the toolbar ready. $toolbar = array (); + +// Place the Add button in the toolbar. +if (has_capability('moodle/contentbank:useeditor', $context)) { + // Get the content types for which the user can use an editor. + $editabletypes = $cb->get_contenttypes_with_capability_feature(\core_contentbank\contenttype::CAN_EDIT, $context); + if (!empty($editabletypes)) { + // Editor base URL. + $editbaseurl = new moodle_url('/contentbank/edit.php', ['contextid' => $contextid]); + $toolbar[] = ['name' => get_string('add'), 'link' => $editbaseurl, 'dropdown' => true, 'contenttypes' => $editabletypes]; + } +} + +// Place the Upload button in the toolbar. if (has_capability('moodle/contentbank:upload', $context)) { // Don' show upload button if there's no plugin to support any file extension. $accepted = $cb->get_supported_extensions_as_string($context); diff --git a/contentbank/templates/bankcontent.mustache b/contentbank/templates/bankcontent.mustache index 0826a65e482..020b9052c7d 100644 --- a/contentbank/templates/bankcontent.mustache +++ b/contentbank/templates/bankcontent.mustache @@ -15,7 +15,7 @@ along with Moodle. If not, see . }} {{! - @template core_contentbank/list + @template core_contentbank/bankcontent Example context (json): { @@ -32,10 +32,36 @@ }, { "name": "resume.pdf", + "title": "resume", + "timemodified": 1589792039, + "size": "699.3KB", + "bytes": 716126, + "type": "Archive (PDF)", "icon": "http://something/theme/image.php/boost/core/1584597850/f/pdf-64" } ], "tools": [ + { + "name": "Add", + "dropdown": true, + "link": "http://something/contentbank/edit.php?contextid=1", + "contenttypes": [ + { + "name": "H5P Interactive Content", + "baseurl": "http://something/contentbank/edit.php?contextid=1&plugin=h5p", + "types": [ + { + "typename": "H5P Interactive Content" + }, + { + "typename": "Accordion", + "typeeditorparams": "library=Accordion-1.4", + "typeicon": "http://something/pluginfile.php/1/core_h5p/libraries/13/H5P.Accordion-1.4/icon.svg" + } + ] + } + ] + }, { "name": "Upload", "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p", diff --git a/contentbank/templates/bankcontent/toolbar.mustache b/contentbank/templates/bankcontent/toolbar.mustache index 04c762a1320..242fa1af3d6 100644 --- a/contentbank/templates/bankcontent/toolbar.mustache +++ b/contentbank/templates/bankcontent/toolbar.mustache @@ -20,6 +20,27 @@ Example context (json): { "tools": [ + { + "name": "Add", + "dropdown": true, + "link": "http://something/contentbank/edit.php?contextid=1", + "contenttypes": [ + { + "name": "h5p", + "baseurl": "http://something/contentbank/edit.php?contextid=1&plugin=h5p", + "types": [ + { + "typename": "H5P Interactive Content" + }, + { + "typename": "Accordion", + "typeeditorparams": "library=Accordion-1.4", + "typeicon": "http://something/pluginfile.php/1/core_h5p/libraries/13/H5P.Accordion-1.4/icon.svg" + } + ] + } + ] + }, { "name": "Upload", "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p", @@ -34,9 +55,14 @@ }} {{#tools}} - - {{#pix}} {{{ icon }}} {{/pix}} {{{ name }}} - + {{#dropdown}} + {{>core_contentbank/bankcontent/toolbar_dropdown}} + {{/dropdown}} + {{^dropdown}} + + {{#pix}} {{{ icon }}} {{/pix}} {{{ name }}} + + {{/dropdown}} {{/tools}} \ No newline at end of file + diff --git a/contentbank/templates/bankcontent/toolbar_dropdown.mustache b/contentbank/templates/bankcontent/toolbar_dropdown.mustache new file mode 100644 index 00000000000..7a2fbf58ecd --- /dev/null +++ b/contentbank/templates/bankcontent/toolbar_dropdown.mustache @@ -0,0 +1,64 @@ +{{! + 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 . +}} +{{! + @template core_contentbank/bankcontent/toolbar_dropdown + + Example context (json): + { + "name": "Add", + "dropdown": true, + "link": "http://something/contentbank/edit.php?contextid=1", + "contenttypes": [ + { + "name": "h5p", + "baseurl": "http://something/contentbank/edit.php?contextid=1&plugin=h5p", + "types": [ + { + "typename": "H5P Interactive Content" + }, + { + "typename": "Accordion", + "typeeditorparams": "library=Accordion-1.4", + "typeicon": "http://something/pluginfile.php/1/core_h5p/libraries/13/H5P.Accordion-1.4/icon.svg" + } + ] + } + ] + } + +}} +
+ + +
diff --git a/contentbank/templates/viewcontent.mustache b/contentbank/templates/viewcontent.mustache new file mode 100644 index 00000000000..7c7d5c0203b --- /dev/null +++ b/contentbank/templates/viewcontent.mustache @@ -0,0 +1,52 @@ +{{! + 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 comments. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_contentbank/view_content + + View content page. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * contenthtml - string - content html. + * usercanedit - boolean - whether the user has permission to edit the content. + * editcontenturl - string - edit page URL. + * closeurl - string - close landing page. + + Example context (json): + { + "contenthtml" : "", + "usercanedit" : true, + "editcontenturl" : "http://something/contentbank/edit.php?contextid=1&plugin=h5p&id=1", + "closeurl" : "http://moodle.test/h5pcb/moodle/contentbank/index.php" + } +}} +
+
+ {{>core_contentbank/viewcontent/toolbarview}} +
+
+ {{{ contenthtml }}} +
+
+ {{>core_contentbank/viewcontent/toolbarview}} +
+
diff --git a/contentbank/templates/viewcontent/toolbarview.mustache b/contentbank/templates/viewcontent/toolbarview.mustache new file mode 100644 index 00000000000..25eaaa50b41 --- /dev/null +++ b/contentbank/templates/viewcontent/toolbarview.mustache @@ -0,0 +1,50 @@ +{{! +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 comments. + +You should have received a copy of the GNU General Public License +along with Moodle. If not, see . +}} +{{! + @template core_contentbank/viewcontent/toolbarview + + Contentbank view toolbar. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * contenthtml - string - content html. + * usercanedit - boolean - whether the user has permission to edit the content. + * editcontenturl - string - edit page URL. + * closeurl - string - close landing page. + + Example context (json): + { + "usercanedit" : true, + "editcontenturl" : "http://something/contentbank/edit.php?contextid=1&plugin=h5p&id=1", + "closeurl" : "http://moodle.test/h5pcb/moodle/contentbank/index.php" + } +}} +{{#usercanedit}} + +{{/usercanedit}} diff --git a/contentbank/tests/behat/edit_content.feature b/contentbank/tests/behat/edit_content.feature new file mode 100644 index 00000000000..713768c1f5a --- /dev/null +++ b/contentbank/tests/behat/edit_content.feature @@ -0,0 +1,99 @@ +@core @core_contentbank @contentbank_h5p @_file_upload @javascript +Feature: Content bank use editor feature + In order to add/edit content + As a user + I need to be able to access the edition options + + Background: + Given I log in as "admin" + And I am on site homepage + And I turn editing mode on + And I add the "Navigation" block if not present + And I configure the "Navigation" block + And I set the following fields to these values: + | Page contexts | Display throughout the entire site | + And I press "Save changes" + + Scenario: Users see the Add button disabled if there is no content type available for creation + Given I click on "Site pages" "list_item" in the "Navigation" "block" + When I click on "Content bank" "link" + Then the "[data-action=Add-content]" "css_element" should be disabled + + Scenario: Users can see the Add button if there is content type available for creation + Given I follow "Dashboard" in the user menu + And I follow "Manage private files..." + And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager + And I click on "Save changes" "button" + And I click on "Site pages" "list_item" in the "Navigation" "block" + And I click on "Content bank" "link" in the "Navigation" "block" + And I click on "Upload" "link" + And I click on "Choose a file..." "button" + And I click on "Private files" "link" in the ".fp-repo-area" "css_element" + And I click on "filltheblanks.h5p" "link" + And I click on "Select this file" "button" + And I click on "Save changes" "button" + When I click on "Content bank" "link" + And I click on "filltheblanks.h5p" "link" + And I click on "Close" "link" + Then I click on "[data-action=Add-content]" "css_element" + And I should see "Fill in the Blanks" + + Scenario: Users can edit content if they have the required permission + Given I follow "Dashboard" in the user menu + And I follow "Manage private files..." + And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager + And I click on "Save changes" "button" + And I click on "Site pages" "list_item" in the "Navigation" "block" + And I click on "Content bank" "link" in the "Navigation" "block" + And I click on "Upload" "link" + And I click on "Choose a file..." "button" + And I click on "Private files" "link" in the ".fp-repo-area" "css_element" + And I click on "filltheblanks.h5p" "link" + And I click on "Select this file" "button" + And I click on "Save changes" "button" + When I click on "Content bank" "link" + And I click on "filltheblanks.h5p" "link" + Then I click on "Edit" "link" + And I switch to "h5p-editor-iframe" class iframe + And I switch to the main frame + And I click on "Cancel" "button" + And I should see "filltheblanks.h5p" in the "h1" "css_element" + + Scenario: Users can create new content if they have the required permission + Given I navigate to "H5P > Manage H5P content types" in site administration + And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager + And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element" + And I should see "H5P content types uploaded successfully" + And I click on "Site pages" "list_item" in the "Navigation" "block" + When I click on "Content bank" "link" in the "Navigation" "block" + And I click on "[data-action=Add-content]" "css_element" + Then I click on "Fill in the Blanks" "link" + And I switch to "h5p-editor-iframe" class iframe + And I switch to the main frame + And I click on "Cancel" "button" + + Scenario: Users can't edit content if they don't have the required permission + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | user1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And I navigate to "H5P > Manage H5P content types" in site administration + And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager + And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element" + And I should see "H5P content types uploaded successfully" + And I log out + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I click on "Site pages" "list_item" in the "Navigation" "block" + And I click on "Content bank" "link" + And "[data-action=Add-content]" "css_element" should exist + When the following "permission overrides" exist: + | capability | permission | role | contextlevel | reference | + | moodle/contentbank:useeditor | Prohibit | editingteacher | System | | + And I reload the page + Then "[data-action=Add-content]" "css_element" should not exist diff --git a/contentbank/tests/contentbank_test.php b/contentbank/tests/contentbank_test.php index ebb688813df..d347a72c276 100644 --- a/contentbank/tests/contentbank_test.php +++ b/contentbank/tests/contentbank_test.php @@ -507,4 +507,100 @@ class core_contentbank_testcase extends advanced_testcase { // Check there's no error when trying to move content context from an empty content bank. $this->assertTrue($cb->delete_contents($systemcontext, $coursecontext)); } + + /** + * Data provider for get_contenttypes_with_capability_feature. + * + * @return array + */ + public function get_contenttypes_with_capability_feature_provider(): array { + return [ + 'no-contenttypes_enabled' => [ + 'contenttypesenabled' => [], + 'contenttypescanfeature' => [], + ], + 'contenttype_enabled_noeditable' => [ + 'contenttypesenabled' => ['testable'], + 'contenttypescanfeature' => [], + ], + 'contenttype_enabled_editable' => [ + 'contenttypesenabled' => ['testable'], + 'contenttypescanfeature' => ['testable'], + ], + 'no-contenttype_enabled_editable' => [ + 'contenttypesenabled' => [], + 'contenttypescanfeature' => ['testable'], + ], + ]; + } + + /** + * Tests for get_contenttypes_with_capability_feature() function. + * + * @dataProvider get_contenttypes_with_capability_feature_provider + * @param array $contenttypesenabled Content types enabled. + * @param array $contenttypescanfeature Content types the user has the permission to use the feature. + * + * @covers ::get_contenttypes_with_capability_feature + */ + public function test_get_contenttypes_with_capability_feature(array $contenttypesenabled, array $contenttypescanfeature): void { + $this->resetAfterTest(); + + $cb = new contentbank(); + + $plugins = []; + + // Content types not enabled where the user has permission to use a feature. + if (empty($contenttypesenabled) && !empty($contenttypescanfeature)) { + $enabled = false; + + // Mock core_plugin_manager class and the method get_plugins_of_type. + $pluginmanager = $this->getMockBuilder(\core_plugin_manager::class) + ->disableOriginalConstructor() + ->setMethods(['get_plugins_of_type']) + ->getMock(); + + // Replace protected singletoninstance reference (core_plugin_manager property) with mock object. + $ref = new \ReflectionProperty(\core_plugin_manager::class, 'singletoninstance'); + $ref->setAccessible(true); + $ref->setValue(null, $pluginmanager); + + // Return values of get_plugins_of_type method. + foreach ($contenttypescanfeature as $contenttypepluginname) { + $contenttypeplugin = new \stdClass(); + $contenttypeplugin->name = $contenttypepluginname; + $contenttypeplugin->type = 'contenttype'; + // Add the feature to the fake content type. + $classname = "\\contenttype_$contenttypepluginname\\contenttype"; + $classname::$featurestotest = ['test2']; + $plugins[] = $contenttypeplugin; + } + + // Set expectations and return values. + $pluginmanager->expects($this->once()) + ->method('get_plugins_of_type') + ->with('contenttype') + ->willReturn($plugins); + } else { + $enabled = true; + // Get access to private property enabledcontenttypes. + $rc = new \ReflectionClass(\core_contentbank\contentbank::class); + $rcp = $rc->getProperty('enabledcontenttypes'); + $rcp->setAccessible(true); + + foreach ($contenttypesenabled as $contenttypename) { + $plugins["\\contenttype_$contenttypename\\contenttype"] = $contenttypename; + // Add to the testable contenttype the feature to test. + if (in_array($contenttypename, $contenttypescanfeature)) { + $classname = "\\contenttype_$contenttypename\\contenttype"; + $classname::$featurestotest = ['test2']; + } + } + // Set as enabled content types only those in the test. + $rcp->setValue($cb, $plugins); + } + + $actual = $cb->get_contenttypes_with_capability_feature('test2', null, $enabled); + $this->assertEquals($contenttypescanfeature, array_values($actual)); + } } diff --git a/contentbank/tests/fixtures/testable_contenttype.php b/contentbank/tests/fixtures/testable_contenttype.php index a70bccb441b..2a5411d5455 100644 --- a/contentbank/tests/fixtures/testable_contenttype.php +++ b/contentbank/tests/fixtures/testable_contenttype.php @@ -37,6 +37,9 @@ class contenttype extends \core_contentbank\contenttype { /** Feature for testing */ const CAN_TEST = 'test'; + /** @var array Additional features for testing */ + public static $featurestotest; + /** * Returns the HTML code to render the icon for content bank contents. * @@ -55,7 +58,13 @@ class contenttype extends \core_contentbank\contenttype { * @return array */ protected function get_implemented_features(): array { - return [self::CAN_TEST]; + $features = [self::CAN_TEST]; + + if (!empty(self::$featurestotest)) { + $features = array_merge($features, self::$featurestotest); + } + + return $features; } /** @@ -66,4 +75,29 @@ class contenttype extends \core_contentbank\contenttype { public function get_manageable_extensions(): array { return ['.txt', '.png', '.h5p']; } + + /** + * Returns the list of different types of the given content type. + * + * @return array + */ + public function get_contenttype_types(): array { + $type = new \stdClass(); + $type->typename = 'testable'; + + return [$type]; + } + + /** + * Returns true, so the user has permission on the feature. + * + * @return bool True if content could be edited or created. False otherwise. + */ + final public function can_test2(): bool { + if (!$this->is_feature_supported('test2')) { + return false; + } + + return true; + } } diff --git a/contentbank/view.php b/contentbank/view.php index c95d7fd72f3..1cf7500dd97 100644 --- a/contentbank/view.php +++ b/contentbank/view.php @@ -53,7 +53,7 @@ if ($PAGE->course) { $PAGE->set_url(new \moodle_url('/contentbank/view.php', ['id' => $id])); $PAGE->set_context($context); $PAGE->navbar->add($record->name); -$PAGE->set_heading($title); +$PAGE->set_heading($record->name); $title .= ": ".$record->name; $PAGE->set_title($title); $PAGE->set_pagetype('contenbank'); @@ -109,7 +109,6 @@ $PAGE->add_header_action(html_writer::div( )); echo $OUTPUT->header(); -echo $OUTPUT->box_start('generalbox'); // If needed, display notifications. if ($errormsg !== '') { @@ -118,8 +117,11 @@ if ($errormsg !== '') { echo $OUTPUT->notification($statusmsg, 'notifysuccess'); } if ($contenttype->can_access()) { - echo $contenttype->get_view_content($content); + $viewcontent = new core_contentbank\output\viewcontent($contenttype, $content); + echo $OUTPUT->render($viewcontent); +} else { + $message = get_string('contenttypenoaccess', 'core_contentbank', $record->contenttype); + echo $OUTPUT->notification($message, 'error'); } -echo $OUTPUT->box_end(); echo $OUTPUT->footer(); diff --git a/lang/en/contentbank.php b/lang/en/contentbank.php index 55108d356fb..ea7fcebbffb 100644 --- a/lang/en/contentbank.php +++ b/lang/en/contentbank.php @@ -24,12 +24,15 @@ $string['author'] = 'Author'; $string['contentbank'] = 'Content bank'; +$string['close'] = 'Close'; $string['contentdeleted'] = 'The content has been deleted.'; $string['contentname'] = 'Content name'; $string['contentnotdeleted'] = 'An error was encountered while trying to delete the content.'; $string['contentnotrenamed'] = 'An error was encountered while trying to rename the content.'; $string['contentrenamed'] = 'The content has been renamed.'; $string['contentsmoved'] = 'Content bank contents moved to {$a}.'; +$string['contenttypenoaccess'] = 'You can not view this {$a} instance'; +$string['contenttypenoedit'] = 'You can not edit contents of the {$a} content type'; $string['eventcontentcreated'] = 'Content created'; $string['eventcontentdeleted'] = 'Content deleted'; $string['eventcontentupdated'] = 'Content updated'; @@ -45,6 +48,7 @@ $string['file_help'] = 'Files may be stored in the content bank for use in cours $string['itemsfound'] = '{$a} items found'; $string['lastmodified'] = 'Last modified'; $string['name'] = 'Content'; +$string['nocontenttypes'] = 'No content types available'; $string['nopermissiontodelete'] = 'You do not have permission to delete content.'; $string['nopermissiontomanage'] = 'You do not have permission to manage content.'; $string['privacy:metadata:content:contenttype'] = 'The contenttype plugin of the content in the content bank.'; diff --git a/lang/en/role.php b/lang/en/role.php index 21530dead6c..3095d567e31 100644 --- a/lang/en/role.php +++ b/lang/en/role.php @@ -156,6 +156,7 @@ $string['contentbank:deleteowncontent'] = 'Delete content from own content bank' $string['contentbank:manageanycontent'] = 'Manage any content from the content bank (rename, move, publish, share, etc.)'; $string['contentbank:manageowncontent'] = 'Manage content from own content bank (rename, move, publish, share, etc.)'; $string['contentbank:upload'] = 'Upload new content in the content bank'; +$string['contentbank:useeditor'] = 'Create or edit content using a content type editor'; $string['context'] = 'Context'; $string['course:activityvisibility'] = 'Hide/show activities'; $string['course:bulkmessaging'] = 'Send a message to many people'; diff --git a/lib/db/access.php b/lib/db/access.php index 0db35e1afd4..0ae1c2a15dd 100644 --- a/lib/db/access.php +++ b/lib/db/access.php @@ -2544,4 +2544,16 @@ $capabilities = array( 'editingteacher' => CAP_ALLOW, ) ], + + // Allow users to create/edit content within the content bank. + 'moodle/contentbank:useeditor' => [ + 'riskbitmask' => RISK_SPAM, + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => array( + 'manager' => CAP_ALLOW, + 'coursecreator' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + ) + ], ); diff --git a/theme/boost/scss/moodle/contentbank.scss b/theme/boost/scss/moodle/contentbank.scss index 49e47bc6217..0eea8025f4c 100644 --- a/theme/boost/scss/moodle/contentbank.scss +++ b/theme/boost/scss/moodle/contentbank.scss @@ -120,4 +120,9 @@ } } } +} + +.cb-toolbar .dropdown-scrollable { + max-height: 190px; + overflow-y: auto; } \ No newline at end of file diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 7f75b4cc4f8..d2661e5d9a0 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -13031,6 +13031,10 @@ table.calendartable caption { .content-bank-container.view-list .cb-btnsort.dir-desc .desc { display: block; } +.cb-toolbar .dropdown-scrollable { + max-height: 190px; + overflow-y: auto; } + /* course.less */ /* COURSE CONTENT */ .section_add_menus { diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index d89bccb974e..862c96013b3 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -13246,6 +13246,10 @@ table.calendartable caption { .content-bank-container.view-list .cb-btnsort.dir-desc .desc { display: block; } +.cb-toolbar .dropdown-scrollable { + max-height: 190px; + overflow-y: auto; } + /* course.less */ /* COURSE CONTENT */ .section_add_menus { diff --git a/version.php b/version.php index 775c6244565..d1a9b53a7e4 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2020052700.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2020052700.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '3.9dev+ (Build: 20200527)'; // Human-friendly version name