diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 3db9de39f48..3fe5a858aee 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -277,6 +277,11 @@ $string['confirmcheckfull'] = 'Are you absolutely sure you want to confirm {$a} $string['confirmcoursemove'] = 'Are you sure you want to move this course ({$a->course}) into this category ({$a->category})?'; $string['considereddigitalminor'] = 'You are too young to create an account on this site.'; $string['content'] = 'Content'; +$string['contentexport_aboutthiscourse'] = 'Course summary'; +$string['contentexport_coursesummary'] = 'This file is part of the content downloaded from {$a->coursename}.'; +$string['contentexport_footersummary'] = 'This file is part of the content downloaded from {$a->coursename} by {$a->userfullname} on {$a->date}'; +$string['contentexport_modulesummary'] = 'This page is part of the content downloaded from {$a->modulename} on {$a->date}. Note that some content and any files larger than {$a->maxfilesize} are not downloaded.'; +$string['contentexport_viewfilename'] = 'View the file {$a}'; $string['contentbank'] = 'Content bank'; $string['continue'] = 'Continue'; $string['continuetocourse'] = 'Click here to enter your course'; diff --git a/lib/classes/content.php b/lib/classes/content.php new file mode 100644 index 00000000000..16992133231 --- /dev/null +++ b/lib/classes/content.php @@ -0,0 +1,150 @@ +. + +/** + * Content API File Area definition. + * + * @package core_files + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core; + +use coding_exception; +use context; +use core\content\export\exporters\course_exporter; +use core\content\export\exporters\component_exporter; +use core\content\export\exporters\abstract_mod_exporter; +use core\content\export\zipwriter; +use core_component; +use moodle_url; +use stdClass; +use stored_file; + +/** + * The Content API allows all parts of Moodle to determine details about content within a component, or plugintype. + * + * This includes the description of files. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class content { + + /** + * Check whether the specified user can export content for the specified context. + * + * @param context $currentcontext + * @param stdClass $user + * @return bool + */ + public static function can_export_context(context $currentcontext, stdClass $user): bool { + return true; + } + + /** + * Export content for the specified context. + * + * @param context $requestedcontext The context to be exported + * @param stdClass $user The user being exported + * @param zipwriter $archive The Zip Archive to export to + */ + public static function export_context(context $requestedcontext, stdClass $user, zipwriter $archive): void { + global $USER; + + if ($requestedcontext->contextlevel != CONTEXT_COURSE) { + throw new coding_exception('The Content Export API currently only supports the export of courses'); + } + + if ($USER->id != $user->id) { + throw new coding_exception('The Content Export API currently only supports export of the current user'); + } + + // Ensure that the zipwriter is aware of the requested context. + $archive->set_root_context($requestedcontext); + + // Fetch all child contexts, indexed by path. + $contextlist = [ + $requestedcontext->path => $requestedcontext, + ]; + foreach ($requestedcontext->get_child_contexts() as $context) { + $contextlist[$context->path] = $context; + } + + // Reverse the order by key - this ensures that child contexts are processed before their parent. + krsort($contextlist); + + // Get the course modinfo. + $modinfo = get_fast_modinfo($requestedcontext->instanceid); + + // Filter out any context which cannot be exported. + $contextlist = array_filter($contextlist, function($context) use ($user, $modinfo): bool { + if ($context->contextlevel == CONTEXT_COURSE) { + return self::can_export_context($context, $user); + } + + if ($context->contextlevel == CONTEXT_MODULE) { + if (empty($modinfo->cms[$context->instanceid])) { + // Unknown coursemodule in the course. + return false; + } + + $cm = $modinfo->cms[$context->instanceid]; + + if (!$cm->uservisible) { + // This user cannot view the activity. + return false; + } + + // Defer to setting checks. + return self::can_export_context($context, $user); + } + + // Only course and activities are supported at this time. + return false; + }); + + // Export each context. + $exportedcontexts = []; + $coursecontroller = new course_exporter($requestedcontext->get_course_context(), $user, $archive); + foreach ($contextlist as $context) { + if ($context->contextlevel === CONTEXT_MODULE) { + $cm = $modinfo->cms[$context->instanceid]; + $component = "mod_{$cm->modname}"; + + // Check for a specific implementation for this module. + // This will export any content specific to this activity. + // For example, in mod_folder it will export the list of folders. + $classname = component_exporter::get_classname_for_component($component); + $exportables = []; + if (class_exists($classname) && is_a($classname, abstract_mod_exporter::class, true)) { + $controller = new $classname($context, $component, $user, $archive); + $exportables = $controller->get_exportables(); + } + + // Pass the exportable content to the course controller for export. + $coursecontroller->export_mod_content($context, $exportables); + + $exportedcontexts[$context->id] = $context; + } else if ($context->contextlevel === CONTEXT_COURSE) { + // Export the course content. + $coursecontroller->export_course($exportedcontexts); + } + } + + $archive->finish(); + } +} diff --git a/lib/classes/content/export/exportable_item.php b/lib/classes/content/export/exportable_item.php new file mode 100644 index 00000000000..4bfb5b68b18 --- /dev/null +++ b/lib/classes/content/export/exportable_item.php @@ -0,0 +1,96 @@ +. + +/** + * The definition of an item which can be exported. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export; + +use context; +use core\content\export\exported_item; +use core\content\export\zipwriter; + +/** + * An object used to represent content which can be served. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class exportable_item { + + /** @var context The context associated with this exportable item */ + protected $context = null; + + /** @var string The component being exported */ + protected $component = null; + + /** @var string The name displayed to the user */ + protected $uservisiblename = null; + + /** + * Create a new exportable_item instance. + * + * @param context $context The context that this content belongs to + * @param string $component The component that this content relates to + * @param string $uservisiblename The name displayed in the export + */ + public function __construct(context $context, string $component, string $uservisiblename) { + $this->context = $context; + $this->component = $component; + $this->uservisiblename = $uservisiblename; + } + + /** + * Get the context that this exportable item is for. + * + * @return context + */ + public function get_context(): context { + return $this->context; + } + + /** + * Get the component that this exportable item relates to. + * + * @return string + */ + public function get_component(): string { + return $this->component; + } + + /** + * Get the user visible name for the exportable item. + * + * @return string + */ + public function get_user_visible_name(): string { + return $this->uservisiblename; + } + + /** + * Add the content to the archive. + * + * @param zipwriter $archive + */ + abstract public function add_to_archive(zipwriter $archive): ?exported_item; +} diff --git a/lib/classes/content/export/exportable_items/exportable_filearea.php b/lib/classes/content/export/exportable_items/exportable_filearea.php new file mode 100644 index 00000000000..754903c4adc --- /dev/null +++ b/lib/classes/content/export/exportable_items/exportable_filearea.php @@ -0,0 +1,179 @@ +. + +/** + * The definition of a set of files in a filearea to be exported. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use context; +use core\content\export\exportable_item; +use core\content\export\exported_item; +use core\content\export\zipwriter; +use moodle_url; +use stored_file; + +/** + * The definition of a set of files in a filearea to be exported. + * + * All files mustbe in a single filearea and itemid combination. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class exportable_filearea extends exportable_item { + + /** @var string The destination path of the text content */ + protected $folderpath; + + /** @var string $filearea The file to be exported */ + protected $filearea; + + /** @var bool|int The itemid in the Files API */ + protected $itemid; + + /** @var int The itemid to use in the pluginfile URL */ + protected $pluginfileitemid; + + /** + * Create a new exportable_item instance. + * + * If no filearea or itemid is specified the no attempt will be made to export files. + * + * @param context $context The context that this content belongs to + * @param string $component + * @param string $uservisiblename The name displayed to the user when filtering + * @param string $filearea The file area in the Files API where these files are located + * @param int $itemid The itemid in the Files API where these files are located + * @param null|int $pluginfileitemid The itemid as used in the Pluginfile URL + * @param string $folderpath Any sub-directory to place files in + */ + public function __construct( + context $context, + string $component, + string $uservisiblename, + string $filearea, + int $itemid, + ?int $pluginfileitemid = null, + string $folderpath = '' + ) { + parent::__construct($context, $component, $uservisiblename); + + $this->filearea = $filearea; + $this->itemid = $itemid; + $this->pluginfileitemid = $pluginfileitemid; + $this->folderpath = $folderpath; + } + + /** + * Add the content to the archive. + * + * @param zipwriter $archive + */ + public function add_to_archive(zipwriter $archive): ?exported_item { + $fs = get_file_storage(); + + $files = $fs->get_area_files($this->context->id, $this->component, $this->filearea, $this->itemid); + + $exporteditem = new exported_item(); + $exporteditem->set_title($this->get_user_visible_name()); + + foreach ($files as $file) { + if ($file->is_directory()) { + // Skip folders. The zipwriter cannot handle them. + continue; + } + // Export the content to [contextpath]/[filepath]. + $relativefilepath = $this->get_filepath_for_file($file); + + $archive->add_file_from_stored_file( + $this->get_context(), + $relativefilepath, + $file + ); + + if ($archive->is_file_in_archive($this->context, $relativefilepath)) { + // The file was successfully added to the archive. + $exporteditem->add_file($relativefilepath, false); + } else { + // The file was not added. Link to the live version instead. + $exporteditem->add_file( + $relativefilepath, + false, + self::get_pluginfile_url_for_stored_file($file, $this->pluginfileitemid) + ); + } + } + + return $exporteditem; + } + + /** + * Get the filepath for the specified stored_file. + * + * @param stored_file $file The file to get a filepath for + * @return string The generated filepath + */ + protected function get_filepath_for_file(stored_file $file): string { + $folderpath = rtrim($this->folderpath); + + if (!empty($folderpath)) { + $folderpath .= '/'; + } + return sprintf( + '%s%s%s%s', + $folderpath, + $file->get_filearea(), + $file->get_filepath(), + $file->get_filename() + ); + } + + /** + * Get the pluginfile URL for a stored file. + * + * Note: The itemid in the pluginfile may be omitted in some URLs, despite an itemid being present in the database. + * Equally, the itemid in the URL may not match the itemid in the files table. + * + * The pluginfileitemid argument provided to this function is the variant in the URL, and not the one in the files + * table. + * + * @param stored_file $file The file whose link will be generated + * @param null|int $pluginfileitemid The itemid of the file in pluginfile URL. + * + */ + protected static function get_pluginfile_url_for_stored_file(stored_file $file, ?int $pluginfileitemid): string { + $link = moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + $pluginfileitemid, + $file->get_filepath(), + $file->get_filename(), + true, + true + ); + + return $link->out(false); + } +} diff --git a/lib/classes/content/export/exportable_items/exportable_stored_file.php b/lib/classes/content/export/exportable_items/exportable_stored_file.php new file mode 100644 index 00000000000..386278324f0 --- /dev/null +++ b/lib/classes/content/export/exportable_items/exportable_stored_file.php @@ -0,0 +1,202 @@ +. + +/** + * The definition of an item which can be exported. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use context; +use core\content\export\exportable_item; +use core\content\export\exported_item; +use core\content\export\zipwriter; +use moodle_url; +use stored_file; + +/** + * An object used to represent content which can be served. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class exportable_stored_file extends exportable_item { + + /** @var string The destination path of the text content */ + protected $folderpath; + + /** @var stored_file The file to be exported */ + protected $file; + + /** @var int The itemid to use in the pluginfile URL */ + protected $pluginfileitemid; + + /** + * Create a new exportable_item instance. + * + * If no filearea or itemid is specified the no attempt will be made to export files. + * + * @param context $context The context that this content belongs to + * @param string $component + * @param string $uservisiblename The name displayed to the user when filtering + * @param stored_file $file + * @param null|int $pluginfileitemid The itemid as used in the pluginfile URL. + * If no itemid is used, then a null value can be provided + * @param string $folderpath Any sub-directory to place files in + */ + public function __construct( + context $context, + string $component, + string $uservisiblename, + stored_file $file, + ?int $pluginfileitemid = null, + string $folderpath = '' + ) { + parent::__construct($context, $component, $uservisiblename); + + $this->file = $file; + $this->folderpath = $folderpath; + $this->pluginfileitemid = $pluginfileitemid; + } + + /** + * Create a set of exportable_items from a set of area paramaters as passed to get_areas_files(). + * + * If no filearea or itemid is specified the no attempt will be made to export files. + * + * @param context $context The context that this content belongs to + * @param string $component + * @param string $filearea + * @param null|int $itemid + * @param null|int $pluginfileitemid The itemid as used in the pluginfile URL. + * If no itemid is used, then a null value can be provided + * @param string $folderpath Any sub-directory to place files in + * @return array + */ + public static function create_from_area_params( + context $context, + string $component, + string $filearea, + ?int $itemid, + ?int $pluginfileitemid = null, + string $folderpath = '' + ): array { + $fs = get_file_storage(); + if ($itemid === null) { + $itemid = false; + } + + $exportables = []; + foreach ($fs->get_area_files($context->id, $component, $filearea, $itemid) as $file) { + if ($file->is_directory()) { + // Do not export directories. + // If they contain file contents the directory structure will be created in the zip file. + continue; + } + $filepath = $file->get_filepath() . $file->get_filename(); + $exportables[] = new self($context, $component, $filepath, $file, $pluginfileitemid, $folderpath); + } + + return $exportables; + } + + /** + * Add the content to the archive. + * + * @param zipwriter $archive + */ + public function add_to_archive(zipwriter $archive): ?exported_item { + // Export the content to [contextpath]/[filepath]. + $relativefilepath = $this->get_filepath_for_file(); + + $archive->add_file_from_stored_file( + $this->get_context(), + $relativefilepath, + $this->file + ); + + $exporteditem = new exported_item(); + $exporteditem->set_title($this->get_user_visible_name()); + + if ($archive->is_file_in_archive($this->context, $relativefilepath)) { + // The file was successfully added to the archive. + $exporteditem->add_file($relativefilepath, false); + } else { + // The file was not added. Link to the live version instead. + $exporteditem->add_file( + $relativefilepath, + false, + self::get_pluginfile_url_for_stored_file($this->file, $this->pluginfileitemid) + ); + } + + return $exporteditem; + } + + /** + * Get the filepath for the specified stored_file. + * + * @return string + */ + protected function get_filepath_for_file(): string { + $folderpath = rtrim($this->folderpath); + + if (!empty($folderpath)) { + $folderpath .= '/'; + } + return sprintf( + '%s%s%s%s', + $folderpath, + $this->file->get_filearea(), + $this->file->get_filepath(), + $this->file->get_filename() + ); + } + + /** + * Get the pluginfile URL for a stored file. + * + * Note: The itemid in the pluginfile may be omitted in some URLs, despite an itemid being present in the database. + * Equally, the itemid in the URL may not match the itemid in the files table. + * + * The pluginfileitemid argument provided to this function is the variant in the URL, and not the one in the files + * table. + * + * @param stored_file $file The file whose link will be generated + * @param null|int $pluginfileitemid The itemid of the file in pluginfile URL. + * + */ + protected static function get_pluginfile_url_for_stored_file(stored_file $file, ?int $pluginfileitemid): string { + $link = moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + $pluginfileitemid, + $file->get_filepath(), + $file->get_filename(), + true, + true + ); + + return $link->out(false); + } +} diff --git a/lib/classes/content/export/exportable_items/exportable_textarea.php b/lib/classes/content/export/exportable_items/exportable_textarea.php new file mode 100644 index 00000000000..246d4786129 --- /dev/null +++ b/lib/classes/content/export/exportable_items/exportable_textarea.php @@ -0,0 +1,166 @@ +. + +/** + * The definition of a text area which can be exported. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use context; +use core\content\export\exportable_item; +use core\content\export\exported_item; +use core\content\export\zipwriter; + +/** + * The definition of a text area which can be exported. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class exportable_textarea extends exportable_item { + + /** @var string The name of the table that ha the textarea within it */ + protected $tablename; + + /** @var int The id in the table */ + protected $id; + + /** @var string The name of the text field within the table */ + protected $textfield; + + /** @var null|string The name of the format field relating to the text field */ + protected $textformatfield; + + /** @var null|string The name of a file area for this content */ + protected $filearea; + + /** @var null|int The itemid for files in this text field */ + protected $itemid; + + /** @var null|int The itemid used for constructing pluginfiles */ + protected $pluginfileitemid; + + /** + * Create a new exportable_item instance. + * + * If no filearea or itemid is specified the no attempt will be made to export files. + * + * @param context $context The context that this content belongs to + * @param string $component The component that this textarea belongs to + * @param string $uservisiblename The name displayed to the user when filtering + * @param string $tablename The name of the table that this textarea is in + * @param string $textfield The field within the tbale + * @param int $id The id in the database + * @param null|string $textformatfield The field in the database relating to the format field if one is present + * @param null|string $filearea The name of the file area for files associated with this text area + * @param null|int $itemid The itemid for files associated with this text area + * @param null|int $pluginfileitemid The itemid to use when constructing the pluginfile URL + * Some fileareas do not use any itemid in the URL and should therefore provide a `null` value here. + */ + public function __construct( + context $context, + string $component, + string $uservisiblename, + string $tablename, + string $textfield, + int $id, + ?string $textformatfield = null, + ?string $filearea = null, + ?int $itemid = null, + ?int $pluginfileitemid = null + ) { + parent::__construct($context, $component, $uservisiblename); + + $this->tablename = $tablename; + $this->textfield = $textfield; + $this->textformatfield = $textformatfield; + $this->id = $id; + $this->filearea = $filearea; + $this->itemid = $itemid; + $this->pluginfileitemid = $pluginfileitemid; + } + + /** + * Add the content to the archive. + * + * @param zipwriter $archive + */ + public function add_to_archive(zipwriter $archive): ?exported_item { + global $DB; + + // Fetch the field. + $fields = [$this->textfield]; + if (!empty($this->textformatfield)) { + $fields[] = $this->textformatfield; + } + $record = $DB->get_record($this->tablename, ['id' => $this->id], implode(', ', $fields)); + + if (empty($record)) { + return null; + } + + // Export all of the files for this text area. + $text = $record->{$this->textfield}; + if (empty($text)) { + $text = ''; + } + + if ($this->may_include_files()) { + // This content may include inline files. + $exporteditem = $archive->add_pluginfiles_for_content( + $this->get_context(), + "", + $text, + $this->component, + $this->filearea, + $this->itemid, + $this->pluginfileitemid + ); + } else { + $exporteditem = new exported_item(); + $exporteditem->set_content($text); + } + + $exporteditem->set_title($this->get_user_visible_name()); + return $exporteditem; + } + + /** + * Whether files may be included in this textarea. + * + * Both a filearea, and itemid are required for files to be exportable. + * + * @return bool + */ + protected function may_include_files(): bool { + if ($this->filearea === null) { + return false; + } + + if ($this->itemid === null) { + return false; + } + + return true; + } +} diff --git a/lib/classes/content/export/exported_item.php b/lib/classes/content/export/exported_item.php new file mode 100644 index 00000000000..21e28235f01 --- /dev/null +++ b/lib/classes/content/export/exported_item.php @@ -0,0 +1,198 @@ +. + +/** + * Exported Item. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export; + +use stdClass; + +/** + * This class describes the files which were exported, and any text content that those files were contained in. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class exported_item { + + /** @var string A short, descriptive, name for this exported item */ + protected $title = null; + + /** @var string Any string content for export */ + protected $content = ''; + + /** @var string[] A list of files which were exported and are not present in the content */ + protected $files = []; + + /** @var string[] A list of files which were exported and are present in the content */ + protected $contentfiles = []; + + /** + * Constructor for the exported_item. + * + * @param array $files A list of all files which were exported + */ + public function __construct(array $files = []) { + $this->add_files($files); + } + + /** + * Set a title for this exported item. + * + * @param string $title + */ + public function set_title(string $title): void { + $this->title = $title; + } + + /** + * Add a file to the list of exported files. + * + * @param string $relativefilepath The path to the content relative to the exported context + * @param bool $incontent Whether this file is included within the content + * @param null|string $url The URL to use of the live file where the file could not be stored in the archive + */ + public function add_file(string $relativefilepath, bool $incontent = false, ?string $url = null): void { + if ($url === null) { + $url = $relativefilepath; + } + + $file = (object) [ + 'filepath' => $url, + 'filename' => basename($relativefilepath), + ]; + + $this->files[$relativefilepath] = $file; + if ($incontent) { + $this->contentfiles[$relativefilepath] = $file; + } + } + + /** + * Add a list of files to the list of exported files. + * + * @param string[] $files The path to the content relative to the exported context + * @param bool $incontent Whether this file is included within the content + */ + public function add_files(array $files, bool $incontent = false): void { + foreach ($files as $relativefilepath) { + $this->add_file($relativefilepath, $incontent); + } + } + + /** + * Set the rewritten content. + * + * @param string $content + */ + public function set_content(string $content): void { + $this->content = $content; + } + + /** + * Fetch the rewritten content. + * + * @return string + */ + public function get_content(): string { + return $this->content; + } + + /** + * Get a short, descriptive name associated with the exported content, if one is avaiable. + * + * @return null|string + */ + public function get_title(): ?string { + return $this->title; + } + + /** + * Get all template data for this exported item. + * + * @return stdClass + */ + public function get_template_data(): stdClass { + return (object) [ + 'title' => $this->get_title(), + 'files' => $this->get_noncontent_files(), + 'content' => $this->content, + ]; + } + + /** + * Get a list of all files in the exported item. + * + * @return array + */ + public function get_all_files(): array { + return $this->files; + } + + /** + * Get a list of all files present in the content. + * + * That is those files which were exported, and which are referenced in some fashion. + * These files typically do not need to be listed separately. + * + * @return array + */ + public function get_content_files(): array { + return $this->contentfiles; + } + + /** + * Get all files which are not already referenced in the content. + * + * These files will typically be displayed in a separate list. + * + * @return array + */ + public function get_noncontent_files(): array { + return array_values(array_diff_key( + $this->get_all_files(), + $this->get_content_files() + )); + } + + /** + * Check whether the exported_item includes any data. + * + * @return bool + */ + public function has_any_data(): bool { + if ($this->get_all_files()) { + // Some files are present. + return true; + } + + if (trim(html_to_text($this->get_content())) !== '') { + // The content is not empty. + return true; + } + + // No truthy conditions match. + return false; + } +} diff --git a/lib/classes/content/export/exporters/abstract_mod_exporter.php b/lib/classes/content/export/exporters/abstract_mod_exporter.php new file mode 100644 index 00000000000..52e35f27d28 --- /dev/null +++ b/lib/classes/content/export/exporters/abstract_mod_exporter.php @@ -0,0 +1,69 @@ +. + +/** + * Activity module exporter for the content API. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core\content\export\exporters; + +use core\content\controllers\export\component_controller; + +/** + * Activity module exporter for the content API. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class abstract_mod_exporter extends component_exporter { + + /** @var \cm_info The activity information for this course module */ + protected $cm; + + /** + * Constructor for the general activity exporter. + */ + public function __construct() { + parent::__construct(...func_get_args()); + + $coursecontext = $this->context->get_course_context(); + $modinfo = get_fast_modinfo($coursecontext->instanceid); + $this->cm = $modinfo->get_cm($this->context->instanceid); + } + + /** + * Get the exportable items for the user in the specified context. + * + * For activities which allow users to submit their own content which is not visible to all users, for example + * graded activities, the caller can request that this be either included, or excluded. + * + * @param bool $includeuserdata Whether to include user data, in addition to shared content. + * @return exportable_item[] + */ + abstract public function get_exportables(bool $includeuserdata = false): array; + + /** + * Get the modname for the activity. + * + * @return string + */ + protected function get_modname(): string { + return $this->cm->modname; + } +} diff --git a/lib/classes/content/export/exporters/component_exporter.php b/lib/classes/content/export/exporters/component_exporter.php new file mode 100644 index 00000000000..8f279fe1ba2 --- /dev/null +++ b/lib/classes/content/export/exporters/component_exporter.php @@ -0,0 +1,112 @@ +. + +/** + * Content API Export definition. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core\content\export\exporters; + +use coding_exception; +use context; +use core\content\export\zipwriter; +use core_component; +use stdClass; + +/** + * A class to help define, describe, and export content in a specific context. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class component_exporter { + + /** @var context The context to be exported */ + protected $context = null; + + /** @var string The component that this instance belongs to */ + protected $component = null; + + /** @var stdClass The user being exported */ + protected $user; + + /** @var zipwriter A reference to the zipwriter */ + protected $archive; + + /** + * Constructor for a new exporter. + * + * @param context $context The context to export + * @param string $component The component that this instance relates to + * @param stdClass $user The user to be exported + * @param zipwriter $archive + */ + public function __construct(context $context, string $component, stdClass $user, zipwriter $archive) { + $this->context = $context; + $this->component = $component; + $this->user = $user; + $this->archive = $archive; + } + + /** + * Get the context being exported. + * + * @return context + */ + public function get_context(): context { + return $this->context; + } + + /** + * Get the component name. + * + * @return string + */ + public function get_component(): string { + [$type, $component] = core_component::normalize_component($this->component); + if ($type === 'core') { + return $component; + } + + return core_component::normalize_componentname($this->component); + } + + /** + * Get the archive used for export. + * + * @return zipwriter + */ + public function get_archive(): zipwriter { + if ($this->archive === null) { + throw new coding_exception("Archive has not been set up yet"); + } + + return $this->archive; + } + + /** + * Get the name of the exporter for the specified component. + * + * @param string $component The component to fetch a classname for + * @return string The classname for the component + */ + public static function get_classname_for_component(string $component): string { + return "{$component}\\content\\exporter"; + } +} diff --git a/lib/classes/content/export/exporters/course_exporter.php b/lib/classes/content/export/exporters/course_exporter.php new file mode 100644 index 00000000000..cc22730db42 --- /dev/null +++ b/lib/classes/content/export/exporters/course_exporter.php @@ -0,0 +1,274 @@ +. + +/** + * The course exporter. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core\content\export\exporters; + +use context_course; +use context_module; +use core\content\export\exported_item; +use core\content\export\zipwriter; +use section_info; +use stdClass; + +/** + * The course exporter. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_exporter extends component_exporter { + + /** @var stdClass The course being exported */ + protected $course; + + /** @var \course_modinfo The course_modinfo instnace for this course */ + protected $modinfo; + + /** + * Constructor for the course exporter. + * + * @param context_course $context The context of the course to export + * @param stdClass $user + * @param zipwriter $archive + */ + public function __construct(context_course $context, stdClass $user, zipwriter $archive) { + $this->course = get_course($context->instanceid); + $this->modinfo = get_fast_modinfo($this->course, $user->id); + + parent::__construct($context, 'core_course', $user, $archive); + } + + /** + * Export the course. + * + * @param context[] $exportedcontexts A list of contexts which were successfully exported + */ + public function export_course(array $exportedcontexts): void { + // A course export is composed of: + // - Course summary (including inline files) + // - Overview files + // - Section: + // -- Section name + // -- Section summary (including inline files) + // -- List of available activities. + + $aboutpagelink = $this->add_course_about_page(); + $templatedata = (object) [ + 'aboutpagelink' => $aboutpagelink, + 'sections' => [], + ]; + + // Add all sections. + foreach ($this->modinfo->get_section_info_all() as $number => $section) { + $templatedata->sections[] = $this->get_course_section($exportedcontexts, $section); + } + + $this->get_archive()->add_file_from_template( + $this->get_context(), + 'index.html', + 'core/content/export/course_index', + $templatedata + ); + } + + /** + * Add course about page. + * + * @return null|string The URL to the about page if one was generated + */ + protected function add_course_about_page(): ?string { + $hascontent = false; + + $templatedata = (object) [ + 'summary' => '', + 'overviewfiles' => [], + ]; + + // Fetch the course summary content. + if ($this->course->summary) { + $summarydata = $this->get_archive()->add_pluginfiles_for_content( + $this->get_context(), + '_course', + $this->course->summary, + 'course', + 'summary', + 0, + null + ); + + if ($summarydata->has_any_data()) { + $hascontent = true; + $templatedata->summary = $summarydata->get_content(); + } + } + + $files = $this->get_archive()->add_pluginfiles_for_content( + $this->get_context(), + '', + '', + 'course', + 'overviewfiles', + 0, + null + )->get_noncontent_files(); + + if (count($files)) { + $templatedata->overviewfiles = $files; + $hascontent = true; + } + + if ($hascontent) { + $this->get_archive()->add_file_from_template( + $this->get_context(), + 'about.html', + 'core/content/export/course_summary', + $templatedata + ); + + return $this->get_archive()->get_relative_context_path($this->get_context(), $this->get_context(), 'about.html'); + } + + return null; + } + + /** + * Fetch data for the specified course section. + * + * @param context[] $exportedcontexts A list of contexts which were successfully exported + * @param section_info $section The section being exported + * @return stdClass + */ + protected function get_course_section(array $exportedcontexts, section_info $section): stdClass { + $sectiondata = (object) [ + 'number' => $section->section, + 'title' => $section->name, + 'summary' => '', + 'activities' => [], + ]; + + $sectiondata->summary = $this->get_archive()->add_pluginfiles_for_content( + $this->get_context(), + "_course", + $section->summary, + 'course', + 'section', + $section->id, + $section->id + )->get_template_data()->content; + + if (empty($this->modinfo->sections[$section->section])) { + return $sectiondata; + } + + foreach ($this->modinfo->sections[$section->section] as $cmid) { + $cm = $this->modinfo->cms[$cmid]; + if (!$cm->uservisible) { + continue; + } + + if (array_key_exists($cm->context->id, $exportedcontexts)) { + // This activity was exported. + // The link to it from the course index should be a relative link. + $url = $this->get_archive()->get_relative_context_path($this->get_context(), $cm->context, 'index.html'); + } else { + // This activity was not included in the export for some reason. + // Link to the live activity. + $url = $cm->url; + } + $sectiondata->activities[] = (object) [ + 'title' => $cm->name, + 'modname' => $cm->modfullname, + 'link' => $url, + ]; + } + + return $sectiondata; + } + + /** + * Export all exportable content for an activity module. + * + * @param context_module $modcontect + * @param exportable_item[] $export_exportables + */ + public function export_mod_content(context_module $modcontext, array $exportables): void { + $cm = $this->modinfo->get_cm($modcontext->instanceid); + $modname = $cm->modname; + + $templatedata = (object) [ + 'modulelink' => $cm->url, + 'modulename' => $cm->get_formatted_name(), + 'intro' => null, + 'sections' => [], + ]; + + if (plugin_supports('mod', $modname, FEATURE_MOD_INTRO, true)) { + $templatedata->intro = $this->get_mod_intro_data($modcontext); + } + + $exporteditems = []; + foreach ($exportables as $exportable) { + $exporteditem = $exportable->add_to_archive($this->get_archive()); + $templatedata->sections[] = $exporteditem->get_template_data(); + } + + // Add the index to the archive. + $this->get_archive()->add_file_from_template( + $modcontext, + 'index.html', + 'core/content/export/module_index', + $templatedata + ); + } + + /** + * Get the course_module introduction data. + * + * @param context_module $modcontect + * @return null|string The content of the intro area + */ + protected function get_mod_intro_data(context_module $modcontext): ?string { + global $DB; + + $cm = $this->modinfo->get_cm($modcontext->instanceid); + $modname = $cm->modname; + + $record = $DB->get_record($modname, ['id' => $cm->instance], 'intro'); + + $exporteditem = $this->get_archive()->add_pluginfiles_for_content( + $modcontext, + '', + $record->intro, + "mod_{$modname}", + 'intro', + 0, + null + ); + + if ($exporteditem->has_any_data()) { + return $exporteditem->get_content(); + } + + return null; + } +} diff --git a/lib/classes/content/export/zipwriter.php b/lib/classes/content/export/zipwriter.php new file mode 100644 index 00000000000..ed2e2d2a8de --- /dev/null +++ b/lib/classes/content/export/zipwriter.php @@ -0,0 +1,563 @@ +. + +/** + * Zip writer wrapper. + * + * @package core + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core\content\export; + +use context; +use context_system; +use moodle_url; +use stdClass; +use stored_file; + +/** + * Zip writer wrapper. + * + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class zipwriter { + + /** @var int Maximum folder length name for a context */ + const MAX_CONTEXT_NAME_LENGTH = 32; + + /** @var \ZipStream\ZipStream */ + protected $archive; + + /** @var int Max file size of an individual file in the archive */ + protected $maxfilesize = 1 * 1024 * 1024 * 10; + + /** @var resource File resource for the file handle for a file-based zip stream */ + protected $zipfilehandle = null; + + /** @var string File path for a file-based zip stream */ + protected $zipfilepath = null; + + /** @var context The context to use as a base for export */ + protected $rootcontext = null; + + /** @var array The files in the zip */ + protected $filesinzip = []; + + /** @var bool Whether page requirements needed for HTML pages have been added */ + protected $pagerequirementsadded = false; + + /** @var stdClass The course relating to the root context */ + protected $course; + + /** @var context The context of the course for the root contect */ + protected $coursecontext; + + /** + * zipwriter constructor. + * + * @param \ZipStream\ZipStream $archive + * @param stdClass|null $options + */ + public function __construct(\ZipStream\ZipStream $archive, stdClass $options = null) { + $this->archive = $archive; + if ($options) { + $this->parse_options($options); + } + + $this->rootcontext = context_system::instance(); + } + + /** + * Set a root context for use during the export. + * + * This is primarily used for creating paths within the archive relative to the root context. + * + * @param context $rootcontext + */ + public function set_root_context(context $rootcontext): void { + $this->rootcontext = $rootcontext; + } + + /** + * Get the course object for the root context. + * + * @return stdClass + */ + protected function get_course(): stdClass { + if ($this->course && ($this->coursecontext !== $this->rootcontext->get_course_context())) { + $this->coursecontext = null; + $this->course = null; + } + if (empty($this->course)) { + $this->coursecontext = $this->rootcontext->get_course_context(); + $this->course = get_course($this->coursecontext->instanceid); + } + + return $this->course; + } + + /** + * Parse options. + * + * @param stdClass $options + */ + protected function parse_options(stdClass $options): void { + if (property_exists($options, 'maxfilesize')) { + $this->maxfilesize = $options->maxfilesize; + } + } + + /** + * Finish writing the zip footer. + */ + public function finish(): void { + $this->archive->finish(); + + if ($this->zipfilehandle) { + fclose($this->zipfilehandle); + } + } + + /** + * Get the stream writer. + * + * @param string $filename + * @param stdClass|null $exportoptions + * @return static + */ + public static function get_stream_writer(string $filename, stdClass $exportoptions = null) { + $options = new \ZipStream\Option\Archive(); + $options->setSendHttpHeaders(true); + $archive = new \ZipStream\ZipStream($filename, $options); + + $zipwriter = new static($archive, $exportoptions); + + \core\session\manager::write_close(); + return $zipwriter; + } + + /** + * Get the file writer. + * + * @param string $filename + * @param stdClass|null $exportoptions + * @return static + */ + public static function get_file_writer(string $filename, stdClass $exportoptions = null) { + $options = new \ZipStream\Option\Archive(); + + $dir = make_request_directory(); + $filepath = $dir . "/$filename"; + $fh = fopen($filepath, 'w'); + + $options->setOutputStream($fh); + $options->setSendHttpHeaders(false); + $archive = new \ZipStream\ZipStream($filename, $options); + + $zipwriter = new static($archive, $exportoptions); + + $zipwriter->zipfilehandle = $fh; + $zipwriter->zipfilepath = $filepath; + + \core\session\manager::write_close(); + return $zipwriter; + } + + /** + * Get the file path for a file-based zip writer. + * + * If this is not a file-based writer then no value is returned. + * + * @return null|string + */ + public function get_file_path(): ?string { + return $this->zipfilepath; + } + + /** + * Add a file from the File Storage API. + * + * @param context $context + * @param string $filepathinzip + * @param stored_file $file The file to add + */ + public function add_file_from_stored_file( + context $context, + string $filepathinzip, + stored_file $file + ): void { + $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); + + if ($file->get_filesize() <= $this->maxfilesize) { + $filehandle = $file->get_content_file_handle(); + $this->archive->addFileFromStream($fullfilepathinzip, $filehandle); + fclose($filehandle); + + $this->filesinzip[] = $fullfilepathinzip; + } + } + + /** + * Add a file from string content. + * + * @param context $context + * @param string $filepathinzip + * @param string $content + */ + public function add_file_from_string( + context $context, + string $filepathinzip, + string $content + ): void { + $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); + + $this->archive->addFile($fullfilepathinzip, $content); + + $this->filesinzip[] = $fullfilepathinzip; + } + + /** + * Create a file based on a Mustache Template and associated data. + * + * @param context $context + * @param string $filepathinzip + * @param string $template + * @param stdClass $templatedata + */ + public function add_file_from_template( + context $context, + string $filepathinzip, + string $template, + stdClass $templatedata + ): void { + global $CFG, $PAGE, $SITE, $USER; + + $exportedcourse = $this->get_course(); + $courselink = (new moodle_url('/course/view.php', ['id' => $exportedcourse->id]))->out(false); + + $this->add_template_requirements(); + + $templatedata->global = (object) [ + 'righttoleft' => right_to_left(), + 'language' => str_replace('_', '-', current_language()), + 'sitename' => $SITE->fullname, + 'siteurl' => $CFG->wwwroot, + 'pathtotop' => $this->get_relative_context_path($context, $this->rootcontext, '/'), + 'contentexportfooter' => get_string('contentexport_footersummary', 'core', (object) [ + 'courselink' => $courselink, + 'coursename' => $exportedcourse->fullname, + 'userfullname' => fullname($USER), + 'date' => userdate(time()), + ]), + 'contentexportsummary' => get_string('contentexport_coursesummary', 'core', (object) [ + 'courselink' => $courselink, + 'coursename' => $exportedcourse->fullname, + 'date' => userdate(time()), + ]), + 'coursename' => $exportedcourse->fullname, + 'courseshortname' => $exportedcourse->shortname, + 'courselink' => $courselink, + 'exportdate' => userdate(time()), + 'maxfilesize' => display_size($this->maxfilesize), + ]; + + $renderer = $PAGE->get_renderer('core'); + $this->add_file_from_string($context, $filepathinzip, $renderer->render_from_template($template, $templatedata)); + } + + /** + * Ensure that all requirements for a templated page are present. + * + * This includes CSS, and any other similar content. + */ + protected function add_template_requirements(): void { + if ($this->pagerequirementsadded) { + return; + } + + // CSS required. + $this->add_content_from_dirroot('/theme/boost/style/moodle.css', 'shared/moodle.css'); + + $this->pagerequirementsadded = true; + } + + /** + * Add content from the dirroot into the specified path in the zip file. + * + * @param string $dirrootpath + * @param string $pathinzip + */ + protected function add_content_from_dirroot(string $dirrootpath, string $pathinzip): void { + global $CFG; + + $this->archive->addFileFromPath( + $this->get_context_path($this->rootcontext, $pathinzip), + "{$CFG->dirroot}/{$dirrootpath}" + ); + } + + /** + * Check whether the file was actually added to the archive. + * + * @param context $context + * @param string $filepathinzip + * @return bool + */ + public function is_file_in_archive(context $context, string $filepathinzip): bool { + $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); + + return in_array($fullfilepathinzip, $this->filesinzip); + } + + /** + * Get the full path to the context within the zip. + * + * @param context $context + * @param string $filepathinzip + * @return string + */ + public function get_context_path(context $context, string $filepathinzip): string { + if (!$context->is_child_of($this->rootcontext, true)) { + throw new \coding_exception("Unexpected path requested"); + } + + // Fetch the path from the course down. + $parentcontexts = array_filter( + $context->get_parent_contexts(true), + function(context $curcontext): bool { + return $curcontext->is_child_of($this->rootcontext, true); + } + ); + + foreach (array_reverse($parentcontexts) as $curcontext) { + $path[] = $this->get_context_folder_name($curcontext); + } + + $path[] = $filepathinzip; + + $finalpath = implode(DIRECTORY_SEPARATOR, $path); + + // Remove relative paths (./). + $finalpath = str_replace('./', '/', $finalpath); + + // De-duplicate slashes. + $finalpath = str_replace('//', '/', $finalpath); + + // Remove leading /. + ltrim($finalpath, '/'); + + return $this->sanitise_filename($finalpath); + } + + /** + * Get a relative path to the specified context path. + * + * @param context $rootcontext + * @param context $targetcontext + * @param string $filepathinzip + * @return string + */ + public function get_relative_context_path(context $rootcontext, context $targetcontext, string $filepathinzip): string { + $path = []; + if ($targetcontext === $rootcontext) { + $lookupcontexts = []; + } else if ($targetcontext->is_child_of($rootcontext, true)) { + // Fetch the path from the course down. + $lookupcontexts = array_filter( + $targetcontext->get_parent_contexts(true), + function(context $curcontext): bool { + return $curcontext->is_child_of($this->rootcontext, false); + } + ); + + foreach ($lookupcontexts as $curcontext) { + array_unshift($path, $this->get_context_folder_name($curcontext)); + } + } else if ($targetcontext->is_parent_of($rootcontext, true)) { + $lookupcontexts = $targetcontext->get_parent_contexts(true); + $path[] = '..'; + } + + $path[] = $filepathinzip; + $relativepath = implode(DIRECTORY_SEPARATOR, $path); + + // De-duplicate slashes and remove leading /. + $relativepath = ltrim(preg_replace('#/+#', '/', $relativepath), '/'); + + if (substr($relativepath, 0, 1) !== '.') { + $relativepath = "./{$relativepath}"; + } + + return $this->sanitise_filename($relativepath); + } + + /** + * Sanitise the file path, removing any unsuitable characters. + * + * @param string $filepath + * @return string + */ + protected function sanitise_filename(string $filepath): string { + // The filename must be sanitised in the same as the parent ZipStream library. + return \ZipStream\File::filterFilename($filepath); + } + + /** + * Get the name of the folder for the specified context. + * + * @param context $context + * @return string + */ + protected function get_context_folder_name(context $context): string { + $shortenedname = shorten_text( + clean_param($context->get_context_name(), PARAM_FILE), + self::MAX_CONTEXT_NAME_LENGTH, + true, + json_decode('"' . '\u2026' . '"') + ); + + return "{$shortenedname}_.{$context->id}"; + } + + /** + * Rewrite any pluginfile URLs in the content. + * + * @param context $context + * @param string $content + * @param string $component + * @param string $filearea + * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs + * @return string + */ + protected function rewrite_other_pluginfile_urls( + context $context, + string $content, + string $component, + string $filearea, + ?int $pluginfileitemid + ): string { + // The pluginfile URLs should have been rewritten when the files were exported, but if any file was too large it + // may not have been included. + // In that situation use a tokenpluginfile URL. + + if (strpos($content, '@@PLUGINFILE@@/') !== false) { + // Some files could not be rewritten. + // Use a tokenurl pluginfile for those. + $content = file_rewrite_pluginfile_urls( + $content, + 'pluginfile.php', + $context->id, + $component, + $filearea, + $pluginfileitemid, + [ + 'includetoken' => true, + ] + ); + } + + return $content; + } + + /** + * Export files releating to this text area. + * + * @param context $context + * @param string $subdir The sub directory to export any files to + * @param string $content + * @param string $component + * @param string $filearea + * @param int $fileitemid The itemid as used in the Files API + * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs + * @return exported_item + */ + public function add_pluginfiles_for_content( + context $context, + string $subdir, + string $content, + string $component, + string $filearea, + int $fileitemid, + ?int $pluginfileitemid + ): exported_item { + // Export all of the files for this text area. + $fs = get_file_storage(); + $files = $fs->get_area_files($context->id, $component, $filearea, $fileitemid); + + $result = new exported_item(); + foreach ($files as $file) { + if ($file->is_directory()) { + continue; + } + + $filepathinzip = self::get_filepath_for_file($file, $subdir, false); + $this->add_file_from_stored_file( + $context, + $filepathinzip, + $file + ); + + if ($this->is_file_in_archive($context, $filepathinzip)) { + // Attempt to rewrite any @@PLUGINFILE@@ URLs for this file in the content. + $searchpath = "@@PLUGINFILE@@" . $file->get_filepath() . rawurlencode($file->get_filename()); + if (strpos($content, $searchpath) !== false) { + $content = str_replace($searchpath, self::get_filepath_for_file($file, $subdir, true), $content); + $result->add_file($filepathinzip, true); + } else { + $result->add_file($filepathinzip, false); + } + } + + } + + $content = $this->rewrite_other_pluginfile_urls($context, $content, $component, $filearea, $pluginfileitemid); + $result->set_content($content); + + return $result; + } + + /** + * Get the filepath for the specified stored_file. + * + * @param stored_file $file + * @param string $parentdir Any parent directory to place this file in + * @param bool $escape + * @return string + */ + protected static function get_filepath_for_file(stored_file $file, string $parentdir, bool $escape): string { + $path = []; + + $filepath = sprintf( + '%s/%s/%s/%s', + $parentdir, + $file->get_filearea(), + $file->get_filepath(), + $file->get_filename() + ); + + if ($escape) { + foreach (explode('/', $filepath) as $dirname) { + $path[] = rawurlencode($dirname); + } + $filepath = implode('/', $path); + } + + return ltrim(preg_replace('#/+#', '/', $filepath), '/'); + } + +} diff --git a/lib/templates/content/export/course_index.mustache b/lib/templates/content/export/course_index.mustache new file mode 100644 index 00000000000..be1a5e5fc41 --- /dev/null +++ b/lib/templates/content/export/course_index.mustache @@ -0,0 +1,106 @@ +{{! + 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/content/export/course_index + + Renders content for a course index. + This template is not for use within moodle. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * global + + Example context (json): + { + "global": { + "righttoleft": 0, + "language": "en", + "sitename": "Kevin's Emporium of fine course material", + "siteurl": "https://kevin.example.com", + "pathtotop": "./", + "contentexportfooter": "This file is part of the content downloaded from Kevin's Emporium of fine course material by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.", + "contentexportsummary": "This file is part of the content downloaded from Kevin's Emporium of fine course material on Tuesday, 24th February 2021, 12:21 am.", + "coursename": "Marketing 101", + "courseshortname": "MKT101", + "courselink": "https://example.com/course/view.php?id=4", + "exportdate": "Tuesday, 24th February 2021, 12:21 am", + "maxfilesize": "40MB" + }, + "aboutpagelink": "./about.html", + "sections": [ + { + "title": "Welcome", + "summary": "

