MDL-62491 core_privacy: Add html files to the user data export.
authorAdrian Greeve <abgreeve@gmail.com>
Fri, 14 Sep 2018 02:22:14 +0000 (10:22 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Mon, 8 Oct 2018 01:00:08 +0000 (09:00 +0800)
This adds html to the data export that allows for easier navigation
and reading of data.

12 files changed:
lang/en/privacy.php
lib/jquery/readme_moodle.txt
lib/requirejs/readme_moodle.txt
privacy/classes/local/request/moodle_content_writer.php
privacy/classes/output/exported_html_page.php [new file with mode: 0644]
privacy/classes/output/exported_navigation_page.php [new file with mode: 0644]
privacy/classes/output/renderer.php [new file with mode: 0644]
privacy/export_files/general.css [new file with mode: 0644]
privacy/export_files/general.js [new file with mode: 0644]
privacy/templates/htmlpage.mustache [new file with mode: 0644]
privacy/templates/navigation.mustache [new file with mode: 0644]
privacy/tests/moodle_content_writer_test.php

index 95c19a8..0045cb6 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['broughtbymoodle'] = 'This data export provided by Moodle';
+$string['exportfrom'] = 'Exported from {$a}';
+$string['exporttime'] = 'Exported on {$a}';
+$string['exportuser'] = 'Data for {$a}';
 $string['privacy:metadata'] = 'The privacy subsystem does not store any data of its own and is designed to act as a channel between components and the interface used to describe, export, and remove their data.';
 $string['trace:done'] = 'Complete';
 $string['trace:exportcomplete'] = 'Export complete';
@@ -32,4 +36,6 @@ $string['trace:processingcomponent'] = 'Processing {$a->component} ({$a->progres
 $string['trace:fetchcomponents'] = 'Fetching {$a->total} components ({$a->datetime})';
 $string['trace:deletingapproved'] = 'Performing removal of approved {$a->total} contexts ({$a->datetime})';
 $string['trace:deletingcontext'] = 'Performing removal of context from {$a->total} components ({$a->datetime})';
+$string['navigation'] = 'Navigation';
 $string['privacy:subsystem:empty'] = 'This subsystem does not store any data.';
+$string['viewdata'] = 'Click on a link in the navigation to view data.';
index d0840bd..e339c5c 100644 (file)
@@ -13,4 +13,6 @@ Description of import of various jQuery libraries into Moodle:
 
 5/ open http://127.0.0.1/lib/tests/other/jquerypage.php
 
+6/ Update the version of jquery in core_privacy\local\request\moodle_content_writer::write_html_data()
+
 Petr Skoda
index 17ce5e7..8d2a465 100644 (file)
@@ -2,3 +2,4 @@ Description of import into Moodle:
 // Download from https://requirejs.org/docs/download.html
 // Put the require.js and require.min.js and LICENSE file in this folder.
 // Check if MDL-60458 workaround can be removed.
+// Check that core_privacy\local\request\moodle_content_writer::write_html_data() does not need to be updated.
index bf25a06..21504fe 100644 (file)
@@ -52,6 +52,11 @@ class moodle_content_writer implements content_writer {
      */
     protected $files = [];
 
+    /**
+     * @var array The list of plugins that have been checked to see if they are installed.
+     */
+    protected $checkedplugins = [];
+
     /**
      * Constructor for the content writer.
      *
@@ -162,7 +167,17 @@ class moodle_content_writer implements content_writer {
      * @return  string                      The processed string
      */
     public function rewrite_pluginfile_urls(array $subcontext, $component, $filearea, $itemid, $text) : string {
-        return str_replace('@@PLUGINFILE@@/', $this->get_files_target_url($component, $filearea, $itemid).'/', $text);
+        // Need to take into consideration the subcontext to provide the full path to this file.
+        $subcontextpath = '';
+        if (!empty($subcontext)) {
+            $subcontextpath = DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $subcontext);
+        }
+        $path = $this->get_context_path();
+        $path = implode(DIRECTORY_SEPARATOR, $path) . $subcontextpath;
+        $returnstring = $path . DIRECTORY_SEPARATOR . $this->get_files_target_url($component, $filearea, $itemid) . '/';
+        $returnstring = clean_param($returnstring, PARAM_PATH);
+
+        return str_replace('@@PLUGINFILE@@/', $returnstring, $text);
     }
 
     /**
@@ -245,7 +260,7 @@ class moodle_content_writer implements content_writer {
         $contexts = array_reverse($this->context->get_parent_contexts(true));
         foreach ($contexts as $context) {
             $name = $context->get_context_name();
-            $id = $context->id;
+            $id = '_.' . $context->id;
             $path[] = shorten_filename(clean_param("{$name} {$id}", PARAM_FILE), MAX_FILENAME_SIZE, true);
         }
 
@@ -263,6 +278,24 @@ class moodle_content_writer implements content_writer {
         $subcontext = shorten_filenames($subcontext, MAX_FILENAME_SIZE, true);
         $name = shorten_filename($name, MAX_FILENAME_SIZE, true);
 
+        // This weird code is to look for a subcontext that contains a number and append an '_' to the front.
+        // This is because there seems to be some weird problem with array_merge_recursive used in finalise_content().
+        $subcontext = array_map(function($data) {
+            if (stripos($data, DIRECTORY_SEPARATOR) !== false) {
+                $newpath = explode(DIRECTORY_SEPARATOR, $data);
+                $newpath = array_map(function($value) {
+                    if (is_numeric($value)) {
+                        return '_' . $value;
+                    }
+                    return $value;
+                }, $newpath);
+                return implode(DIRECTORY_SEPARATOR, $newpath);
+            } else if (is_numeric($data)) {
+                $data = '_' . $data;
+            }
+            return $data;
+        }, $subcontext);
+
         // Combine the context path, and the subcontext data.
         $path = array_merge(
             $this->get_context_path(),
@@ -331,7 +364,7 @@ class moodle_content_writer implements content_writer {
         $parts = ['_files', $filearea];
 
         if (!empty($itemid)) {
-            $parts[] = $itemid;
+            $parts[] = '_' . $itemid;
         }
 
         return implode('/', $parts);
@@ -350,12 +383,308 @@ class moodle_content_writer implements content_writer {
         $this->files[$path] = $targetpath;
     }
 
+    /**
+     * Copy a file to the specified path.
+     *
+     * @param  array  $path        Current location of the file.
+     * @param  array  $destination Destination path to copy the file to.
+     */
+    protected function copy_data(array $path, array $destination) {
+        // Do we not have a moodle function to do something like this?
+        $systempath = getcwd();
+        // This is likely to be running from admin/cli.
+        if (stripos($systempath, 'admin' . DIRECTORY_SEPARATOR . 'cli') !== false) {
+            $bits = explode('admin' . DIRECTORY_SEPARATOR . 'cli', $systempath);
+            $systempath = implode('', $bits);
+        }
+        $filename = array_pop($destination);
+        $destdirectory = implode(DIRECTORY_SEPARATOR, $destination);
+        $fulldestination = $this->path . DIRECTORY_SEPARATOR . $destdirectory;
+        check_dir_exists($fulldestination, true, true);
+        $fulldestination .= $filename;
+        $currentpath = $systempath . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $path);
+        copy($currentpath, $fulldestination);
+        $this->files[$destdirectory . DIRECTORY_SEPARATOR . $filename] = $fulldestination;
+    }
+
+    /**
+     * This creates three different bits of data from all of the files that will be
+     * exported.
+     * $tree - A multidimensional array of the navigation tree structure.
+     * $treekey - An array with the short path of the file and element data for
+     *            html (data_file_{number} or 'No var')
+     * $allfiles - All *.json files that need to be added as an index to be referenced
+     *             by the js files to display the user data.
+     *
+     * @return array returns a tree, tree key, and a list of all files.
+     */
+    protected function prepare_for_export() : Array {
+        $tree = [];
+        $treekey = [];
+        $allfiles = [];
+        $i = 1;
+        foreach ($this->files as $shortpath => $fullfile) {
+
+            // Generate directory tree as an associative array.
+            $items = explode(DIRECTORY_SEPARATOR, $shortpath);
+            $newitems = $this->condense_array($items);
+            $tree = array_merge_recursive($tree, $newitems);
+
+            if (is_string($fullfile)) {
+                $filearray = explode(DIRECTORY_SEPARATOR, $shortpath);
+                $filename = array_pop($filearray);
+                $filenamearray = explode('.', $filename);
+                // Don't process files that are not json files.
+                if (end($filenamearray) !== 'json') {
+                    continue;
+                }
+                // Chop the last two characters of the extension. json => js.
+                $filename = substr($filename, 0, -2);
+                array_push($filearray, $filename);
+                $newshortpath = implode(DIRECTORY_SEPARATOR, $filearray);
+
+                $varname = 'data_file_' . $i;
+                $i++;
+
+                $quicktemp = clean_param($shortpath, PARAM_PATH);
+                $treekey[$quicktemp] = $varname;
+                $allfiles[$varname] = clean_param($newshortpath, PARAM_PATH);
+
+                // Need to load up the current json file and add a variable (varname mentioned above) at the start.
+                // Then save it as a js file.
+                $content = $this->get_file_content($fullfile);
+                $jsondecodedcontent = json_decode($content);
+                $jsonencodedcontent = json_encode($jsondecodedcontent, JSON_PRETTY_PRINT);
+                $variablecontent = 'var ' . $varname . ' = ' . $jsonencodedcontent;
+
+                $this->write_data($newshortpath, $variablecontent);
+            } else {
+                $treekey[$shortpath] = 'No var';
+            }
+        }
+        return [$tree, $treekey, $allfiles];
+    }
+
+    /**
+     * Add more detail to the tree to help with sorting and display in the renderer.
+     *
+     * @param  array  $tree       The file structure currently as a multidimensional array.
+     * @param  array  $treekey    An array of the current file paths.
+     * @param  array  $currentkey The current short path of the tree.
+     * @return array An array of objects that has additional data.
+     */
+    protected function make_tree_object(array $tree, array $treekey, array $currentkey = []) : Array {
+        $newtree = [];
+        // Try to extract the context id and then add the context object.
+        $addcontext = function($index, $object) {
+            if (stripos($index, '_.') !== false) {
+                $namearray = explode('_.', $index);
+                $contextid = array_pop($namearray);
+                if (is_numeric($contextid)) {
+                    $object[$index]->name = implode('_.', $namearray);
+                    $object[$index]->context = \context::instance_by_id($contextid);
+                }
+            } else {
+                $object[$index]->name = $index;
+            }
+        };
+        // Just add the final data to the tree object.
+        $addfinalfile = function($directory, $treeleaf, $file) use ($treekey) {
+            $url = implode(DIRECTORY_SEPARATOR, $directory);
+            $url = clean_param($url, PARAM_PATH);
+            $treeleaf->name = $file;
+            $treeleaf->itemtype = 'item';
+            $gokey = $url . DIRECTORY_SEPARATOR . $file;
+            if (isset($treekey[$gokey]) && $treekey[$gokey] !== 'No var') {
+                $treeleaf->datavar = $treekey[$gokey];
+            } else {
+                $treeleaf->url = new \moodle_url($url . DIRECTORY_SEPARATOR . $file);
+            }
+        };
+
+        foreach ($tree as $key => $value) {
+            $newtree[$key] = new \stdClass();
+            if (is_array($value)) {
+                $newtree[$key]->itemtype = 'treeitem';
+                // The array merge recursive adds a numeric index, and so we only add to the current
+                // key if it is now numeric.
+                $currentkey = is_numeric($key) ? $currentkey : array_merge($currentkey, [$key]);
+
+                // Try to extract the context id and then add the context object.
+                $addcontext($key, $newtree);
+                $newtree[$key]->children = $this->make_tree_object($value, $treekey, $currentkey);
+
+                if (!is_numeric($key)) {
+                    // We're heading back down the tree, so remove the last key.
+                    array_pop($currentkey);
+                }
+            } else {
+                // If the key is not numeric then we want to add a directory and put the file under that.
+                if (!is_numeric($key)) {
+                    $newtree[$key]->itemtype = 'treeitem';
+                    // Try to extract the context id and then add the context object.
+                    $addcontext($key, $newtree);
+                     array_push($currentkey, $key);
+
+                    $newtree[$key]->children[$value] = new \stdClass();
+                    $addfinalfile($currentkey, $newtree[$key]->children[$value], $value);
+                    array_pop($currentkey);
+                } else {
+                    // If the key is just a number then we just want to show the file instead.
+                    $addfinalfile($currentkey, $newtree[$key], $value);
+                }
+            }
+        }
+        return $newtree;
+    }
+
+    /**
+     * Sorts the tree list into an order that makes more sense.
+     * Order is:
+     * 1 - Items with a context first, the lower the number the higher up the tree.
+     * 2 - Items that are directories.
+     * 3 - Items that are log directories.
+     * 4 - Links to a page.
+     *
+     * @param  array $tree The tree structure to order.
+     */
+    protected function sort_my_list(array &$tree) {
+        uasort($tree, function($a, $b) {
+            if (isset($a->context) && isset($b->context)) {
+                return $a->context->contextlevel <=> $b->context->contextlevel;
+            }
+            if (isset($a->context) && !isset($b->context)) {
+                return -1;
+            }
+            if (isset($b->context) && !isset($a->context)) {
+                return 1;
+            }
+            if ($a->itemtype == 'treeitem' && $b->itemtype == 'treeitem') {
+                // Ugh need to check that this plugin has not been uninstalled.
+                if ($this->check_plugin_is_installed('tool_log')) {
+                    if (trim($a->name) == get_string('privacy:path:logs', 'tool_log')) {
+                        return 1;
+                    } else if (trim($b->name) == get_string('privacy:path:logs', 'tool_log')) {
+                        return -1;
+                    }
+                    return 0;
+                }
+            }
+            if ($a->itemtype == 'treeitem' && $b->itemtype == 'item') {
+                return -1;
+            }
+            if ($b->itemtype == 'treeitem' && $a->itemtype == 'item') {
+                return 1;
+            }
+            return 0;
+        });
+        foreach ($tree as $treeobject) {
+            if (isset($treeobject->children)) {
+                $this->sort_my_list($treeobject->children);
+            }
+        }
+    }
+
+    /**
+     * Check to see if a specified plugin is installed.
+     *
+     * @param  string $component The component name e.g. tool_log
+     * @return bool Whether this component is installed.
+     */
+    protected function check_plugin_is_installed(string $component) : Bool {
+        if (!isset($this->checkedplugins[$component])) {
+            $pluginmanager = \core_plugin_manager::instance();
+            $plugin = $pluginmanager->get_plugin_info($component);
+            $this->checkedplugins[$component] = !is_null($plugin);
+        }
+        return $this->checkedplugins[$component];
+    }
+
+    /**
+     * Writes the appropriate files for creating an HTML index page for human navigation of the user data export.
+     */
+    protected function write_html_data() {
+        global $PAGE, $SITE, $USER, $CFG;
+
+        // Do this first before adding more files to $this->files.
+        list($tree, $treekey, $allfiles) = $this->prepare_for_export();
+        // Add more detail to the tree such as contexts.
+        $richtree = $this->make_tree_object($tree, $treekey);
+        // Now that we have more detail we can use that to sort it.
+        $this->sort_my_list($richtree);
+
+        // Copy over the JavaScript required to display the html page.
+        $jspath = ['privacy', 'export_files', 'general.js'];
+        $targetpath = ['js', 'general.js'];
+        $this->copy_data($jspath, $targetpath);
+
+        $jquery = ['lib', 'jquery', 'jquery-3.2.1.min.js'];
+        $jquerydestination = ['js', 'jquery-3.2.1.min.js'];
+        $this->copy_data($jquery, $jquerydestination);
+
+        $requirecurrentpath = ['lib', 'requirejs', 'require.min.js'];
+        $destination = ['js', 'require.min.js'];
+        $this->copy_data($requirecurrentpath, $destination);
+
+        $treepath = ['lib', 'amd', 'build', 'tree.min.js'];
+        $destination = ['js', 'tree.min.js'];
+        $this->copy_data($treepath, $destination);
+
+        // Icons to be used.
+        $expandediconpath = ['pix', 't', 'expanded.svg'];
+        $this->copy_data($expandediconpath, ['pix', 'expanded.svg']);
+        $collapsediconpath = ['pix', 't', 'collapsed.svg'];
+        $this->copy_data($collapsediconpath, ['pix', 'collapsed.svg']);
+        $naviconpath = ['pix', 'i', 'navigationitem.svg'];
+        $this->copy_data($naviconpath, ['pix', 'navigationitem.svg']);
+        $moodleimgpath = ['pix', 'moodlelogo.svg'];
+        $this->copy_data($moodleimgpath, ['pix', 'moodlelogo.svg']);
+
+        // Additional required css.
+        // Determine what direction to show the data export page according to the user preference.
+        $rtl = right_to_left();
+        if (!$rtl) {
+            $bootstrapdestination = 'bootstrap.min.css';
+            $this->write_url_content('https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css',
+                    $bootstrapdestination);
+        } else {
+            $rtldestination = 'rtlbootstrap.min.css';
+            $this->write_url_content('https://cdn.rtlcss.com/bootstrap/v4.0.0/css/bootstrap.min.css', $rtldestination);
+        }
+
+        $csspath = ['privacy', 'export_files', 'general.css'];
+        $destination = ['general.css'];
+        $this->copy_data($csspath, $destination);
+
+        // Create an index file that lists all, to be newly created, js files.
+        $encoded = json_encode($allfiles,  JSON_PRETTY_PRINT);
+        $encoded = 'var user_data_index = ' . $encoded;
+
+        $path = 'js' . DIRECTORY_SEPARATOR . 'data_index.js';
+        $this->write_data($path, $encoded);
+
+        $output = $PAGE->get_renderer('core_privacy');
+        $navigationpage = new \core_privacy\output\exported_navigation_page(current($richtree));
+        $navigationhtml = $output->render_navigation($navigationpage);
+
+        $systemname = $SITE->fullname;
+        $fullusername = fullname($USER);
+        $siteurl = $CFG->wwwroot;
+
+        // Create custom index.html file.
+        $htmlpage = new \core_privacy\output\exported_html_page($navigationhtml, $systemname, $fullusername, $rtl, $siteurl);
+        $outputpage = $output->render_html_page($htmlpage);
+        $this->write_data('index.html', $outputpage);
+    }
+
     /**
      * Perform any required finalisation steps and return the location of the finalised export.
      *
      * @return  string
      */
     public function finalise_content() : string {
+        $this->write_html_data();
+
         $exportfile = make_request_directory() . '/export.zip';
 
         $fp = get_file_packer();
@@ -366,4 +695,53 @@ class moodle_content_writer implements content_writer {
 
         return $exportfile;
     }
+
+    /**
+     * Creates a multidimensional array out of array elements.
+     *
+     * @param  array  $array Array which items are to be condensed into a multidimensional array.
+     * @return array The multidimensional array.
+     */
+    protected function condense_array(array $array) : Array {
+        if (count($array) === 2) {
+            return [$array[0] => $array[1]];
+        }
+        if (isset($array[0])) {
+            return [$array[0] => $this->condense_array(array_slice($array, 1))];
+        }
+        return [];
+    }
+
+    /**
+     * Get the contents of a file.
+     *
+     * @param  string $filepath The file path.
+     * @return string contents of the file.
+     */
+    protected function get_file_content(string $filepath) : String {
+        $filepointer = fopen($filepath, 'r');
+        $content = '';
+        while (!feof($filepointer)) {
+            $content .= fread($filepointer, filesize($filepath));
+        }
+        return $content;
+    }
+
+    /**
+     * Write url files to the export.
+     *
+     * @param  string $url  Url of the file.
+     * @param  string $path Path to store the file.
+     */
+    protected function write_url_content(string $url, string $path) {
+        $filepointer = fopen($url, 'r');
+        $targetpath = $this->path . DIRECTORY_SEPARATOR . $path;
+        check_dir_exists(dirname($targetpath), true, true);
+        $status = file_put_contents($targetpath, $filepointer);
+        if ($status === false) {
+            // There was an error. Throw an exception to allow the download status to remain as requiring download.
+            throw new \moodle_exception('Content download was incomplete');
+        }
+        $this->files[$path] = $targetpath;
+    }
 }
