diff --git a/lang/en/privacy.php b/lang/en/privacy.php index 95c19a89291..0045cb6462d 100644 --- a/lang/en/privacy.php +++ b/lang/en/privacy.php @@ -22,6 +22,10 @@ * @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.'; diff --git a/lib/jquery/readme_moodle.txt b/lib/jquery/readme_moodle.txt index d0840bd8b1e..e339c5c317e 100644 --- a/lib/jquery/readme_moodle.txt +++ b/lib/jquery/readme_moodle.txt @@ -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 diff --git a/lib/requirejs/readme_moodle.txt b/lib/requirejs/readme_moodle.txt index 17ce5e75f23..8d2a4650dbc 100644 --- a/lib/requirejs/readme_moodle.txt +++ b/lib/requirejs/readme_moodle.txt @@ -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. diff --git a/privacy/classes/local/request/moodle_content_writer.php b/privacy/classes/local/request/moodle_content_writer.php index bf25a061a14..21504fe2184 100644 --- a/privacy/classes/local/request/moodle_content_writer.php +++ b/privacy/classes/local/request/moodle_content_writer.php @@ -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 index 00000000000..8dbea89d78d --- /dev/null +++ b/privacy/classes/output/exported_html_page.php @@ -0,0 +1,87 @@ +. + +/** + * 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 index 00000000000..49819dd147b --- /dev/null +++ b/privacy/classes/output/exported_navigation_page.php @@ -0,0 +1,98 @@ +. + +/** + * 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 index 00000000000..dfff7377ec0 --- /dev/null +++ b/privacy/classes/output/renderer.php @@ -0,0 +1,58 @@ +. + +/** + * Privacy renderer. + * + * @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; +/** + * Privacy renderer's for privacy stuff. + * + * @since Moodle 3.6 + * @package core_privacy + * @copyright 2018 Adrian Greeve + * @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 index 00000000000..5eff5043cfb --- /dev/null +++ b/privacy/export_files/general.css @@ -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 index 00000000000..b88786184af --- /dev/null +++ b/privacy/export_files/general.js @@ -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 = ''; + 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 index 00000000000..163dd3ea07e --- /dev/null +++ b/privacy/templates/htmlpage.mustache @@ -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 . +}} +{{! + @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" + } +}} + + + + + + Data export + {{^righttoleft}} + + {{/righttoleft}} + {{#righttoleft}} + + {{/righttoleft}} + + + + +
+ + {{{navigation}}} +
+

{{#str}}viewdata, core_privacy{{/str}}

+
+ + + + + +
+
+
+
{{#str}}exportfrom, core_privacy, {{systemname}}{{/str}}
+
{{#str}}exporttime, core_privacy, {{#userdate}}{{timegenerated}},{{#str}}strftimedatetime, langconfig{{/str}}{{/userdate}}{{/str}}
+
{{#str}}exportuser, core_privacy, {{username}}{{/str}}
+
+
+
+ + \ No newline at end of file diff --git a/privacy/templates/navigation.mustache b/privacy/templates/navigation.mustache new file mode 100644 index 00000000000..4031d8d6055 --- /dev/null +++ b/privacy/templates/navigation.mustache @@ -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 . +}} +{{! + @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" + } +}} + \ No newline at end of file diff --git a/privacy/tests/moodle_content_writer_test.php b/privacy/tests/moodle_content_writer_test.php index f4ccc3bd66b..4de799c842f 100644 --- a/privacy/tests/moodle_content_writer_test.php +++ b/privacy/tests/moodle_content_writer_test.php @@ -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, '

', - '

', + '

', ], 'nonzeroitemid' => [ 'submission_content', 34, '

First

', - '

First

', + '

First

', ], 'withfilepath' => [ 'post_content', 9889, 'Click here!', - 'Click here!', + 'Click here!', ], ]; } + + 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); + } }