Merge branch 'MDL-68963-master' of git://github.com/bmbrands/moodle
[moodle.git] / h5p / classes / editor.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * H5P editor class.
19  *
20  * @package    core_h5p
21  * @copyright  2020 Victor Deniz <victor@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_h5p;
27 use core_h5p\local\library\autoloader;
28 use core_h5p\output\h5peditor as editor_renderer;
29 use H5PCore;
30 use H5peditor;
31 use stdClass;
32 use coding_exception;
33 use MoodleQuickForm;
35 defined('MOODLE_INTERNAL') || die();
37 /**
38  * H5P editor class, for editing local H5P content.
39  *
40  * @package    core_h5p
41  * @copyright  2020 Victor Deniz <victor@moodle.com>
42  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  */
44 class editor {
46     /**
47      * @var core The H5PCore object.
48      */
49     private $core;
51     /**
52      * @var H5peditor $h5peditor The H5P Editor object.
53      */
54     private $h5peditor;
56     /**
57      * @var int Id of the H5P content from the h5p table.
58      */
59     private $id = null;
61     /**
62      * @var array Existing H5P content instance before edition.
63      */
64     private $oldcontent = null;
66     /**
67      * @var stored_file File of ane existing H5P content before edition.
68      */
69     private $oldfile = null;
71     /**
72      * @var array File area to save the file of a new H5P content.
73      */
74     private $filearea = null;
76     /**
77      * @var string H5P Library name
78      */
79     private $library = null;
81     /**
82      * Inits the H5P editor.
83      */
84     public function __construct() {
85         autoloader::register();
87         $factory = new factory();
88         $this->h5peditor = $factory->get_editor();
89         $this->core = $factory->get_core();
90     }
92     /**
93      * Loads an existing content for edition.
94      *
95      * If the H5P content or its file can't be retrieved, it is not possible to edit the content.
96      *
97      * @param int $id Id of the H5P content from the h5p table.
98      *
99      * @return void
100      */
101     public function set_content(int $id): void {
102         $this->id = $id;
104         // Load the present content.
105         $this->oldcontent = $this->core->loadContent($id);
106         if ($this->oldcontent === null) {
107             print_error('invalidelementid');
108         }
110         // Identify the content type library.
111         $this->library = H5PCore::libraryToString($this->oldcontent['library']);
113         // Get current file and its file area.
114         $pathnamehash = $this->oldcontent['pathnamehash'];
115         $fs = get_file_storage();
116         $oldfile = $fs->get_file_by_hash($pathnamehash);
117         if (!$oldfile) {
118             print_error('invalidelementid');
119         }
120         $this->set_filearea(
121             $oldfile->get_contextid(),
122             $oldfile->get_component(),
123             $oldfile->get_filearea(),
124             $oldfile->get_itemid(),
125             $oldfile->get_filepath(),
126             $oldfile->get_filename(),
127             $oldfile->get_userid()
128         );
129         $this->oldfile = $oldfile;
130     }
132     /**
133      * Sets the content type library and the file area to create a new H5P content.
134      *
135      * Note: this method must be used to create new content, to edit an existing
136      * H5P content use only set_content with the ID from the H5P table.
137      *
138      * @param string $library Library of the H5P content type to create.
139      * @param int $contextid Context where the file of the H5P content will be stored.
140      * @param string $component Component where the file of the H5P content will be stored.
141      * @param string $filearea File area where the file of the H5P content will be stored.
142      * @param int $itemid Item id file of the H5P content.
143      * @param string $filepath File path where the file of the H5P content will be stored.
144      * @param null|string $filename H5P content file name.
145      * @param null|int $userid H5P content file owner userid (default will use $USER->id).
146      *
147      * @return void
148      */
149     public function set_library(string $library, int $contextid, string $component, string $filearea,
150             ?int $itemid = 0, string $filepath = '/', ?string $filename = null, ?int $userid = null): void {
152         $this->library = $library;
153         $this->set_filearea($contextid, $component, $filearea, $itemid, $filepath, $filename, $userid);
154     }
156     /**
157      * Sets the Moodle file area where the file of a new H5P content will be stored.
158      *
159      * @param int $contextid Context where the file of the H5P content will be stored.
160      * @param string $component Component where the file of the H5P content will be stored.
161      * @param string $filearea File area where the file of the H5P content will be stored.
162      * @param int $itemid Item id file of the H5P content.
163      * @param string $filepath File path where the file of the H5P content will be stored.
164      * @param null|string $filename H5P content file name.
165      * @param null|int $userid H5P content file owner userid (default will use $USER->id).
166      *
167      * @return void
168      */
169     private function set_filearea(int $contextid, string $component, string $filearea,
170             int $itemid, string $filepath = '/', ?string $filename = null, ?int $userid = null): void {
171         global $USER;
173         $this->filearea = [
174             'contextid' => $contextid,
175             'component' => $component,
176             'filearea' => $filearea,
177             'itemid' => $itemid,
178             'filepath' => $filepath,
179             'filename' => $filename,
180             'userid' => $userid ?? $USER->id,
181         ];
182     }
184     /**
185      * Adds an H5P editor to a form.
186      *
187      * @param MoodleQuickForm $mform Moodle Quick Form
188      *
189      * @return void
190      */
191     public function add_editor_to_form(MoodleQuickForm $mform): void {
192         global $PAGE;
194         $this->add_assets_to_page();
196         $data = $this->data_preprocessing();
198         // Hidden fields used bu H5P editor.
199         $mform->addElement('hidden', 'h5plibrary', $data->h5plibrary);
200         $mform->setType('h5plibrary', PARAM_RAW);
202         $mform->addElement('hidden', 'h5pparams', $data->h5pparams);
203         $mform->setType('h5pparams', PARAM_RAW);
205         $mform->addElement('hidden', 'h5paction');
206         $mform->setType('h5paction', PARAM_ALPHANUMEXT);
208         // Render H5P editor.
209         $ui = new editor_renderer($data);
210         $editorhtml = $PAGE->get_renderer('core_h5p')->render($ui);
211         $mform->addElement('html', $editorhtml);
212     }
214     /**
215      * Creates or updates an H5P content.
216      *
217      * @param stdClass $content Object containing all the necessary data.
218      *
219      * @return int Content id
220      */
221     public function save_content(stdClass $content): int {
223         if (empty($content->h5pparams)) {
224             throw new coding_exception('Missing H5P params.');
225         }
227         if (!isset($content->h5plibrary)) {
228             throw new coding_exception('Missing H5P library.');
229         }
231         $content->params = $content->h5pparams;
233         if (!empty($this->oldcontent)) {
234             $content->id = $this->oldcontent['id'];
235             // Get old parameters for comparison.
236             $oldparams = json_decode($this->oldcontent['params']) ?? null;
237             // Keep the existing display options.
238             $content->disable = $this->oldcontent['disable'];
239             $oldlib = $this->oldcontent['library'];
240         } else {
241             $oldparams = null;
242             $oldlib = null;
243         }
245         // Prepare library data to be save.
246         $content->library = H5PCore::libraryFromString($content->h5plibrary);
247         $content->library['libraryId'] = $this->core->h5pF->getLibraryId($content->library['machineName'],
248             $content->library['majorVersion'],
249             $content->library['minorVersion']);
251         // Prepare current parameters.
252         $params = json_decode($content->params);
254         $modified = false;
255         if (empty($params->metadata)) {
256             $params->metadata = new stdClass();
257             $modified = true;
258         }
259         if (empty($params->metadata->title)) {
260             // Use a default string if not available.
261             $params->metadata->title = 'Untitled';
262             $modified = true;
263         }
264         if (!isset($content->title)) {
265             $content->title = $params->metadata->title;
266         }
267         if ($modified) {
268             $content->params = json_encode($params);
269         }
271         // Save content.
272         $content->id = $this->core->saveContent((array)$content);
274         // Move any uploaded images or files. Determine content dependencies.
275         $this->h5peditor->processParameters($content, $content->library, $params->params, $oldlib, $oldparams);
277         $this->update_h5p_file($content);
279         return $content->id;
280     }
282     /**
283      * Creates or updates the H5P file and the related database data.
284      *
285      * @param stdClass $content Object containing all the necessary data.
286      *
287      * @return void
288      */
289     private function update_h5p_file(stdClass $content): void {
290         global $USER;
292         // Keep title before filtering params.
293         $title = $content->title;
294         $contentarray = $this->core->loadContent($content->id);
295         $contentarray['title'] = $title;
297         // Generates filtered params and export file.
298         $this->core->filterParameters($contentarray);
300         $slug = isset($contentarray['slug']) ? $contentarray['slug'] . '-' : '';
301         $filename = $contentarray['id'] ?? $contentarray['title'];
302         $filename = $slug . $filename . '.h5p';
303         $file = $this->core->fs->get_export_file($filename);
304         $fs = get_file_storage();
306         if ($file) {
307             $fields['contenthash'] = $file->get_contenthash();
309             // Create or update H5P file.
310             if (empty($this->filearea['filename'])) {
311                 $this->filearea['filename'] = $contentarray['slug'] . '.h5p';
312             }
313             if (!empty($this->oldfile)) {
314                 $this->oldfile->replace_file_with($file);
315                 $newfile = $this->oldfile;
316             } else {
317                 $newfile = $fs->create_file_from_storedfile($this->filearea, $file);
318             }
319             if (empty($this->oldcontent)) {
320                 $pathnamehash = $newfile->get_pathnamehash();
321             } else {
322                 $pathnamehash = $this->oldcontent['pathnamehash'];
323             }
325             // Update hash fields in the h5p table.
326             $fields['pathnamehash'] = $pathnamehash;
327             $this->core->h5pF->updateContentFields($contentarray['id'], $fields);
328         }
329     }
331     /**
332      * Add required assets for displaying the editor.
333      *
334      * @return void
335      * @throws coding_exception If page header is already printed.
336      */
337     private function add_assets_to_page(): void {
338         global $PAGE, $CFG;
340         if ($PAGE->headerprinted) {
341             throw new coding_exception('H5P assets cannot be added when header is already printed.');
342         }
344         $context = \context_system::instance();
346         $settings = helper::get_core_assets();
348         // Use jQuery and styles from core.
349         $assets = [
350             'css' => $settings['core']['styles'],
351             'js' => $settings['core']['scripts']
352         ];
354         // Use relative URL to support both http and https.
355         $url = autoloader::get_h5p_editor_library_url()->out();
356         $url = '/' . preg_replace('/^[^:]+:\/\/[^\/]+\//', '', $url);
358         // Make sure files are reloaded for each plugin update.
359         $cachebuster = helper::get_cache_buster();
361         // Add editor styles.
362         foreach (H5peditor::$styles as $style) {
363             $assets['css'][] = $url . $style . $cachebuster;
364         }
366         // Add editor JavaScript.
367         foreach (H5peditor::$scripts as $script) {
368             // We do not want the creator of the iframe inside the iframe.
369             if ($script !== 'scripts/h5peditor-editor.js') {
370                 $assets['js'][] = $url . $script . $cachebuster;
371             }
372         }
374         // Add JavaScript with library framework integration (editor part).
375         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-editor.js' . $cachebuster), true);
376         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-init.js' . $cachebuster), true);
378         // Load editor translations.
379         $language = framework::get_language();
380         $editorstrings = $this->get_editor_translations($language);
381         $PAGE->requires->data_for_js('H5PEditor.language.core', $editorstrings, false);
383         // Add JavaScript settings.
384         $root = $CFG->wwwroot;
385         $filespathbase = "{$root}/pluginfile.php/{$context->id}/core_h5p/";
387         $factory = new factory();
388         $contentvalidator = $factory->get_content_validator();
390         $editorajaxtoken = core::createToken(editor_ajax::EDITOR_AJAX_TOKEN);
391         $sesskey = sesskey();
392         $settings['editor'] = [
393             'filesPath' => $filespathbase . 'editor',
394             'fileIcon' => [
395                 'path' => $url . 'images/binary-file.png',
396                 'width' => 50,
397                 'height' => 50,
398             ],
399             'ajaxPath' => $CFG->wwwroot . "/h5p/ajax.php?sesskey={$sesskey}&token={$editorajaxtoken}&action=",
400             'libraryUrl' => $url,
401             'copyrightSemantics' => $contentvalidator->getCopyrightSemantics(),
402             'metadataSemantics' => $contentvalidator->getMetadataSemantics(),
403             'assets' => $assets,
404             'apiVersion' => H5PCore::$coreApi,
405             'language' => $language,
406         ];
408         if (!empty($this->id)) {
409             $settings['editor']['nodeVersionId'] = $this->id;
411             // Override content URL.
412             $contenturl = "{$root}/pluginfile.php/{$context->id}/core_h5p/content/{$this->id}";
413             $settings['contents']['cid-' . $this->id]['contentUrl'] = $contenturl;
414         }
416         $PAGE->requires->data_for_js('H5PIntegration', $settings, true);
417     }
419     /**
420      * Get editor translations for the defined language.
421      * Check if the editor strings have been translated in Moodle.
422      * If the strings exist, they will override the existing ones in the JS file.
423      *
424      * @param string $language The language for the translations to be returned.
425      * @return array The editor string translations.
426      */
427     private function get_editor_translations(string $language): array {
428         global $CFG;
430         // Add translations.
431         $languagescript = "language/{$language}.js";
433         if (!file_exists("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript))) {
434             $languagescript = 'language/en.js';
435         }
437         // Check if the editor strings have been translated in Moodle.
438         // If the strings exist, they will override the existing ones in the JS file.
440         // Get existing strings from current JS language file.
441         $langcontent = file_get_contents("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript));
443         // Get only the content between { } (for instance, ; at the end of the file has to be removed).
444         $langcontent = substr($langcontent, 0, strpos($langcontent, '}', -0) + 1);
445         $langcontent = substr($langcontent, strpos($langcontent, '{'));
447         // Parse the JS language content and get a PHP array.
448         $editorstrings = helper::parse_js_array($langcontent);
449         foreach ($editorstrings as $key => $value) {
450             $stringkey = 'editor:'.strtolower(trim($key));
451             $value = autoloader::get_h5p_string($stringkey, $language);
452             if (!empty($value)) {
453                 $editorstrings[$key] = $value;
454             }
455         }
457         return $editorstrings;
458     }
460     /**
461      * Preprocess the data sent through the form to the H5P JS Editor Library.
462      *
463      * @return stdClass
464      */
465     private function data_preprocessing(): stdClass {
467         $defaultvalues = [
468             'id' => $this->id,
469             'h5plibrary' => $this->library,
470         ];
472         // In case both contentid and library have values, content(edition) takes precedence over library(creation).
473         if (empty($this->oldcontent)) {
474             $maincontentdata = ['params' => (object)[]];
475         } else {
476             $params = $this->core->filterParameters($this->oldcontent);
477             $maincontentdata = ['params' => json_decode($params)];
478             if (isset($this->oldcontent['metadata'])) {
479                 $maincontentdata['metadata'] = $this->oldcontent['metadata'];
480             }
481         }
483         $defaultvalues['h5pparams'] = json_encode($maincontentdata, true);
485         return (object) $defaultvalues;
486     }