$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 href="{$a->courselink}">{$a->coursename}</a>.';
+$string['contentexport_footersummary'] = 'This file is part of the content downloaded from <a href="{$a->courselink}">{$a->coursename}</a> by {$a->userfullname} on {$a->date}';
+$string['contentexport_modulesummary'] = 'This page is part of the content downloaded from <a href="{$a->modulelink}">{$a->modulename}</a> 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';
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Content API File Area definition.
+ *
+ * @package core_files
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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();
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The definition of an item which can be exported.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The definition of a set of files in a filearea to be exported.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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);
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The definition of an item which can be exported.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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);
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The definition of a text area which can be exported.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Exported Item.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Activity module exporter for the content API.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Content API Export definition.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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";
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The course exporter.
+ *
+ * @package core
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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;
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Zip writer wrapper.
+ *
+ * @package core
+ * @copyright 2020 Simey Lameze <simey@moodle.com>
+ * @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 <simey@moodle.com>
+ * @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), '/');
+ }
+
+}
--- /dev/null
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+ @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 <a href='https://example.com'>Kevin's Emporium of fine course material</a> by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.",
+ "contentexportsummary": "This file is part of the content downloaded from <a href='https://example.com/course/view.php?id=4'>Kevin's Emporium of fine course material</a> 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": "<p>Welcome to my <em>awesome</em> course.</p>",
+ "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"
+ }
+ ]
+ }
+ ]
+ }
+}}
+{{<core/content/export/external_page}}
+{{$pagecontent}}
+ <div class="alert alert-info alert-block">
+ {{{global.contentexportsummary}}}
+ </div>
+ {{#aboutpagelink}}
+ <div>
+ <a href="{{{aboutpagelink}}}">{{#str}}contentexport_aboutthiscourse, core{{/str}}</a>
+ </div>
+ {{/aboutpagelink}}
+
+ {{#sections.0}}
+ <div>
+ {{#sections}}
+ <h3>{{{title}}}</h3>
+ {{#summary}}
+ <div>
+ {{{summary}}}
+ </div>
+ {{/summary}}
+ {{#activities.0}}
+ <ul>
+ {{#activities}}
+ <li><a href="{{{link}}}">{{{title}}} ({{{modname}}})</a></li>
+ {{/activities}}
+ </ul>
+ {{/activities.0}}
+ <hr>
+ {{/sections}}
+ </div>
+ {{/sections.0}}
+{{/pagecontent}}
+{{/core/content/export/external_page}}
--- /dev/null
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+ @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 <a href='https://example.com'>Kevin's Emporium of fine course material</a> by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.",
+ "contentexportsummary": "This file is part of the content downloaded from <a href='https://example.com/course/view.php?id=4'>Kevin's Emporium of fine course material</a> 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": "<p>This is a summary of the course, and it may contain PLUGINFILE references.</p>",
+ "overviewfiles": [
+ {
+ "filepath": "./_course/overviewfiles/example.pdf",
+ "filename": "Example PDF"
+ },
+ {
+ "filepath": "./_course/overviewfiles/example.jpg",
+ "filename": "Example JPG"
+ }
+ ]
+ }
+}}
+{{<core/content/export/external_page}}
+{{$pagecontent}}
+ <div class="alert alert-info alert-block">
+ {{{global.contentexportsummary}}}
+ </div>
+ {{#summary}}
+ <h2>{{#str}}summary, core{{/str}}</h2>
+ <div>
+ {{{summary}}}
+ </div>
+ <hr>
+ {{/summary}}
+
+ {{#overviewfiles.0}}
+ <h2>{{#str}}courseoverviewfiles, core{{/str}}</h2>
+ <div>
+ {{#overviewfiles.0}}
+ <ul>
+ {{#overviewfiles}}
+ <li><a href="{{filepath}}" title="{{#str}}contentexport_viewfilename, core, {{filename}}{{/str}}">{{filename}}</a></li>
+ {{/overviewfiles}}
+ </ul>
+ {{/overviewfiles.0}}
+ </div>
+ <hr>
+ {{/overviewfiles.0}}
+{{/pagecontent}}
+{{/core/content/export/external_page}}
--- /dev/null
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+ @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 <a href='https://example.com'>Kevin's Emporium of fine course material</a> by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.",
+ "contentexportsummary": "This file is part of the content downloaded from <a href='https://example.com/course/view.php?id=4'>Kevin's Emporium of fine course material</a> 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"
+ }
+ }
+}}
+<!DOCTYPE html>
+<html lang="{{global.language}}"{{#global.righttoleft}} dir="rtl"{{/global.righttoleft}}>
+<head>
+ <meta charset="UTF-8">
+ <title>{{global.sitename}} - {{global.coursename}}</title>
+ <link rel="stylesheet" type="text/css" href="{{global.pathtotop}}/shared/moodle.css" />
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+</head>
+<body>
+ <div class="d-flex flex-column h-100">
+ <nav class="navbar navbar-light bg-white border-bottom">
+ <a class="navbar-brand" href="{{{global.siteurl}}}" title="{{{global.sitename}}}">
+ {{{global.sitename}}}
+ </a>
+ </nav>
+ <main class="container-fluid mt-2 flex-grow-1 flex-shrink-1">
+ <div>
+ <h1><a href="{{global.pathtotop}}index.html">{{global.coursename}}</a></h1>
+ <p>{{global.courseshortname}}</p>
+ {{$pagecontent}}{{/pagecontent}}
+ </div>
+ </main>
+ <footer id="page-footer" class="py-3 bg-dark text-light">
+ <div class="container">
+ <div>{{{global.contentexportfooter}}}</div>
+ </div>
+ </footer>
+ </div>
+</body>
+</html>
--- /dev/null
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+ @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 <a href='https://example.com'>Kevin's Emporium of fine course material</a> by Jennifer Collins on Tuesday, 24th February 2021, 12:21 am.",
+ "contentexportsummary": "This file is part of the content downloaded from <a href='https://example.com/course/view.php?id=4'>Kevin's Emporium of fine course material</a> 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": "<p>This forum provides a place for you to discuss the concepts of data structures.</p>",
+ "sections": [
+ {
+ "title": "The title set in an exportable_item",
+ "content": "<p>Some text area included in an exportable_item.</p>",
+ "files": [
+ {
+ "filename": "Example file which was not included in the text field above",
+ "filepath": "./sections/0/_files"
+ }
+ ]
+ }
+ ]
+ }
+}}
+{{<core/content/export/external_page}}
+{{$pagecontent}}
+ <h2>
+ {{{modulename}}}
+ </h2>
+
+ <div class="alert alert-info alert-block">
+ {{#str}}contentexport_modulesummary, core,
+{
+ "modulelink": "{{modulelink}}",
+ "modulename": {{#quote}}{{{modulename}}}{{/quote}},
+ "date": "{{global.exportdate}}",
+ "maxfilesize": "{{global.maxfilesize}}"
+}
+ {{/str}}
+ </div>
+
+ {{#intro}}
+ <h3>{{#str}}moduleintro, core{{/str}}</h3>
+ <div>
+ {{{intro}}}
+ </div>
+ <hr>
+ {{/intro}}
+
+ {{#sections}}
+ <div>
+ <h3>{{{title}}}</h3>
+ <div>
+ {{#content}}
+ <div>
+ {{{content}}}
+ </div>
+ {{/content}}
+
+ {{#files.0}}
+ <ul>
+ {{#files}}
+ <li><a href="{{filepath}}" title="{{#str}}contentexport_viewfilename, core, {{filename}}{{/str}}">{{filename}}</a></li>
+ {{/files}}
+ </ul>
+ {{/files.0}}
+ </div>
+ </div>
+ <hr>
+ {{/sections}}
+{{/pagecontent}}
+{{/core/content/export/external_page}}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core\content\exportable_items\exportable_filearea.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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);
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core\content\exportable_items\exportable_stored_file.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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);
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core\content\exportable_items\exportable_textarea.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 = '<h1>Hello</h1><p>World!</p>';
+
+ $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 = '<h1>Hello</h1><p>World!</p>';
+
+ $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 = <<<EOF
+<h1>Hello</h1><p>World!</p>
+<img src='@@PLUGINFILE@@/file.txt'>
+<img src='@@PLUGINFILE@@/other/file.txt'>
+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 = <<<EOF
+<h1>Hello</h1><p>World!</p>
+<img src='content/file.txt'>
+<img src='{$pluginfilebase}/other/file.txt'>
+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);
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core\content\export\exporters\course_exporter.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Simey Lameze <simey@moodle.com>
+ * @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();
+ }
+}
--- /dev/null
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core\content\zipwriter.
+ *
+ * @package core
+ * @category test
+ * @copyright 2020 Simey Lameze <simey@moodle.com>
+ * @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);
+ }
+}