Merge branch 'MDL-69549-310-7' of git://github.com/andrewnicols/moodle into MOODLE_31...
authorAdrian Greeve <abgreeve@gmail.com>
Thu, 15 Oct 2020 07:11:29 +0000 (15:11 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Thu, 15 Oct 2020 07:11:29 +0000 (15:11 +0800)
23 files changed:
lang/en/moodle.php
lib/classes/content.php [new file with mode: 0644]
lib/classes/content/export/exportable_item.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_filearea.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_stored_file.php [new file with mode: 0644]
lib/classes/content/export/exportable_items/exportable_textarea.php [new file with mode: 0644]
lib/classes/content/export/exported_item.php [new file with mode: 0644]
lib/classes/content/export/exporters/abstract_mod_exporter.php [new file with mode: 0644]
lib/classes/content/export/exporters/component_exporter.php [new file with mode: 0644]
lib/classes/content/export/exporters/course_exporter.php [new file with mode: 0644]
lib/classes/content/export/zipwriter.php [new file with mode: 0644]
lib/templates/content/export/course_index.mustache [new file with mode: 0644]
lib/templates/content/export/course_summary.mustache [new file with mode: 0644]
lib/templates/content/export/external_page.mustache [new file with mode: 0644]
lib/templates/content/export/module_index.mustache [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_filearea_test.php [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_stored_file_test.php [new file with mode: 0644]
lib/tests/content/export/exportable_items/exportable_textarea_test.php [new file with mode: 0644]
lib/tests/content/export/exporters/course_exporter_test.php [new file with mode: 0644]
lib/tests/content/export/zipwriter_test.php [new file with mode: 0644]
mod/folder/classes/content/exporter.php [new file with mode: 0644]
mod/page/classes/content/exporter.php [new file with mode: 0644]
mod/resource/classes/content/exporter.php [new file with mode: 0644]

index 3db9de3..3fe5a85 100644 (file)
@@ -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 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';
diff --git a/lib/classes/content.php b/lib/classes/content.php
new file mode 100644 (file)
index 0000000..1699213
--- /dev/null
@@ -0,0 +1,150 @@
+<?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();
+    }
+}
diff --git a/lib/classes/content/export/exportable_item.php b/lib/classes/content/export/exportable_item.php
new file mode 100644 (file)
index 0000000..4bfb5b6
--- /dev/null
@@ -0,0 +1,96 @@
+<?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;
+}
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 (file)
index 0000000..754903c
--- /dev/null
@@ -0,0 +1,179 @@
+<?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);
+    }
+}
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 (file)
index 0000000..3862783
--- /dev/null
@@ -0,0 +1,202 @@
+<?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);
+    }
+}
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 (file)
index 0000000..246d478
--- /dev/null
@@ -0,0 +1,166 @@
+<?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;
+    }
+}
diff --git a/lib/classes/content/export/exported_item.php b/lib/classes/content/export/exported_item.php
new file mode 100644 (file)
index 0000000..21e2823
--- /dev/null
@@ -0,0 +1,198 @@
+<?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;
+    }
+}
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 (file)
index 0000000..52e35f2
--- /dev/null
@@ -0,0 +1,69 @@
+<?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;
+    }
+}
diff --git a/lib/classes/content/export/exporters/component_exporter.php b/lib/classes/content/export/exporters/component_exporter.php
new file mode 100644 (file)
index 0000000..8f279fe
--- /dev/null
@@ -0,0 +1,112 @@
+<?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";
+    }
+}
diff --git a/lib/classes/content/export/exporters/course_exporter.php b/lib/classes/content/export/exporters/course_exporter.php
new file mode 100644 (file)
index 0000000..cc22730
--- /dev/null
@@ -0,0 +1,274 @@
+<?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;
+    }
+}
diff --git a/lib/classes/content/export/zipwriter.php b/lib/classes/content/export/zipwriter.php
new file mode 100644 (file)
index 0000000..ed2e2d2
--- /dev/null
@@ -0,0 +1,563 @@
+<?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), '/');
+    }
+
+}
diff --git a/lib/templates/content/export/course_index.mustache b/lib/templates/content/export/course_index.mustache
new file mode 100644 (file)
index 0000000..be1a5e5
--- /dev/null
@@ -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 <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}}
diff --git a/lib/templates/content/export/course_summary.mustache b/lib/templates/content/export/course_summary.mustache
new file mode 100644 (file)
index 0000000..1c687c9
--- /dev/null
@@ -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 <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}}
diff --git a/lib/templates/content/export/external_page.mustache b/lib/templates/content/export/external_page.mustache
new file mode 100644 (file)
index 0000000..4f5f0f8
--- /dev/null
@@ -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 <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>
diff --git a/lib/templates/content/export/module_index.mustache b/lib/templates/content/export/module_index.mustache
new file mode 100644 (file)
index 0000000..4b49482
--- /dev/null
@@ -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 <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}}
diff --git a/lib/tests/content/export/exportable_items/exportable_filearea_test.php b/lib/tests/content/export/exportable_items/exportable_filearea_test.php
new file mode 100644 (file)
index 0000000..4e6d09e
--- /dev/null
@@ -0,0 +1,254 @@
+<?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);
+    }
+}
diff --git a/lib/tests/content/export/exportable_items/exportable_stored_file_test.php b/lib/tests/content/export/exportable_items/exportable_stored_file_test.php
new file mode 100644 (file)
index 0000000..dab75e5
--- /dev/null
@@ -0,0 +1,232 @@
+<?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);
+    }
+}
diff --git a/lib/tests/content/export/exportable_items/exportable_textarea_test.php b/lib/tests/content/export/exportable_items/exportable_textarea_test.php
new file mode 100644 (file)
index 0000000..51b1196
--- /dev/null
@@ -0,0 +1,376 @@
+<?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);
+    }
+}
diff --git a/lib/tests/content/export/exporters/course_exporter_test.php b/lib/tests/content/export/exporters/course_exporter_test.php
new file mode 100644 (file)
index 0000000..2115756
--- /dev/null
@@ -0,0 +1,155 @@
+<?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();
+    }
+}
diff --git a/lib/tests/content/export/zipwriter_test.php b/lib/tests/content/export/zipwriter_test.php
new file mode 100644 (file)
index 0000000..8c30165
--- /dev/null
@@ -0,0 +1,119 @@
+<?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);
+    }
+}
diff --git a/mod/folder/classes/content/exporter.php b/mod/folder/classes/content/exporter.php
new file mode 100644 (file)
index 0000000..159ad8c
--- /dev/null
@@ -0,0 +1,61 @@
+<?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 export definition.
+ *
+ * @package     mod_folder
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_folder\content;
+
+use core\content\export\exportable_items\exportable_filearea;
+use core\content\export\exporters\abstract_mod_exporter;
+
+/**
+ * A class which assists a component to export content.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporter extends abstract_mod_exporter {
+
+    /**
+     * Get the exportable items for mod_folder.
+     *
+     * @param   bool $includeuserdata Whether to include user data, in addition to shared content.
+     * @return  \core\content\export\exportable_item[]
+     */
+    public function get_exportables(bool $includeuserdata = false): array {
+        $contentitems = [];
+
+        $contentitems[]  = new exportable_filearea(
+            $this->get_context(),
+            $this->get_component(),
+            get_string('foldercontent', 'mod_folder'),
+
+            // The files held in mod_folder are stored in the 'content' filearea, under itemid 0.
+            'content',
+            0,
+
+            // The itemid is used in the URL when accessing.
+            0
+        );
+
+        return $contentitems;
+    }
+}
diff --git a/mod/page/classes/content/exporter.php b/mod/page/classes/content/exporter.php
new file mode 100644 (file)
index 0000000..55167c7
--- /dev/null
@@ -0,0 +1,67 @@
+<?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 export definition.
+ *
+ * @package     mod_page
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_page\content;
+
+use core\content\export\exportable_items\exportable_textarea;
+use core\content\export\exporters\abstract_mod_exporter;
+
+/**
+ * A class which assists a component to export content.
+ *
+ * @copyright   2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporter extends abstract_mod_exporter {
+
+    /**
+     * Get the exportable items for mod_page.
+     *
+     * @param   bool $includeuserdata Whether to include user data, in addition to shared content.
+     * @return  \core\content\export\exportable_item[]
+     */
+    public function get_exportables(bool $includeuserdata = false): array {
+        $contentitems = [];
+
+        $contentitems[] = new exportable_textarea(
+            $this->get_context(),
+            $this->get_component(),
+            get_string('content', 'mod_page'),
+
+            // Content is in the 'content' field of the 'page' table.
+            $this->get_modname(),
+            'content',
+
+            // The record ID in the database is the CMID.
+            $this->cm->instance,
+            'contentformat',
+
+            // The mod_page content has files in 'content/0', and the itemid (0) is present in the URL.
+            'content',
+            0,
+            0
+        );
+
+        return $contentitems;
+    }
+}
diff --git a/mod/resource/classes/content/exporter.php b/mod/resource/classes/content/exporter.php
new file mode 100644 (file)
index 0000000..255f947
--- /dev/null
@@ -0,0 +1,61 @@
+<?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 export definition.
+ *
+ * @package     mod_resource
+ * @copyright   2020 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_resource\content;
+
+use core\content\export\exportable_items\exportable_filearea;
+use core\content\export\exporters\abstract_mod_exporter;
+
+/**
+ * A class which assists a component to export content.
+ *
+ * @copyright   2020 Simey Lameze <simey@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exporter extends abstract_mod_exporter {
+
+    /**
+     * Get the exportable items for mod_resource.
+     *
+     * @param   bool $includeuserdata Whether to include user data, in addition to shared content.
+     * @return  \core\content\export\exportable_item[]
+     */
+    public function get_exportables(bool $includeuserdata = false): array {
+        $contentitems = [];
+
+        $contentitems[]  = new exportable_filearea(
+            $this->get_context(),
+            $this->get_component(),
+            get_string('resourcecontent', 'mod_resource'),
+
+            // The files held in mod_resource are stored in the 'content' filearea, under itemid 0.
+            'content',
+            0,
+
+            // The itemid is used in the URL when accessing.
+            0
+        );
+
+        return $contentitems;
+    }
+}