Welcome to my awesome course.

", + "activities": [ + { + "title": "Data Structures - Arrays and Objects", + "modname": "Assignment", + "link": "./dsao_.1/index.html" + }, + { + "title": "Data Structures - Discussion", + "modname": "Forum", + "link": "./dsao_.2/index.html" + }, + { + "title": "Data Structures - Lecture Notes", + "modname": "Folder", + "link": "./dsao_.3/index.html" + } + ] + } + ] + } +}} +{{ + {{{global.contentexportsummary}}} + + {{#aboutpagelink}} + + {{/aboutpagelink}} + + {{#sections.0}} +
+ {{#sections}} +

{{{title}}}

+ {{#summary}} +
+ {{{summary}}} +
+ {{/summary}} + {{#activities.0}} + + {{/activities.0}} +
+ {{/sections}} +
+ {{/sections.0}} +{{/pagecontent}} +{{/core/content/export/external_page}} diff --git a/lib/templates/content/export/course_summary.mustache b/lib/templates/content/export/course_summary.mustache new file mode 100644 index 00000000000..1c687c9da18 --- /dev/null +++ b/lib/templates/content/export/course_summary.mustache @@ -0,0 +1,88 @@ +{{! + 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/content/export/course_summary + + Renders course summary and overview information. + This template is not for use within moodle. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * global + + Example context (json): + { + "global": { + "righttoleft": 0, + "language": "en", + "sitename": "Kevin's Emporium of fine course material", + "siteurl": "https://kevin.example.com", + "pathtotop": "./", + "contentexportfooter": "This file is part of the content downloaded from Kevin's Emporium of fine course material by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.", + "contentexportsummary": "This file is part of the content downloaded from Kevin's Emporium of fine course material on Tuesday, 24th February 2021, 12:21 am.", + "coursename": "Marketing 101", + "courseshortname": "MKT101", + "courselink": "https://example.com/course/view.php?id=4", + "exportdate": "Tuesday, 24th February 2021, 12:21 am", + "maxfilesize": "40MB" + }, + "summary": "

This is a summary of the course, and it may contain PLUGINFILE references.

", + "overviewfiles": [ + { + "filepath": "./_course/overviewfiles/example.pdf", + "filename": "Example PDF" + }, + { + "filepath": "./_course/overviewfiles/example.jpg", + "filename": "Example JPG" + } + ] + } +}} +{{ + {{{global.contentexportsummary}}} + + {{#summary}} +

{{#str}}summary, core{{/str}}

+
+ {{{summary}}} +
+
+ {{/summary}} + + {{#overviewfiles.0}} +

{{#str}}courseoverviewfiles, core{{/str}}

+
+ {{#overviewfiles.0}} +
    + {{#overviewfiles}} +
  • {{filename}}
  • + {{/overviewfiles}} +
+ {{/overviewfiles.0}} +
+
+ {{/overviewfiles.0}} +{{/pagecontent}} +{{/core/content/export/external_page}} diff --git a/lib/templates/content/export/external_page.mustache b/lib/templates/content/export/external_page.mustache new file mode 100644 index 00000000000..4f5f0f844a0 --- /dev/null +++ b/lib/templates/content/export/external_page.mustache @@ -0,0 +1,79 @@ +{{! + 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/content/export/external_page + + Renders content in a basic HTML wrapper designed for viewing offline. + This template is not for use within moodle. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * global + + Example context (json): + { + "global": { + "righttoleft": 0, + "language": "en", + "sitename": "Kevin's Emporium of fine course material", + "siteurl": "https://kevin.example.com", + "pathtotop": "./", + "contentexportfooter": "This file is part of the content downloaded from Kevin's Emporium of fine course material by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.", + "contentexportsummary": "This file is part of the content downloaded from Kevin's Emporium of fine course material on Tuesday, 24th February 2021, 12:21 am.", + "coursename": "Marketing 101", + "courseshortname": "MKT101", + "courselink": "https://example.com/course/view.php?id=4", + "exportdate": "Tuesday, 24th February 2021, 12:21 am", + "maxfilesize": "40MB" + } + } +}} + + + + + {{global.sitename}} - {{global.coursename}} + + + + +
+ +
+
+

{{global.coursename}}

+

{{global.courseshortname}}

+ {{$pagecontent}}{{/pagecontent}} +
+
+
+
+
{{{global.contentexportfooter}}}
+
+
+
+ + diff --git a/lib/templates/content/export/module_index.mustache b/lib/templates/content/export/module_index.mustache new file mode 100644 index 00000000000..4b49482ddb7 --- /dev/null +++ b/lib/templates/content/export/module_index.mustache @@ -0,0 +1,112 @@ +{{! + 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/content/export/module_index + + Renders content for a course index. + This template is not for use within moodle. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * global + + Example context (json): + { + "global": { + "righttoleft": 0, + "language": "en", + "sitename": "Kevin's Emporium of fine course material", + "siteurl": "https://kevin.example.com", + "pathtotop": "./", + "contentexportfooter": "This file is part of the content downloaded from Kevin's Emporium of fine course material by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.", + "contentexportsummary": "This file is part of the content downloaded from Kevin's Emporium of fine course material on Tuesday, 24th February 2021, 12:21 am.", + "coursename": "Marketing 101", + "courseshortname": "MKT101", + "courselink": "https://example.com/course/view.php?id=4", + "exportdate": "Tuesday, 24th February 2021, 12:21 am", + "maxfilesize": "40MB" + }, + "modulename": "Data Structures - Discussion", + "modulelink": "https://kevin.example.com/mod/forum/view.php?id=53", + "intro": "

This forum provides a place for you to discuss the concepts of data structures.

", + "sections": [ + { + "title": "The title set in an exportable_item", + "content": "

Some text area included in an exportable_item.

", + "files": [ + { + "filename": "Example file which was not included in the text field above", + "filepath": "./sections/0/_files" + } + ] + } + ] + } +}} +{{ + {{{modulename}}} + + +
+ {{#str}}contentexport_modulesummary, core, +{ + "modulelink": "{{modulelink}}", + "modulename": {{#quote}}{{{modulename}}}{{/quote}}, + "date": "{{global.exportdate}}", + "maxfilesize": "{{global.maxfilesize}}" +} + {{/str}} +
+ + {{#intro}} +

{{#str}}moduleintro, core{{/str}}

+
+ {{{intro}}} +
+
+ {{/intro}} + + {{#sections}} +
+

{{{title}}}

+
+ {{#content}} +
+ {{{content}}} +
+ {{/content}} + + {{#files.0}} + + {{/files.0}} +
+
+
+ {{/sections}} +{{/pagecontent}} +{{/core/content/export/external_page}} diff --git a/lib/tests/content/export/exportable_items/exportable_filearea_test.php b/lib/tests/content/export/exportable_items/exportable_filearea_test.php new file mode 100644 index 00000000000..4e6d09ee2c8 --- /dev/null +++ b/lib/tests/content/export/exportable_items/exportable_filearea_test.php @@ -0,0 +1,254 @@ +. + +/** + * Unit tests for core\content\exportable_items\exportable_filearea. + * + * @package core + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use advanced_testcase; +use context; +use context_system; +use core\content\export\zipwriter; +use core\content\export\exported_item; +use moodle_url; +use stdClass; + +/** + * Unit tests for the `exportable_filearea` export item class. + * + * @coversDefaultClass core\content\exportable_items\exportable_filearea + */ +class exportable_filearea_test extends advanced_testcase { + + /** + * Ensure that the the exportable_filearea does not fetch files when none exist. + */ + public function test_no_files(): void { + $exportable = new exportable_filearea( + context_system::instance(), + 'fake', + 'Some fake filearea', + 'filearea', + 1 + ); + + $this->assertInstanceOf(exportable_filearea::class, $exportable); + } + + /** + * Ensure that the exportable_filearea returns all stored_file items for only the specified itemid, but those which + * are not included in the archive receive a pluginfile URL. + */ + public function test_specified_itemid_excluded_from_zip(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + $otherfiles2 = $this->create_files(context_system::instance(), $component, "other{$filearea}", 2); + + $exportable = new exportable_filearea( + $context, + $component, + 'Some filearea description', + $filearea, + 2 + ); + + // There is only one exportable. + $this->assertInstanceOf(exportable_filearea::class, $exportable); + + $file2 = reset($files2); + $item = $this->assert_exportable_matches_file($component, $user, $context, $filearea, '', $files2, false, $exportable); + $this->assertCount(count($files2), $item->get_all_files()); + $comparisonurl = new moodle_url('/tokenpluginfile.php/'); + foreach ($item->get_all_files() as $url) { + $this->assertStringStartsWith($comparisonurl->out(false), $url->filepath); + } + } + + /** + * Ensure that the exportable_filearea returns all stored_file items for only the specified itemid. + */ + public function test_specified_itemid(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + $otherfiles2 = $this->create_files(context_system::instance(), $component, "other{$filearea}", 2); + + $exportable = new exportable_filearea( + $context, + $component, + 'Some filearea description', + $filearea, + 2 + ); + + // There is only one exportable. + $this->assertInstanceOf(exportable_filearea::class, $exportable); + + $file2 = reset($files2); + $item = $this->assert_exportable_matches_file($component, $user, $context, $filearea, '', $files2, true, $exportable); + $this->assertCount(count($files2), $item->get_all_files()); + } + + /** + * Ensure that the exportable_filearea returns all stored_files into the correct file location. + */ + public function test_in_subdir(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + $subdir = 'a/path/to/my/subdir'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + + $exportable = new exportable_filearea( + $context, + $component, + 'Some filearea description', + $filearea, + 2, + 2, + $subdir + ); + + // There is only one exportable. + $this->assertInstanceOf(exportable_filearea::class, $exportable); + + $item = $this->assert_exportable_matches_file($component, $user, $context, $filearea, $subdir, $files2, true, $exportable); + $this->assertCount(count($files2), $item->get_all_files()); + } + + /** + * Create files for use in testing. + * + * @param context $context + * @param string $component + * @param string $filearea + * @param int $itemid + * @param int $count + * @return filearea[] + */ + protected function create_files(context $context, string $component, string $filearea, int $itemid, int $count = 1): array { + $fs = get_file_storage(); + + $files = []; + for ($i = 0; $i < $count; $i++) { + + $filepath = '/'; + for ($j = 0; $j < $i; $j++) { + $filepath .= "{$j}/"; + } + + $files[] = $fs->create_file_from_string( + (object) [ + 'contextid' => $context->id, + 'component' => $component, + 'filearea' => $filearea, + 'filepath' => $filepath, + 'filename' => "file.txt", + 'itemid' => $itemid, + ], + "File content: {$i}" + ); + } + + return $files; + } + + /** + * Assert that the supplied expotable matches the supplied file. + * + * @param string $component + * @param stdClass $user + * @param context $context + * @param string $filearea + * @param string $subdir + * @param stored_file[] $expectedfiles + * @param bool $addfilestozip Whether to allow files to be added to the archive + * @param exportable_filearea $exportable + * @return exported_item + */ + protected function assert_exportable_matches_file( + string $component, + stdClass $user, + context $context, + string $filearea, + string $subdir, + array $expectedfiles, + bool $addfilestozip, + exportable_filearea $exportable + ): exported_item { + $archive = $this->getMockBuilder(zipwriter::class) + ->setConstructorArgs([$this->getMockBuilder(\ZipStream\ZipStream::class)->getmock()]) + ->setMethods([ + 'add_file_from_stored_file', + 'is_file_in_archive', + ]) + ->getMock(); + + $archive->expects($this->any()) + ->method('is_file_in_archive') + ->willReturn($addfilestozip); + + $storedfileargs = []; + foreach ($expectedfiles as $file) { + $filepathinzip = $subdir . '/' . $file->get_filearea() . '/' . $file->get_filepath() . $file->get_filename(); + $filepathinzip = ltrim(preg_replace('#/+#', '/', $filepathinzip), '/'); + $storedfileargs[] = [ + $this->equalTo($context), + $this->equalTo($filepathinzip), + $this->equalTo($file), + ]; + } + + $archive->expects($this->exactly(count($expectedfiles))) + ->method('add_file_from_stored_file') + ->withConsecutive(...$storedfileargs); + + return $exportable->add_to_archive($archive); + } +} diff --git a/lib/tests/content/export/exportable_items/exportable_stored_file_test.php b/lib/tests/content/export/exportable_items/exportable_stored_file_test.php new file mode 100644 index 00000000000..dab75e5d1c5 --- /dev/null +++ b/lib/tests/content/export/exportable_items/exportable_stored_file_test.php @@ -0,0 +1,232 @@ +. + +/** + * Unit tests for core\content\exportable_items\exportable_stored_file. + * + * @package core + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use advanced_testcase; +use context; +use context_system; +use core\content\export\zipwriter; +use stdClass; +use stored_file; + +/** + * Unit tests for the `exportable_stored_file` export item class. + * + * @coversDefaultClass core\content\exportable_items\exportable_stored_file + */ +class exportable_stored_file_test extends advanced_testcase { + + /** + * Ensure that the create_from_area_params function returns an array. + */ + public function test_create_from_area_params_no_files(): void { + $exportables = exportable_stored_file::create_from_area_params( + context_system::instance(), + 'fake', + 'filearea', + null + ); + + $this->assertIsArray($exportables); + $this->assertCount(0, $exportables); + } + + /** + * Ensure that the create_from_area_params function returns a set of exportable_stored_file items, for all itemids. + */ + public function test_create_from_area_params_no_itemid(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + $files = array_values(array_merge($files1, $files2, $files3)); + + $exportables = exportable_stored_file::create_from_area_params($context, $component, $filearea, null); + + $this->assertIsArray($exportables); + $this->assertCount(3, $exportables); + + // There should be three exportables. These are listed in order of itemid. + for ($i = 0; $i < 3; $i++) { + $exportable = $exportables[$i]; + $file = $files[$i]; + + $this->assertInstanceOf(exportable_stored_file::class, $exportable); + $this->assert_exportable_matches_file($component, $user, $context, $filearea, '', $file, $exportable); + } + + } + + /** + * Ensure that the create_from_area_params function returns a set of exportable_stored_file items, for the requested + * itemid + */ + public function test_create_from_area_params_specified_itemid(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + + $exportables = exportable_stored_file::create_from_area_params($context, $component, $filearea, 2); + + $this->assertIsArray($exportables); + $this->assertCount(1, $exportables); + + // There is only one exportable. + $exportable = array_shift($exportables); + $this->assertInstanceOf(exportable_stored_file::class, $exportable); + + $file2 = reset($files2); + $this->assert_exportable_matches_file($component, $user, $context, $filearea, '', $file2, $exportable); + } + + /** + * Ensure that the create_from_area_params function returns a set of exportable_stored_file items, for the requested + * itemid + */ + public function test_create_from_area_params_in_subdir(): void { + $this->resetAfterTest(true); + + // Setup for test. + $user = $this->getDataGenerator()->create_user(); + $context = context_system::instance(); + $component = 'fake'; + $filearea = 'myfirstfilearea'; + $subdir = 'a/path/to/my/subdir'; + + $files1 = $this->create_files(context_system::instance(), $component, $filearea, 1); + $files2 = $this->create_files(context_system::instance(), $component, $filearea, 2); + $files3 = $this->create_files(context_system::instance(), $component, $filearea, 3); + + $exportables = exportable_stored_file::create_from_area_params($context, $component, $filearea, 2, 2, $subdir); + + $this->assertIsArray($exportables); + $this->assertCount(1, $exportables); + + // There is only one exportable. + $exportable = array_shift($exportables); + $this->assertInstanceOf(exportable_stored_file::class, $exportable); + + $file2 = reset($files2); + $this->assert_exportable_matches_file($component, $user, $context, $filearea, $subdir, $file2, $exportable); + } + + /** + * Create files for use in testing. + * + * @param context $context + * @param string $component + * @param string $filearea + * @param int $itemid + * @param int $count + * @return stored_file[] + */ + protected function create_files(context $context, string $component, string $filearea, int $itemid, int $count = 1): array { + $fs = get_file_storage(); + + $files = []; + for ($i = 0; $i < $count; $i++) { + + $filepath = '/'; + for ($j = 0; $j < $i; $j++) { + $filepath .= "{$j}/"; + } + + $files[] = $fs->create_file_from_string( + (object) [ + 'contextid' => $context->id, + 'component' => $component, + 'filearea' => $filearea, + 'filepath' => $filepath, + 'filename' => "file.txt", + 'itemid' => $itemid, + ], + "File content: {$i}" + ); + } + + return $files; + } + + /** + * Assert that the supplied expotable matches the supplied file. + * + * @param string $component + * @param stdClass $user + * @param context $context + * @param string $filearea + * @param string $subdir + * @param stored_file $file + * @param exportable_stored_file $exportable + */ + protected function assert_exportable_matches_file( + string $component, + stdClass $user, + context $context, + string $filearea, + string $subdir, + stored_file $file, + exportable_stored_file $exportable + ): void { + $archive = $this->getMockBuilder(zipwriter::class) + ->setConstructorArgs([$this->getMockBuilder(\ZipStream\ZipStream::class)->getmock()]) + ->setMethods([ + 'add_file_from_stored_file', + ]) + ->getMock(); + + $this->assertEquals($file->get_filepath() . $file->get_filename(), $exportable->get_user_visible_name()); + + $expectedfilepath = implode('/', array_filter([$subdir, $filearea, $file->get_filepath(), $file->get_filename()])); + $expectedfilepath = preg_replace('#/+#', '/', $expectedfilepath); + + $archive->expects($this->once()) + ->method('add_file_from_stored_file') + ->with( + $this->equalTo($context), + $this->equalTo($expectedfilepath), + $this->equalTo($file) + ); + + $exportable->add_to_archive($archive); + } +} diff --git a/lib/tests/content/export/exportable_items/exportable_textarea_test.php b/lib/tests/content/export/exportable_items/exportable_textarea_test.php new file mode 100644 index 00000000000..51b1196b3c3 --- /dev/null +++ b/lib/tests/content/export/exportable_items/exportable_textarea_test.php @@ -0,0 +1,376 @@ +. + +/** + * Unit tests for core\content\exportable_items\exportable_textarea. + * + * @package core + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core\content\export\exportable_items; + +use advanced_testcase; +use context; +use context_module; +use context_system; +use core\content\export\zipwriter; +use moodle_url; +use stdClass; + +/** + * Unit tests for the `exportable_textarea` export item class. + * + * @coversDefaultClass core\content\exportable_items\exportable_textarea + */ +class exportable_textarea_test extends advanced_testcase { + + /** + * Ensure that an exportable textarea which does not relate to any content, does not attempt to export any content. + */ + public function test_valid_table_without_content(): void { + $this->resetAfterTest(true); + + $user = $this->getDataGenerator()->create_user(); + + $context = context_system::instance(); + $component = 'page'; + $uservisiblename = 'Page content'; + $tablename = 'page'; + $fieldname = 'content'; + $fieldid = -1; + $formatfieldname = 'contentformat'; + $filearea = 'content'; + + $exportable = new exportable_textarea( + $context, + $component, + $uservisiblename, + $tablename, + $fieldname, + $fieldid, + $formatfieldname + ); + + $this->assertInstanceOf(exportable_textarea::class, $exportable); + + $this->assert_exportable_empty($component, $user, $context, $exportable); + } + + /** + * Ensure that the an exportable textarea exports content from the appropriate locations, but without any files. + */ + public function test_valid_table_with_content_no_filearea_specified(): void { + $this->resetAfterTest(true); + + $content = '

Hello

World!

'; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', (object) [ + 'course' => $course, + 'content' => $content, + 'contentformat' => FORMAT_HTML, + ]); + + $context = context_module::instance($page->cmid); + $expectedfiles = $this->create_files($context, 'mod_page', 'content', (int) $page->id, 5); + + // Unexpected files. + $this->create_files($context, 'mod_page', 'content', (int) $page->id + 1, 5); + $this->create_files($context, 'mod_page', 'othercontent', (int) $page->id, 5); + $this->create_files($context, 'mod_foo', 'content', (int) $page->id, 5); + + $component = 'page'; + $uservisiblename = 'Page content'; + $tablename = 'page'; + $fieldname = 'content'; + $fieldid = (int) $page->id; + $formatfieldname = 'contentformat'; + + $exportable = new exportable_textarea( + $context, + $component, + $uservisiblename, + $tablename, + $fieldname, + $fieldid, + $formatfieldname + ); + + $this->assertInstanceOf(exportable_textarea::class, $exportable); + + // Although files exist, the filearea and itemid were not included. + $this->assert_exportable_matches_file($component, $user, $context, null, $content, [], '', $exportable); + } + + /** + * Ensure that the an exportable textarea exports content from the appropriate locations, but without any files. + */ + public function test_valid_table_with_content_no_itemid_specified(): void { + $this->resetAfterTest(true); + + $content = '

Hello

World!

'; + + $user = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', (object) [ + 'course' => $course, + 'content' => $content, + 'contentformat' => FORMAT_HTML, + ]); + + $context = context_module::instance($page->cmid); + $expectedfiles = $this->create_files($context, 'mod_page', 'content', (int) $page->id, 5); + + // Unexpected files. + $this->create_files($context, 'mod_page', 'content', (int) $page->id + 1, 5); + $this->create_files($context, 'mod_page', 'othercontent', (int) $page->id, 5); + $this->create_files($context, 'mod_foo', 'content', (int) $page->id, 5); + + $component = 'page'; + $uservisiblename = 'Page content'; + $tablename = 'page'; + $fieldname = 'content'; + $fieldid = (int) $page->id; + $formatfieldname = 'contentformat'; + $filearea = 'content'; + + $exportable = new exportable_textarea( + $context, + $component, + $uservisiblename, + $tablename, + $fieldname, + $fieldid, + $formatfieldname, + $filearea + ); + + $this->assertInstanceOf(exportable_textarea::class, $exportable); + + // Although files exist, the filearea and itemid were not included. + $this->assert_exportable_matches_file($component, $user, $context, null, $content, [], '', $exportable); + } + + /** + * Ensure that the an exportable textarea exports content from the appropriate locations, with files. + */ + public function test_valid_table_with_content_and_files(): void { + $this->resetAfterTest(true); + $user = $this->getDataGenerator()->create_user(); + + $contentin = <<Hello

World!

+ + +EOF; + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', (object) [ + 'course' => $course, + 'content' => $contentin, + 'contentformat' => FORMAT_HTML, + ]); + + $this->setUser($user); + + $context = context_module::instance($page->cmid); + $expectedfiles = $this->create_files( + $context, + 'mod_page', + 'content', + (int) $page->id, + 5, + 'contentformat', + 'content', + (int) $page->id, + 5 + ); + + // Unexpected files. + $this->create_files($context, 'mod_page', 'content', (int) $page->id + 1, 5); + $this->create_files($context, 'mod_page', 'othercontent', (int) $page->id, 5); + $this->create_files($context, 'mod_foo', 'content', (int) $page->id, 5); + + $component = 'mod_page'; + $uservisiblename = 'Page content'; + $tablename = 'page'; + $fieldname = 'content'; + $fieldid = (int) $page->id; + $formatfieldname = 'contentformat'; + $filearea = 'content'; + $itemid = (int) $page->id; + + $exportable = new exportable_textarea( + $context, + $component, + $uservisiblename, + $tablename, + $fieldname, + $fieldid, + $formatfieldname, + $filearea, + $itemid, + null + ); + + $this->assertInstanceOf(exportable_textarea::class, $exportable); + + $pluginfilebase = moodle_url::make_pluginfile_url( + $context->id, $component, $filearea, null, '', '', false, true + )->out(false); + $expectedcontent = <<Hello

World!

+ + +EOF; + + // Although files exist, the filearea and itemid were not included. + $this->assert_exportable_matches_file( + $component, $user, $context, $filearea, $expectedcontent, $expectedfiles, '', $exportable + ); + } + + /** + * Create files for use in testing. + * + * @param context $context + * @param string $component + * @param string $filearea + * @param int $itemid + * @param int $count + * @return stored_file[] + */ + protected function create_files(context $context, string $component, string $filearea, int $itemid, int $count = 1): array { + $fs = get_file_storage(); + + $files = []; + for ($i = 0; $i < $count; $i++) { + + $filepath = '/'; + for ($j = 0; $j < $i; $j++) { + $filepath .= "{$j}/"; + } + + $files[] = $fs->create_file_from_string( + (object) [ + 'contextid' => $context->id, + 'component' => $component, + 'filearea' => $filearea, + 'filepath' => $filepath, + 'filename' => "file.txt", + 'itemid' => $itemid, + ], + "File content: {$i}" + ); + } + + return $files; + } + + /** + * Assert that the supplied expotable matches the supplied file. + * + * @param string $component + * @param stdClass $user + * @param context $context + * @param string $filearea + * @param string $content + * @param stored_file[] $expectedfiles + * @param string $subdir + * @param exportable_textarea $exportable + */ + protected function assert_exportable_matches_file( + string $component, + stdClass $user, + context $context, + ?string $filearea, + string $content, + array $expectedfiles, + string $subdir, + exportable_textarea $exportable + ): void { + $archive = $this->getMockBuilder(zipwriter::class) + ->setConstructorArgs([$this->getMockBuilder(\ZipStream\ZipStream::class)->getmock()]) + ->setMethods([ + 'is_file_in_archive', + 'add_file_from_string', + 'add_file_from_stored_file', + ]) + ->getMock(); + + $archive->expects($this->any()) + ->method('is_file_in_archive') + ->willReturn(true); + + $storedfileargs = []; + foreach ($expectedfiles as $file) { + $filepathinzip = dirname($subdir) . $file->get_filearea() . '/' . $file->get_filepath() . $file->get_filename(); + $filepathinzip = ltrim(preg_replace('#/+#', '/', $filepathinzip), '/'); + $storedfileargs[] = [ + $this->equalTo($context), + $this->equalTo($filepathinzip), + $this->equalTo($file), + ]; + } + + $archive->expects($this->exactly(count($expectedfiles))) + ->method('add_file_from_stored_file') + ->withConsecutive(...$storedfileargs); + + $archive->expects($this->never()) + ->method('add_file_from_string'); + + $exportable->add_to_archive($archive); + } + + /** + * Assert that the supplied expotable matches the supplied file. + * + * @param string $component + * @param stdClass $user + * @param context $context + * @param exportable_textarea $exportable + */ + protected function assert_exportable_empty( + string $component, + stdClass $user, + context $context, + exportable_textarea $exportable + ): void { + $archive = $this->getMockBuilder(zipwriter::class) + ->setConstructorArgs([$this->getMockBuilder(\ZipStream\ZipStream::class)->getmock()]) + ->setMethods([ + 'add_file_from_stored_file', + 'add_file_from_string', + 'add_file_from_template', + ]) + ->getMock(); + + $archive->expects($this->never()) + ->method('add_file_from_stored_file'); + $archive->expects($this->never()) + ->method('add_file_from_string'); + $archive->expects($this->never()) + ->method('add_file_from_template'); + + $exportable->add_to_archive($archive); + } +} diff --git a/lib/tests/content/export/exporters/course_exporter_test.php b/lib/tests/content/export/exporters/course_exporter_test.php new file mode 100644 index 00000000000..211575633e5 --- /dev/null +++ b/lib/tests/content/export/exporters/course_exporter_test.php @@ -0,0 +1,155 @@ +. + +/** + * Unit tests for core\content\export\exporters\course_exporter. + * + * @package core + * @category test + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core\content\export\exporters; + +use advanced_testcase; +use context_course; +use context_module; +use ZipArchive; +use core\content\export\zipwriter; + +/** + * Unit tests for activity exporter. + * + * @coversDefaultClass \core\content\export\exporters\course_exporter + */ +class course_exporter_test extends advanced_testcase { + + /** + * The course_exporter should still export a module intro when no exportables are passed. + */ + public function test_no_exportables_exported(): void { + $this->resetAfterTest(true); + + $generator = $this->getDataGenerator(); + + $course = $generator->create_course(); + $coursecontext = context_course::instance($course->id); + + $intro = 'XX Some introduction should go here XX'; + $content = 'YY Some content should go here YY'; + $module = $generator->create_module('page', [ + 'course' => $course->id, + 'intro' => $intro, + 'content' => $content, + ]); + $modcontext = context_module::instance($module->cmid); + + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + + // Only the module index should be added. + $archive = $this->get_mocked_zipwriter(['add_file_from_string']); + $archive->expects($this->once()) + ->method('add_file_from_string') + ->with( + $modcontext, + 'index.html', + $this->callback(function($html) use ($intro, $content): bool { + if (strpos($html, $intro) === false) { + return false; + } + + if (strpos($html, $content) !== false) { + // The content as not exported. + return false; + } + + return true; + }) + ); + $archive->set_root_context($coursecontext); + + $coursecontroller = new course_exporter($modcontext->get_course_context(), $user, $archive); + $coursecontroller->export_mod_content($modcontext, []); + } + + /** + * The course_exporter should still export exportables as well as module intro. + */ + public function test_exportables_exported(): void { + $this->resetAfterTest(true); + + $generator = $this->getDataGenerator(); + + $course = $generator->create_course(); + $coursecontext = context_course::instance($course->id); + + $intro = 'XX Some introduction should go here XX'; + $content = 'YY Some content should go here YY'; + $module = $generator->create_module('page', [ + 'course' => $course->id, + 'intro' => $intro, + 'content' => $content, + ]); + $modcontext = context_module::instance($module->cmid); + + $user = $generator->create_user(); + $generator->enrol_user($user->id, $course->id); + + // Only the module index should be added. + $archive = $this->get_mocked_zipwriter(['add_file_from_string']); + $archive->expects($this->once()) + ->method('add_file_from_string') + ->with( + $modcontext, + 'index.html', + $this->callback(function($html) use ($intro, $content): bool { + if (strpos($html, $intro) === false) { + return false; + } + + if (strpos($html, $content) === false) { + // Content was exported. + return false; + } + + return true; + }) + ); + $archive->set_root_context($coursecontext); + + $pagecontroller = new \mod_page\content\exporter($modcontext, "mod_page", $user, $archive); + + $coursecontroller = new course_exporter($modcontext->get_course_context(), $user, $archive); + $coursecontroller->export_mod_content($modcontext, $pagecontroller->get_exportables()); + } + + /** + * Get a mocked zipwriter instance, stubbing the supplieid classes. + * + * @param string[] $methods + * @return zipwriter + */ + protected function get_mocked_zipwriter(?array $methods = []): zipwriter { + return $this->getMockBuilder(zipwriter::class) + ->setConstructorArgs([$this->getMockBuilder(\ZipStream\ZipStream::class)->getmock()]) + ->setMethods($methods) + ->getMock(); + } +} diff --git a/lib/tests/content/export/zipwriter_test.php b/lib/tests/content/export/zipwriter_test.php new file mode 100644 index 00000000000..8c301659e34 --- /dev/null +++ b/lib/tests/content/export/zipwriter_test.php @@ -0,0 +1,119 @@ +. + +/** + * Unit tests for core\content\zipwriter. + * + * @package core + * @category test + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core\content\export; + +use advanced_testcase; +use context_module; +use context_system; +use ZipArchive; + +/** + * Unit tests for core\content\zipwriter. + * + * @coversDefaultClass \core\content\export\zipwriter + */ +class zipwriter_test extends advanced_testcase { + + /** + * Test add_file_from_stored_file(). + */ + public function test_add_file_from_stored_file(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $folder = $this->getDataGenerator()->create_module('folder', ['course' => $course->id]); + $context = \context_course::instance($course->id); + + // Add a file to the intro. + $fileintroname = "fileintro.txt"; + $filerecord = [ + 'contextid' => context_module::instance($folder->cmid)->id, + 'component' => 'mod_folder', + 'filearea' => 'intro', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $fileintroname, + ]; + $fs = get_file_storage(); + $storedfile = $fs->create_file_from_string($filerecord, 'image contents'); + + $pathinfolder = $storedfile->get_filepath() . $storedfile->get_filename(); + + $zipwriter = zipwriter::get_file_writer('test.zip'); + $zipwriter->add_file_from_stored_file($context, $pathinfolder, $storedfile); + $zipwriter->finish(); + + $zipfilepath = $zipwriter->get_file_path(); + $zip = new ZipArchive(); + $opened = $zip->open($zipfilepath); + $this->assertTrue($opened); + + $pathinzip = $zipwriter->get_context_path($context, $pathinfolder); + $this->assertEquals($storedfile->get_content(), $zip->getFromName($pathinzip)); + } + + /** + * Test add_file_from_string(). + */ + public function test_add_file_from_string(): void { + $context = context_system::instance(); + + $pathinfolder = "/path/to/my/file.txt"; + $mycontent = "Zippidy do dah"; + + $zipwriter = zipwriter::get_file_writer('test.zip'); + $zipwriter->add_file_from_string($context, $pathinfolder, $mycontent); + $zipwriter->finish(); + + $zipfilepath = $zipwriter->get_file_path(); + $zip = new ZipArchive(); + $opened = $zip->open($zipfilepath); + $this->assertTrue($opened); + + $pathinzip = ltrim($zipwriter->get_context_path($context, $pathinfolder), '/'); + $this->assertEquals($mycontent, $zip->getFromName($pathinzip)); + } + + /** + * Test get_file_writer(). + */ + public function test_get_file_writer(): void { + $zipwriter = zipwriter::get_file_writer('test.zip'); + $this->assertInstanceOf(zipwriter::class, $zipwriter); + $this->assertTrue(file_exists($zipwriter->get_file_path())); + } + + /** + * Test get_stream_writer(). + */ + public function test_get_stream_writer(): void { + $zipwriter = zipwriter::get_stream_writer('test.zip'); + $this->assertInstanceOf(zipwriter::class, $zipwriter); + } +}