diff --git a/privacy/classes/output/exported_html_page.php b/privacy/classes/output/exported_html_page.php
new file mode 100644 (file)
index 0000000..8dbea89
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+/**
+ * Contains the navigation renderable for user data exports.
+ *
+ * @package    core_privacy
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_privacy\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+
+/**
+ * Class containing the navigation renderable
+ *
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exported_html_page implements renderable, templatable {
+
+    /** @var string $navigationdata navigation html to be displayed about the system. */
+    protected $navigationdata;
+
+    /** @var string $systemname systemname for the page. */
+    protected $systemname;
+
+    /** @var string $username The full name of the user. */
+    protected $username;
+
+    /** @var bool $rtl The direction to show the page (right to left) */
+    protected $rtl;
+
+    /** @var string $siteurl The url back to the site that created this export. */
+    protected $siteurl;
+
+    /**
+     * Constructor.
+     *
+     * @param string $navigationdata Navigation html to be displayed about the system.
+     * @param string $systemname systemname for the page.
+     * @param string $username The full name of the user.
+     * @param bool $righttoleft Is the language used right to left?
+     * @param string $siteurl The url to the site that created this export.
+     */
+    public function __construct(string $navigationdata, string $systemname, string $username, bool $righttoleft, string $siteurl) {
+        $this->navigationdata = $navigationdata;
+        $this->systemname = $systemname;
+        $this->username = $username;
+        $this->rtl = $righttoleft;
+        $this->siteurl = $siteurl;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return array
+     */
+    public function export_for_template(renderer_base $output) : Array {
+        return [
+            'navigation' => $this->navigationdata,
+            'systemname' => $this->systemname,
+            'timegenerated' => time(),
+            'username' => $this->username,
+            'righttoleft' => $this->rtl,
+            'siteurl' => $this->siteurl
+        ];
+    }
+}
\ No newline at end of file
diff --git a/privacy/classes/output/exported_navigation_page.php b/privacy/classes/output/exported_navigation_page.php
new file mode 100644 (file)
index 0000000..49819dd
--- /dev/null
@@ -0,0 +1,98 @@
+<?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/>.
+
+/**
+ * Contains the navigation renderable for user data exports.
+ *
+ * @package    core_privacy
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_privacy\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+
+/**
+ * Class containing the navigation renderable
+ *
+ * @copyright  2018 Adrian Greeve
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class exported_navigation_page implements renderable, templatable {
+
+    /** @var array $tree Full tree in multidimensional form. */
+    protected $tree;
+
+    /** @var boolean $firstelement This is used to create unique classes for the first elements in the navigation tree. */
+    protected $firstelement = true;
+
+    /**
+     * Constructor
+     *
+     * @param \stdClass $tree Full tree to create navigation out of.
+     */
+    public function __construct(\stdClass $tree) {
+        $this->tree = $tree;
+    }
+
+    /**
+     * Creates the navigation list html. Why this and not a template? My attempts at trying to get a recursive template
+     * working failed.
+     *
+     * @param  \stdClass $tree Full tree to create navigation out of.
+     * @return string navigation html.
+     */
+    protected function create_navigation(\stdClass $tree) {
+        if ($this->firstelement) {
+            $html = \html_writer::start_tag('ul', ['class' => 'treeview parent block_tree list', 'id' => 'my-tree']);
+            $this->firstelement = false;
+        } else {
+            $html = \html_writer::start_tag('ul', ['class' => 'parent', 'role' => 'group']);
+        }
+        foreach ($tree->children as $child) {
+            if (isset($child->children)) {
+                $html .= \html_writer::start_tag('li', ['class' => 'menu-item', 'role' => 'treeitem', 'aria-expanded' => 'false']);
+                $html .= $child->name;
+                $html .= $this->create_navigation($child);
+            } else {
+                $html .= \html_writer::start_tag('li', ['class' => 'item', 'role' => 'treeitem', 'aria-expanded' => 'false']);
+                // Normal display.
+                if (isset($child->datavar)) {
+                    $html .= \html_writer::link('#', $child->name, ['data-var' => $child->datavar]);
+                } else {
+                    $html .= \html_writer::link($child->url, $child->name, ['target' => '_blank']);
+                }
+            }
+            $html .= \html_writer::end_tag('li');
+        }
+        $html .= \html_writer::end_tag('ul');
+        return $html;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return array navigation data for the template.
+     */
+    public function export_for_template(renderer_base $output) : Array {
+        $data = $this->create_navigation($this->tree);
+        return ['navigation' => $data];
+    }
+}
\ No newline at end of file
diff --git a/privacy/classes/output/renderer.php b/privacy/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..dfff737
--- /dev/null
@@ -0,0 +1,58 @@
+<?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/>.
+
+/**
+ * Privacy renderer.
+ *
+ * @package    core_privacy
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_privacy\output;
+defined('MOODLE_INTERNAL') || die;
+/**
+ * Privacy renderer's for privacy stuff.
+ *
+ * @since      Moodle 3.6
+ * @package    core_privacy
+ * @copyright  2018 Adrian Greeve <adrian@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render the whole tree.
+     *
+     * @param navigation_page $page
+     * @return string
+     */
+    public function render_navigation(exported_navigation_page $page) {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('core_privacy/navigation', $data);
+    }
+
+    /**
+     * Render the html page.
+     *
+     * @param html_page $page
+     * @return string
+     */
+    public function render_html_page(exported_html_page $page) {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('core_privacy/htmlpage', $data);
+    }
+}
\ No newline at end of file
diff --git a/privacy/export_files/general.css b/privacy/export_files/general.css
new file mode 100644 (file)
index 0000000..5eff504
--- /dev/null
@@ -0,0 +1,50 @@
+.hide {
+    display: none;
+}
+
+li.menu-item {
+    cursor: pointer;
+}
+
+li[aria-expanded=false]:not(.item) {
+    list-style-image: url('pix/collapsed.svg');
+}
+
+li[aria-expanded=true]:not(.item) {
+    list-style-image: url('pix/expanded.svg');
+}
+
+[aria-expanded="false"] > [role="group"] {
+    display: none;
+}
+
+#navigation {
+    display: inline-block;
+    width: 20%;
+    vertical-align: top;
+    overflow: scroll;
+    border-radius: 0.3rem;
+}
+
+[data-main-content] {
+    display: inline-block;
+    width: 69%;
+    vertical-align: top;
+}
+
+.title {
+    font-size: large;
+    font-weight: bold;
+}
+
+.block {
+    padding: 19px;
+}
+
+.item {
+    list-style-image: url('pix/navigationitem.svg');
+}
+
+.moodle-logo {
+    width: 110px;
+}
diff --git a/privacy/export_files/general.js b/privacy/export_files/general.js
new file mode 100644 (file)
index 0000000..b887861
--- /dev/null
@@ -0,0 +1,138 @@
+var currentlyloaded = [];
+
+/**
+ * Loads the data for the clicked navigation item.
+ *
+ * @param  {Object} clickednode The jquery object for the clicked node.
+ */
+function handleClick(clickednode) {
+    var contextcrumb = '';
+    var parentnodes = clickednode.parents('li');
+    for (var i = parentnodes.length; i >= 0; i--) {
+        var treenodes = window.$(parentnodes[i]);
+        if (treenodes.hasClass('item')) {
+            if (contextcrumb == '') {
+                contextcrumb = treenodes[0].innerText;
+            } else {
+                contextcrumb = contextcrumb + ' | ' + treenodes[0].innerText;
+            }
+        } else if (treenodes.hasClass('menu-item')) {
+            if (contextcrumb == '') {
+                contextcrumb = treenodes[0].firstChild.textContent;
+            } else {
+                contextcrumb = contextcrumb + ' | ' + treenodes[0].firstChild.textContent;
+            }
+        }
+    }
+    var datafile = clickednode.attr('data-var');
+    loadContent(datafile, function() {
+        addFileDataToMainArea(window[datafile], contextcrumb);
+    });
+}
+
+/**
+ * Load content to be displayed.
+ *
+ * @param  {String}   datafile The json data to be displayed.
+ * @param  {Function} callback The function to run after loading the json file.
+ */
+function loadContent(datafile, callback) {
+
+    // Check to see if this file has already been loaded. If so just go straight to the callback.
+    if (fileIsLoaded(datafile)) {
+        callback();
+        return;
+    }
+
+    // This (user_data_index) is defined in data_index.js
+    var data = window.user_data_index[datafile];
+    var newscript = document.createElement('script');
+
+    if (newscript.readyState) {
+        newscript.onreadystatechange = function() {
+            if (this.readyState == 'complete' || this.readyState == 'loaded') {
+                this.onreadystatechange = null;
+                callback();
+            }
+        };
+    } else {
+        newscript.onload = function() {
+            callback();
+        };
+    }
+
+    newscript.type = 'text/javascript';
+    newscript.src = data;
+    newscript.charset = 'utf-8';
+    document.getElementsByTagName("head")[0].appendChild(newscript);
+
+    // Keep track that this file has already been loaded.
+    currentlyloaded.push(datafile);
+}
+
+/**
+ * Checks to see if the datafile has already been loaded onto the page or not.
+ *
+ * @param  {String} datafile The file entry we are checking to see if it is already loaded.
+ * @return {Boolean} True if already loaded otherwise false.
+ */
+function fileIsLoaded(datafile) {
+    for (var index in currentlyloaded) {
+        if (currentlyloaded[index] == datafile) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Adds the loaded data to the main content area of the page.
+ *
+ * @param {Object} data  Data to be added to the main content area of the page.
+ * @param {String} title Title for the content area.
+ */
+function addFileDataToMainArea(data, title) {
+    var dataarea = window.$('[data-main-content]');
+    while (dataarea[0].firstChild) {
+        dataarea[0].removeChild(dataarea[0].firstChild);
+    }
+    var htmldata = makeList(data);
+
+    var areatitle = document.createElement('h2');
+    areatitle.innerHTML = title;
+    dataarea[0].appendChild(areatitle);
+
+    var maincontentlist = document.createElement('div');
+    maincontentlist.innerHTML = htmldata;
+    dataarea[0].appendChild(maincontentlist.firstChild);
+}
+
+/**
+ * Creates an unordered list with the json data provided.
+ *
+ * @param  {Object} jsondata The json data to turn into an unordered list.
+ * @return {String} The html string of the unordered list.
+ */
+function makeList(jsondata) {
+    var html = '<ul>';
+    for (var key in jsondata) {
+        html += '<li>';
+        if (typeof jsondata[key] == 'object') {
+            html += key;
+            html += makeList(jsondata[key]);
+        } else {
+            html += key + ': ' + jsondata[key];
+        }
+        html += '</li>';
+    }
+    html += '</ul>';
+    return html;
+}
+
+window.$(document).ready(function() {
+    window.$('[data-var]').click(function(e) {
+        e.preventDefault();
+        e.stopPropagation();
+        handleClick(window.$(this));
+    });
+});
\ No newline at end of file
diff --git a/privacy/templates/htmlpage.mustache b/privacy/templates/htmlpage.mustache
new file mode 100644 (file)
index 0000000..163dd3e
--- /dev/null
@@ -0,0 +1,110 @@
+{{!
+    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_privacy/htmlpage
+
+    Renders the user export html page.
+    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:
+    * righttoleft
+    * navigation
+    * systemname
+    * timegenerated
+    * username
+
+    Example context (json):
+    {
+        "righttoleft": 0,
+        "navigation": "Navigation html",
+        "systemname": "Test System",
+        "siteurl": "#",
+        "timegenerated": 1536906530,
+        "username": "John Jones"
+    }
+}}
+
+<!DOCTYPE html>
+<html {{#righttoleft}}dir="rtl"{{/righttoleft}}>
+<head>
+    <meta charset="UTF-8">
+    <title>Data export</title>
+    {{^righttoleft}}
+    <link rel="stylesheet" href="bootstrap.min.css">
+    {{/righttoleft}}
+    {{#righttoleft}}
+    <link rel="stylesheet" href="rtlbootstrap.min.css">
+    {{/righttoleft}}
+    <link rel="stylesheet" type="text/css" href="general.css" />
+</head>
+<body>
+    <nav class="navbar navbar-light bg-light border-bottom">
+        <a class="navbar-brand" href="https://www.moodle.org" title="Moodle">
+            <img class="moodle-logo" src="pix/moodlelogo.svg" alt="Moodle logo" />
+        </a>
+        <span class="navbar-brand">{{#str}}broughtbymoodle, core_privacy{{/str}}</span>
+    </nav>
+    <div id="page" class="container-fluid mt-2">
+
+        {{{navigation}}}
+        <div data-main-content class="jumbotron bg-light border">
+            <h2 class="display-8">{{#str}}viewdata, core_privacy{{/str}}</h2>
+        </div>
+        <script src="js/jquery-3.2.1.min.js"></script>
+        <script src="js/data_index.js"></script>
+        <script src="js/general.js"></script>
+        <script src="js/require.min.js"></script>
+        <script>
+            requirejs.config({
+                "baseUrl": "./",
+                "paths": {
+                    "app": "./",
+                    "jquery": "./js/jquery-3.2.1.min",
+                    "tree": "./js/tree.min"
+                }
+            });
+            var tree;
+
+            require(['tree'], function(t) {
+                var tree = new t('#my-tree');
+                tree.registerEnterCallback(function(item) {
+                    var basenode = $(item[0].childNodes[0]);
+                    if (basenode.attr('data-var') != undefined) {
+                        handleClick(basenode);
+                    } else if (basenode.attr('href') != undefined) {
+                        window.location.href = basenode.attr('href');
+                    }
+                });
+            });
+        </script>
+        <footer class="footer">
+            <div class="container">
+                <hr />
+                <div class="text-center text-muted">{{#str}}exportfrom, core_privacy, <a href="{{{siteurl}}}" title="{{systemname}}">{{systemname}}</a>{{/str}}</div>
+                <div class="text-center text-muted">{{#str}}exporttime, core_privacy, {{#userdate}}{{timegenerated}},{{#str}}strftimedatetime, langconfig{{/str}}{{/userdate}}{{/str}}</div>
+                <div class="text-center text-muted">{{#str}}exportuser, core_privacy, {{username}}{{/str}}</div>
+            </div>
+        </footer>
+    </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/privacy/templates/navigation.mustache b/privacy/templates/navigation.mustache
new file mode 100644 (file)
index 0000000..4031d8d
--- /dev/null
@@ -0,0 +1,48 @@
+{{!
+    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_privacy/navigation
+
+    Renders the Navigation section for the user export html page.
+    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:
+    * navigation
+
+    Example context (json):
+    {
+        "navigation": "Navigation html"
+    }
+}}
+<div id="navigation" class="bg-light border">
+    <div class="m-2">
+        <div class="header">
+            <div class="title">
+                <h2>{{#str}}navigation, core_privacy{{/str}}</h2>
+            </div>
+        </div>
+        <div class="content">
+            {{{navigation}}}
+        </div>
+    </div>
+</div>
\ No newline at end of file
index f4ccc3b..4de799c 100644 (file)
@@ -426,7 +426,7 @@ class moodle_content_writer_test extends advanced_testcase {
     public function test_export_file($filearea, $itemid, $filepath, $filename, $content) {
         $this->resetAfterTest();
         $context = \context_system::instance();
-        $filenamepath = '/' . $filearea . '/' . ($itemid ?: '') . $filepath . $filename;
+        $filenamepath = '/' . $filearea . '/' . ($itemid ? '_' . $itemid : '') . $filepath . $filename;
 
         $filerecord = array(
             'contextid' => $context->id,
@@ -989,7 +989,7 @@ class moodle_content_writer_test extends advanced_testcase {
         $fileroot = $this->fetch_exported_content($writer);
 
         $contextpath = $this->get_context_path($context, $subcontext, 'data.json');
-        $expectedpath = "System {$context->id}/{$expected}/data.json";
+        $expectedpath = "System _.{$context->id}/{$expected}/data.json";
         $this->assertEquals($expectedpath, $contextpath);
 
         $json = $fileroot->getChild($contextpath)->getContent();
@@ -1019,7 +1019,7 @@ class moodle_content_writer_test extends advanced_testcase {
         $fileroot = $this->fetch_exported_content($writer);
 
         $contextpath = $this->get_context_path($context, $subcontext, 'name.json');
-        $expectedpath = "System {$context->id}/{$expected}/name.json";
+        $expectedpath = "System _.{$context->id}/{$expected}/name.json";
         $this->assertEquals($expectedpath, $contextpath);
 
         $json = $fileroot->getChild($contextpath)->getContent();
@@ -1049,7 +1049,7 @@ class moodle_content_writer_test extends advanced_testcase {
         $fileroot = $this->fetch_exported_content($writer);
 
         $contextpath = $this->get_context_path($context, $subcontext, 'metadata.json');
-        $expectedpath = "System {$context->id}/{$expected}/metadata.json";
+        $expectedpath = "System _.{$context->id}/{$expected}/metadata.json";
         $this->assertEquals($expectedpath, $contextpath);
 
         $json = $fileroot->getChild($contextpath)->getContent();
@@ -1077,7 +1077,7 @@ class moodle_content_writer_test extends advanced_testcase {
             core_filetypes::add_type('json', 'application/json', 'archive', [], '', 'JSON file archive');
         }
         $context = \context_system::instance();
-        $expectedpath = "System {$context->id}/User preferences/{$expected}.json";
+        $expectedpath = "System _.{$context->id}/User preferences/{$expected}.json";
 
         $component = $longtext;
 
@@ -1206,20 +1206,354 @@ class moodle_content_writer_test extends advanced_testcase {
                 'intro',
                 0,
                 '<p><img src="@@PLUGINFILE@@/hello.gif" /></p>',
-                '<p><img src="_files/intro/hello.gif" /></p>',
+                '<p><img src="System _.1/_files/intro/hello.gif" /></p>',
             ],
             'nonzeroitemid' => [
                 'submission_content',
                 34,
                 '<p><img src="@@PLUGINFILE@@/first.png" alt="First" /></p>',
-                '<p><img src="_files/submission_content/34/first.png" alt="First" /></p>',
+                '<p><img src="System _.1/_files/submission_content/_34/first.png" alt="First" /></p>',
             ],
             'withfilepath' => [
                 'post_content',
                 9889,
                 '<a href="@@PLUGINFILE@@/embedded/docs/muhehe.exe">Click here!</a>',
-                '<a href="_files/post_content/9889/embedded/docs/muhehe.exe">Click here!</a>',
+                '<a href="System _.1/_files/post_content/_9889/embedded/docs/muhehe.exe">Click here!</a>',
             ],
         ];
     }
+
+    public function test_export_html_functions() {
+        $this->resetAfterTest();
+
+        $data = (object) ['key' => 'value'];
+
+        $context = \context_system::instance();
+        $subcontext = [];
+
+        $writer = $this->get_writer_instance()
+            ->set_context($context)
+            ->export_data($subcontext, (object) $data);
+
+        $writer->set_context($context)->export_data(['paper'], $data);
+
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $categorycontext = \context_coursecat::instance($coursecategory->id);
+        $course = $this->getDataGenerator()->create_course();
+        $misccoursecxt = \context_coursecat::instance($course->category);
+        $coursecontext = \context_course::instance($course->id);
+        $cm = $this->getDataGenerator()->create_module('chat', ['course' => $course->id]);
+        $modulecontext = \context_module::instance($cm->cmid);
+
+        $writer->set_context($modulecontext)->export_data([], $data);
+        $writer->set_context($coursecontext)->export_data(['grades'], $data);
+        $writer->set_context($categorycontext)->export_data([], $data);
+        $writer->set_context($context)->export_data([get_string('privacy:path:logs', 'tool_log'), 'Standard log'], $data);
+
+        // Add a file.
+        $fs = get_file_storage();
+        $file = (object) [
+            'component' => 'core_privacy',
+            'filearea' => 'tests',
+            'itemid' => 0,
+            'path' => '/',
+            'name' => 'a.txt',
+            'content' => 'Test file 0',
+        ];
+        $record = [
+            'contextid' => $context->id,
+            'component' => $file->component,
+            'filearea'  => $file->filearea,
+            'itemid'    => $file->itemid,
+            'filepath'  => $file->path,
+            'filename'  => $file->name,
+        ];
+
+        $file->namepath = '/' . $file->filearea . '/' . ($file->itemid ?: '') . $file->path . $file->name;
+        $file->storedfile = $fs->create_file_from_string($record, $file->content);
+        $writer->set_context($context)->export_area_files([], 'core_privacy', 'tests', 0);
+
+        list($tree, $treelist, $indexdata) = phpunit_util::call_internal_method($writer, 'prepare_for_export', [],
+                '\core_privacy\local\request\moodle_content_writer');
+
+        $expectedtreeoutput = [
+            'System _.1' => [
+                'data.json',
+                'paper' => 'data.json',
+                'Category Miscellaneous _.' . $misccoursecxt->id => [
+                    'Course Test course 1 _.' . $coursecontext->id => [
+                        'Chat Chat 1 _.' . $modulecontext->id => 'data.json',
+                        'grades' => 'data.json'
+                    ]
+                ],
+                'Category Course category 1 _.' . $categorycontext->id => 'data.json',
+                '_files' => [
+                    'tests' => 'a.txt'
+                ],
+                'Logs' => [
+                    'Standard log' => 'data.json'
+                ]
+            ]
+        ];
+        $this->assertEquals($expectedtreeoutput, $tree);
+
+        $expectedlistoutput = [
+            'System _.1/data.json' => 'data_file_1',
+            'System _.1/paper/data.json' => 'data_file_2',
+            'System _.1/Category Miscellaneous _.' . $misccoursecxt->id . '/Course Test course 1 _.' .
+                    $coursecontext->id . '/Chat Chat 1 _.' . $modulecontext->id . '/data.json'   => 'data_file_3',
+            'System _.1/Category Miscellaneous _.' . $misccoursecxt->id . '/Course Test course 1 _.' .
+                    $coursecontext->id . '/grades/data.json'   => 'data_file_4',
+            'System _.1/Category Course category 1 _.' . $categorycontext->id . '/data.json' => 'data_file_5',
+            'System _.1/_files/tests/a.txt' => 'No var',
+            'System _.1/Logs/Standard log/data.json' => 'data_file_6'
+        ];
+        $this->assertEquals($expectedlistoutput, $treelist);
+
+        $expectedindex = [
+            'data_file_1' => 'System _.1/data.js',
+            'data_file_2' => 'System _.1/paper/data.js',
+            'data_file_3' => 'System _.1/Category Miscellaneous _.' . $misccoursecxt->id . '/Course Test course 1 _.' .
+                    $coursecontext->id . '/Chat Chat 1 _.' . $modulecontext->id . '/data.js',
+            'data_file_4' => 'System _.1/Category Miscellaneous _.' . $misccoursecxt->id . '/Course Test course 1 _.' .
+                    $coursecontext->id . '/grades/data.js',
+            'data_file_5' => 'System _.1/Category Course category 1 _.' . $categorycontext->id . '/data.js',
+            'data_file_6' => 'System _.1/Logs/Standard log/data.js'
+        ];
+        $this->assertEquals($expectedindex, $indexdata);
+
+        $richtree = phpunit_util::call_internal_method($writer, 'make_tree_object', [$tree, $treelist],
+                '\core_privacy\local\request\moodle_content_writer');
+
+        // This is a big one.
+        $expectedrichtree = [
+            'System _.1' => (object) [
+                'itemtype' => 'treeitem',
+                'name' => 'System ',
+                'context' => \context_system::instance(),
+                'children' => [
+                    (object) [
+                        'name' => 'data.json',
+                        'itemtype' => 'item',
+                        'datavar' => 'data_file_1'
+                    ],
+                    'paper' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'paper',
+                        'children' => [
+                            'data.json' => (object) [
+                                'name' => 'data.json',
+                                'itemtype' => 'item',
+                                'datavar' => 'data_file_2'
+                            ]
+                        ]
+                    ],
+                    'Category Miscellaneous _.' . $misccoursecxt->id => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Category Miscellaneous ',
+                        'context' => $misccoursecxt,
+                        'children' => [
+                            'Course Test course 1 _.' . $coursecontext->id => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'Course Test course 1 ',
+                                'context' => $coursecontext,
+                                'children' => [
+                                    'Chat Chat 1 _.' . $modulecontext->id => (object) [
+                                        'itemtype' => 'treeitem',
+                                        'name' => 'Chat Chat 1 ',
+                                        'context' => $modulecontext,
+                                        'children' => [
+                                            'data.json' => (object) [
+                                                'name' => 'data.json',
+                                                'itemtype' => 'item',
+                                                'datavar' => 'data_file_3'
+                                            ]
+                                        ]
+                                    ],
+                                    'grades' => (object) [
+                                        'itemtype' => 'treeitem',
+                                        'name' => 'grades',
+                                        'children' => [
+                                            'data.json' => (object) [
+                                                'name' => 'data.json',
+                                                'itemtype' => 'item',
+                                                'datavar' => 'data_file_4'
+                                            ]
+                                        ]
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ],
+                    'Category Course category 1 _.' . $categorycontext->id => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Category Course category 1 ',
+                        'context' => $categorycontext,
+                        'children' => [
+                            'data.json' => (object) [
+                                'name' => 'data.json',
+                                'itemtype' => 'item',
+                                'datavar' => 'data_file_5'
+                            ]
+                        ]
+                    ],
+                    '_files' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => '_files',
+                        'children' => [
+                            'tests' => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'tests',
+                                'children' => [
+                                    'a.txt' => (object) [
+                                        'name' => 'a.txt',
+                                        'itemtype' => 'item',
+                                        'url' => new \moodle_url('System _.1/_files/tests/a.txt')
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ],
+                    'Logs' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Logs',
+                        'children' => [
+                            'Standard log' => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'Standard log',
+                                'children' => [
+                                    'data.json' => (object) [
+                                        'name' => 'data.json',
+                                        'itemtype' => 'item',
+                                        'datavar' => 'data_file_6'
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ]
+                ]
+            ]
+        ];
+        $this->assertEquals($expectedrichtree, $richtree);
+
+        // The phpunit_util::call_internal_method() method doesn't allow for referenced parameters so we have this joyful code
+        // instead to do the same thing, but with references working obviously.
+        $funfunction = function($object, $data) {
+            return $object->sort_my_list($data);
+        };
+
+        $funfunction = Closure::bind($funfunction, null, $writer);
+        $funfunction($writer, $richtree);
+
+        // This is a big one.
+        $expectedsortedtree = [
+            'System _.1' => (object) [
+                'itemtype' => 'treeitem',
+                'name' => 'System ',
+                'context' => \context_system::instance(),
+                'children' => [
+                    'Category Miscellaneous _.' . $misccoursecxt->id => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Category Miscellaneous ',
+                        'context' => $misccoursecxt,
+                        'children' => [
+                            'Course Test course 1 _.' . $coursecontext->id => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'Course Test course 1 ',
+                                'context' => $coursecontext,
+                                'children' => [
+                                    'Chat Chat 1 _.' . $modulecontext->id => (object) [
+                                        'itemtype' => 'treeitem',
+                                        'name' => 'Chat Chat 1 ',
+                                        'context' => $modulecontext,
+                                        'children' => [
+                                            'data.json' => (object) [
+                                                'name' => 'data.json',
+                                                'itemtype' => 'item',
+                                                'datavar' => 'data_file_3'
+                                            ]
+                                        ]
+                                    ],
+                                    'grades' => (object) [
+                                        'itemtype' => 'treeitem',
+                                        'name' => 'grades',
+                                        'children' => [
+                                            'data.json' => (object) [
+                                                'name' => 'data.json',
+                                                'itemtype' => 'item',
+                                                'datavar' => 'data_file_4'
+                                            ]
+                                        ]
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ],
+                    'Category Course category 1 _.' . $categorycontext->id => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Category Course category 1 ',
+                        'context' => $categorycontext,
+                        'children' => [
+                            'data.json' => (object) [
+                                'name' => 'data.json',
+                                'itemtype' => 'item',
+                                'datavar' => 'data_file_5'
+                            ]
+                        ]
+                    ],
+                    '_files' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => '_files',
+                        'children' => [
+                            'tests' => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'tests',
+                                'children' => [
+                                    'a.txt' => (object) [
+                                        'name' => 'a.txt',
+                                        'itemtype' => 'item',
+                                        'url' => new \moodle_url('System _.1/_files/tests/a.txt')
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ],
+                    'Logs' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'Logs',
+                        'children' => [
+                            'Standard log' => (object) [
+                                'itemtype' => 'treeitem',
+                                'name' => 'Standard log',
+                                'children' => [
+                                    'data.json' => (object) [
+                                        'name' => 'data.json',
+                                        'itemtype' => 'item',
+                                        'datavar' => 'data_file_6'
+                                    ]
+                                ]
+                            ]
+                        ]
+                    ],
+                    'paper' => (object) [
+                        'itemtype' => 'treeitem',
+                        'name' => 'paper',
+                        'children' => [
+                            'data.json' => (object) [
+                                'name' => 'data.json',
+                                'itemtype' => 'item',
+                                'datavar' => 'data_file_2'
+                            ]
+                        ]
+                    ],
+                    (object) [
+                        'name' => 'data.json',
+                        'itemtype' => 'item',
+                        'datavar' => 'data_file_1'
+                    ]
+                ]
+            ]
+        ];
+        $this->assertEquals($expectedsortedtree, $richtree);
+    }
 }