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