Merge branch 'dashboard-split-overview-block' of https://github.com/ryanwyllie/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Sep 2018 06:08:55 +0000 (14:08 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Sep 2018 06:08:55 +0000 (14:08 +0800)
115 files changed:
.travis.yml
admin/roles/allow.php
admin/roles/classes/capability_table_with_risks.php
admin/roles/manage.php
admin/settings/appearance.php
admin/settings/courses.php
admin/templates/settings_search_results.mustache
admin/tool/policy/classes/form/policydoc.php
admin/tool/policy/classes/output/page_agreedocs.php
admin/tool/policy/classes/output/page_viewdoc.php
admin/tool/policy/classes/policy_version.php
admin/tool/policy/db/install.xml
admin/tool/policy/db/upgrade.php [new file with mode: 0644]
admin/tool/policy/index.php
admin/tool/policy/lang/en/tool_policy.php
admin/tool/policy/lib.php
admin/tool/policy/templates/page_agreedocs.mustache
admin/tool/policy/templates/page_viewdoc.mustache
admin/tool/policy/tests/behat/behat_tool_policy.php
admin/tool/policy/tests/behat/consent.feature
admin/tool/policy/version.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
calendar/classes/external/event_exporter.php
calendar/templates/event_summary_body.mustache
config-dist.php
course/classes/category.php
course/edit.php
course/lib.php
course/tests/courselib_test.php
enrol/category/tests/plugin_test.php
enrol/tests/enrollib_test.php
filter/glossary/filter.php
filter/glossary/lang/en/filter_glossary.php
filter/glossary/tests/filter_test.php
filter/mathjaxloader/filter.php
filter/mathjaxloader/tests/filtermath_test.php [new file with mode: 0644]
filter/upgrade.txt
grade/tests/report_graderlib_test.php
lang/en/admin.php
lang/en/question.php
lib/accesslib.php
lib/adminlib.php
lib/behat/classes/util.php
lib/db/install.xml
lib/db/upgrade.php
lib/ddl/mssql_sql_generator.php
lib/ddl/oracle_sql_generator.php
lib/ddl/sql_generator.php
lib/ddl/tests/ddl_test.php
lib/dml/moodle_database.php
lib/dml/tests/dml_test.php
lib/enrollib.php
lib/filterlib.php
lib/grouplib.php
lib/moodlelib.php
lib/navigationlib.php
lib/phpunit/classes/util.php
lib/questionlib.php
lib/tests/accesslib_test.php
lib/tests/grouplib_test.php
lib/tests/questionlib_test.php
lib/upgrade.txt
lib/xmldb/xmldb_index.php
lib/xmldb/xmldb_table.php
message/tests/api_test.php
mod/assign/feedback/editpdf/classes/event/observer.php
mod/assign/feedback/editpdf/classes/task/convert_submissions.php
mod/assign/feedback/editpdf/db/install.xml
mod/assign/feedback/editpdf/db/upgrade.php
mod/assign/feedback/editpdf/tests/editpdf_test.php
mod/assign/feedback/editpdf/version.php
mod/assign/submission/file/locallib.php
mod/feedback/tests/lib_test.php
mod/forum/lib.php
mod/forum/tests/subscriptions_test.php
npm-shrinkwrap.json
question/category.php
question/category_class.php
question/category_form.php
question/engine/tests/helpers.php
question/format.php
question/format/xml/format.php
question/format/xml/tests/fixtures/export_category.xml
question/format/xml/tests/fixtures/nested_categories.xml
question/format/xml/tests/fixtures/nested_categories_with_questions.xml
question/format/xml/tests/qformat_xml_import_export_test.php
question/format/xml/tests/xmlformat_test.php
question/tests/backup_test.php
question/tests/behat/question_categories_idnumber.feature [new file with mode: 0644]
question/tests/generator/lib.php
question/tests/generator_test.php
question/type/ddwtos/tests/questiontype_test.php
question/type/edit_question_form.php
question/type/gapselect/tests/questiontype_test.php
question/type/match/tests/questiontype_test.php
question/type/missingtype/tests/missingtype_test.php
question/type/questiontypebase.php
tag/tests/external_test.php
theme/boost/scss/moodle/admin.scss
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/scss/moodle/core.scss
theme/boost/style/moodle.css
theme/boost/templates/core/initials_bar.mustache
theme/boost/templates/core/navbar.mustache
theme/boost/templates/core_admin/settings_search_results.mustache
theme/bootstrapbase/less/moodle/admin.less
theme/bootstrapbase/style/moodle.css
user/classes/participants_table.php
user/index.php
user/lib.php
user/renderer.php
user/tests/behat/filter_participants.feature
user/tests/userlib_test.php
version.php

index 0b6690a..aa0db16 100644 (file)
@@ -263,7 +263,9 @@ script:
         grunt ;
         # Add all files to the git index and then run diff --cached to see all changes.
         # This ensures that we get the status of all files, including new files.
+        # We ignore npm-shrinkwrap.json to make the tasks immune to npm changes.
         git add . ;
+        git reset -- npm-shrinkwrap.json ;
         git diff --cached --exit-code ;
       fi
 
index 71e9bcb..88609cf 100644 (file)
@@ -46,7 +46,6 @@ $controller = new $classformode[$mode]();
 
 if (optional_param('submit', false, PARAM_BOOL) && data_submitted() && confirm_sesskey()) {
     $controller->process_submission();
-    $syscontext->mark_dirty();
     $event = null;
     // Create event depending on mode.
     switch ($mode) {
index db7e16b..4e597f4 100644 (file)
@@ -123,9 +123,6 @@ abstract class core_role_capability_table_with_risks extends core_role_capabilit
             assign_capability($changedcap, $this->permissions[$changedcap],
                 $this->roleid, $this->context->id, true);
         }
-
-        // Force accessinfo refresh for users visiting this context.
-        $this->context->mark_dirty();
     }
 
     public function display() {
index fb390ea..d2e703f 100644 (file)
@@ -85,12 +85,10 @@ switch ($action) {
             die;
         }
         if (!delete_role($roleid)) {
-            // The delete failed, but mark the context dirty in case.
-            $systemcontext->mark_dirty();
+            // The delete failed.
             print_error('cannotdeleterolewithid', 'error', $baseurl, $roleid);
         }
         // Deleted a role sitewide...
-        $systemcontext->mark_dirty();
         redirect($baseurl);
         break;
 
index 6114dcd..b971e30 100644 (file)
@@ -32,9 +32,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { // sp
         'customusermenuitems',
         new lang_string('customusermenuitems', 'admin'),
         new lang_string('configcustomusermenuitems', 'admin'),
-        'grades,grades|/grade/report/mygrades.php|grades
-messages,message|/message/index.php|message
-preferences,moodle|/user/preferences.php|preferences',
+        'grades,grades|/grade/report/mygrades.php|t/grades
+messages,message|/message/index.php|t/message
+preferences,moodle|/user/preferences.php|t/preferences',
         PARAM_RAW,
         '50',
         '10'
index 64ef150..cf9b286 100644 (file)
@@ -43,6 +43,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/category:manage')
         )
     );
+    $ADMIN->add('courses',
+        new admin_externalpage('addnewcourse', new lang_string('addnewcourse'),
+            new moodle_url('/course/edit.php', array('category' => 0)),
+            array('moodle/category:manage')
+        )
+    );
     $ADMIN->add('courses',
         new admin_externalpage('restorecourse', new lang_string('restorecourse', 'admin'),
             new moodle_url('/backup/restorefile.php', array('contextid' => context_system::instance()->id)),
index 78b6205..df82b0f 100644 (file)
@@ -22,7 +22,7 @@
     Context variables required for this template:
     * actionurl - Url to post to
     * hasresults - True if there are results
-    * results - List of results containing url, title, settings (array of raw html)
+    * results - List of results containing url, title, path (array of strings), settings (array of raw html)
     * showsave - Show save buttons
 
     Example context (json):
@@ -30,7 +30,7 @@
         "actionurl": "/",
         "hasresults": true,
         "results": [
-            { "url": "/", "title": "Match!", "settings": [ "blah blah blah" ] }
+            { "url": "/", "title": "Match!", "path": ["Administration", "Match!"], "settings": [ "blah blah blah" ] }
         ]
     }
 }}
     </div>
     <fieldset>
         <div class="clearer"></div>
+        <h2 class="main">{{#str}}searchresults, admin{{/str}}</h2>
         {{#hasresults}}
             {{#results}}
-                <h2 class="main">{{#str}}searchresults, admin{{/str}} - <a href="{{url}}">{{{title}}}</a></h2>
+                <h3 class="adminpagetitle"><a href="{{url}}">{{{title}}}</a></h3>
+                <ul class="adminpagepath" aria-label="{{#str}} pagepath, core {{/str}}">
+                    {{#path}}
+                    <li>{{.}}</li>
+                    {{/path}}
+                </ul>
                 <fieldset class="adminsettings">
                     {{#settings}}
                         <div class="clearer"></div>
index 91e4ce0..9c16180 100644 (file)
@@ -89,6 +89,8 @@ class policydoc extends moodleform {
             api::policy_content_field_options());
         $mform->addRule('content_editor', null, 'required', null, 'client');
 
+        $mform->addElement('selectyesno', 'agreementstyle', get_string('policypriorityagreement', 'tool_policy'));
+
         if (!$formdata->id || $formdata->status == policy_version::STATUS_DRAFT) {
             // Creating a new version or editing a draft/archived version.
             $mform->addElement('hidden', 'minorchange');
index af6ebd6..771ac05 100644 (file)
@@ -51,6 +51,9 @@ class page_agreedocs implements renderable, templatable {
     /** @var array $policies List of public policies objects with information about the user acceptance. */
     protected $policies = null;
 
+    /** @var array List of policy version ids that were displayed to the user to agree with. */
+    protected $listdocs = null;
+
     /** @var array $agreedocs List of policy identifiers which the user has agreed using the form. */
     protected $agreedocs = null;
 
@@ -75,22 +78,20 @@ class page_agreedocs implements renderable, templatable {
     /**
      * Prepare the page for rendering.
      *
-     * @param array $agreedocs Array with the policy identifiers which the user has agreed using the form.
+     * @param array $listdocs List of policy version ids that were displayed to the user to agree with.
+     * @param array $agreedocs List of policy version ids that the user actually agreed with.
      * @param int $behalfid The userid to accept the policy versions as (such as child's id).
      * @param string $action Form action to identify when user agreeds policies.
      */
-    public function __construct($agreedocs = null, $behalfid = 0, $action = null) {
+    public function __construct(array $listdocs, array $agreedocs = [], $behalfid = 0, $action = null) {
         global $USER;
         $realuser = manager::get_realuser();
 
+        $this->listdocs = $listdocs;
         $this->agreedocs = $agreedocs;
-        if (empty($this->agreedocs)) {
-            $this->agreedocs = [];
-        }
-
         $this->action = $action;
-
         $this->isexistinguser = isloggedin() && !isguestuser();
+
         $behalfid = $behalfid ?: $USER->id;
         if ($realuser->id != $behalfid) {
             $this->behalfuser = core_user::get_user($behalfid, '*', MUST_EXIST);
@@ -124,12 +125,14 @@ class page_agreedocs implements renderable, templatable {
                 // Accept / revoke policies.
                 $acceptversionids = array();
                 foreach ($this->policies as $policy) {
-                    if (in_array($policy->id, $this->agreedocs)) {
-                        // Save policy version doc to accept it.
-                        $acceptversionids[] = $policy->id;
-                    } else {
-                        // Revoke policy doc.
-                        api::revoke_acceptance($policy->id, $this->behalfid);
+                    if (in_array($policy->id, $this->listdocs)) {
+                        if (in_array($policy->id, $this->agreedocs)) {
+                            // Save policy version doc to accept it.
+                            $acceptversionids[] = $policy->id;
+                        } else {
+                            // If the policy was displayed but not agreed, revoke the eventually given acceptance.
+                            api::revoke_acceptance($policy->id, $this->behalfid);
+                        }
                     }
                 }
                 // Accept all policy docs saved in $acceptversionids.
@@ -158,15 +161,26 @@ class page_agreedocs implements renderable, templatable {
         } else {
             // New user.
             if (!empty($this->action) && confirm_sesskey()) {
-                // The form has been sent.
                 $currentpolicyversionids = [];
+                $presignupcache = \cache::make('core', 'presignup');
+                $acceptances = $presignupcache->get('tool_policy_policyversionidsagreed');
+                if (!$acceptances) {
+                    $acceptances = [];
+                }
                 foreach ($this->policies as $policy) {
                     $currentpolicyversionids[] = $policy->id;
+                    if (in_array($policy->id, $this->listdocs)) {
+                        if (in_array($policy->id, $this->agreedocs)) {
+                            $acceptances[] = $policy->id;
+                        } else {
+                            $acceptances = array_values(array_diff($acceptances, [$policy->id]));
+                        }
+                    }
                 }
                 // If the user has accepted all the policies, add it to the session to let continue with the signup process.
-                $this->signupuserpolicyagreed = empty(array_diff($currentpolicyversionids, $this->agreedocs));
-                \cache::make('core', 'presignup')->set('tool_policy_userpolicyagreed',
-                    $this->signupuserpolicyagreed);
+                $this->signupuserpolicyagreed = empty(array_diff($currentpolicyversionids, $acceptances));
+                $presignupcache->set('tool_policy_userpolicyagreed', $this->signupuserpolicyagreed);
+                $presignupcache->set('tool_policy_policyversionidsagreed', $acceptances);
             } else if (empty($this->policies)) {
                 // There are no policies to agree to. Update the policyagreed value to avoid show empty consent page.
                 \cache::make('core', 'presignup')->set('tool_policy_userpolicyagreed', 1);
@@ -190,18 +204,41 @@ class page_agreedocs implements renderable, templatable {
      * @param moodle_url $returnurl URL to return after shown the policy docs.
      */
     protected function redirect_to_policies($userid, $returnurl = null) {
+
+        // Make a list of all policies that the user has not accepted yet.
         $allpolicies = $this->policies;
+
         if ($this->isexistinguser) {
             $acceptances = api::get_user_acceptances($userid);
-            foreach ($allpolicies as $policy) {
+            foreach ($allpolicies as $ix => $policy) {
                 if (api::is_user_version_accepted($userid, $policy->id, $acceptances)) {
-                    // If this version is accepted by the user, remove from the pending policies list.
-                    unset($allpolicies[array_search($policy, $allpolicies)]);
+                    unset($allpolicies[$ix]);
+                }
+            }
+        } else {
+            $presignupcache = \cache::make('core', 'presignup');
+            $acceptances = $presignupcache->get('tool_policy_policyversionidsagreed');
+            if ($acceptances) {
+                foreach ($allpolicies as $ix => $policy) {
+                    if (in_array($policy->id, $acceptances)) {
+                        unset($allpolicies[$ix]);
+                    }
                 }
             }
         }
 
         if (!empty($allpolicies)) {
+            // Check if some of the to-be-accepted policies should be agreed on their own page.
+            foreach ($allpolicies as $policy) {
+                if ($policy->agreementstyle == policy_version::AGREEMENTSTYLE_OWNPAGE) {
+                    if (empty($returnurl)) {
+                        $returnurl = (new moodle_url('/admin/tool/policy/index.php'))->out_as_local_url(false);
+                    }
+                    $urlparams = ['versionid' => $policy->id, 'returnurl' => $returnurl];
+                    redirect(new moodle_url('/admin/tool/policy/view.php', $urlparams));
+                }
+            }
+
             $currentpolicyversionids = [];
             foreach ($allpolicies as $policy) {
                 $currentpolicyversionids[] = $policy->id;
@@ -232,6 +269,8 @@ class page_agreedocs implements renderable, templatable {
                 ];
                 redirect(new moodle_url('/admin/tool/policy/view.php', $urlparams));
             }
+        } else {
+            $this->redirect_to_previous_url();
         }
     }
 
@@ -401,7 +440,10 @@ class page_agreedocs implements renderable, templatable {
             }
         }
 
-        $data->policies = array_values($this->policies);
+        // Filter out policies already shown on their own page, keep just policies to be shown here on the consent page.
+        $data->policies = array_values(array_filter($this->policies, function ($policy) {
+            return $policy->agreementstyle == policy_version::AGREEMENTSTYLE_CONSENTPAGE;
+        }));
 
         // If viewing docs in behalf of other user, get his/her full name and profile link.
         if (!empty($this->behalfuser)) {
index 95613a7..73c216f 100644 (file)
@@ -151,6 +151,7 @@ class page_viewdoc implements renderable, templatable {
      * @return stdClass
      */
     public function export_for_template(renderer_base $output) {
+        global $USER;
 
         $data = (object) [
             'pluginbaseurl' => (new moodle_url('/admin/tool/policy'))->out(false),
@@ -163,6 +164,18 @@ class page_viewdoc implements renderable, templatable {
             $data->editurl = (new moodle_url('/admin/tool/policy/editpolicydoc.php', $paramsurl))->out(false);
         }
 
+        if ($this->policy->agreementstyle == policy_version::AGREEMENTSTYLE_OWNPAGE) {
+            if (!api::is_user_version_accepted($USER->id, $this->policy->id)) {
+                unset($data->returnurl);
+                $data->accepturl = (new moodle_url('/admin/tool/policy/index.php', [
+                    'listdoc[]' => $this->policy->id,
+                    'agreedoc[]' => $this->policy->id,
+                    'submit' => 'accept',
+                    'sesskey' => sesskey(),
+                ]))->out(false);
+            }
+        }
+
         $data->policy = clone($this->policy);
 
         return $data;
index b3bbd29..f675ac9 100644 (file)
@@ -69,6 +69,12 @@ class policy_version extends persistent {
     /** @var int Policy version has been archived. */
     const STATUS_ARCHIVED = 2;
 
+    /** @var int Policy to be accepted together with others on the consent page. */
+    const AGREEMENTSTYLE_CONSENTPAGE = 0;
+
+    /** @var int Policy to be accepted on its own page before reaching the consent page. */
+    const AGREEMENTSTYLE_OWNPAGE = 1;
+
     /**
      * Return the definition of the properties of this model.
      *
@@ -106,6 +112,14 @@ class policy_version extends persistent {
             'policyid' => [
                 'type' => PARAM_INT,
             ],
+            'agreementstyle' => [
+                'type' => PARAM_INT,
+                'choices' => [
+                    self::AGREEMENTSTYLE_CONSENTPAGE,
+                    self::AGREEMENTSTYLE_OWNPAGE,
+                ],
+                'default' => self::AGREEMENTSTYLE_CONSENTPAGE,
+            ],
             'revision' => [
                 'type' => PARAM_TEXT,
                 'default' => '',
index c1601f6..0ab0d63 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/policy/db" VERSION="20180307" COMMENT="The plugin allows to manage various policy documents that users have to accept to use the site."
+<XMLDB PATH="admin/tool/policy/db" VERSION="20180829" COMMENT="The plugin allows to manage various policy documents that users have to accept to use the site."
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -26,6 +26,7 @@
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the policy version was created."/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when the policy version was last modified."/>
         <FIELD NAME="policyid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="ID of the policy document we are version of."/>
+        <FIELD NAME="agreementstyle" TYPE="int" LENGTH="3" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="How this agreement should flow: 0 - on the consent page, 1 - on a separate page before reaching the consent page."/>
         <FIELD NAME="revision" TYPE="char" LENGTH="1333" NOTNULL="true" SEQUENCE="false" COMMENT="Human readable version of the policy document"/>
         <FIELD NAME="summary" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Policy text summary"/>
         <FIELD NAME="summaryformat" TYPE="int" LENGTH="3" NOTNULL="true" SEQUENCE="false" COMMENT="Format of the summary field"/>
diff --git a/admin/tool/policy/db/upgrade.php b/admin/tool/policy/db/upgrade.php
new file mode 100644 (file)
index 0000000..7aabeef
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Plugin upgrade steps are defined here.
+ *
+ * @package     tool_policy
+ * @category    upgrade
+ * @copyright   2018 David Mudr├ík <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Execute the plugin upgrade steps from the given old version.
+ *
+ * @param int $oldversion
+ * @return bool
+ */
+function xmldb_tool_policy_upgrade($oldversion) {
+    global $DB;
+
+    $dbman = $DB->get_manager();
+
+    if ($oldversion < 2018082900) {
+        // Add field agreementstyle to the table tool_policy_versions.
+        $table = new xmldb_table('tool_policy_versions');
+        $field = new xmldb_field('agreementstyle', XMLDB_TYPE_INTEGER, '3', null, XMLDB_NOTNULL, null, '0', 'policyid');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        upgrade_plugin_savepoint(true, 2018082900, 'tool', 'policy');
+    }
+
+    return true;
+}
+
index 067d8fa..9f03344 100644 (file)
@@ -18,7 +18,8 @@
  * Show a user the policy documents to be agreed to.
  *
  * Script parameters:
- *  agreedoc=<array> Policy version id which have been accepted by the user.
+ *  listdoc=<array> List of policy version ids that were displayed to the user to accept.
+ *  agreedoc=<array> List of policy version ids that were accepted by the user.
  *  behalfid=<id> The user id to view the policy version as (such as child's id).
  *
  * @package     tool_policy
@@ -37,7 +38,8 @@ require(__DIR__.'/../../../config.php');
 
 $submit = optional_param('submit', null, PARAM_NOTAGS);
 $cancel = optional_param('cancel', null, PARAM_NOTAGS);
-$agreedocs = optional_param_array('agreedoc', null, PARAM_INT);
+$listdocs = optional_param_array('listdoc', [], PARAM_INT);
+$agreedocs = optional_param_array('agreedoc', [], PARAM_INT);
 $behalfid = optional_param('userid', null, PARAM_INT);
 
 $PAGE->set_context(context_system::instance());
@@ -45,6 +47,10 @@ $PAGE->set_pagelayout('standard');
 $PAGE->set_url('/admin/tool/policy/index.php');
 $PAGE->set_popup_notification_allowed(false);
 
+if (array_diff($agreedocs, $listdocs)) {
+    throw new moodle_exception('invalidaccessparameter');
+}
+
 if (isloggedin() && !isguestuser()) {
     // Existing user.
     $haspermissionagreedocs = api::can_accept_policies($behalfid);
@@ -61,7 +67,7 @@ if (!$haspermissionagreedocs) {
     if (!$behalfid && \core\session\manager::is_loggedinas()) {
         $behalfid = $USER->id;
     }
-    $outputpage = new \tool_policy\output\page_agreedocs($agreedocs, $behalfid, $submit);
+    $outputpage = new \tool_policy\output\page_agreedocs($listdocs, $agreedocs, $behalfid, $submit);
 }
 
 $output = $PAGE->get_renderer('tool_policy');
index 3ede09b..e3250cb 100644 (file)
@@ -129,6 +129,7 @@ $string['policydoctype2'] = 'Third parties policy';
 $string['policydoctype99'] = 'Other policy';
 $string['policydocuments'] = 'Policy documents';
 $string['policynamedversion'] = 'Policy {$a->name} (version {$a->revision} - {$a->id})';
+$string['policypriorityagreement'] = 'Show policy before showing other policies';
 $string['policyversionacceptedinbehalf'] = 'Consent for this policy has been given on your behalf.';
 $string['policyversionacceptedinotherlang'] = 'Consent for this policy version has been given in a different language.';
 $string['previousversions'] = '{$a} previous versions';
index 563254d..4f36d31 100644 (file)
@@ -85,9 +85,13 @@ function tool_policy_before_standard_html_head() {
             && empty($USER->policyagreed)
             && (isguestuser() || !isloggedin())) {
         $output = $PAGE->get_renderer('tool_policy');
-        $page = new \tool_policy\output\guestconsent();
-
-        $message = $output->render($page);
+        try {
+            $page = new \tool_policy\output\guestconsent();
+            $message = $output->render($page);
+        } catch (dml_read_exception $e) {
+            // During upgrades, the new plugin code with new SQL could be in place but the DB not upgraded yet.
+            $message = null;
+        }
     }
 
     return $message;
index 31674de..8773b4b 100644 (file)
@@ -92,6 +92,7 @@
         <div class="agreedoc-form m-t-1">
             <div class="agreedoc-checkbox">
                 <label>
+                    <input value="{{id}}" name="listdoc[]" type="hidden" >
                     <input value="{{id}}" name="agreedoc[]" {{#versionagreed}}checked="{{.}}"{{/versionagreed}} type="checkbox">
                     <strong>{{# str }}iagree, tool_policy, {{{name}}} {{/ str }}</strong>
                     <i class="icon fa fa-exclamation-circle text-danger fa-fw" title="{{# str }} required {{/ str }}" ></i>
index 8eb290a..b351f2d 100644 (file)
@@ -38,7 +38,8 @@
             "content": "Policy <em>content</em>"
         },
         "returnurl": "#",
-        "editurl": "#"
+        "editurl": "#",
+        "accepturl": "#"
     }
 }}
 
@@ -88,6 +89,9 @@
     {{#editurl}}
         <a role="button" href="{{editurl}}" class="btn">{{#str}} edit {{/str}}</a>
     {{/editurl}}
+    {{#accepturl}}
+        <a role="button" href="{{accepturl}}" class="btn btn-primary">{{#str}} iagree, tool_policy, {{{policy.name}}} {{/str}}</a>
+    {{/accepturl}}
 
     <div class="pull-right">
         <a href="#top">
index 11b73cb..0183be7 100644 (file)
@@ -51,6 +51,7 @@ class behat_tool_policy extends behat_base {
      * - Type: 0 - site policy, 1 - privacy policy, 2 - third party policy, 99 - other.
      * - Summary: Policy summary text.
      * - Content: Policy full text.
+     * - Agreement style (agreementstyle): 0 - On the consent page, 1 - On its own page
      *
      * @param TableNode $data
      */
@@ -69,6 +70,7 @@ class behat_tool_policy extends behat_base {
             'type',
             'content',
             'summary',
+            'agreementstyle',
         ];
 
         // Associative array "policy identifier" => id in the database .
index 047ac5e..75f1a8f 100644 (file)
@@ -663,3 +663,217 @@ Feature: User must accept policy managed by this plugin when logging in and sign
     And I follow "Policies and agreements"
     And "Agreed" "icon" should exist in the "This site policy" "table_row"
     And I log out
+
+  Scenario: Accepting policies on sign up, multiple policies with different style of giving ageement.
+    Given the following config values are set as admin:
+      | registerauth      | email       |
+      | passwordpolicy    | 0           |
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | name                          | summary                   | content             | agreementstyle  |
+      | Privacy policy                | We scan your thoughts     | Here goes content.  | 0               |
+      | Digital maturity declaration  | You declare be old enough | Here goes content.  | 1               |
+      | Cookies policy                | We eat cookies, srsly     | Here goes content.  | 0               |
+      | Terms of Service              | We teach, you learn       | Here goes content.  | 1               |
+    And I am on site homepage
+    And I follow "Log in"
+    When I press "Create new account"
+    # The first policy with the agreement style "on its own page" must be accepted first.
+    Then I should see "Digital maturity declaration" in the "region-main" "region"
+    And I should see "You declare be old enough"
+    And I should see "Here goes content."
+    And I press "I agree to the Digital maturity declaration"
+    # The second policy with the agreement style "on its own page" must be accepted now.
+    And I should see "Terms of Service" in the "region-main" "region"
+    And I should see "We teach, you learn"
+    And I should see "Here goes content."
+    And I press "I agree to the Terms of Service"
+    # Only now we see the remaining consent page policies.
+    And I should see "Policy 1 out of 2"
+    And I should see "Privacy policy" in the "region-main" "region"
+    And I should see "We scan your thoughts"
+    And I should see "Here goes content."
+    And I press "Next"
+    And I should see "Policy 2 out of 2"
+    And I should see "Cookies policy" in the "region-main" "region"
+    And I should see "We eat cookies, srsly"
+    And I should see "Here goes content."
+    And I press "Next"
+    And I should see "Please agree to the following policies"
+    And I should see "Privacy policy"
+    And I should see "Cookies policy"
+    And I should not see "Digital maturity declaration" in the "region-main" "region"
+    And I should not see "Terms of Service" in the "region-main" "region"
+    And I should not see "Here goes content."
+    And I set the field "I agree to the Privacy policy" to "1"
+    And I set the field "I agree to the Cookies policy" to "1"
+    And I press "Next"
+    And I should see "New account"
+    And I set the following fields to these values:
+      | Username      | user1                 |
+      | Password      | user1                 |
+      | Email address | user1@address.invalid |
+      | Email (again) | user1@address.invalid |
+      | First name    | User1                 |
+      | Surname       | L1                    |
+    And I press "Create my new account"
+    And I should see "Confirm your account"
+    And I should see "An email should have been sent to your address at user1@address.invalid"
+    And I confirm email for "user1"
+    And I should see "Thanks, User1 L1"
+    And I should see "Your registration has been confirmed"
+    And I open my profile in edit mode
+    And the field "First name" matches value "User1"
+    And I log out
+    # Confirm that user can login and browse the site.
+    And I log in as "user1"
+    And I follow "Profile" in the user menu
+    # User can see his own agreements in the profile.
+    And I follow "Policies and agreements"
+    And "Agreed" "icon" should exist in the "Privacy policy" "table_row"
+    And "Agreed" "icon" should exist in the "Cookies policy" "table_row"
+    And "Agreed" "icon" should exist in the "Terms of Service" "table_row"
+    And "Agreed" "icon" should exist in the "Digital maturity declaration" "table_row"
+    And I log out
+
+  Scenario: Accepting policies on login, multiple policies with different style of giving ageement.
+    Given the following config values are set as admin:
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | name                          | summary                   | content             | agreementstyle  |
+      | Digital maturity declaration  | You declare be old enough | Here goes content.  | 1               |
+      | Privacy policy                | We scan your thoughts     | Here goes content.  | 0               |
+      | Terms of Service              | We teach, you learn       | Here goes content.  | 1               |
+      | Cookies policy                | We eat cookies, srsly     | Here goes content.  | 0               |
+    And the following "users" exist:
+      | username | firstname | lastname | email             |
+      | user1    | User      | One      | user1@example.com |
+    And I log in as "user1"
+    # The first policy with the agreement style "on its own page" must be accepted first.
+    Then I should see "Digital maturity declaration" in the "region-main" "region"
+    And I should see "You declare be old enough"
+    And I should see "Here goes content."
+    And I press "I agree to the Digital maturity declaration"
+    # The second policy with the agreement style "on its own page" must be accepted now.
+    And I should see "Terms of Service" in the "region-main" "region"
+    And I should see "We teach, you learn"
+    And I should see "Here goes content."
+    # If the user logs out now, only the first policy is accepted and we return to the same page.
+    And I log out
+    And I log in as "user1"
+    And I should see "Terms of Service" in the "region-main" "region"
+    And I should see "We teach, you learn"
+    And I should see "Here goes content."
+    And I press "I agree to the Terms of Service"
+    # Only now we see the remaining consent page policies.
+    And I should see "Policy 1 out of 2"
+    And I should see "Privacy policy" in the "region-main" "region"
+    And I should see "We scan your thoughts"
+    And I should see "Here goes content."
+    And I press "Next"
+    And I should see "Policy 2 out of 2"
+    And I should see "Cookies policy" in the "region-main" "region"
+    And I should see "We eat cookies, srsly"
+    And I should see "Here goes content."
+    And I press "Next"
+    And I should see "Please agree to the following policies"
+    And I should see "Privacy policy"
+    And I should see "Cookies policy"
+    And I should not see "Digital maturity declaration" in the "region-main" "region"
+    And I should not see "Terms of Service" in the "region-main" "region"
+    And I should not see "Here goes content."
+    And I set the field "I agree to the Privacy policy" to "1"
+    And I set the field "I agree to the Cookies policy" to "1"
+    And I press "Next"
+    And I follow "Profile" in the user menu
+    # User can see his own agreements in the profile.
+    And I follow "Policies and agreements"
+    And "Agreed" "icon" should exist in the "Privacy policy" "table_row"
+    And "Agreed" "icon" should exist in the "Cookies policy" "table_row"
+    And "Agreed" "icon" should exist in the "Terms of Service" "table_row"
+    And "Agreed" "icon" should exist in the "Digital maturity declaration" "table_row"
+    And I log out
+
+  Scenario: Accepting policies on login, all and loggedin policies to be accepted on their own page.
+    Given the following config values are set as admin:
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | name                          | summary                   | content             | agreementstyle  | audience  |
+      | Privacy policy                | We scan your thoughts     | Here goes content.  | 1               | all       |
+      | Digital maturity declaration  | You declare be old enough | Here goes content.  | 1               | loggedin  |
+      | Cookies policy                | We eat cookies, srsly     | Here goes content.  | 1               | guest     |
+      | Terms of Service              | We teach, you learn       | Here goes content.  | 1               | all       |
+    And the following "users" exist:
+      | username | firstname | lastname | email             |
+      | user1    | User      | One      | user1@example.com |
+    And I log in as "user1"
+    # All the policies to be displayed one by one with a button to accept each of them prior seeing the next.
+    Then I should see "Privacy policy" in the "region-main" "region"
+    And I should see "We scan your thoughts"
+    And I should see "Here goes content."
+    And I press "I agree to the Privacy policy"
+    And I should see "Digital maturity declaration" in the "region-main" "region"
+    And I should see "You declare be old enough"
+    And I should see "Here goes content."
+    And I press "I agree to the Digital maturity declaration"
+    And I should see "Terms of Service" in the "region-main" "region"
+    And I should see "We teach, you learn"
+    And I should see "Here goes content."
+    And I press "I agree to the Terms of Service"
+    And I follow "Profile" in the user menu
+    And I follow "Policies and agreements"
+    And "Agreed" "icon" should exist in the "Privacy policy" "table_row"
+    And "Agreed" "icon" should exist in the "Terms of Service" "table_row"
+    And "Agreed" "icon" should exist in the "Digital maturity declaration" "table_row"
+    And "Cookies policy" "table_row" should not exist
+    And I log out
+
+  Scenario: Accepting policies on sign up, policies to be accepted on their own page.
+    Given the following config values are set as admin:
+      | registerauth      | email       |
+      | passwordpolicy    | 0           |
+      | sitepolicyhandler | tool_policy |
+    And the following policies exist:
+      | name                          | summary                   | content             | agreementstyle  | audience  |
+      | Privacy policy                | We scan your thoughts     | Here goes content.  | 1               | guest     |
+      | Digital maturity declaration  | You declare be old enough | Here goes content.  | 1               | all       |
+      | Cookies policy                | We eat cookies, srsly     | Here goes content.  | 1               | loggedin  |
+      | Terms of Service              | We teach, you learn       | Here goes content.  | 1               | guest     |
+    And I am on site homepage
+    And I follow "Log in"
+    When I press "Create new account"
+    # All the policies to be displayed one by one with a button to accept each of them prior seeing the next.
+    Then I should see "Digital maturity declaration" in the "region-main" "region"
+    And I should see "You declare be old enough"
+    And I should see "Here goes content."
+    And I press "I agree to the Digital maturity declaration"
+    And I should see "Cookies policy" in the "region-main" "region"
+    And I should see "We eat cookies, srsly"
+    And I press "I agree to the Cookies policy"
+    And I should see "New account"
+    And I set the following fields to these values:
+      | Username      | user1                 |
+      | Password      | user1                 |
+      | Email address | user1@address.invalid |
+      | Email (again) | user1@address.invalid |
+      | First name    | User1                 |
+      | Surname       | L1                    |
+    And I press "Create my new account"
+    And I should see "Confirm your account"
+    And I should see "An email should have been sent to your address at user1@address.invalid"
+    And I confirm email for "user1"
+    And I should see "Thanks, User1 L1"
+    And I should see "Your registration has been confirmed"
+    And I open my profile in edit mode
+    And the field "First name" matches value "User1"
+    And I log out
+    # Confirm that user can login and browse the site.
+    And I log in as "user1"
+    And I follow "Profile" in the user menu
+    # User can see his own agreements in the profile.
+    And I follow "Policies and agreements"
+    And "Agreed" "icon" should exist in the "Digital maturity declaration" "table_row"
+    And "Agreed" "icon" should exist in the "Cookies policy" "table_row"
+    And "Privacy policy" "table_row" should not exist
+    And "Terms of Service" "table_row" should not exist
+    And I log out
index e87145d..9f09c60 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018051400;         // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2018082900;         // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2018050800;         // Requires this Moodle version.
 $plugin->component = 'tool_policy';      // Full name of the plugin (used for diagnostics).
index 3793d9b..b90490a 100644 (file)
@@ -2211,7 +2211,7 @@ class backup_questions_structure_step extends backup_structure_step {
         $qcategory = new backup_nested_element('question_category', array('id'), array(
             'name', 'contextid', 'contextlevel', 'contextinstanceid',
             'info', 'infoformat', 'stamp', 'parent',
-            'sortorder'));
+            'sortorder', 'idnumber'));
 
         $questions = new backup_nested_element('questions');
 
@@ -2219,7 +2219,7 @@ class backup_questions_structure_step extends backup_structure_step {
             'parent', 'name', 'questiontext', 'questiontextformat',
             'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty',
             'qtype', 'length', 'stamp', 'version',
-            'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby'));
+            'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber'));
 
         // attach qtype plugin structure to $question element, only one allowed
         $this->add_plugin_structure('qtype', $question, false);
index 8443174..250aeb2 100644 (file)
@@ -4434,6 +4434,12 @@ class restore_create_categories_and_questions extends restore_structure_step {
                 $data->stamp = make_unique_id_code();
             }
 
+            // The idnumber if it exists also needs to be unique within a context or reset it to null.
+            if (!empty($data->idnumber) && $DB->record_exists('question_categories',
+                    ['idnumber' => $data->idnumber, 'contextid' => $data->contextid])) {
+                unset($data->idnumber);
+            }
+
             // Let's create the question_category and save mapping.
             $newitemid = $DB->insert_record('question_categories', $data);
             $this->set_mapping('question_category', $oldid, $newitemid);
@@ -4479,6 +4485,13 @@ class restore_create_categories_and_questions extends restore_structure_step {
 
         // With newitemid = 0, let's create the question
         if (!$questionmapping->newitemid) {
+
+            // The idnumber if it exists also needs to be unique within a category or reset it to null.
+            if (!empty($data->idnumber) && $DB->record_exists('question',
+                    ['idnumber' => $data->idnumber, 'category' => $data->category])) {
+                unset($data->idnumber);
+            }
+
             $newitemid = $DB->insert_record('question', $data);
             $this->set_mapping('question', $oldid, $newitemid);
             // Also annotate them as question_created, we need
index 5ebfdce..51ee3e4 100644 (file)
@@ -92,6 +92,10 @@ class event_exporter extends event_exporter_base {
         }
         $values['url'] = $url->out(false);
 
+        // Override default formatted time to make sure the date portion of the time is always rendered.
+        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
+        $values['formattedtime'] = calendar_format_event_time($legacyevent, time(), null, false);
+
         if ($event instanceof action_event_interface) {
             $actionrelated = [
                 'context' => $context,
index 576ffbe..be43e5f 100644 (file)
@@ -42,7 +42,7 @@
     <div class="container-fluid">
         <div class="row">
             <div class="col-xs-1">{{#pix}} i/calendareventtime, core, {{#str}} when, core_calendar {{/str}} {{/pix}}</div>
-            <div class="col-xs-11">{{#userdate}} {{timestart}}, {{#str}} strftimerecentfull {{/str}} {{/userdate}}</div>
+            <div class="col-xs-11">{{{formattedtime}}}</div>
         </div>
         <div class="row m-t-1">
             <div class="col-xs-1">{{#pix}} i/calendar, core, {{#str}} eventtype, core_calendar {{/str}} {{/pix}}</div>
index 0b9c4e8..291afd9 100644 (file)
@@ -575,6 +575,14 @@ $CFG->admin = 'admin';
 //
 //      $CFG->upgradekey = 'put_some_password-like_value_here';
 //
+// Document conversion limit
+//
+// How many times the background task should attempt to convert a given attempt
+// before removing it from the queue. Currently this limit is only used by the
+// mod_assign conversion task.
+//
+//      $CFG->conversionattemptlimit = 3;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index a0345e6..bf4bfd6 100644 (file)
@@ -445,9 +445,6 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $path = $parent->path . '/' . $newcategory->id;
         $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
 
-        // We should mark the context as dirty.
-        context_coursecat::instance($newcategory->id)->mark_dirty();
-
         fix_course_sortorder();
 
         // If this is data from form results, save embedded files and update description.
index 727a1f7..81626d8 100644 (file)
@@ -103,8 +103,13 @@ if ($id) {
     $PAGE->set_context($catcontext);
 
 } else {
+    // Creating new course in default category.
+    $course = null;
     require_login();
-    print_error('needcoursecategroyid');
+    $category = core_course_category::get_default();
+    $catcontext = context_coursecat::instance($category->id);
+    require_capability('moodle/course:create', $catcontext);
+    $PAGE->set_context($catcontext);
 }
 
 // Prepare course and the editor.
index 07e7f08..c255e55 100644 (file)
@@ -2451,9 +2451,6 @@ function create_course($data, $editoroptions = NULL) {
     // purge appropriate caches in case fix_course_sortorder() did not change anything
     cache_helper::purge_by_event('changesincourse');
 
-    // new context created - better mark it as dirty
-    $context->mark_dirty();
-
     // Trigger a course created event.
     $event = \core\event\course_created::create(array(
         'objectid' => $course->id,
index 3898588..297e54b 100644 (file)
@@ -985,7 +985,6 @@ class core_course_courselib_testcase extends advanced_testcase {
         $modulecontext = context_module::instance($assign1->cmid);
         assign_capability('moodle/course:manageactivities', CAP_PROHIBIT, $roleids['editingteacher'],
             $modulecontext);
-        $modulecontext->mark_dirty();
         $this->assertFalse(course_can_delete_section($courseweeks, 1));
         $this->assertTrue(course_can_delete_section($courseweeks, 2));
 
@@ -3131,7 +3130,6 @@ class core_course_courselib_testcase extends advanced_testcase {
         $CFG->enableblogs = 0;
         // Disable view participants capability.
         assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $roleid, $context);
-        $context->mark_dirty();
 
         $navoptions = course_get_user_navigation_options($context);
         $this->assertFalse($navoptions->blogs);
@@ -3427,6 +3425,39 @@ class core_course_courselib_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Test reset_course_userdata() with reset_roles_overrides enabled.
+     */
+    public function test_course_roles_reset() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        // Create test course and user, enrol one in the other.
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'student'), MUST_EXIST);
+        $generator->enrol_user($user->id, $course->id, $roleid);
+
+        // Override course so it does NOT allow students 'mod/forum:viewdiscussion'.
+        $coursecontext = context_course::instance($course->id);
+        assign_capability('mod/forum:viewdiscussion', CAP_PREVENT, $roleid, $coursecontext->id);
+
+        // Check expected capabilities so far.
+        $this->assertFalse(has_capability('mod/forum:viewdiscussion', $coursecontext, $user));
+
+        // Oops, preventing student from viewing forums was a mistake, let's reset the course.
+        $resetdata = new stdClass();
+        $resetdata->id = $course->id;
+        $resetdata->reset_roles_overrides = true;
+        reset_course_userdata($resetdata);
+
+        // Check new expected capabilities - override at the course level should be reset.
+        $this->assertTrue(has_capability('mod/forum:viewdiscussion', $coursecontext, $user));
+    }
+
     public function test_course_check_module_updates_since() {
         global $CFG, $DB, $USER;
         require_once($CFG->dirroot . '/mod/glossary/lib.php');
index 3d9ab46..7e264fc 100644 (file)
@@ -42,33 +42,15 @@ class enrol_category_plugin_testcase extends advanced_testcase {
     }
 
     protected function enable_role_sync($roleid) {
-        global $DB;
-
         $syscontext = context_system::instance();
 
-        if ($rc = $DB->record_exists('role_capabilities', array('capability'=>'enrol/category:synchronised', 'roleid'=>$roleid, 'contextid'=>$syscontext->id))) {
-            if ($rc->permission != CAP_ALLOW) {
-                $rc->permission = CAP_ALLOW;
-                $DB->update_record('role_capabilities', $rc);
-            }
-        } else {
-            $rc = new stdClass();
-            $rc->capability = 'enrol/category:synchronised';
-            $rc->roleid = $roleid;
-            $rc->contextid = $syscontext->id;
-            $rc->permission = CAP_ALLOW;
-            $rc->timemodified = time();
-            $rc->modifierid = 0;
-            $DB->insert_record('role_capabilities', $rc);
-        }
+        assign_capability('enrol/category:synchronised', CAP_ALLOW, $roleid, $syscontext, true);
     }
 
     protected function disable_role_sync($roleid) {
-        global $DB;
-
         $syscontext = context_system::instance();
 
-        $DB->delete_records('role_capabilities', array('capability'=>'enrol/category:synchronised', 'roleid'=>$roleid, 'contextid'=>$syscontext->id));
+        unassign_capability('enrol/category:synchronised', $roleid, $syscontext);
     }
 
     /**
index 1678462..0cabb96 100644 (file)
@@ -751,4 +751,45 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertCount(2, enrol_get_course_users($course1->id, false));
         $this->assertCount(1, enrol_get_course_users($course1->id, true));
     }
+
+    /**
+     * Test count of enrolled users
+     *
+     * @return void
+     */
+    public function test_count_enrolled_users() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $context = \context_course::instance($course->id);
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        // Add each user to the manual enrolment instance.
+        $manual = enrol_get_plugin('manual');
+
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+        $manual->enrol_user($manualinstance, $user2->id, $studentrole->id);
+
+        $this->assertEquals(2, count_enrolled_users($context));
+
+        // Create a self enrolment instance, enrol first user only.
+        $self = enrol_get_plugin('self');
+
+        $selfid = $self->add_instance($course,
+            ['status' => ENROL_INSTANCE_ENABLED, 'name' => 'Self', 'customint6' => 1, 'roleid' => $studentrole->id]);
+        $selfinstance = $DB->get_record('enrol', ['id' => $selfid], '*', MUST_EXIST);
+
+        $self->enrol_user($selfinstance, $user1->id, $studentrole->id);
+
+        // There are still only two distinct users.
+        $this->assertEquals(2, count_enrolled_users($context));
+    }
 }
index 5c4ba44..1f25918 100644 (file)
@@ -33,12 +33,8 @@ defined('MOODLE_INTERNAL') || die();
  * NOTE: multilang glossary entries are not compatible with this filter.
  */
 class filter_glossary extends moodle_text_filter {
-    /** @var int $cachecourseid cache invalidation flag in case content from multiple courses displayed. */
-    protected $cachecourseid = null;
-    /** @var int $cacheuserid cache invalidation flag in case user is switched. */
-    protected $cacheuserid = null;
-    /** @var array $cacheconceptlist page level filter cache, this should be always faster than MUC */
-    protected $cacheconceptlist = null;
+    /** @var null|cache_store cache used to store the terms for this course. */
+    protected $cache = null;
 
     public function setup($page, $context) {
         if ($page->requires->should_create_one_time_item_now('filter_glossary_autolinker')) {
@@ -50,8 +46,16 @@ class filter_glossary extends moodle_text_filter {
         }
     }
 
-    public function filter($text, array $options = array()) {
-        global $CFG, $USER, $GLOSSARY_EXCLUDEENTRY;
+    /**
+     * Get all the concepts for this context.
+     * @return filterobject[] the concepts, and filterobjects.
+     */
+    protected function get_all_concepts() {
+        global $USER;
+
+        if ($this->cache === null) {
+            $this->cache = cache::make_from_params(cache_store::MODE_REQUEST, 'filter', 'glossary');
+        }
 
         // Try to get current course.
         $coursectx = $this->context->get_course_context(false);
@@ -62,96 +66,131 @@ class filter_glossary extends moodle_text_filter {
             $courseid = $coursectx->instanceid;
         }
 
-        if ($this->cachecourseid != $courseid or $this->cacheuserid != $USER->id) {
+        $cached = $this->cache->get('concepts');
+        if ($cached !== false && ($cached->cachecourseid != $courseid || $cached->cacheuserid != $USER->id)) {
             // Invalidate the page cache.
-            $this->cacheconceptlist = null;
+            $cached = false;
         }
 
-        if (is_array($this->cacheconceptlist) and empty($GLOSSARY_EXCLUDEENTRY)) {
-            if (empty($this->cacheconceptlist)) {
-                return $text;
-            }
-            return filter_phrases($text, $this->cacheconceptlist);
+        if ($cached !== false && is_array($cached->cacheconceptlist)) {
+            return $cached->cacheconceptlist;
         }
 
         list($glossaries, $allconcepts) = \mod_glossary\local\concept_cache::get_concepts($courseid);
 
         if (!$allconcepts) {
-            $this->cacheuserid = $USER->id;
-            $this->cachecourseid = $courseid;
-            $this->cacheconcepts = array();
-            return $text;
+            $tocache = new stdClass();
+            $tocache->cacheuserid = $USER->id;
+            $tocache->cachecourseid = $courseid;
+            $tocache->cacheconceptlist = [];
+            $this->cache->set('concepts', $tocache);
+            return [];
         }
 
-        $strcategory = get_string('category', 'glossary');
-
         $conceptlist = array();
-        $excluded = false;
 
         foreach ($allconcepts as $concepts) {
             foreach ($concepts as $concept) {
-                if (!empty($GLOSSARY_EXCLUDEENTRY) and $concept->id == $GLOSSARY_EXCLUDEENTRY) {
-                    $excluded = true;
-                    continue;
-                }
-                if ($concept->category) { // Link to a category.
-                    // TODO: Fix this string usage.
-                    $title = $glossaries[$concept->glossaryid] . ': ' . $strcategory . ' ' . $concept->concept;
-                    $link = new moodle_url('/mod/glossary/view.php', array('g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id));
-                    $attributes = array(
-                        'href'  => $link,
-                        'title' => $title,
-                        'class' => 'glossary autolink category glossaryid' . $concept->glossaryid);
-
-                } else { // Link to entry or alias
-                    $title = $glossaries[$concept->glossaryid] . ': ' . $concept->concept;
-                    // Hardcoding dictionary format in the URL rather than defaulting
-                    // to the current glossary format which may not work in a popup.
-                    // for example "entry list" means the popup would only contain
-                    // a link that opens another popup.
-                    $link = new moodle_url('/mod/glossary/showentry.php', array('eid' => $concept->id, 'displayformat' => 'dictionary'));
-                    $attributes = array(
-                        'href'  => $link,
-                        'title' => str_replace('&amp;', '&', $title), // Undo the s() mangling.
-                        'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid);
-                }
-                // This flag is optionally set by resource_pluginfile()
-                // if processing an embedded file use target to prevent getting nested Moodles.
-                if (!empty($CFG->embeddedsoforcelinktarget)) {
-                    $attributes['target'] = '_top';
-                }
-                $href_tag_begin = html_writer::start_tag('a', $attributes);
-
-                $conceptlist[] = new filterobject($concept->concept, $href_tag_begin, '</a>',
-                    $concept->casesensitive, $concept->fullmatch);
+                $conceptlist[] = new filterobject($concept->concept, null, null,
+                        $concept->casesensitive, $concept->fullmatch, null,
+                        [$this, 'filterobject_prepare_replacement_callback'], [$concept, $glossaries]);
             }
         }
 
-        usort($conceptlist, 'filter_glossary::sort_entries_by_length');
+        // We sort longest first, so that when we replace the terms,
+        // the longest ones are replaced first. This does the right thing
+        // when you have two terms like 'Moodle' and 'Moodle 3.5'. You want the longest match.
+        usort($conceptlist, [$this, 'sort_entries_by_length']);
+
+        $conceptlist = filter_prepare_phrases_for_filtering($conceptlist);
+
+        $tocache = new stdClass();
+        $tocache->cacheuserid = $USER->id;
+        $tocache->cachecourseid = $courseid;
+        $tocache->cacheconceptlist = $conceptlist;
+        $this->cache->set('concepts', $tocache);
+
+        return $conceptlist;
+    }
+
+    /**
+     * Callback used by filterobject / filter_phrases.
+     *
+     * @param object $concept the concept that is being replaced (from get_all_concepts).
+     * @param array $glossaries the list of glossary titles (from get_all_concepts).
+     * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject.
+     */
+    public function filterobject_prepare_replacement_callback($concept, $glossaries) {
+        global $CFG;
+
+        if ($concept->category) { // Link to a category.
+            $title = get_string('glossarycategory', 'filter_glossary',
+                    ['glossary' => $glossaries[$concept->glossaryid], 'category' => $concept->concept]);
+            $link = new moodle_url('/mod/glossary/view.php',
+                    ['g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id]);
+            $attributes = array(
+                    'href'  => $link,
+                    'title' => $title,
+                    'class' => 'glossary autolink category glossaryid' . $concept->glossaryid);
+
+        } else { // Link to entry or alias.
+            $title = get_string('glossaryconcept', 'filter_glossary',
+                    ['glossary' => $glossaries[$concept->glossaryid], 'concept' => $concept->concept]);
+            // Hardcoding dictionary format in the URL rather than defaulting
+            // to the current glossary format which may not work in a popup.
+            // for example "entry list" means the popup would only contain
+            // a link that opens another popup.
+            $link = new moodle_url('/mod/glossary/showentry.php',
+                    ['eid' => $concept->id, 'displayformat' => 'dictionary']);
+            $attributes = array(
+                    'href'  => $link,
+                    'title' => str_replace('&amp;', '&', $title), // Undo the s() mangling.
+                    'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid);
+        }
 
-        if (!$excluded) {
-            // Do not cache the excluded list here, it is used once per page only.
-            $this->cacheuserid = $USER->id;
-            $this->cachecourseid = $courseid;
-            $this->cacheconceptlist = $conceptlist;
+        // This flag is optionally set by resource_pluginfile()
+        // if processing an embedded file use target to prevent getting nested Moodles.
+        if (!empty($CFG->embeddedsoforcelinktarget)) {
+            $attributes['target'] = '_top';
         }
 
+        return [html_writer::start_tag('a', $attributes), '</a>', null];
+    }
+
+    public function filter($text, array $options = array()) {
+        global $GLOSSARY_EXCLUDEENTRY;
+
+        $conceptlist = $this->get_all_concepts();
+
         if (empty($conceptlist)) {
             return $text;
         }
-        return filter_phrases($text, $conceptlist);   // Actually search for concepts!
-    }
 
-    private static function sort_entries_by_length($entry0, $entry1) {
-        $len0 = strlen($entry0->phrase);
-        $len1 = strlen($entry1->phrase);
+        if (!empty($GLOSSARY_EXCLUDEENTRY)) {
+            foreach ($conceptlist as $key => $filterobj) {
+                // The original concept object was stored here in when $filterobj was constructed in
+                // get_all_concepts(). Get it back out now so we can check to see if it is excluded.
+                $concept = $filterobj->replacementcallbackdata[0];
+                if (!$concept->category && $concept->id == $GLOSSARY_EXCLUDEENTRY) {
+                    unset($conceptlist[$key]);
+                }
+            }
+        }
 
-        if ($len0 < $len1) {
-            return 1;
-        } else if ($len0 > $len1) {
-            return -1;
-        } else {
-            return 0;
+        if (empty($conceptlist)) {
+            return $text;
         }
+
+        return filter_phrases($text, $conceptlist, null, null, false, true);
+    }
+
+    /**
+     * usort helper used in get_all_concepts above.
+     * @param filterobject $filterobject0 first item to compare.
+     * @param filterobject $filterobject1 second item to compare.
+     * @return int -1, 0 or 1.
+     */
+    private function sort_entries_by_length($filterobject0, $filterobject1) {
+        return strlen($filterobject1->phrase) <=> strlen($filterobject0->phrase);
     }
 }
index 0f14c51..d7917af 100644 (file)
@@ -25,5 +25,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['glossarycategory'] = '{$a->glossary}: Category {$a->category}';
+$string['glossaryconcept'] = '{$a->glossary}: {$a->concept}';
 $string['filtername'] = 'Glossary auto-linking';
 $string['privacy:metadata'] = 'The Glossary auto-linking plugin does not store any personal data.';
index ccf54ed..f22822c 100644 (file)
@@ -33,6 +33,119 @@ require_once($CFG->dirroot . '/filter/glossary/filter.php'); // Include the code
  */
 class filter_glossary_filter_testcase extends advanced_testcase {
 
+    public function test_link_to_entry_with_alias() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $normal = $generator->create_content($glossary, array('concept' => 'entry name'),
+                array('first alias', 'second alias'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>First we have entry name, then we have it twp aliases first alias and second alias.</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 3 glossary links.
+        $this->assertEquals(3, count($matches[1]));
+        $this->assertEquals($normal->id, $matches[1][0]);
+        $this->assertEquals($normal->id, $matches[1][1]);
+        $this->assertEquals($normal->id, $matches[1][2]);
+
+        // Check text of title attribute.
+        $this->assertEquals($glossary->name . ': entry name',   $matches[2][0]);
+        $this->assertEquals($glossary->name . ': first alias',  $matches[2][1]);
+        $this->assertEquals($glossary->name . ': second alias', $matches[2][2]);
+    }
+
+    public function test_longest_link_used() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $shorter = $generator->create_content($glossary, array('concept' => 'Tim'));
+        $longer = $generator->create_content($glossary, array('concept' => 'Time'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>Time will tell</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary link to Time, not Tim.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($longer->id, $matches[1][0]);
+
+        // Check text of title attribute.
+        $this->assertEquals($glossary->name . ': Time',   $matches[2][0]);
+    }
+
+    public function test_link_to_category() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        /** @var mod_glossary_generator $generator */
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $category = $generator->create_category($glossary, array('name' => 'My category', 'usedynalink' => 1));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>This is My category you know.</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~hook=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary link.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($category->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': Category My category', $matches[2][0]);
+    }
+
     /**
      * Test ampersands.
      */
@@ -59,9 +172,6 @@ class filter_glossary_filter_testcase extends advanced_testcase {
         $amp1 = $generator->create_content($glossary, array('concept' => 'A&B'));
         $amp2 = $generator->create_content($glossary, array('concept' => 'C&amp;D'));
 
-        filter_manager::reset_caches();
-        \mod_glossary\local\concept_cache::reset_caches();
-
         // Format text with all three entries in HTML.
         $html = '<p>A&amp;B C&amp;D normal</p>';
         $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
@@ -81,4 +191,81 @@ class filter_glossary_filter_testcase extends advanced_testcase {
         $this->assertEquals($glossary->name . ': C&amp;D', $matches[2][1]);
         $this->assertEquals($glossary->name . ': normal', $matches[2][2]);
     }
+
+    public function test_exclude_excludes_link_to_entry_with_alias() {
+        global $CFG, $GLOSSARY_EXCLUDEENTRY;
+
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $tobeexcluded = $generator->create_content($glossary, array('concept' => 'entry name'),
+                array('first alias', 'second alias'));
+        $normal = $generator->create_content($glossary, array('concept' => 'other entry'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>First we have entry name, then we have it twp aliases first alias and second alias. ' .
+                'In this case, those should not be linked, but this other entry should be.</p>';
+        $GLOSSARY_EXCLUDEENTRY = $tobeexcluded->id;
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+        $GLOSSARY_EXCLUDEENTRY = null;
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary links.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($normal->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': other entry', $matches[2][0]);
+    }
+
+    public function test_exclude_does_not_exclude_categories() {
+        global $CFG, $GLOSSARY_EXCLUDEENTRY;
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        /** @var mod_glossary_generator $generator */
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $category = $generator->create_category($glossary, array('name' => 'My category', 'usedynalink' => 1));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>This is My category you know.</p>';
+        $GLOSSARY_EXCLUDEENTRY = $category->id;
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+        $GLOSSARY_EXCLUDEENTRY = null;
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~hook=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary link.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($category->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': Category My category', $matches[2][0]);
+    }
 }
index 50e568e..0906e18 100644 (file)
@@ -128,37 +128,116 @@ class filter_mathjaxloader extends moodle_text_filter {
             $text = str_replace('\\]', '\\)', $text);
         }
 
-        $hasinline = strpos($text, '\\(') !== false && strpos($text, '\\)') !== false;
-        $hasdisplay = (strpos($text, '$$') !== false) ||
-                      (strpos($text, '\\[') !== false && strpos($text, '\\]') !== false);
-
         $hasextra = false;
-
         foreach ($extradelimiters as $extra) {
             if ($extra && strpos($text, $extra) !== false) {
                 $hasextra = true;
                 break;
             }
         }
-        if ($hasinline || $hasdisplay || $hasextra) {
+
+        $hasdisplayorinline = false;
+        if ($hasextra) {
+            // If custom dilimeters are used, wrap whole text to prevent autolinking.
+            $text = '<span class="nolink">' . $text . '</span>';
+        } else {
+            // Wrap display and inline math environments in nolink spans.
+            // Do not wrap nested environments, i.e., if inline math is nested
+            // inside display math, only the outer display math is wrapped in
+            // a span. The span HTML inside a LaTex math environment would break
+            // MathJax. See MDL-61981.
+            list($text, $hasdisplayorinline) = $this->wrap_math_in_nolink($text);
+        }
+
+        if ($hasdisplayorinline || $hasextra) {
             $PAGE->requires->yui_module('moodle-filter_mathjaxloader-loader', 'M.filter_mathjaxloader.typeset');
-            if ($hasextra) {
-                // If custom dilimeters are used, wrap whole text to prevent autolinking.
-                $text = '<span class="nolink">' . $text . '</span>';
-            } else {
-                if ($hasinline) {
-                    // If the default inline TeX delimiters \( \) are present, wrap each pair in nolink.
-                    $text = preg_replace('/\\\\\\([\S\s]*?\\\\\\)/u',
-                        '<span class="nolink">\0</span>', $text);
+            return '<span class="filter_mathjaxloader_equation">' . $text . '</span>';
+        }
+        return $text;
+    }
+
+    /**
+     * Find math environments in the $text and wrap them in no link spans
+     * (<span class="nolink"></span>). If math environments are nested, only
+     * the outer environment is wrapped in the span.
+     *
+     * The recognized math environments are \[ \] and $$ $$ for display
+     * mathematics and \( \) for inline mathematics.
+     *
+     * @param string $text The text to filter.
+     * @return array An array containing the potentially modified text and
+     * a boolean that is true if any changes were made to the text.
+     */
+    protected function wrap_math_in_nolink($text) {
+        $i = 1;
+        $len = strlen($text);
+        $displaystart = -1;
+        $displaybracket = false;
+        $displaydollar = false;
+        $inlinestart = -1;
+        $changesdone = false;
+        // Loop over the $text once.
+        while ($i < $len) {
+            if ($displaystart === -1) {
+                // No display math has started yet.
+                if ($text[$i - 1] === '\\' && $text[$i] === '[') {
+                    // Display mode \[ begins.
+                    $displaystart = $i - 1;
+                    $displaybracket = true;
+                } else if ($text[$i - 1] === '$' && $text[$i] === '$') {
+                    // Display mode $$ begins.
+                    $displaystart = $i - 1;
+                    $displaydollar = true;
+                } else if ($text[$i - 1] === '\\' && $text[$i] === '(') {
+                    // Inline math \( begins, not nested inside display math.
+                    $inlinestart = $i - 1;
+                } else if ($text[$i - 1] === '\\' && $text[$i] === ')' && $inlinestart > -1) {
+                    // Inline math ends, not nested inside display math.
+                    // Wrap the span around it.
+                    $text = $this->insert_span($text, $inlinestart, $i);
+
+                    $inlinestart = -1; // Reset.
+                    $i += 28; // The $text length changed due to the <span>.
+                    $len += 28;
+                    $changesdone = true;
                 }
-                if ($hasdisplay) {
-                    // If default display TeX is used, wrap $$ $$ or \[ \] individually.
-                    $text = preg_replace('/\$\$[\S\s]*?\$\$|\\\\\\[[\S\s]*?\\\\\\]/u',
-                        '<span class="nolink">\0</span>', $text);
+            } else {
+                // Display math open.
+                if (($text[$i - 1] === '\\' && $text[$i] === ']' && $displaybracket) ||
+                        ($text[$i - 1] === '$' && $text[$i] === '$' && $displaydollar)) {
+                    // Display math ends, wrap the span around it.
+                    $text = $this->insert_span($text, $displaystart, $i);
+
+                    $displaystart = -1; // Reset.
+                    $displaybracket = false;
+                    $displaydollar = false;
+                    $i += 28; // The $text length changed due to the <span>.
+                    $len += 28;
+                    $changesdone = true;
                 }
             }
-            return '<span class="filter_mathjaxloader_equation">' . $text . '</span>';
+
+            ++$i;
         }
-        return $text;
+        return array($text, $changesdone);
+    }
+
+    /**
+     * Wrap a portion of the $text inside a no link span
+     * (<span class="nolink"></span>). The whole text is then returned.
+     *
+     * @param string $text The text to modify.
+     * @param int $start The start index of the substring in $text that should
+     * be wrapped in the span.
+     * @param int $end The end index of the substring in $text that should be
+     * wrapped in the span.
+     * @return string The whole $text with the span inserted around
+     * the defined substring.
+     */
+    protected function insert_span($text, $start, $end) {
+        return substr_replace($text,
+                '<span class="nolink">'. substr($text, $start, $end - $start + 1) .'</span>',
+                $start,
+                $end - $start + 1);
     }
 }
diff --git a/filter/mathjaxloader/tests/filtermath_test.php b/filter/mathjaxloader/tests/filtermath_test.php
new file mode 100644 (file)
index 0000000..31bd1ee
--- /dev/null
@@ -0,0 +1,102 @@
+<?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/>.
+/**
+ * Provides the {@link filter_mathjaxloader_filtermath_testcase} class.
+ *
+ * @package     filter_mathjaxloader
+ * @category    test
+ * @copyright   2018 Markku Riekkinen
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+require_once($CFG->dirroot.'/filter/mathjaxloader/filter.php');
+/**
+ * Unit tests for the MathJax loader filter.
+ *
+ * @copyright 2018 Markku Riekkinen
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filter_mathjaxloader_filtermath_testcase extends advanced_testcase {
+
+    /**
+     * Test the functionality of {@link filter_mathjaxloader::filter()}.
+     *
+     * @param string $inputtext The text given by the user.
+     * @param string $expected The expected output after filtering.
+     *
+     * @dataProvider test_math_filtering_inputs
+     */
+    public function test_math_filtering($inputtext, $expected) {
+        $filter = new filter_mathjaxloader(context_system::instance(), []);
+        $this->assertEquals($expected, $filter->filter($inputtext));
+    }
+
+    /**
+     * Data provider for {@link self::test_math_filtering()}.
+     *
+     * @return array of [inputtext, expectedoutput] tuples.
+     */
+    public function test_math_filtering_inputs() {
+        return [
+            // One inline formula.
+            ['Some inline math \\( y = x^2 \\).',
+            '<span class="filter_mathjaxloader_equation">Some inline math <span class="nolink">\\( y = x^2 \\)</span>.</span>'],
+
+            // One inline and one display.
+            ['Some inline math \\( y = x^2 \\) and display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\]',
+            '<span class="filter_mathjaxloader_equation">Some inline math <span class="nolink">\\( y = x^2 \\)</span> and '
+                . 'display formula <span class="nolink">\\[ S = \\sum_{n=1}^{\\infty} 2^n \\]</span></span>'],
+
+            // One display and one inline.
+            ['Display formula \\[ S = \\sum_{n=1}^{\\infty} 2^n \\] and some inline math \\( y = x^2 \\).',
+            '<span class="filter_mathjaxloader_equation">Display formula <span class="nolink">\\[ S = \\sum_{n=1}^{\\infty} 2^n \\]</span> and '
+                . 'some inline math <span class="nolink">\\( y = x^2 \\)</span>.</span>'],
+
+            // One inline and one display (with dollars).
+            ['Some inline math \\( y = x^2 \\) and display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$',
+            '<span class="filter_mathjaxloader_equation">Some inline math <span class="nolink">\\( y = x^2 \\)</span> and '
+                . 'display formula <span class="nolink">$$ S = \\sum_{n=1}^{\\infty} 2^n $$</span></span>'],
+
+            // One display (with dollars) and one inline.
+            ['Display formula $$ S = \\sum_{n=1}^{\\infty} 2^n $$ and some inline math \\( y = x^2 \\).',
+            '<span class="filter_mathjaxloader_equation">Display formula <span class="nolink">$$ S = \\sum_{n=1}^{\\infty} 2^n $$</span> and '
+                . 'some inline math <span class="nolink">\\( y = x^2 \\)</span>.</span>'],
+
+            // Inline math environment nested inside display environment (using a custom LaTex macro).
+            ['\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] '
+                . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\).',
+            '<span class="filter_mathjaxloader_equation"><span class="nolink">'
+                . '\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\]</span> '
+                . 'Text with inline formula using the custom LaTex macro <span class="nolink">\\( a = \\NullF \\)</span>.</span>'],
+
+            // Nested environments and some more content.
+            ['\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\] '
+                . 'Text with inline formula using the custom LaTex macro \\( a = \\NullF \\). Finally, a display formula '
+                . '$$ b = \\NullF $$',
+            '<span class="filter_mathjaxloader_equation"><span class="nolink">'
+                . '\\[ \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} \\]</span> '
+                . 'Text with inline formula using the custom LaTex macro <span class="nolink">\\( a = \\NullF \\)</span>. '
+                . 'Finally, a display formula <span class="nolink">$$ b = \\NullF $$</span></span>'],
+
+            // Broken math: the delimiters ($$) are not closed.
+            ['Writing text and starting display math. $$ k = i^3 \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} '
+                . 'More text and inline math \\( x = \\NullF \\).',
+            'Writing text and starting display math. $$ k = i^3 \\newcommand{\\False}{\\mathsf{F}} \\newcommand{\\NullF}{\\fbox{\\(\\False\\)}} '
+                . 'More text and inline math \\( x = \\NullF \\).'],
+        ];
+    }
+}
index ddfbd82..3ccfc6e 100644 (file)
@@ -1,6 +1,20 @@
 This file describes API changes in core filter API and plugins,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+
+* Although there is no API change that require you to update your filter,
+  if you use the filter_phrases() helper method, you may wish to take
+  advantage of the changes that were made in MDL-47962 to improve performance.
+
+  Now, instead of having to compute the replacement HTML for each phrase before
+  you construct the filterobject for it. You can instead pass a callback to
+  the filterobject constructor which is only called if the phrase is used.
+
+  To understand how to use this, see the comment on filterobject::__construct and
+  look at the filter_glossary changes as an example:
+  https://github.com/moodle/moodle/commit/5a8c44d000ecc5669db26aefebe447f688e8f2ce
+
 === 3.0 ===
 
 * New argument $skipfilters to filter_manager::filter_text to allow applying
index 75f52c3..152fc3b 100644 (file)
@@ -287,7 +287,6 @@ class core_grade_report_graderlib_testcase extends advanced_testcase {
         $context = context_course::instance($course->id);
         $managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
         assign_capability('moodle/grade:viewhidden', CAP_PROHIBIT, $managerroleid, $context->id, true);
-        $context->mark_dirty();
         $this->assertFalse(has_capability('moodle/grade:viewhidden', $context));
 
         // Recreate the report. Confirm it returns successfully still.
index db19102..1c93d92 100644 (file)
@@ -194,7 +194,7 @@ Moodle community|https://moodle.org
 -Moodle development|https://moodle.org/development
 Moodle.com|http://moodle.com/
 </pre>';
-$string['configcustomusermenuitems'] = 'You can configure the contents of the user menu (with the exception of the log out link, which is automatically added). Each line is separated by pipe characters and consists of 1) a string in "langstringname, componentname" form or as plain text, 2) a URL, and 3) an icon either as a pix icon (in the folder pix/t, or prefix the icon name with ../ if icon is in another pix folder) or as a URL. Dividers can be used by adding a line of one or more # characters where desired.';
+$string['configcustomusermenuitems'] = 'You can configure the contents of the user menu (with the exception of the log out link, which is automatically added). Each line is separated by pipe characters and consists of 1) a string in "langstringname, componentname" form or as plain text, 2) a URL, and 3) an icon either as a pix icon (in the folder pix with the structure [subfoldername]/[iconname], e.g. i/publish) or as a URL. Dividers can be used by adding a line of one or more # characters where desired.';
 $string['configdbsessions'] = 'If enabled, this setting will use the database to store information about current sessions. Note that changing this setting now will log out all current users (including you). If you are using MySQL please make sure that \'max_allowed_packet\' in my.cnf (or my.ini) is at least 4M. Other session drivers can be configured directly in config.php, see config-dist.php for more information. This option disappears if you specify session driver in config.php file.';
 $string['configdebug'] = 'If you turn this on, then PHP\'s error_reporting will be increased so that more warnings are printed.  This is only useful for developers.';
 $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the HTML page. This is practical, but breaks XHTML, JS, cookies and HTTP headers in general. Set to off, it will send the output to your server logs, allowing better debugging. The PHP setting error_log controls which log this goes to.';
index fb8fe82..b853063 100644 (file)
@@ -170,6 +170,8 @@ $string['getcontextfromfile'] = 'Get context from file';
 $string['changepublishstatuscat'] = '<a href="{$a->caturl}">Category "{$a->name}"</a> in course "{$a->coursename}" will have it\'s sharing status changed from <strong>{$a->changefrom} to {$a->changeto}</strong>.';
 $string['chooseqtypetoadd'] = 'Choose a question type to add';
 $string['editquestions'] = 'Edit questions';
+$string['idnumber'] = 'ID number';
+$string['idnumber_help'] = 'If used, the ID number must be unique within each question category. It provides another way of identifying a question which is sometimes useful, but can usually be left blank.';
 $string['ignorebroken'] = 'Ignore broken links';
 $string['impossiblechar'] = 'Impossible character {$a} detected as parenthesis character';
 $string['import'] = 'Import';
index 8211ffb..27ec530 100644 (file)
@@ -62,6 +62,8 @@
  * <b>Name conventions</b>
  *
  * "ctx" means context
+ * "ra" means role assignment
+ * "rdef" means role definition
  *
  * <b>accessdata</b>
  *
@@ -72,9 +74,7 @@
  * against userid in $ACCESSLIB_PRIVATE->accessdatabyuser).
  *
  * $accessdata is a multidimensional array, holding
- * role assignments (RAs), role-capabilities-perm sets
- * (role defs) and a list of courses we have loaded
- * data for.
+ * role assignments (RAs), role switches and initialization time.
  *
  * Things are keyed on "contextpaths" (the path field of
  * the context table) for fast walking up/down the tree.
@@ -182,6 +182,7 @@ global $ACCESSLIB_PRIVATE;
 $ACCESSLIB_PRIVATE = new stdClass();
 $ACCESSLIB_PRIVATE->cacheroledefs    = array(); // Holds site-wide role definitions.
 $ACCESSLIB_PRIVATE->dirtycontexts    = null;    // Dirty contexts cache, loaded from DB once per page
+$ACCESSLIB_PRIVATE->dirtyusers       = null;    // Dirty users cache, loaded from DB once per $USER->id
 $ACCESSLIB_PRIVATE->accessdatabyuser = array(); // Holds the cache of $accessdata structure for users (including $USER)
 
 /**
@@ -201,6 +202,7 @@ function accesslib_clear_all_caches_for_unit_testing() {
     }
 
     accesslib_clear_all_caches(true);
+    accesslib_reset_role_cache();
 
     unset($USER->access);
 }
@@ -218,17 +220,34 @@ function accesslib_clear_all_caches($resetcontexts) {
     global $ACCESSLIB_PRIVATE;
 
     $ACCESSLIB_PRIVATE->dirtycontexts    = null;
+    $ACCESSLIB_PRIVATE->dirtyusers       = null;
     $ACCESSLIB_PRIVATE->accessdatabyuser = array();
-    $ACCESSLIB_PRIVATE->cacheroledefs    = array();
-
-    $cache = cache::make('core', 'roledefs');
-    $cache->purge();
 
     if ($resetcontexts) {
         context_helper::reset_caches();
     }
 }
 
+/**
+ * Full reset of accesslib's private role cache. ONLY TO BE USED FROM THIS LIBRARY FILE!
+ *
+ * This reset does not touch global $USER.
+ *
+ * Note: Only use this when the roles that need a refresh are unknown.
+ *
+ * @see accesslib_clear_role_cache()
+ *
+ * @access private
+ * @return void
+ */
+function accesslib_reset_role_cache() {
+    global $ACCESSLIB_PRIVATE;
+
+    $ACCESSLIB_PRIVATE->cacheroledefs = array();
+    $cache = cache::make('core', 'roledefs');
+    $cache->purge();
+}
+
 /**
  * Clears accesslib's private cache of a specific role or roles. ONLY BE USED FROM THIS LIBRARY FILE!
  *
@@ -990,14 +1009,19 @@ function reload_all_capabilities() {
 
     accesslib_clear_all_caches(true);
     unset($USER->access);
-    $ACCESSLIB_PRIVATE->dirtycontexts = array(); // prevent dirty flags refetching on this page
+
+    // Prevent dirty flags refetching on this page.
+    $ACCESSLIB_PRIVATE->dirtycontexts = array();
+    $ACCESSLIB_PRIVATE->dirtyusers    = array($USER->id => false);
 
     load_all_capabilities();
 
     foreach ($sw as $path => $roleid) {
         if ($record = $DB->get_record('context', array('path'=>$path))) {
             $context = context::instance_by_id($record->id);
-            role_switch($roleid, $context);
+            if (has_capability('moodle/role:switchroles', $context)) {
+                role_switch($roleid, $context);
+            }
         }
     }
 }
@@ -1280,8 +1304,6 @@ function delete_role($roleid) {
 /**
  * Function to write context specific overrides, or default capabilities.
  *
- * NOTE: use $context->mark_dirty() after this
- *
  * @param string $capability string name
  * @param int $permission CAP_ constants
  * @param int $roleid role id
@@ -1335,8 +1357,6 @@ function assign_capability($capability, $permission, $roleid, $contextid, $overw
 /**
  * Unassign a capability from a role.
  *
- * NOTE: use $context->mark_dirty() after this
- *
  * @param string $capability the name of the capability
  * @param int $roleid the role id
  * @param int|context $contextid null means all contexts
@@ -1493,13 +1513,8 @@ function role_assign($roleid, $userid, $contextid, $component = '', $itemid = 0,
 
     $ra->id = $DB->insert_record('role_assignments', $ra);
 
-    // mark context as dirty - again expensive, but needed
-    $context->mark_dirty();
-
-    if (!empty($USER->id) && $USER->id == $userid) {
-        // If the user is the current user, then do full reload of capabilities too.
-        reload_all_capabilities();
-    }
+    // Role assignments have changed, so mark user as dirty.
+    mark_user_dirty($userid);
 
     core_course_category::role_assignment_changed($roleid, $context);
 
@@ -1592,12 +1607,9 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
     foreach($ras as $ra) {
         $DB->delete_records('role_assignments', array('id'=>$ra->id));
         if ($context = context::instance_by_id($ra->contextid, IGNORE_MISSING)) {
-            // this is a bit expensive but necessary
-            $context->mark_dirty();
-            // If the user is the current user, then do full reload of capabilities too.
-            if (!empty($USER->id) && $USER->id == $ra->userid) {
-                reload_all_capabilities();
-            }
+            // Role assignments have changed, so mark user as dirty.
+            mark_user_dirty($ra->userid);
+
             $event = \core\event\role_unassigned::create(array(
                 'context' => $context,
                 'objectid' => $ra->roleid,
@@ -1631,12 +1643,9 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
                 $ras = $DB->get_records('role_assignments', $mparams);
                 foreach($ras as $ra) {
                     $DB->delete_records('role_assignments', array('id'=>$ra->id));
-                    // this is a bit expensive but necessary
-                    $context->mark_dirty();
-                    // If the user is the current user, then do full reload of capabilities too.
-                    if (!empty($USER->id) && $USER->id == $ra->userid) {
-                        reload_all_capabilities();
-                    }
+                    // Role assignments have changed, so mark user as dirty.
+                    mark_user_dirty($ra->userid);
+
                     $event = \core\event\role_unassigned::create(
                         array('context'=>$context, 'objectid'=>$ra->roleid, 'relateduserid'=>$ra->userid,
                             'other'=>array('id'=>$ra->id, 'component'=>$ra->component, 'itemid'=>$ra->itemid)));
@@ -1655,6 +1664,30 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
     }
 }
 
+/**
+ * Mark a user as dirty (with timestamp) so as to force reloading of the user session.
+ *
+ * @param int $userid
+ * @return void
+ */
+function mark_user_dirty($userid) {
+    global $CFG, $ACCESSLIB_PRIVATE;
+
+    if (during_initial_install()) {
+        return;
+    }
+
+    // Throw exception if invalid userid is provided.
+    if (empty($userid)) {
+        throw new coding_exception('Invalid user parameter supplied for mark_user_dirty() function!');
+    }
+
+    // Set dirty flag in database, set dirty field locally, and clear local accessdata cache.
+    set_cache_flag('accesslib/dirtyusers', $userid, 1, time() + $CFG->sessiontimeout);
+    $ACCESSLIB_PRIVATE->dirtyusers[$userid] = 1;
+    unset($ACCESSLIB_PRIVATE->accessdatabyuser[$userid]);
+}
+
 /**
  * Determines if a user is currently logged in
  *
@@ -2097,9 +2130,6 @@ function reset_role_capabilities($roleid) {
 
     // Reset any cache of this role, including MUC.
     accesslib_clear_role_cache($roleid);
-
-    // Mark the system context dirty.
-    context_system::instance()->mark_dirty();
 }
 
 /**
@@ -2217,7 +2247,7 @@ function update_capabilities($component = 'moodle') {
     capabilities_cleanup($component, $filecaps);
 
     // reset static caches
-    accesslib_clear_all_caches(false);
+    accesslib_reset_role_cache();
 
     // Flush the cached again, as we have changed DB.
     cache::make('core', 'capabilities')->delete('core_capabilities');
@@ -2381,8 +2411,6 @@ function is_inside_frontpage(context $context) {
  * @return stdClass or null if capability not found
  */
 function get_capability_info($capabilityname) {
-    global $ACCESSLIB_PRIVATE, $DB; // one request per page only
-
     $caps = get_all_capabilities();
 
     if (!isset($caps[$capabilityname])) {
@@ -4592,7 +4620,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
 
     if ($permission == CAP_INHERIT) {
         unassign_capability($capname, $roleid, $context->id);
-        $context->mark_dirty();
         return;
     }
 
@@ -4625,7 +4652,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
                 // permission already set in parent context or parent - just unset in this context
                 // we do this because we want as few overrides as possible for performance reasons
                 unassign_capability($capname, $roleid, $context->id);
-                $context->mark_dirty();
                 return;
             }
         }
@@ -4639,9 +4665,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
 
     // assign the needed capability
     assign_capability($capname, $permission, $roleid, $context->id, true);
-
-    // force cap reloading
-    $context->mark_dirty();
 }
 
 
@@ -5066,8 +5089,6 @@ abstract class context extends stdClass implements IteratorAggregate {
 
         $trans = $DB->start_delegated_transaction();
 
-        $this->mark_dirty();
-
         $setdepth = '';
         if (($newparent->depth +1) != $this->_depth) {
             $diff = $newparent->depth - $this->_depth + 1;
@@ -5155,15 +5176,23 @@ abstract class context extends stdClass implements IteratorAggregate {
         require_once($CFG->dirroot.'/grade/grading/lib.php');
         grading_manager::delete_all_for_context($this->_id);
 
-        $ids = $DB->get_fieldset_select('role_capabilities', 'DISTINCT roleid', 'contextid = ?', array($this->_id));
-
         // now delete stuff from role related tables, role_unassign_all
         // and unenrol should be called earlier to do proper cleanup
         $DB->delete_records('role_assignments', array('contextid'=>$this->_id));
-        $DB->delete_records('role_capabilities', array('contextid'=>$this->_id));
         $DB->delete_records('role_names', array('contextid'=>$this->_id));
+        $this->delete_capabilities();
+    }
+
+    /**
+     * Unassign all capabilities from a context.
+     */
+    public function delete_capabilities() {
+        global $DB;
 
+        $ids = $DB->get_fieldset_select('role_capabilities', 'DISTINCT roleid', 'contextid = ?', array($this->_id));
         if ($ids) {
+            $DB->delete_records('role_capabilities', array('contextid' => $this->_id));
+
             // Reset any cache of these roles, including MUC.
             accesslib_clear_role_cache($ids);
         }
@@ -5189,11 +5218,6 @@ abstract class context extends stdClass implements IteratorAggregate {
         $DB->delete_records('context', array('id'=>$this->_id));
         // purge static context cache if entry present
         context::cache_remove($this);
-
-        // do not mark dirty contexts if parents unknown
-        if (!is_null($this->_path) and $this->_depth > 0) {
-            $this->mark_dirty();
-        }
     }
 
     // ====== context level related methods ======
@@ -5297,7 +5321,7 @@ abstract class context extends stdClass implements IteratorAggregate {
      * Returns parent contexts of this context in reversed order, i.e. parent first,
      * then grand parent, etc.
      *
-     * @param bool $includeself tre means include self too
+     * @param bool $includeself true means include self too
      * @return array of context instances
      */
     public function get_parent_contexts($includeself = false) {
@@ -5315,10 +5339,10 @@ abstract class context extends stdClass implements IteratorAggregate {
     }
 
     /**
-     * Returns parent contexts of this context in reversed order, i.e. parent first,
+     * Returns parent context ids of this context in reversed order, i.e. parent first,
      * then grand parent, etc.
      *
-     * @param bool $includeself tre means include self too
+     * @param bool $includeself true means include self too
      * @return array of context ids
      */
     public function get_parent_context_ids($includeself = false) {
@@ -5335,6 +5359,35 @@ abstract class context extends stdClass implements IteratorAggregate {
         return array_reverse($parentcontexts);
     }
 
+    /**
+     * Returns parent context paths of this context.
+     *
+     * @param bool $includeself true means include self too
+     * @return array of context paths
+     */
+    public function get_parent_context_paths($includeself = false) {
+        if (empty($this->_path)) {
+            return array();
+        }
+
+        $contextids = explode('/', $this->_path);
+
+        $path = '';
+        $paths = array();
+        foreach ($contextids as $contextid) {
+            if ($contextid) {
+                $path .= '/' . $contextid;
+                $paths[$contextid] = $path;
+            }
+        }
+
+        if (!$includeself) {
+            unset($paths[$this->_id]);
+        }
+
+        return $paths;
+    }
+
     /**
      * Returns parent context
      *
@@ -5412,24 +5465,43 @@ abstract class context extends stdClass implements IteratorAggregate {
                 $ACCESSLIB_PRIVATE->dirtycontexts = array();
             }
         } else {
+            if (!isset($USER->access['time'])) {
+                // Nothing has been loaded yet, so we do not need to check dirty flags now.
+                return;
+            }
+
+            // From skodak: No idea why -2 is there, server cluster time difference maybe...
+            $changedsince = $USER->access['time'] - 2;
+
             if (!isset($ACCESSLIB_PRIVATE->dirtycontexts)) {
-                if (!isset($USER->access['time'])) {
-                    // nothing was loaded yet, we do not need to check dirty contexts now
-                    return;
-                }
-                // no idea why -2 is there, server cluster time difference maybe... (skodak)
-                $ACCESSLIB_PRIVATE->dirtycontexts = get_cache_flags('accesslib/dirtycontexts', $USER->access['time']-2);
+                $ACCESSLIB_PRIVATE->dirtycontexts = get_cache_flags('accesslib/dirtycontexts', $changedsince);
+            }
+
+            if (!isset($ACCESSLIB_PRIVATE->dirtyusers[$USER->id])) {
+                $ACCESSLIB_PRIVATE->dirtyusers[$USER->id] = get_cache_flag('accesslib/dirtyusers', $USER->id, $changedsince);
             }
         }
 
-        foreach ($ACCESSLIB_PRIVATE->dirtycontexts as $path=>$unused) {
-            if ($path === $this->_path or strpos($this->_path, $path.'/') === 0) {
-                // reload all capabilities of USER and others - preserving loginas, roleswitches, etc
-                // and then cleanup any marks of dirtyness... at least from our short term memory! :-)
-                reload_all_capabilities();
-                break;
+        $dirty = false;
+
+        if (!empty($ACCESSLIB_PRIVATE->dirtyusers[$USER->id])) {
+            $dirty = true;
+        } else if (!empty($ACCESSLIB_PRIVATE->dirtycontexts)) {
+            $paths = $this->get_parent_context_paths(true);
+
+            foreach ($paths as $path) {
+                if (isset($ACCESSLIB_PRIVATE->dirtycontexts[$path])) {
+                    $dirty = true;
+                    break;
+                }
             }
         }
+
+        if ($dirty) {
+            // Reload all capabilities of USER and others - preserving loginas, roleswitches, etc.
+            // Then cleanup any marks of dirtyness... at least from our short term memory!
+            reload_all_capabilities();
+        }
     }
 
     /**
index 4b909cf..d6cd9ac 100644 (file)
@@ -8179,6 +8179,15 @@ function admin_search_settings_html($query) {
             continue;
         }
 
+        // Locate the page in the admin root and populate its visiblepath attribute.
+        $path = array();
+        $located = $adminroot->locate($page->name, true);
+        if ($located) {
+            foreach ($located->visiblepath as $pathitem) {
+                array_unshift($path, (string) $pathitem);
+            }
+        }
+
         $sectionsettings = [];
         if (!empty($settings)) {
             foreach ($settings as $setting) {
@@ -8198,6 +8207,7 @@ function admin_search_settings_html($query) {
 
         $tpldata->results[] = (object) [
             'title' => $heading,
+            'path' => $path,
             'url' => $headingurl->out(false),
             'settings' => $sectionsettings
         ];
index ab97abb..a9ab97a 100644 (file)
@@ -354,6 +354,7 @@ class behat_util extends testing_util {
 
         // Reset all static caches.
         accesslib_clear_all_caches(true);
+        accesslib_reset_role_cache();
         // Reset the nasty strings list used during the last test.
         nasty_strings::reset_used_strings();
 
index c7d1a81..2101c71 100644 (file)
         <FIELD NAME="stamp" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="parent" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="999" SEQUENCE="false"/>
+        <FIELD NAME="idnumber" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       <INDEXES>
         <INDEX NAME="contextid" UNIQUE="false" FIELDS="contextid" COMMENT="links to context table"/>
         <INDEX NAME="contextidstamp" UNIQUE="true" FIELDS="contextid, stamp"/>
+        <INDEX NAME="contextididnumber" UNIQUE="true" FIELDS="contextid, idnumber"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="question" COMMENT="The questions themselves">
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="time that question was last modified"/>
         <FIELD NAME="createdby" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="userid of person who created this question"/>
         <FIELD NAME="modifiedby" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="userid of person who last edited this question"/>
+        <FIELD NAME="idnumber" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="qtype" UNIQUE="false" FIELDS="qtype"/>
+        <INDEX NAME="categoryidnumber" UNIQUE="true" FIELDS="category, idnumber"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="question_answers" COMMENT="Answers, with a fractional grade (0-1) and feedback">
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index fc5e777..7f6a1f1 100644 (file)
@@ -2360,5 +2360,44 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2018091700.01);
     }
 
+    // Add idnumber fields to question and question_category tables.
+    // This is done in four parts to aid error recovery during upgrade, should that occur.
+    if ($oldversion < 2018092100.01) {
+        $table = new xmldb_table('question');
+        $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'modifiedby');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        upgrade_main_savepoint(true, 2018092100.01);
+    }
+
+    if ($oldversion < 2018092100.02) {
+        $table = new xmldb_table('question');
+        $index = new xmldb_index('categoryidnumber', XMLDB_INDEX_UNIQUE, array('category, idnumber'));
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+        upgrade_main_savepoint(true, 2018092100.02);
+    }
+
+    if ($oldversion < 2018092100.03) {
+        $table = new xmldb_table('question_categories');
+        $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'sortorder');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        upgrade_main_savepoint(true, 2018092100.03);
+    }
+
+    if ($oldversion < 2018092100.04) {
+        $table = new xmldb_table('question_categories');
+        $index = new xmldb_index('contextididnumber', XMLDB_INDEX_UNIQUE, array('contextid, idnumber'));
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018092100.04);
+    }
+
     return true;
 }
index 1fcd98a..ff672ed 100644 (file)
@@ -140,6 +140,36 @@ class mssql_sql_generator extends sql_generator {
         return $tablename;
     }
 
+    public function getCreateIndexSQL($xmldb_table, $xmldb_index) {
+        list($indexsql) = parent::getCreateIndexSQL($xmldb_table, $xmldb_index);
+
+        // Unique indexes need to work-around non-standard SQL server behaviour.
+        if ($xmldb_index->getUnique()) {
+            // Find any nullable columns. We need to add a
+            // WHERE field IS NOT NULL to the index definition for each one.
+            //
+            // For example if you have a unique index on the three columns
+            // (required, option1, option2) where the first one is non-null,
+            // and the others nullable, then the SQL will end up as
+            //
+            // CREATE UNIQUE INDEX index_name ON table_name (required, option1, option2)
+            // WHERE option1 IS NOT NULL AND option2 IS NOT NULL
+            //
+            // The first line comes from parent calls above. The WHERE is added below.
+            $extraconditions = [];
+            foreach ($this->get_nullable_fields_in_index($xmldb_table, $xmldb_index) as $fieldname) {
+                $extraconditions[] = $this->getEncQuoted($fieldname) .
+                        ' IS NOT NULL';
+            }
+
+            if ($extraconditions) {
+                $indexsql .= ' WHERE ' . implode(' AND ', $extraconditions);
+            }
+        }
+
+        return [$indexsql];
+    }
+
     /**
      * Given one correct xmldb_table, returns the SQL statements
      * to create temporary table (inside one array).
index 063eddb..4fb03bd 100644 (file)
@@ -131,6 +131,61 @@ class oracle_sql_generator extends sql_generator {
         return $tablename;
     }
 
+    public function getCreateIndexSQL($xmldb_table, $xmldb_index) {
+        if ($error = $xmldb_index->validateDefinition($xmldb_table)) {
+            throw new coding_exception($error);
+        }
+
+        $indexfields = $this->getEncQuoted($xmldb_index->getFields());
+
+        $unique = '';
+        $suffix = 'ix';
+        if ($xmldb_index->getUnique()) {
+            $unique = ' UNIQUE';
+            $suffix = 'uix';
+
+            $nullablefields = $this->get_nullable_fields_in_index($xmldb_table, $xmldb_index);
+            if ($nullablefields) {
+                // If this is a unique index with nullable fields, then we have to
+                // apply the work-around from https://community.oracle.com/message/9518046#9518046.
+                //
+                // For example if you have a unique index on the three columns
+                // (required, option1, option2) where the first one is non-null,
+                // and the others nullable, then the SQL will end up as
+                //
+                // CREATE UNIQUE INDEX index_name ON table_name (
+                // CASE WHEN option1 IS NOT NULL AND option2 IS NOT NULL THEN required ELSE NULL END,
+                // CASE WHEN option1 IS NOT NULL AND option2 IS NOT NULL THEN option1 ELSE NULL END,
+                // CASE WHEN option1 IS NOT NULL AND option2 IS NOT NULL THEN option2 ELSE NULL END)
+                //
+                // Basically Oracle behaves according to the standard if either
+                // none of the columns are NULL or all columns contain NULL. Therefore,
+                // if any column is NULL, we treat them all as NULL for the index.
+                $conditions = [];
+                foreach ($nullablefields as $fieldname) {
+                    $conditions[] = $this->getEncQuoted($fieldname) .
+                            ' IS NOT NULL';
+                }
+                $condition = implode(' AND ', $conditions);
+
+                $updatedindexfields = [];
+                foreach ($indexfields as $fieldname) {
+                    $updatedindexfields[] = 'CASE WHEN ' . $condition . ' THEN ' .
+                            $fieldname . ' ELSE NULL END';
+                }
+                $indexfields = $updatedindexfields;
+            }
+
+        }
+
+        $index = 'CREATE' . $unique . ' INDEX ';
+        $index .= $this->getNameForObject($xmldb_table->getName(), implode(', ', $xmldb_index->getFields()), $suffix);
+        $index .= ' ON ' . $this->getTableName($xmldb_table);
+        $index .= ' (' . implode(', ', $indexfields) . ')';
+
+        return array($index);
+    }
+
     /**
      * Given one correct xmldb_table, returns the SQL statements
      * to create temporary table (inside one array).
index 721fce7..9ad419f 100644 (file)
@@ -1129,7 +1129,7 @@ abstract class sql_generator {
      * if it's a reserved word
      *
      * @param string|array $input String to quote.
-     * @return string Quoted string.
+     * @return string|array Quoted string.
      */
     public function getEncQuoted($input) {
 
@@ -1408,4 +1408,44 @@ abstract class sql_generator {
         $s = str_replace("'",  "\\'", $s);
         return $s;
     }
+
+    /**
+     * Get the fields from an index definition that might be null.
+     * @param xmldb_table $xmldb_table the table
+     * @param xmldb_index $xmldb_index the index
+     * @return array list of fields in the index definition that might be null.
+     */
+    public function get_nullable_fields_in_index($xmldb_table, $xmldb_index) {
+        global $DB;
+
+        // If we don't have the field info passed in, we need to query it from the DB.
+        $fieldsfromdb = null;
+
+        $nullablefields = [];
+        foreach ($xmldb_index->getFields() as $fieldname) {
+            if ($field = $xmldb_table->getField($fieldname)) {
+                // We have the field details in the table definition.
+                if ($field->getNotNull() !== XMLDB_NOTNULL) {
+                    $nullablefields[] = $fieldname;
+                }
+
+            } else {
+                // We don't have the table definition loaded. Need to
+                // inspect the database.
+                if ($fieldsfromdb === null) {
+                    $fieldsfromdb = $DB->get_columns($xmldb_table->getName(), false);
+                }
+                if (!isset($fieldsfromdb[$fieldname])) {
+                    throw new coding_exception('Unknown field ' . $fieldname .
+                            ' in index ' . $xmldb_index->getName());
+                }
+
+                if (!$fieldsfromdb[$fieldname]->not_null) {
+                    $nullablefields[] = $fieldname;
+                }
+            }
+        }
+
+        return $nullablefields;
+    }
 }
index 76e1d01..a913ce7 100644 (file)
 defined('MOODLE_INTERNAL') || die();
 
 class core_ddl_testcase extends database_driver_testcase {
+    /** @var xmldb_table[] keys are table name. Created in setUp. */
     private $tables = array();
-    private $records= array();
+    /** @var array table name => array of stdClass test records loaded into that table. Created in setUp. */
+    private $records = array();
 
     protected function setUp() {
         parent::setUp();
@@ -2322,6 +2324,28 @@ class core_ddl_testcase extends database_driver_testcase {
         }
     }
 
+    public function test_get_nullable_fields_in_index() {
+        $DB = $this->tdb;
+        $gen = $DB->get_manager()->generator;
+
+        $indexwithoutnulls = $this->tables['test_table0']->getIndex('type-name');
+        $this->assertSame([], $gen->get_nullable_fields_in_index(
+                $this->tables['test_table0'], $indexwithoutnulls));
+
+        $indexwithnulls = new xmldb_index('course-grade', XMLDB_INDEX_UNIQUE, ['course', 'grade']);
+        $this->assertSame(['grade'], $gen->get_nullable_fields_in_index(
+                $this->tables['test_table0'], $indexwithnulls));
+
+        $this->create_deftable('test_table0');
+
+        // Now test using a minimal xmldb_table, to ensure we get the data from the DB.
+        $table = new xmldb_table('test_table0');
+        $this->assertSame([], $gen->get_nullable_fields_in_index(
+                $table, $indexwithoutnulls));
+        $this->assertSame(['grade'], $gen->get_nullable_fields_in_index(
+                $table, $indexwithnulls));
+    }
+
     // Following methods are not supported == Do not test.
     /*
         public function testRenameIndex() {
index 77d0b98..9e781cd 100644 (file)
@@ -1080,7 +1080,7 @@ abstract class moodle_database {
      * Returns detailed information about columns in table. This information is cached internally.
      * @param string $table The table's name.
      * @param bool $usecache Flag to use internal cacheing. The default is true.
-     * @return array of database_column_info objects indexed with column names
+     * @return database_column_info[] of database_column_info objects indexed with column names
      */
     public abstract function get_columns($table, $usecache=true);
 
index 866625d..fb144e9 100644 (file)
@@ -2407,6 +2407,68 @@ class core_dml_testcase extends database_driver_testcase {
         }
     }
 
+    public function test_insert_record_with_nullable_unique_index() {
+        $DB = $this->tdb;
+        $dbman = $DB->get_manager();
+
+        $table = $this->get_test_table();
+        $tablename = $table->getName();
+
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('notnull1', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('nullable1', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('nullable2', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $table->add_index('notnull1-nullable1-nullable2', XMLDB_INDEX_UNIQUE,
+                array('notnull1', 'nullable1', 'nullable2'));
+        $dbman->create_table($table);
+
+        // Insert one record. Should be OK (no exception).
+        $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => 1, 'nullable2' => 1]);
+
+        $this->assertEquals(1, $DB->count_records($table->getName()));
+        $this->assertEquals(1, $DB->count_records($table->getName(), ['nullable1' => 1]));
+
+        // Inserting a duplicate should fail.
+        try {
+            $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => 1, 'nullable2' => 1]);
+            $this->fail('dml_write_exception expected when a record violates a unique index');
+        } catch (moodle_exception $e) {
+            $this->assertInstanceOf('dml_write_exception', $e);
+        }
+
+        $this->assertEquals(1, $DB->count_records($table->getName()));
+        $this->assertEquals(1, $DB->count_records($table->getName(), ['nullable1' => 1]));
+
+        // Inserting a record with nulls in the nullable columns should work.
+        $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => null, 'nullable2' => null]);
+
+        $this->assertEquals(2, $DB->count_records($table->getName()));
+        $this->assertEquals(1, $DB->count_records($table->getName(), ['nullable1' => 1]));
+        $this->assertEquals(1, $DB->count_records($table->getName(), ['nullable1' => null]));
+
+        // And it should be possible to insert a duplicate.
+        $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => null, 'nullable2' => null]);
+
+        $this->assertEquals(3, $DB->count_records($table->getName()));
+        $this->assertEquals(1, $DB->count_records($table->getName(), ['nullable1' => 1]));
+        $this->assertEquals(2, $DB->count_records($table->getName(), ['nullable1' => null]));
+
+        // Same, but with only one of the nullable columns being null.
+        $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => 1, 'nullable2' => null]);
+
+        $this->assertEquals(4, $DB->count_records($table->getName()));
+        $this->assertEquals(2, $DB->count_records($table->getName(), ['nullable1' => 1]));
+        $this->assertEquals(2, $DB->count_records($table->getName(), ['nullable1' => null]));
+
+        $DB->insert_record($tablename, (object) ['notnull1' => 1, 'nullable1' => 1, 'nullable2' => null]);
+
+        $this->assertEquals(5, $DB->count_records($table->getName()));
+        $this->assertEquals(3, $DB->count_records($table->getName(), ['nullable1' => 1]));
+        $this->assertEquals(2, $DB->count_records($table->getName(), ['nullable1' => null]));
+
+    }
+
     public function test_import_record() {
         // All the information in this test is fetched from DB by get_recordset() so we
         // have such method properly tested against nulls, empties and friends...
index b8e6345..fa20f65 100644 (file)
@@ -1291,7 +1291,7 @@ function is_enrolled(context $context, $user = null, $withcapability = '', $only
  * @param string|array $capability optional, may include a capability name, or array of names.
  *      If an array is provided then this is the equivalent of a logical 'OR',
  *      i.e. the user needs to have one of these capabilities.
- * @param int $group optional, 0 indicates no current group, otherwise the group id
+ * @param int $group optional, 0 indicates no current group and USERSWITHOUTGROUP users without any group; otherwise the group id
  * @param bool $onlyactive consider only active enrolments in enabled plugins and time restrictions
  * @param bool $onlysuspended inverse of onlyactive, consider only suspended enrolments
  * @param int $enrolid The enrolment ID. If not 0, only users enrolled using this enrolment method will be returned.
@@ -1316,9 +1316,12 @@ function get_enrolled_with_capabilities_join(context $context, $prefix = '', $ca
     }
 
     if ($group) {
-        $groupjoin = groups_get_members_join($group, $uid);
+        $groupjoin = groups_get_members_join($group, $uid, $context);
         $joins[] = $groupjoin->joins;
         $params = array_merge($params, $groupjoin->params);
+        if (!empty($groupjoin->wheres)) {
+            $wheres[] = $groupjoin->wheres;
+        }
     }
 
     $joins = implode("\n", $joins);
@@ -1336,7 +1339,7 @@ function get_enrolled_with_capabilities_join(context $context, $prefix = '', $ca
  *
  * @param context $context
  * @param string $withcapability
- * @param int $groupid 0 means ignore groups, any other value limits the result by group id
+ * @param int $groupid 0 means ignore groups, USERSWITHOUTGROUP without any group and any other value limits the result by group id
  * @param bool $onlyactive consider only active enrolments in enabled plugins and time restrictions
  * @param bool $onlysuspended inverse of onlyactive, consider only suspended enrolments
  * @param int $enrolid The enrolment ID. If not 0, only users enrolled using this enrolment method will be returned.
@@ -1462,7 +1465,7 @@ function get_enrolled_join(context $context, $useridcolumn, $onlyactive = false,
  *
  * @param context $context
  * @param string $withcapability
- * @param int $groupid 0 means ignore groups, any other value limits the result by group id
+ * @param int $groupid 0 means ignore groups, USERSWITHOUTGROUP without any group and any other value limits the result by group id
  * @param string $userfields requested user record fields
  * @param string $orderby
  * @param int $limitfrom return a subset of records, starting at this point (optional, required if $limitnum is set).
@@ -1506,7 +1509,7 @@ function count_enrolled_users(context $context, $withcapability = '', $groupid =
     $capjoin = get_enrolled_with_capabilities_join(
             $context, '', $withcapability, $groupid, $onlyactive);
 
-    $sql = "SELECT count(u.id)
+    $sql = "SELECT COUNT(DISTINCT u.id)
               FROM {user} u
             $capjoin->joins
              WHERE $capjoin->wheres AND u.deleted = 0";
@@ -1987,7 +1990,9 @@ abstract class enrol_plugin {
         $ue->modifierid = $USER->id;
         $ue->timemodified = time();
         $DB->update_record('user_enrolments', $ue);
-        context_course::instance($instance->courseid)->mark_dirty(); // reset enrol caches
+
+        // User enrolments have changed, so mark user as dirty.
+        mark_user_dirty($userid);
 
         // Invalidate core_access cache for get_suspended_userids.
         cache_helper::invalidate_by_definition('core', 'suspended_userids', array(), array($instance->courseid));
@@ -2084,8 +2089,9 @@ abstract class enrol_plugin {
                     )
                 );
         $event->trigger();
-        // reset all enrol caches
-        $context->mark_dirty();
+
+        // User enrolments have changed, so mark user as dirty.
+        mark_user_dirty($userid);
 
         // Check if courrse contacts cache needs to be cleared.
         core_course_category::user_enrolment_changed($courseid, $ue->userid, ENROL_USER_SUSPENDED);
index bbb9289..d991075 100644 (file)
@@ -216,7 +216,7 @@ class filter_manager {
     public function filter_text($text, $context, array $options = array(),
             array $skipfilters = null) {
         $text = $this->apply_filter_chain($text, $this->get_text_filters($context), $options, $skipfilters);
-        // <nolink> tags removed for XHTML compatibility
+        // Remove <nolink> tags for XHTML compatibility.
         $text = str_replace(array('<nolink>', '</nolink>'), '', $text);
         return $text;
     }
@@ -461,54 +461,75 @@ abstract class moodle_text_filter {
 /**
  * This is just a little object to define a phrase and some instructions
  * for how to process it.  Filters can create an array of these to pass
- * to the filter_phrases function below.
+ * to the @{link filter_phrases()} function below.
+ *
+ * Note that although the fields here are public, you almost certainly should
+ * never use that. All that is supported is contructing new instances of this
+ * class, and then passing an array of them to filter_phrases.
  *
  * @copyright 1999 onwards Martin Dougiamas  {@link http://moodle.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- **/
+ */
 class filterobject {
-    /** @var string */
-    var $phrase;
-    var $hreftagbegin;
-    var $hreftagend;
-    /** @var bool */
-    var $casesensitive;
-    var $fullmatch;
-    /** @var mixed */
-    var $replacementphrase;
-    var $work_phrase;
-    var $work_hreftagbegin;
-    var $work_hreftagend;
-    var $work_casesensitive;
-    var $work_fullmatch;
-    var $work_replacementphrase;
-    /** @var bool */
-    var $work_calculated;
+    /** @var string this is the phrase that should be matched. */
+    public $phrase;
+
+    /** @var bool whether to match complete words. If true, 'T' won't be matched in 'Tim'. */
+    public $fullmatch;
+
+    /** @var bool whether the match needs to be case sensitive. */
+    public $casesensitive;
+
+    /** @var string HTML to insert before any match. */
+    public $hreftagbegin;
+    /** @var string HTML to insert after any match. */
+    public $hreftagend;
+
+    /** @var null|string replacement text to go inside begin and end. If not set,
+     * the body of the replacement will be the original phrase.
+     */
+    public $replacementphrase;
+
+    /** @var null|string once initialised, holds the regexp for matching this phrase. */
+    public $workregexp = null;
+
+    /** @var null|string once initialised, holds the mangled HTML to replace the regexp with. */
+    public $workreplacementphrase = null;
 
     /**
-     * A constructor just because I like constructing
+     * Constructor.
      *
-     * @param string $phrase
-     * @param string $hreftagbegin
-     * @param string $hreftagend
-     * @param bool $casesensitive
-     * @param bool $fullmatch
-     * @param mixed $replacementphrase
+     * @param string $phrase this is the phrase that should be matched.
+     * @param string $hreftagbegin HTML to insert before any match. Default '<span class="highlight">'.
+     * @param string $hreftagend HTML to insert after any match. Default '</span>'.
+     * @param bool $casesensitive whether the match needs to be case sensitive
+     * @param bool $fullmatch whether to match complete words. If true, 'T' won't be matched in 'Tim'.
+     * @param mixed $replacementphrase replacement text to go inside begin and end. If not set,
+     * the body of the replacement will be the original phrase.
+     * @param callback $replacementcallback if set, then this will be called just before
+     * $hreftagbegin, $hreftagend and $replacementphrase are needed, so they can be computed only if required.
+     * The call made is
+     * list($linkobject->hreftagbegin, $linkobject->hreftagend, $linkobject->replacementphrase) =
+     *         call_user_func_array($linkobject->replacementcallback, $linkobject->replacementcallbackdata);
+     * so the return should be an array [$hreftagbegin, $hreftagend, $replacementphrase], the last of which may be null.
+     * @param array $replacementcallbackdata data to be passed to $replacementcallback (optional).
      */
     public function __construct($phrase, $hreftagbegin = '<span class="highlight">',
-                                   $hreftagend = '</span>',
-                                   $casesensitive = false,
-                                   $fullmatch = false,
-                                   $replacementphrase = NULL) {
-
-        $this->phrase           = $phrase;
-        $this->hreftagbegin     = $hreftagbegin;
-        $this->hreftagend       = $hreftagend;
-        $this->casesensitive    = $casesensitive;
-        $this->fullmatch        = $fullmatch;
-        $this->replacementphrase= $replacementphrase;
-        $this->work_calculated  = false;
-
+            $hreftagend = '</span>',
+            $casesensitive = false,
+            $fullmatch = false,
+            $replacementphrase = null,
+            $replacementcallback = null,
+            array $replacementcallbackdata = null) {
+
+        $this->phrase                  = $phrase;
+        $this->hreftagbegin            = $hreftagbegin;
+        $this->hreftagend              = $hreftagend;
+        $this->casesensitive           = !empty($casesensitive);
+        $this->fullmatch               = !empty($fullmatch);
+        $this->replacementphrase       = $replacementphrase;
+        $this->replacementcallback     = $replacementcallback;
+        $this->replacementcallbackdata = $replacementcallbackdata;
     }
 }
 
@@ -574,7 +595,6 @@ function filter_set_global_state($filtername, $state, $move = 0) {
     }
 
     if (strpos($filtername, 'filter/') === 0) {
-        //debugging("Old filtername '$filtername' parameter used in filter_set_global_state()", DEBUG_DEVELOPER);
         $filtername = substr($filtername, 7);
     } else if (strpos($filtername, '/') !== false) {
         throw new coding_exception("Invalid filter name '$filtername' used in filter_set_global_state()");
@@ -588,7 +608,7 @@ function filter_set_global_state($filtername, $state, $move = 0) {
     $on = array();
     $off = array();
 
-    foreach($filters as $f) {
+    foreach ($filters as $f) {
         if ($f->active == TEXTFILTER_DISABLED) {
             $off[$f->filter] = $f;
         } else {
@@ -675,13 +695,13 @@ function filter_set_global_state($filtername, $state, $move = 0) {
     $i = 1;
     foreach ($on as $f) {
         if ($f->sortorder != $i) {
-            $DB->set_field('filter_active', 'sortorder', $i, array('id'=>$f->id));
+            $DB->set_field('filter_active', 'sortorder', $i, array('id' => $f->id));
         }
         $i++;
     }
     foreach ($off as $f) {
         if ($f->sortorder != $i) {
-            $DB->set_field('filter_active', 'sortorder', $i, array('id'=>$f->id));
+            $DB->set_field('filter_active', 'sortorder', $i, array('id' => $f->id));
         }
         $i++;
     }
@@ -696,7 +716,6 @@ function filter_set_global_state($filtername, $state, $move = 0) {
  */
 function filter_is_enabled($filtername) {
     if (strpos($filtername, 'filter/') === 0) {
-        //debugging("Old filtername '$filtername' parameter used in filter_is_enabled()", DEBUG_DEVELOPER);
         $filtername = substr($filtername, 7);
     } else if (strpos($filtername, '/') !== false) {
         throw new coding_exception("Invalid filter name '$filtername' used in filter_is_enabled()");
@@ -707,7 +726,6 @@ function filter_is_enabled($filtername) {
 /**
  * Return a list of all the filters that may be in use somewhere.
  *
- * @staticvar array $enabledfilters
  * @return array where the keys and values are both the filter name, like 'tex'.
  */
 function filter_get_globally_enabled() {
@@ -965,7 +983,7 @@ function filter_get_active_in_context($context) {
     $contextids = str_replace('/', ',', trim($context->path, '/'));
 
     // The following SQL is tricky. It is explained on
-    // http://docs.moodle.org/dev/Filter_enable/disable_by_context
+    // http://docs.moodle.org/dev/Filter_enable/disable_by_context.
     $sql = "SELECT active.filter, fc.name, fc.value
          FROM (SELECT f.filter, MAX(f.sortorder) AS sortorder
              FROM {filter_active} f
@@ -1007,7 +1025,7 @@ function filter_preload_activities(course_modinfo $modinfo) {
         $FILTERLIB_PRIVATE = new stdClass();
     }
 
-    // Don't repeat preload
+    // Don't repeat preload.
     if (!isset($FILTERLIB_PRIVATE->preloaded)) {
         $FILTERLIB_PRIVATE->preloaded = array();
     }
@@ -1016,7 +1034,7 @@ function filter_preload_activities(course_modinfo $modinfo) {
     }
     $FILTERLIB_PRIVATE->preloaded[$modinfo->get_course_id()] = true;
 
-    // Get contexts for all CMs
+    // Get contexts for all CMs.
     $cmcontexts = array();
     $cmcontextids = array();
     foreach ($modinfo->get_cms() as $cm) {
@@ -1025,16 +1043,16 @@ function filter_preload_activities(course_modinfo $modinfo) {
         $cmcontexts[] = $modulecontext;
     }
 
-    // Get course context and all other parents...
+    // Get course context and all other parents.
     $coursecontext = context_course::instance($modinfo->get_course_id());
     $parentcontextids = explode('/', substr($coursecontext->path, 1));
     $allcontextids = array_merge($cmcontextids, $parentcontextids);
 
-    // Get all filter_active rows relating to all these contexts
+    // Get all filter_active rows relating to all these contexts.
     list ($sql, $params) = $DB->get_in_or_equal($allcontextids);
     $filteractives = $DB->get_records_select('filter_active', "contextid $sql", $params, 'sortorder');
 
-    // Get all filter_config only for the cm contexts
+    // Get all filter_config only for the cm contexts.
     list ($sql, $params) = $DB->get_in_or_equal($cmcontextids);
     $filterconfigs = $DB->get_records_select('filter_config', "contextid $sql", $params);
 
@@ -1044,20 +1062,20 @@ function filter_preload_activities(course_modinfo $modinfo) {
     // filter_get_active_in_context, this does seem to be correct.
 
     // Build course default active list. Initially this will be an array of
-    // filter name => active score (where an active score >0 means it's active)
+    // filter name => active score (where an active score >0 means it's active).
     $courseactive = array();
 
-    // Also build list of filter_active rows below course level, by contextid
+    // Also build list of filter_active rows below course level, by contextid.
     $remainingactives = array();
 
-    // Array lists filters that are banned at top level
+    // Array lists filters that are banned at top level.
     $banned = array();
 
-    // Add any active filters in parent contexts to the array
+    // Add any active filters in parent contexts to the array.
     foreach ($filteractives as $row) {
         $depth = array_search($row->contextid, $parentcontextids);
         if ($depth !== false) {
-            // Find entry
+            // Find entry.
             if (!array_key_exists($row->filter, $courseactive)) {
                 $courseactive[$row->filter] = 0;
             }
@@ -1072,7 +1090,7 @@ function filter_preload_activities(course_modinfo $modinfo) {
                 $banned[$row->filter] = true;
             }
         } else {
-            // Build list of other rows indexed by contextid
+            // Build list of other rows indexed by contextid.
             if (!array_key_exists($row->contextid, $remainingactives)) {
                 $remainingactives[$row->contextid] = array();
             }
@@ -1081,7 +1099,7 @@ function filter_preload_activities(course_modinfo $modinfo) {
     }
 
     // Chuck away the ones that aren't active.
-    foreach ($courseactive as $filter=>$score) {
+    foreach ($courseactive as $filter => $score) {
         if ($score <= 0) {
             unset($courseactive[$filter]);
         } else {
@@ -1095,7 +1113,7 @@ function filter_preload_activities(course_modinfo $modinfo) {
         $FILTERLIB_PRIVATE->active = array();
     }
     foreach ($cmcontextids as $contextid) {
-        // Copy course list
+        // Copy course list.
         $FILTERLIB_PRIVATE->active[$contextid] = $courseactive;
 
         // Are there any changes to the active list?
@@ -1242,33 +1260,40 @@ function filter_context_may_have_filter_settings($context) {
 /**
  * Process phrases intelligently found within a HTML text (such as adding links).
  *
- * @staticvar array $usedpharses
- * @param string $text             the text that we are filtering
- * @param array $link_array       an array of filterobjects
+ * @param string $text            the text that we are filtering
+ * @param filterobject[] $linkarray an array of filterobjects
  * @param array $ignoretagsopen   an array of opening tags that we should ignore while filtering
  * @param array $ignoretagsclose  an array of corresponding closing tags
  * @param bool $overridedefaultignore True to only use tags provided by arguments
+ * @param bool $linkarrayalreadyprepared True to say that filter_prepare_phrases_for_filtering
+ *      has already been called for $linkarray. Default false.
  * @return string
- **/
-function filter_phrases($text, &$link_array, $ignoretagsopen=NULL, $ignoretagsclose=NULL,
-        $overridedefaultignore=false) {
+ */
+function filter_phrases($text, $linkarray, $ignoretagsopen = null, $ignoretagsclose = null,
+        $overridedefaultignore = false, $linkarrayalreadyprepared = false) {
 
     global $CFG;
 
-    static $usedphrases;
+    // Used if $CFG->filtermatchoneperpage is on. Array with keys being the workregexp
+    // for things that have already been matched on this page.
+    static $usedphrases = [];
 
     $ignoretags = array();  // To store all the enclosig tags to be completely ignored.
     $tags = array();        // To store all the simple tags to be ignored.
 
+    if (!$linkarrayalreadyprepared) {
+        $linkarray = filter_prepare_phrases_for_filtering($linkarray);
+    }
+
     if (!$overridedefaultignore) {
-        // A list of open/close tags that we should not replace within
-        // Extended to include <script>, <textarea>, <select> and <a> tags
-        // Regular expression allows tags with or without attributes
-        $filterignoretagsopen  = array('<head>' , '<nolink>' , '<span(\s[^>]*?)?class="nolink"(\s[^>]*?)?>',
+        // A list of open/close tags that we should not replace within.
+        // Extended to include <script>, <textarea>, <select> and <a> tags.
+        // Regular expression allows tags with or without attributes.
+        $filterignoretagsopen  = array('<head>', '<nolink>', '<span(\s[^>]*?)?class="nolink"(\s[^>]*?)?>',
                 '<script(\s[^>]*?)?>', '<textarea(\s[^>]*?)?>',
                 '<select(\s[^>]*?)?>', '<a(\s[^>]*?)?>');
         $filterignoretagsclose = array('</head>', '</nolink>', '</span>',
-                 '</script>', '</textarea>', '</select>','</a>');
+                 '</script>', '</textarea>', '</select>', '</a>');
     } else {
         // Set an empty default list.
         $filterignoretagsopen = array();
@@ -1285,170 +1310,157 @@ function filter_phrases($text, &$link_array, $ignoretagsopen=NULL, $ignoretagscl
         }
     }
 
-    // Invalid prefixes and suffixes for the fullmatch searches
-    // Every "word" character, but the underscore, is a invalid suffix or prefix.
-    // (nice to use this because it includes national characters (accents...) as word characters.
-    $filterinvalidprefixes = '([^\W_])';
-    $filterinvalidsuffixes = '([^\W_])';
-
-    // Double up some magic chars to avoid "accidental matches"
-    $text = preg_replace('/([#*%])/','\1\1',$text);
-
+    // Double up some magic chars to avoid "accidental matches".
+    $text = preg_replace('/([#*%])/', '\1\1', $text);
 
-    //Remove everything enclosed by the ignore tags from $text
-    filter_save_ignore_tags($text,$filterignoretagsopen,$filterignoretagsclose,$ignoretags);
+    // Remove everything enclosed by the ignore tags from $text.
+    filter_save_ignore_tags($text, $filterignoretagsopen, $filterignoretagsclose, $ignoretags);
 
-    // Remove tags from $text
-    filter_save_tags($text,$tags);
+    // Remove tags from $text.
+    filter_save_tags($text, $tags);
 
-    // Time to cycle through each phrase to be linked
-    $size = sizeof($link_array);
-    for ($n=0; $n < $size; $n++) {
-        $linkobject =& $link_array[$n];
+    // Prepare the limit for preg_match calls.
+    if (!empty($CFG->filtermatchonepertext) || !empty($CFG->filtermatchoneperpage)) {
+        $pregreplacelimit = 1;
+    } else {
+        $pregreplacelimit = -1; // No limit.
+    }
 
-        // Set some defaults if certain properties are missing
-        // Properties may be missing if the filterobject class has not been used to construct the object
-        if (empty($linkobject->phrase)) {
+    // Time to cycle through each phrase to be linked.
+    foreach ($linkarray as $key => $linkobject) {
+        if ($linkobject->workregexp === null) {
+            // This is the case if, when preparing the phrases for filtering,
+            // we decided that this was not a suitable phrase to match.
             continue;
         }
 
-        // Avoid integers < 1000 to be linked. See bug 1446.
-        $intcurrent = intval($linkobject->phrase);
-        if (!empty($intcurrent) && strval($intcurrent) == $linkobject->phrase && $intcurrent < 1000) {
+        // If $CFG->filtermatchoneperpage, avoid previously matched linked phrases.
+        if (!empty($CFG->filtermatchoneperpage) && isset($usedphrases[$linkobject->workregexp])) {
             continue;
         }
 
-        // All this work has to be done ONLY it it hasn't been done before
-         if (!$linkobject->work_calculated) {
-            if (!isset($linkobject->hreftagbegin) or !isset($linkobject->hreftagend)) {
-                $linkobject->work_hreftagbegin = '<span class="highlight"';
-                $linkobject->work_hreftagend   = '</span>';
-            } else {
-                $linkobject->work_hreftagbegin = $linkobject->hreftagbegin;
-                $linkobject->work_hreftagend   = $linkobject->hreftagend;
-            }
+        // Do our highlighting.
+        $resulttext = preg_replace_callback($linkobject->workregexp,
+                function ($matches) use ($linkobject) {
+                    if ($linkobject->workreplacementphrase === null) {
+                        filter_prepare_phrase_for_replacement($linkobject);
+                    }
 
-            // Double up chars to protect true duplicates
-            // be cleared up before returning to the user.
-            $linkobject->work_hreftagbegin = preg_replace('/([#*%])/','\1\1',$linkobject->work_hreftagbegin);
+                    return str_replace('$1', $matches[1], $linkobject->workreplacementphrase);
+                }, $text, $pregreplacelimit);
 
-            if (empty($linkobject->casesensitive)) {
-                $linkobject->work_casesensitive = false;
-            } else {
-                $linkobject->work_casesensitive = true;
-            }
-            if (empty($linkobject->fullmatch)) {
-                $linkobject->work_fullmatch = false;
-            } else {
-                $linkobject->work_fullmatch = true;
+        // If the text has changed we have to look for links again.
+        if ($resulttext != $text) {
+            $text = $resulttext;
+            // Remove everything enclosed by the ignore tags from $text.
+            filter_save_ignore_tags($text, $filterignoretagsopen, $filterignoretagsclose, $ignoretags);
+            // Remove tags from $text.
+            filter_save_tags($text, $tags);
+            // If $CFG->filtermatchoneperpage, save linked phrases to request.
+            if (!empty($CFG->filtermatchoneperpage)) {
+                $usedphrases[$linkobject->workregexp] = 1;
             }
+        }
+    }
 
-            // Strip tags out of the phrase
-            $linkobject->work_phrase = strip_tags($linkobject->phrase);
+    // Rebuild the text with all the excluded areas.
+    if (!empty($tags)) {
+        $text = str_replace(array_keys($tags), $tags, $text);
+    }
 
-            // Double up chars that might cause a false match -- the duplicates will
-            // be cleared up before returning to the user.
-            $linkobject->work_phrase = preg_replace('/([#*%])/','\1\1',$linkobject->work_phrase);
+    if (!empty($ignoretags)) {
+        $ignoretags = array_reverse($ignoretags);     // Reversed so "progressive" str_replace() will solve some nesting problems.
+        $text = str_replace(array_keys($ignoretags), $ignoretags, $text);
+    }
 
-            // Set the replacement phrase properly
-            if ($linkobject->replacementphrase) {    //We have specified a replacement phrase
-                // Strip tags
-                $linkobject->work_replacementphrase = strip_tags($linkobject->replacementphrase);
-            } else {                                 //The replacement is the original phrase as matched below
-                $linkobject->work_replacementphrase = '$1';
-            }
+    // Remove the protective doubleups.
+    $text = preg_replace('/([#*%])(\1)/', '\1', $text);
 
-            // Quote any regular expression characters and the delimiter in the work phrase to be searched
-            $linkobject->work_phrase = preg_quote($linkobject->work_phrase, '/');
+    // Add missing javascript for popus.
+    $text = filter_add_javascript($text);
 
-            // Work calculated
-            $linkobject->work_calculated = true;
+    return $text;
+}
 
-        }
+/**
+ * Prepare a list of link for processing with {@link filter_phrases()}.
+ *
+ * @param filterobject[] $linkarray the links that will be passed to filter_phrases().
+ * @return filterobject[] the updated list of links with necessary pre-processing done.
+ */
+function filter_prepare_phrases_for_filtering(array $linkarray) {
+    // Time to cycle through each phrase to be linked.
+    foreach ($linkarray as $linkobject) {
 
-        // If $CFG->filtermatchoneperpage, avoid previously (request) linked phrases
-        if (!empty($CFG->filtermatchoneperpage)) {
-            if (!empty($usedphrases) && in_array($linkobject->work_phrase,$usedphrases)) {
-                continue;
-            }
+        // Set some defaults if certain properties are missing.
+        // Properties may be missing if the filterobject class has not been used to construct the object.
+        if (empty($linkobject->phrase)) {
+            continue;
         }
 
-        // Regular expression modifiers
-        $modifiers = ($linkobject->work_casesensitive) ? 's' : 'isu'; // works in unicode mode!
-
-        // Do we need to do a fullmatch?
-        // If yes then go through and remove any non full matching entries
-        if ($linkobject->work_fullmatch) {
-            $notfullmatches = array();
-            $regexp = '/'.$filterinvalidprefixes.'('.$linkobject->work_phrase.')|('.$linkobject->work_phrase.')'.$filterinvalidsuffixes.'/'.$modifiers;
+        // Avoid integers < 1000 to be linked. See bug 1446.
+        $intcurrent = intval($linkobject->phrase);
+        if (!empty($intcurrent) && strval($intcurrent) == $linkobject->phrase && $intcurrent < 1000) {
+            continue;
+        }
 
-            preg_match_all($regexp,$text,$list_of_notfullmatches);
+        // Strip tags out of the phrase.
+        $linkobject->workregexp = strip_tags($linkobject->phrase);
 
-            if ($list_of_notfullmatches) {
-                foreach (array_unique($list_of_notfullmatches[0]) as $key=>$value) {
-                    $notfullmatches['<*'.$key.'*>'] = $value;
-                }
-                if (!empty($notfullmatches)) {
-                    $text = str_replace($notfullmatches,array_keys($notfullmatches),$text);
-                }
-            }
+        if (!$linkobject->casesensitive) {
+            $linkobject->workregexp = core_text::strtolower($linkobject->workregexp);
         }
 
-        // Finally we do our highlighting
-        if (!empty($CFG->filtermatchonepertext) || !empty($CFG->filtermatchoneperpage)) {
-            $resulttext = preg_replace('/('.$linkobject->work_phrase.')/'.$modifiers,
-                                      $linkobject->work_hreftagbegin.
-                                      $linkobject->work_replacementphrase.
-                                      $linkobject->work_hreftagend, $text, 1);
-        } else {
-            $resulttext = preg_replace('/('.$linkobject->work_phrase.')/'.$modifiers,
-                                      $linkobject->work_hreftagbegin.
-                                      $linkobject->work_replacementphrase.
-                                      $linkobject->work_hreftagend, $text);
-        }
+        // Double up chars that might cause a false match -- the duplicates will
+        // be cleared up before returning to the user.
+        $linkobject->workregexp = preg_replace('/([#*%])/', '\1\1', $linkobject->workregexp);
 
+        // Quote any regular expression characters and the delimiter in the work phrase to be searched.
+        $linkobject->workregexp = preg_quote($linkobject->workregexp, '/');
 
-        // If the text has changed we have to look for links again
-        if ($resulttext != $text) {
-            // Set $text to $resulttext
-            $text = $resulttext;
-            // Remove everything enclosed by the ignore tags from $text
-            filter_save_ignore_tags($text,$filterignoretagsopen,$filterignoretagsclose,$ignoretags);
-            // Remove tags from $text
-            filter_save_tags($text,$tags);
-            // If $CFG->filtermatchoneperpage, save linked phrases to request
-            if (!empty($CFG->filtermatchoneperpage)) {
-                $usedphrases[] = $linkobject->work_phrase;
-            }
+        if ($linkobject->fullmatch) {
+            $linkobject->workregexp = '\b' . $linkobject->workregexp . '\b';
         }
 
+        $linkobject->workregexp = '/(' . $linkobject->workregexp . ')/s';
 
-        // Replace the not full matches before cycling to next link object
-        if (!empty($notfullmatches)) {
-            $text = str_replace(array_keys($notfullmatches),$notfullmatches,$text);
-            unset($notfullmatches);
+        if (!$linkobject->casesensitive) {
+            $linkobject->workregexp .= 'iu';
         }
     }
 
-    // Rebuild the text with all the excluded areas
+    return $linkarray;
+}
 
-    if (!empty($tags)) {
-        $text = str_replace(array_keys($tags), $tags, $text);
+/**
+ * Fill in the remaining ->work... fields, that would be needed to replace the phrase.
+ *
+ * @param filterobject $linkobject the link object on which to set additional fields.
+ */
+function filter_prepare_phrase_for_replacement(filterobject $linkobject) {
+    if ($linkobject->replacementcallback !== null) {
+        list($linkobject->hreftagbegin, $linkobject->hreftagend, $linkobject->replacementphrase) =
+                call_user_func_array($linkobject->replacementcallback, $linkobject->replacementcallbackdata);
     }
 
-    if (!empty($ignoretags)) {
-        $ignoretags = array_reverse($ignoretags);     // Reversed so "progressive" str_replace() will solve some nesting problems.
-        $text = str_replace(array_keys($ignoretags),$ignoretags,$text);
+    if (!isset($linkobject->hreftagbegin) or !isset($linkobject->hreftagend)) {
+        $linkobject->hreftagbegin = '<span class="highlight"';
+        $linkobject->hreftagend   = '</span>';
     }
 
-    // Remove the protective doubleups
-    $text =  preg_replace('/([#*%])(\1)/','\1',$text);
-
-    // Add missing javascript for popus
-    $text = filter_add_javascript($text);
+    // Double up chars to protect true duplicates
+    // be cleared up before returning to the user.
+    $hreftagbeginmangled = preg_replace('/([#*%])/', '\1\1', $linkobject->hreftagbegin);
 
+    // Set the replacement phrase properly.
+    if ($linkobject->replacementphrase) {    // We have specified a replacement phrase.
+        $linkobject->workreplacementphrase = strip_tags($linkobject->replacementphrase);
+    } else {                                 // The replacement is the original phrase as matched below.
+        $linkobject->workreplacementphrase = '$1';
+    }
 
-    return $text;
+    $linkobject->workreplacementphrase = $hreftagbeginmangled .
+            $linkobject->workreplacementphrase . $linkobject->hreftagend;
 }
 
 /**
@@ -1459,12 +1471,12 @@ function filter_phrases($text, &$link_array, $ignoretagsopen=NULL, $ignoretagscl
  */
 function filter_remove_duplicates($linkarray) {
 
-    $concepts  = array(); // keep a record of concepts as we cycle through
-    $lconcepts = array(); // a lower case version for case insensitive
+    $concepts  = array(); // Keep a record of concepts as we cycle through.
+    $lconcepts = array(); // A lower case version for case insensitive.
 
     $cleanlinks = array();
 
-    foreach ($linkarray as $key=>$filterobject) {
+    foreach ($linkarray as $key => $filterobject) {
         if ($filterobject->casesensitive) {
             $exists = in_array($filterobject->phrase, $concepts);
         } else {
@@ -1494,21 +1506,21 @@ function filter_remove_duplicates($linkarray) {
  **/
 function filter_save_ignore_tags(&$text, $filterignoretagsopen, $filterignoretagsclose, &$ignoretags) {
 
-    // Remove everything enclosed by the ignore tags from $text
-    foreach ($filterignoretagsopen as $ikey=>$opentag) {
+    // Remove everything enclosed by the ignore tags from $text.
+    foreach ($filterignoretagsopen as $ikey => $opentag) {
         $closetag = $filterignoretagsclose[$ikey];
-        // form regular expression
-        $opentag  = str_replace('/','\/',$opentag); // delimit forward slashes
-        $closetag = str_replace('/','\/',$closetag); // delimit forward slashes
+        // Form regular expression.
+        $opentag  = str_replace('/', '\/', $opentag); // Delimit forward slashes.
+        $closetag = str_replace('/', '\/', $closetag); // Delimit forward slashes.
         $pregexp = '/'.$opentag.'(.*?)'.$closetag.'/is';
 
-        preg_match_all($pregexp, $text, $list_of_ignores);
-        foreach (array_unique($list_of_ignores[0]) as $key=>$value) {
-            $prefix = (string)(count($ignoretags) + 1);
+        preg_match_all($pregexp, $text, $listofignores);
+        foreach (array_unique($listofignores[0]) as $key => $value) {
+            $prefix = (string) (count($ignoretags) + 1);
             $ignoretags['<#'.$prefix.TEXTFILTER_EXCL_SEPARATOR.$key.'#>'] = $value;
         }
         if (!empty($ignoretags)) {
-            $text = str_replace($ignoretags,array_keys($ignoretags),$text);
+            $text = str_replace($ignoretags, array_keys($ignoretags), $text);
         }
     }
 }
@@ -1523,13 +1535,13 @@ function filter_save_ignore_tags(&$text, $filterignoretagsopen, $filterignoretag
  **/
 function filter_save_tags(&$text, &$tags) {
 
-    preg_match_all('/<([^#%*].*?)>/is',$text,$list_of_newtags);
-    foreach (array_unique($list_of_newtags[0]) as $ntkey=>$value) {
+    preg_match_all('/<([^#%*].*?)>/is', $text, $listofnewtags);
+    foreach (array_unique($listofnewtags[0]) as $ntkey => $value) {
         $prefix = (string)(count($tags) + 1);
         $tags['<%'.$prefix.TEXTFILTER_EXCL_SEPARATOR.$ntkey.'%>'] = $value;
     }
     if (!empty($tags)) {
-        $text = str_replace($tags,array_keys($tags),$text);
+        $text = str_replace($tags, array_keys($tags), $text);
     }
 }
 
@@ -1542,13 +1554,13 @@ function filter_save_tags(&$text, &$tags) {
 function filter_add_javascript($text) {
     global $CFG;
 
-    if (stripos($text, '</html>') === FALSE) {
+    if (stripos($text, '</html>') === false) {
         return $text; // This is not a html file.
     }
-    if (strpos($text, 'onclick="return openpopup') === FALSE) {
+    if (strpos($text, 'onclick="return openpopup') === false) {
         return $text; // No popup - no need to add javascript.
     }
-    $js ="
+    $js = "
     <script type=\"text/javascript\">
     <!--
         function openpopup(url,name,options,fullscreen) {
@@ -1563,7 +1575,7 @@ function filter_add_javascript($text) {
         }
     // -->
     </script>";
-    if (stripos($text, '</head>') !== FALSE) {
+    if (stripos($text, '</head>') !== false) {
         // Try to add it into the head element.
         $text = str_ireplace('</head>', $js.'</head>', $text);
         return $text;
index 336e372..687eef1 100644 (file)
@@ -38,6 +38,11 @@ define('SEPARATEGROUPS', 1);
  */
 define('VISIBLEGROUPS', 2);
 
+/**
+ * This is for filtering users without any group.
+ */
+define('USERSWITHOUTGROUP', -1);
+
 
 /**
  * Determines if a group with a given groupid exists.
@@ -976,15 +981,20 @@ function groups_group_visible($groupid, $course, $cm = null, $userid = null) {
  * Get sql and parameters that will return user ids for a group
  *
  * @param int $groupid
+ * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
  * @return array($sql, $params)
+ * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_ids_sql($groupid) {
-    $groupjoin = groups_get_members_join($groupid, 'u.id');
+function groups_get_members_ids_sql($groupid, context $context = null) {
+    $groupjoin = groups_get_members_join($groupid, 'u.id', $context);
 
     $sql = "SELECT DISTINCT u.id
               FROM {user} u
             $groupjoin->joins
              WHERE u.deleted = 0";
+    if (!empty($groupjoin->wheres)) {
+        $sql .= ' AND '. $groupjoin->wheres;
+    }
 
     return array($sql, $groupjoin->params);
 }
@@ -992,20 +1002,42 @@ function groups_get_members_ids_sql($groupid) {
 /**
  * Get sql join to return users in a group
  *
- * @param int $groupid
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
  * @param string $useridcolumn The column of the user id from the calling SQL, e.g. u.id
+ * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
  * @return \core\dml\sql_join Contains joins, wheres, params
+ * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_join($groupid, $useridcolumn) {
+function groups_get_members_join($groupid, $useridcolumn, context $context = null) {
     // Use unique prefix just in case somebody makes some SQL magic with the result.
     static $i = 0;
     $i++;
     $prefix = 'gm' . $i . '_';
 
-    $join = "JOIN {groups_members} {$prefix}gm ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.groupid = :{$prefix}gmid)";
-    $param = array("{$prefix}gmid" => $groupid);
+    $coursecontext = (!empty($context)) ? $context->get_course_context() : null;
+    if ($groupid == USERSWITHOUTGROUP && empty($coursecontext)) {
+        // Throw an exception if $context is empty or invalid because it's needed to get the users without any group.
+        throw new coding_exception('Missing or wrong $context parameter in an attempt to get members without any group');
+    }
+
+    if ($groupid == USERSWITHOUTGROUP) {
+        // Get members without any group.
+        $join = "LEFT JOIN (
+                    SELECT g.courseid, m.groupid, m.userid
+                    FROM {groups_members} m
+                    JOIN {groups} g ON g.id = m.groupid
+                ) {$prefix}gm ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.courseid = :{$prefix}gcourseid)";
+        $where = "{$prefix}gm.userid IS NULL";
+        $param = array("{$prefix}gcourseid" => $coursecontext->instanceid);
+    } else {
+        // Get members of defined groupid.
+        $join = "JOIN {groups_members} {$prefix}gm
+                ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.groupid = :{$prefix}gmid)";
+        $where = '';
+        $param = array("{$prefix}gmid" => $groupid);
+    }
 
-    return new \core\dml\sql_join($join, '', $param);
+    return new \core\dml\sql_join($join, $where, $param);
 }
 
 /**
index 57a0d0b..2e921e7 100644 (file)
@@ -5349,11 +5349,9 @@ function reset_course_userdata($data) {
     if (!empty($data->reset_roles_overrides)) {
         $children = $context->get_child_contexts();
         foreach ($children as $child) {
-            $DB->delete_records('role_capabilities', array('contextid' => $child->id));
+            $child->delete_capabilities();
         }
-        $DB->delete_records('role_capabilities', array('contextid' => $context->id));
-        // Force refresh for logged in users.
-        $context->mark_dirty();
+        $context->delete_capabilities();
         $status[] = array('component' => $componentstr, 'item' => get_string('deletecourseoverrides', 'role'), 'error' => false);
     }
 
@@ -5362,8 +5360,6 @@ function reset_course_userdata($data) {
         foreach ($children as $child) {
             role_unassign_all(array('contextid' => $child->id));
         }
-        // Force refresh for logged in users.
-        $context->mark_dirty();
         $status[] = array('component' => $componentstr, 'item' => get_string('deletelocalroles', 'role'), 'error' => false);
     }
 
index f290cea..94f995d 100644 (file)
@@ -686,6 +686,15 @@ class navigation_node implements renderable {
         return !empty($this->action);
     }
 
+    /**
+     * Used to easily determine if this link in the breadcrumbs is hidden.
+     *
+     * @return boolean
+     */
+    public function is_hidden() {
+        return $this->hidden;
+    }
+
     /**
      * Gets the CSS class to add to this node to describe its type
      *
index 965ce0f..8c594f6 100644 (file)
@@ -208,6 +208,7 @@ class phpunit_util extends testing_util {
         // reset all static caches
         \core\event\manager::phpunit_reset();
         accesslib_clear_all_caches(true);
+        accesslib_reset_role_cache();
         get_string_manager()->reset_caches(true);
         reset_text_filters_cache(true);
         core_text::reset_caches();
index 268c066..1439ea8 100644 (file)
@@ -673,7 +673,7 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
             array('id' => $newcategoryid));
     list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
     $questions = $DB->get_records_sql("
-            SELECT q.id, q.qtype, qc.contextid
+            SELECT q.id, q.qtype, qc.contextid, q.idnumber
               FROM {question} q
               JOIN {question_categories} qc ON q.category = qc.id
              WHERE  q.id $questionidcondition", $params);
@@ -682,6 +682,27 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
             question_bank::get_qtype($question->qtype)->move_files(
                     $question->id, $question->contextid, $newcontextid);
         }
+        // Check whether there could be a clash of idnumbers in the new category.
+        if (((string) $question->idnumber !== '') &&
+                $DB->record_exists('question', ['idnumber' => $question->idnumber, 'category' => $newcategoryid])) {
+            $rec = $DB->get_records_select('question', "category = ? AND idnumber LIKE ?",
+                    [$newcategoryid, $question->idnumber . '_%'], 'idnumber DESC', 'id, idnumber', 0, 1);
+            $unique = 1;
+            if (count($rec)) {
+                $rec = reset($rec);
+                $idnumber = $rec->idnumber;
+                if (strpos($idnumber, '_') !== false) {
+                    $unique = substr($idnumber, strpos($idnumber, '_') + 1) + 1;
+                }
+            }
+            // For the move process, add a numerical increment to the idnumber. This means that if a question is
+            // mistakenly moved then the idnumber will not be completely lost.
+            $q = new stdClass();
+            $q->id = $question->id;
+            $q->category = $newcategoryid;
+            $q->idnumber = $question->idnumber . '_' . $unique;
+            $DB->update_record('question', $q);
+        }
     }
 
     // Move the questions themselves.
index 3ff6192..932b4bb 100644 (file)
@@ -196,7 +196,6 @@ class core_accesslib_testcase extends advanced_testcase {
 
         // Prevent the capability for this user role.
         assign_capability($capability, CAP_PROHIBIT, $role->id, $coursecontext);
-        $coursecontext->mark_dirty();
         $this->assertFalse(has_capability($capability, $coursecontext, $user->id));
 
         // Again, we seed the cache first by checking initial enrolment,
@@ -2335,6 +2334,40 @@ class core_accesslib_testcase extends advanced_testcase {
 
     }
 
+    /**
+     * Test that enrolled users SQL does not return any values for users
+     * without a group when $context is not a valid course context.
+     */
+    public function test_get_enrolled_sql_userswithoutgroup() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $systemcontext = context_system::instance();
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        groups_add_member($group, $user1);
+
+        $enrolled   = get_enrolled_users($coursecontext);
+        $this->assertCount(2, $enrolled);
+
+        // Get users without any group on the course context.
+        $enrolledwithoutgroup = get_enrolled_users($coursecontext, '', USERSWITHOUTGROUP);
+        $this->assertCount(1, $enrolledwithoutgroup);
+        $this->assertFalse(isset($enrolledwithoutgroup[$user1->id]));
+
+        // Get users without any group on the system context (it should throw an exception).
+        $this->expectException('coding_exception');
+        get_enrolled_users($systemcontext, '', USERSWITHOUTGROUP);
+    }
+
     public function get_enrolled_sql_provider() {
         return array(
             array(
@@ -2734,12 +2767,17 @@ class core_accesslib_testcase extends advanced_testcase {
         $this->assertEquals(array($systemcontext->id=>$systemcontext), $systemcontext->get_parent_contexts(true));
         $this->assertEquals(array(), $systemcontext->get_parent_context_ids());
         $this->assertEquals(array($systemcontext->id), $systemcontext->get_parent_context_ids(true));
+        $this->assertEquals(array(), $systemcontext->get_parent_context_paths());
+        $this->assertEquals(array($systemcontext->id => $systemcontext->path), $systemcontext->get_parent_context_paths(true));
 
         $this->assertEquals($systemcontext, $frontpagecontext->get_parent_context());
         $this->assertEquals(array($systemcontext->id=>$systemcontext), $frontpagecontext->get_parent_contexts());
         $this->assertEquals(array($frontpagecontext->id=>$frontpagecontext, $systemcontext->id=>$systemcontext), $frontpagecontext->get_parent_contexts(true));
         $this->assertEquals(array($systemcontext->id), $frontpagecontext->get_parent_context_ids());
         $this->assertEquals(array($frontpagecontext->id, $systemcontext->id), $frontpagecontext->get_parent_context_ids(true));
+        $this->assertEquals(array($systemcontext->id => $systemcontext->path), $frontpagecontext->get_parent_context_paths());
+        $expected = array($systemcontext->id => $systemcontext->path, $frontpagecontext->id => $frontpagecontext->path);
+        $this->assertEquals($expected, $frontpagecontext->get_parent_context_paths(true));
 
         $this->assertFalse($systemcontext->get_parent_context());
         $frontpagecontext = context_course::instance($SITE->id);
@@ -3212,7 +3250,7 @@ class core_accesslib_testcase extends advanced_testcase {
         $context = context_course::instance($course->id);
         $this->assertEquals($categorycontext, $context->get_parent_context());
         $dirty = get_cache_flags('accesslib/dirtycontexts', time()-2);
-        $this->assertTrue(isset($dirty[$oldpath]));
+        $this->assertFalse(isset($dirty[$oldpath]));
         $this->assertTrue(isset($dirty[$context->path]));
 
 
@@ -3238,7 +3276,7 @@ class core_accesslib_testcase extends advanced_testcase {
         $DB->delete_records('cache_flags', array());
         $context->delete(); // Should delete also linked blocks.
         $dirty = get_cache_flags('accesslib/dirtycontexts', time()-2);
-        $this->assertTrue(isset($dirty[$context->path]));
+        $this->assertFalse(isset($dirty[$context->path]));
         $this->assertFalse($DB->record_exists('context', array('id'=>$context->id)));
         $this->assertFalse($DB->record_exists('context', array('id'=>$bicontext->id)));
         $this->assertFalse($DB->record_exists('context', array('contextlevel'=>CONTEXT_MODULE, 'instanceid'=>$testpages[4])));
@@ -3258,7 +3296,7 @@ class core_accesslib_testcase extends advanced_testcase {
         $DB->delete_records('cache_flags', array());
         context_helper::delete_instance(CONTEXT_COURSE, $lastcourse);
         $dirty = get_cache_flags('accesslib/dirtycontexts', time()-2);
-        $this->assertTrue(isset($dirty[$coursecontext->path]));
+        $this->assertFalse(isset($dirty[$coursecontext->path]));
         $this->assertEquals(0, context_inspection::test_context_cache_size());
         $this->assertFalse($DB->record_exists('context', array('contextlevel'=>CONTEXT_COURSE, 'instanceid'=>$lastcourse)));
         context_course::instance($lastcourse);
index f9bfbee..ccf66c7 100644 (file)
@@ -176,7 +176,6 @@ class core_grouplib_testcase extends advanced_testcase {
         $this->assertEquals($grouping, groups_get_grouping_by_idnumber($course->id, $idnumber2));
     }
 
-
     public function test_groups_get_members_ids_sql() {
         global $DB;
 
@@ -185,7 +184,9 @@ class core_grouplib_testcase extends advanced_testcase {
         $generator = $this->getDataGenerator();
 
         $course = $generator->create_course();
-        $student = $generator->create_user();
+        $coursecontext = context_course::instance($course->id);
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
         $plugin = enrol_get_plugin('manual');
         $role = $DB->get_record('role', array('shortname' => 'student'));
         $group = $generator->create_group(array('courseid' => $course->id));
@@ -196,20 +197,122 @@ class core_grouplib_testcase extends advanced_testcase {
 
         $this->assertNotEquals($instance, false);
 
-        // Enrol the user in the course.
-        $plugin->enrol_user($instance, $student->id, $role->id);
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
 
-        list($sql, $params) = groups_get_members_ids_sql($group->id, true);
+        list($sql, $params) = groups_get_members_ids_sql($group->id);
 
         // Test an empty group.
         $users = $DB->get_records_sql($sql, $params);
-
-        $this->assertFalse(array_key_exists($student->id, $users));
-        groups_add_member($group->id, $student->id);
+        $this->assertFalse(array_key_exists($student1->id, $users));
 
         // Test with a group member.
+        groups_add_member($group->id, $student1->id);
         $users = $DB->get_records_sql($sql, $params);
-        $this->assertTrue(array_key_exists($student->id, $users));
+        $this->assertTrue(array_key_exists($student1->id, $users));
+    }
+
+    public function test_groups_get_members_ids_sql_valid_context() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        $course = $generator->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
+        $plugin = enrol_get_plugin('manual');
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $instance = $DB->get_record('enrol', array(
+                'courseid' => $course->id,
+                'enrol' => 'manual',
+        ));
+
+        $this->assertNotEquals($instance, false);
+
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
+
+        // Add student1 to the group.
+        groups_add_member($group->id, $student1->id);
+
+        // Test with members at any group and with a valid $context.
+        list($sql, $params) = groups_get_members_ids_sql(USERSWITHOUTGROUP, $coursecontext);
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertFalse(array_key_exists($student1->id, $users));
+        $this->assertTrue(array_key_exists($student2->id, $users));
+    }
+
+    public function test_groups_get_members_ids_sql_empty_context() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        $course = $generator->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
+        $plugin = enrol_get_plugin('manual');
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $instance = $DB->get_record('enrol', array(
+                'courseid' => $course->id,
+                'enrol' => 'manual',
+        ));
+
+        $this->assertNotEquals($instance, false);
+
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
+
+        // Add student1 to the group.
+        groups_add_member($group->id, $student1->id);
+
+        // Test with members at any group and without the $context.
+        $this->expectException('coding_exception');
+        list($sql, $params) = groups_get_members_ids_sql(USERSWITHOUTGROUP);
+    }
+
+    public function test_groups_get_members_ids_sql_invalid_context() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        $course = $generator->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
+        $plugin = enrol_get_plugin('manual');
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $group = $generator->create_group(array('courseid' => $course->id));
+        $instance = $DB->get_record('enrol', array(
+                'courseid' => $course->id,
+                'enrol' => 'manual',
+        ));
+
+        $this->assertNotEquals($instance, false);
+
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
+
+        // Add student1 to the group.
+        groups_add_member($group->id, $student1->id);
+
+        // Test with members at any group and with an invalid $context.
+        $syscontext = context_system::instance();
+        $this->expectException('coding_exception');
+        list($sql, $params) = groups_get_members_ids_sql(USERSWITHOUTGROUP, $syscontext);
     }
 
     public function test_groups_get_group_by_name() {
index 41a03bd..cab8753 100644 (file)
@@ -1669,7 +1669,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $context->id);
         }
-        $context->mark_dirty();
 
         $this->setUser($user);
 
@@ -1719,7 +1718,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $context->id);
         }
-        $context->mark_dirty();
 
         // Create the question.
         $qtype = 'truefalse';
@@ -1771,7 +1769,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $context->id);
         }
-        $context->mark_dirty();
 
         // Create the question.
         $qtype = 'truefalse';
@@ -1823,7 +1820,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $context->id);
         }
-        $context->mark_dirty();
 
         // Create the question.
         $qtype = 'truefalse';
@@ -1881,8 +1877,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $newcontext->id);
         }
-        $context->mark_dirty();
-        $newcontext->mark_dirty();
 
         // Create the question.
         $qtype = 'truefalse';
@@ -1938,7 +1932,6 @@ class core_questionlib_testcase extends advanced_testcase {
         foreach ($capabilities as $capname => $capvalue) {
             assign_capability($capname, $capvalue, $roleid, $context->id);
         }
-        $context->mark_dirty();
 
         // Create the question.
         $question = $questiongenerator->create_question('truefalse', null, [
index 70b6a59..6d6ecec 100644 (file)
@@ -71,12 +71,11 @@ information provided here is intended especially for developers.
     - I navigate to "PATH > ITEM" in site administration
     - I navigate to course participants
     - I navigate to "TAB1 > TAB2" in the course gradebook
-* The core\session\util class has been removed. This contained one function only used by the memcached class which has
-  been moved there instead (connection_string_to_memcache_servers).
-
   If some items are not available without Navigation block at all, one can use combination of:
     - I add the "Navigation" block if not present
     - I click on "LINK" "link" in the "Navigation" "block"
+* The core\session\util class has been removed. This contained one function only used by the memcached class which has
+  been moved there instead (connection_string_to_memcache_servers).
 * Removed the lib/password_compat/lib/password.php file.
 * The eventslib.php file has been deleted and its functions have been moved to deprecatedlib.php. The affected functions are:
   - events_get_cached()
@@ -121,6 +120,16 @@ information provided here is intended especially for developers.
   - phone1
   - phone2
   - address
+* New function mark_user_dirty() must be called after changing data that gets cached in user sessions. Examples:
+  - Assigning roles to users.
+  - Unassigning roles from users.
+  - Enrolling users into courses.
+  - Unenrolling users from courses.
+* New optional parameter $context for the groups_get_members_join() function and ability to filter users that are not members of
+any group. Besides, groups_get_members_ids_sql, get_enrolled_sql and get_enrolled_users now accepts -1 (USERSWITHOUTGROUP) for
+the groupid field.
+* Added $CFG->conversionattemptlimit setting to config.php allowing a maximum number of retries before giving up conversion
+  of a given document by the assignfeedback_editpdf\task\convert_submissions task. Default value: 3.
 
 === 3.5 ===
 
index 72eae49..a29426a 100644 (file)
@@ -338,7 +338,7 @@ class xmldb_index extends xmldb_object {
      */
     public function validateDefinition(xmldb_table $xmldb_table=null) {
         if (!$xmldb_table) {
-            return 'Invalid xmldb_index->validateDefinition() call, $xmldb_table si required.';
+            return 'Invalid xmldb_index->validateDefinition() call, $xmldb_table is required.';
         }
 
         $total = 0;
index b380fa5..b68350a 100644 (file)
@@ -28,13 +28,13 @@ defined('MOODLE_INTERNAL') || die();
 
 class xmldb_table extends xmldb_object {
 
-    /** @var array table columns */
+    /** @var xmldb_field[] table columns */
     protected $fields;
 
-    /** @var array keys */
+    /** @var xmldb_key[] keys */
     protected $keys;
 
-    /** @var array indexes */
+    /** @var xmldb_index[] indexes */
     protected $indexes;
 
     /**
@@ -239,7 +239,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will return the array of fields in the table
-     * @return array
+     * @return xmldb_field[]
      */
     public function getFields() {
         return $this->fields;
@@ -247,7 +247,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will return the array of keys in the table
-     * @return array
+     * @return xmldb_key[]
      */
     public function getKeys() {
         return $this->keys;
@@ -255,7 +255,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will return the array of indexes in the table
-     * @return array
+     * @return xmldb_index[]
      */
     public function getIndexes() {
         return $this->indexes;
@@ -264,7 +264,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns one xmldb_field
      * @param string $fieldname
-     * @return mixed
+     * @return xmldb_field|null
      */
     public function getField($fieldname) {
         $i = $this->findFieldInArray($fieldname);
@@ -277,7 +277,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns the position of one field in the array.
      * @param string $fieldname
-     * @return mixed
+     * @return int|null index of the field, or null if not found.
      */
     public function findFieldInArray($fieldname) {
         foreach ($this->fields as $i => $field) {
@@ -290,7 +290,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will reorder the array of fields
-     * @return bool
+     * @return bool whether the reordering succeeded.
      */
     public function orderFields() {
         $result = $this->orderElements($this->fields);
@@ -305,7 +305,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns one xmldb_key
      * @param string $keyname
-     * @return mixed
+     * @return xmldb_key|null
      */
     public function getKey($keyname) {
         $i = $this->findKeyInArray($keyname);
@@ -318,7 +318,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns the position of one key in the array.
      * @param string $keyname
-     * @return mixed
+     * @return int|null index of the key, or null if not found.
      */
     public function findKeyInArray($keyname) {
         foreach ($this->keys as $i => $key) {
@@ -331,7 +331,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will reorder the array of keys
-     * @return bool
+     * @return bool whether the reordering succeeded.
      */
     public function orderKeys() {
         $result = $this->orderElements($this->keys);
@@ -346,7 +346,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns one xmldb_index
      * @param string $indexname
-     * @return mixed
+     * @return xmldb_index|null
      */
     public function getIndex($indexname) {
         $i = $this->findIndexInArray($indexname);
@@ -359,7 +359,7 @@ class xmldb_table extends xmldb_object {
     /**
      * Returns the position of one index in the array.
      * @param string $indexname
-     * @return mixed
+     * @return int|null index of the index, or null if not found.
      */
     public function findIndexInArray($indexname) {
         foreach ($this->indexes as $i => $index) {
@@ -372,7 +372,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will reorder the array of indexes
-     * @return bool
+     * @return bool whether the reordering succeeded.
      */
     public function orderIndexes() {
         $result = $this->orderElements($this->indexes);
@@ -386,7 +386,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will set the array of fields in the table
-     * @param array $fields
+     * @param xmldb_field[] $fields
      */
     public function setFields($fields) {
         $this->fields = $fields;
@@ -394,7 +394,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will set the array of keys in the table
-     * @param array $keys
+     * @param xmldb_key[] $keys
      */
     public function setKeys($keys) {
         $this->keys = $keys;
@@ -402,7 +402,7 @@ class xmldb_table extends xmldb_object {
 
     /**
      * This function will set the array of indexes in the table
-     * @param array $indexes
+     * @param xmldb_index[] $indexes
      */
     public function setIndexes($indexes) {
         $this->indexes = $indexes;
index 60a5bb7..42f8192 100644 (file)
@@ -254,7 +254,6 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         // Remove the viewparticipants capability from one of the courses.
         $course5context = context_course::instance($course5->id);
         assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $role->id, $course5context->id);
-        $course5context->mark_dirty();
 
         // Perform a search.
         list($contacts, $courses, $noncontacts) = \core_message\api::search_users($user1->id, 'search');
index 99de165..0aa8f73 100644 (file)
@@ -35,17 +35,7 @@ class observer {
      * @param \mod_assign\event\submission_created $event
      */
     public static function submission_created(\mod_assign\event\submission_created $event) {
-        global $DB;
-
-        $submissionid = $event->other['submissionid'];
-        $submissionattempt = $event->other['submissionattempt'];
-        $fields = array( 'submissionid' => $submissionid, 'submissionattempt' => $submissionattempt);
-        $record = (object) $fields;
-
-        $exists = $DB->get_records('assignfeedback_editpdf_queue', $fields);
-        if (!$exists) {
-            $DB->insert_record('assignfeedback_editpdf_queue', $record);
-        }
+        self::queue_conversion($event);
     }
 
     /**
@@ -53,6 +43,14 @@ class observer {
      * @param \mod_assign\event\submission_updated $event
      */
     public static function submission_updated(\mod_assign\event\submission_updated $event) {
+        self::queue_conversion($event);
+    }
+
+    /**
+     * Queue the submission for processing.
+     * @param \mod_assign\event\base $event The submission created/updated event.
+     */
+    protected static function queue_conversion($event) {
         global $DB;
 
         $submissionid = $event->other['submissionid'];
@@ -60,9 +58,13 @@ class observer {
         $fields = array( 'submissionid' => $submissionid, 'submissionattempt' => $submissionattempt);
         $record = (object) $fields;
 
-        $exists = $DB->get_records('assignfeedback_editpdf_queue', $fields);
+        $exists = $DB->get_record('assignfeedback_editpdf_queue', $fields);
         if (!$exists) {
             $DB->insert_record('assignfeedback_editpdf_queue', $record);
+        } else {
+            // This submission attempt was already queued, so just reset the existing failure counter to ensure it gets processed.
+            $exists->attemptedconversions = 0;
+            $DB->update_record('assignfeedback_editpdf_queue', $exists);
         }
     }
 }
index 6771820..74a781c 100644 (file)
@@ -58,15 +58,21 @@ class convert_submissions extends scheduled_task {
 
         $assignmentcache = array();
 
+        $conversionattemptlimit = !empty($CFG->conversionattemptlimit) ? $CFG->conversionattemptlimit : 3;
         foreach ($records as $record) {
             $submissionid = $record->submissionid;
             $submission = $DB->get_record('assign_submission', array('id' => $submissionid), '*', IGNORE_MISSING);
-            if (!$submission) {
-                // Submission no longer exists.
+            if (!$submission || $record->attemptedconversions >= $conversionattemptlimit) {
+                // Submission no longer exists; or we've exceeded the conversion attempt limit.
                 $DB->delete_records('assignfeedback_editpdf_queue', array('id' => $record->id));
                 continue;
             }
 
+            // Record that we're attempting the conversion ahead of time.
+            // We can't do this afterwards as its possible for the conversion process to crash the script entirely.
+            $DB->set_field('assignfeedback_editpdf_queue', 'attemptedconversions',
+                    $record->attemptedconversions + 1, ['id' => $record->id]);
+
             $assignmentid = $submission->assignment;
             $attemptnumber = $record->submissionattempt;
 
index f8bdd55..3180246 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/assign/feedback/editpdf/db" VERSION="20160216" COMMENT="XMLDB file for Moodle mod/assign/feedback/editpdf"
+<XMLDB PATH="mod/assign/feedback/editpdf/db" VERSION="20180925" COMMENT="XMLDB file for Moodle mod/assign/feedback/editpdf"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="submissionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="submissionattempt" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="attemptedconversions" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="submissionid-submissionattempt" TYPE="unique" FIELDS="submissionid, submissionattempt"/>
       </KEYS>
     </TABLE>
   </TABLES>
index 995afce..3140fc1 100644 (file)
@@ -62,5 +62,38 @@ function xmldb_assignfeedback_editpdf_upgrade($oldversion) {
     // Automatically generated Moodle v3.5.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2018051401) {
+        $table = new xmldb_table('assignfeedback_editpdf_queue');
+        $field = new xmldb_field('attemptedconversions', XMLDB_TYPE_INTEGER, '10', null,
+            XMLDB_NOTNULL, null, 0, 'submissionattempt');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Attempts are removed from the queue after being processed, a duplicate row won't achieve anything productive.
+        // So look for any duplicates and remove them so we can add a unique key.
+        $sql = "SELECT MIN(id) as minid, submissionid, submissionattempt
+                FROM {assignfeedback_editpdf_queue}
+                GROUP BY submissionid, submissionattempt
+                HAVING COUNT(id) > 1";
+
+        if ($duplicatedrows = $DB->get_recordset_sql($sql)) {
+            foreach ($duplicatedrows as $row) {
+                $DB->delete_records_select('assignfeedback_editpdf_queue',
+                    'submissionid = :submissionid AND submissionattempt = :submissionattempt AND id <> :minid', (array)$row);
+            }
+        }
+        $duplicatedrows->close();
+
+        // Define key submissionid-submissionattempt to be added to assignfeedback_editpdf_queue.
+        $table = new xmldb_table('assignfeedback_editpdf_queue');
+        $key = new xmldb_key('submissionid-submissionattempt', XMLDB_KEY_UNIQUE, ['submissionid', 'submissionattempt']);
+
+        $dbman->add_key($table, $key);
+
+        upgrade_plugin_savepoint(true, 2018051401, 'assignfeedback', 'editpdf');
+    }
+
     return true;
 }
index db5d8d1..a6fda7b 100644 (file)
@@ -338,6 +338,63 @@ class assignfeedback_editpdf_testcase extends advanced_testcase {
         $this->assertEmpty($file3);
     }
 
+    public function test_conversion_task() {
+        global $DB;
+        $this->require_ghostscript();
+        $this->resetAfterTest();
+        cron_setup_user();
+
+        $task = new \assignfeedback_editpdf\task\convert_submissions;
+
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $assignopts = [
+            'assignsubmission_file_enabled' => 1,
+            'assignsubmission_file_maxfiles' => 1,
+            'assignfeedback_editpdf_enabled' => 1,
+            'assignsubmission_file_maxsizebytes' => 1000000,
+        ];
+        $assign = $this->create_instance($course, $assignopts);
+
+        // Add the standard submission.
+        $this->add_file_submission($student, $assign);
+
+        // Run the conversion task.
+        ob_start();
+        $task->execute();
+        $output = ob_get_clean();
+
+        // Verify it acted on both submissions in the queue.
+        $this->assertContains("Convert 1 submission attempt(s) for assignment {$assign->get_instance()->id}", $output);
+        $this->assertEquals(0, $DB->count_records('assignfeedback_editpdf_queue'));
+
+        // Set a known limit.
+        set_config('conversionattemptlimit', 3);
+
+        // Trigger a re-queue by 'updating' a submission.
+        $submission = $assign->get_user_submission($student->id, true);
+        $plugin = $assign->get_submission_plugin_by_type('file');
+        $plugin->save($submission, (new stdClass));
+
+        // Verify that queued a conversion task.
+        $this->assertEquals(1, $DB->count_records('assignfeedback_editpdf_queue'));
+
+        // Fake some failed attempts for it.
+        $queuerecord = $DB->get_record('assignfeedback_editpdf_queue', ['submissionid' => $submission->id]);
+        $queuerecord->attemptedconversions = 3;
+        $DB->update_record('assignfeedback_editpdf_queue', $queuerecord);
+
+        ob_start();
+        $task->execute();
+        $output = ob_get_clean();
+
+        // Verify that the cron task skipped the submission.
+        $this->assertNotContains("Convert 1 submission attempt(s) for assignment {$assign->get_instance()->id}", $output);
+        // And it removed it from the queue.
+        $this->assertEquals(0, $DB->count_records('assignfeedback_editpdf_queue'));
+
+    }
+
     /**
      * Test that modifying the annotated pdf form return true when modified
      * and false when not modified.
index 908d160..63735fc 100644 (file)
@@ -24,7 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018051400;
+$plugin->version   = 2018051401;
 $plugin->requires  = 2018050800;
 $plugin->component = 'assignfeedback_editpdf';
-
index 99b0309..b254f3d 100644 (file)
@@ -67,11 +67,13 @@ class assign_submission_file extends assign_submission_plugin {
     public function get_settings(MoodleQuickForm $mform) {
         global $CFG, $COURSE;
 
-        $defaultmaxfilesubmissions = $this->get_config('maxfilesubmissions');
-        $defaultmaxsubmissionsizebytes = $this->get_config('maxsubmissionsizebytes');
         if ($this->assignment->has_instance()) {
+            $defaultmaxfilesubmissions = $this->get_config('maxfilesubmissions');
+            $defaultmaxsubmissionsizebytes = $this->get_config('maxsubmissionsizebytes');
             $defaultfiletypes = $this->get_config('filetypeslist');
         } else {
+            $defaultmaxfilesubmissions = get_config('assignsubmission_file', 'maxfiles');
+            $defaultmaxsubmissionsizebytes = get_config('assignsubmission_file', 'maxbytes');
             $defaultfiletypes = get_config('assignsubmission_file', 'filetypes');
         }
         $defaultfiletypes = (string)$defaultfiletypes;
index 0990f40..4276414 100644 (file)
@@ -279,7 +279,6 @@ class mod_feedback_lib_testcase extends advanced_testcase {
 
         $this->setUser($user);
         assign_capability('mod/feedback:complete', CAP_PROHIBIT, $studentrole->id, $context);
-        $context->mark_dirty();
 
         $factory = new \core_calendar\action_factory();
         $action = mod_feedback_core_calendar_provide_event_action($event, $factory);
index 0f624d8..4a28c23 100644 (file)
@@ -3050,6 +3050,7 @@ function forum_get_course_forum($courseid, $type) {
         case "news":
             $forum->name  = get_string("namenews", "forum");
             $forum->intro = get_string("intronews", "forum");
+            $forum->introformat = FORMAT_HTML;
             $forum->forcesubscribe = FORUM_FORCESUBSCRIBE;
             $forum->assessed = 0;
             if ($courseid == SITEID) {
@@ -3060,12 +3061,14 @@ function forum_get_course_forum($courseid, $type) {
         case "social":
             $forum->name  = get_string("namesocial", "forum");
             $forum->intro = get_string("introsocial", "forum");
+            $forum->introformat = FORMAT_HTML;
             $forum->assessed = 0;
             $forum->forcesubscribe = 0;
             break;
         case "blog":
             $forum->name = get_string('blogforum', 'forum');
             $forum->intro = get_string('introblog', 'forum');
+            $forum->introformat = FORMAT_HTML;
             $forum->assessed = 0;
             $forum->forcesubscribe = 0;
             break;
index d48bf0e..57035c2 100644 (file)
@@ -155,7 +155,6 @@ class mod_forum_subscriptions_testcase extends advanced_testcase {
         $roleids = $DB->get_records_menu('role', null, '', 'shortname, id');
         $context = \context_course::instance($course->id);
         assign_capability('moodle/course:viewhiddenactivities', CAP_ALLOW, $roleids['student'], $context);
-        $context->mark_dirty();
 
         // All of the unsubscribable forums should now be listed.
         $result = \mod_forum\subscriptions::get_unsubscribable_forums();
@@ -906,7 +905,6 @@ class mod_forum_subscriptions_testcase extends advanced_testcase {
         $cm = get_coursemodule_from_instance('forum', $forum->id);
         $context = \context_module::instance($cm->id);
         assign_capability('mod/forum:allowforcesubscribe', CAP_PROHIBIT, $roleids['student'], $context);
-        $context->mark_dirty();
         $this->assertFalse(has_capability('mod/forum:allowforcesubscribe', $context, $user->id));
 
         // Check that the user is no longer subscribed to the forum.
index d899d00..d46d28e 100644 (file)
@@ -10,7 +10,7 @@
       "dev": true,
       "requires": {
         "jsonparse": "0.0.5",
-        "through": "2.3.8"
+        "through": ">=2.2.7 <3"
       }
     },
     "abbrev": {
@@ -20,9 +20,9 @@
       "dev": true
     },
     "acorn": {
-      "version": "5.5.3",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.5.3.tgz",
-      "integrity": "sha512-jd5MkIUlbbmb07nXH0DT3y7rDVtkzDi4XZOUVWAer8ajmF/DTSSbl5oNFyDOl/OXA33Bl79+ypHhl2pN20VeOQ==",
+      "version": "5.7.3",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
+      "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==",
       "dev": true
     },
     "acorn-jsx": {
       "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=",
       "dev": true,
       "requires": {
-        "acorn": "3.3.0"
+        "acorn": "^3.0.4"
       },
       "dependencies": {
         "acorn": {
           "version": "3.3.0",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
+          "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
           "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=",
           "dev": true
         }
       "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
       "dev": true,
       "requires": {
-        "co": "4.6.0",
-        "fast-deep-equal": "1.1.0",
-        "fast-json-stable-stringify": "2.0.0",
-        "json-schema-traverse": "0.3.1"
+        "co": "^4.6.0",
+        "fast-deep-equal": "^1.0.0",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.3.0"
       }
     },
     "ajv-keywords": {
@@ -66,9 +66,9 @@
       "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
       "dev": true,
       "requires": {
-        "kind-of": "3.2.2",
-        "longest": "1.0.1",
-        "repeat-string": "1.6.1"
+        "kind-of": "^3.0.2",
+        "longest": "^1.0.1",
+        "repeat-string": "^1.5.2"
       }
     },
     "amdefine": {
       "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
       "dev": true,
       "requires": {
-        "delegates": "1.0.0",
-        "readable-stream": "2.3.6"
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
       }
     },
     "argparse": {
       "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
       "dev": true,
       "requires": {
-        "sprintf-js": "1.0.3"
+        "sprintf-js": "~1.0.2"
       }
     },
     "arr-diff": {
       "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=",
       "dev": true,
       "requires": {
-        "arr-flatten": "1.1.0"
+        "arr-flatten": "^1.0.1"
       }
     },
     "arr-flatten": {
       "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=",
       "dev": true,
       "requires": {
-        "array-uniq": "1.0.3"
+        "array-uniq": "^1.0.1"
       }
     },
     "array-uniq": {
       "optional": true
     },
     "asn1": {
-      "version": "0.2.3",
-      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
-      "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=",
-      "dev": true
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "dev": true,
+      "requires": {
+        "safer-buffer": "~2.1.0"
+      }
     },
     "assert-plus": {
       "version": "1.0.0",
       "integrity": "sha1-Hb0cg1ZY41zj+ZhAmdsAWFx4IBQ=",
       "dev": true,
       "requires": {
-        "browserslist": "1.7.7",
-        "caniuse-db": "1.0.30000833",
-        "normalize-range": "0.1.2",
-        "num2fraction": "1.2.2",
-        "postcss": "5.2.18",
-        "postcss-value-parser": "3.3.0"
+        "browserslist": "^1.7.6",
+        "caniuse-db": "^1.0.30000634",
+        "normalize-range": "^0.1.2",
+        "num2fraction": "^1.2.2",
+        "postcss": "^5.2.16",
+        "postcss-value-parser": "^3.2.3"
       }
     },
     "aws-sign2": {
       "version": "0.7.0",
       "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
       "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
-      "dev": true,
-      "optional": true
+      "dev": true
     },
     "aws4": {
-      "version": "1.7.0",
-      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz",
-      "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==",
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz",
+      "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==",
       "dev": true
     },
     "babel-code-frame": {
       "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
       "dev": true,
       "requires": {
-        "chalk": "1.1.3",
-        "esutils": "2.0.2",
-        "js-tokens": "3.0.2"
+        "chalk": "^1.1.3",
+        "esutils": "^2.0.2",
+        "js-tokens": "^3.0.2"
       },
       "dependencies": {
         "chalk": {
           "version": "1.1.3",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+          "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
           "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
           "dev": true,
           "requires": {
-            "ansi-styles": "2.2.1",
-            "escape-string-regexp": "1.0.5",
-            "has-ansi": "2.0.0",
-            "strip-ansi": "3.0.1",
-            "supports-color": "2.0.0"
+            "ansi-styles": "^2.2.1",
+            "escape-string-regexp": "^1.0.2",
+            "has-ansi": "^2.0.0",
+            "strip-ansi": "^3.0.0",
+            "supports-color": "^2.0.0"
           }
         },
         "strip-ansi": {
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "dev": true,
           "requires": {
-            "ansi-regex": "2.1.1"
+            "ansi-regex": "^2.0.0"
           }
         }
       }
       "dev": true
     },
     "bcrypt-pbkdf": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz",
-      "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
       "dev": true,
       "optional": true,
       "requires": {
-        "tweetnacl": "0.14.5"
+        "tweetnacl": "^0.14.3"
       }
     },
     "block-stream": {
       "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
       "dev": true,
       "requires": {
-        "inherits": "2.0.3"
+        "inherits": "~2.0.0"
       }
     },
     "body-parser": {
       "dev": true,
       "requires": {
         "bytes": "2.2.0",
-        "content-type": "1.0.4",
-        "debug": "2.2.0",
-        "depd": "1.1.2",
-        "http-errors": "1.3.1",
+        "content-type": "~1.0.1",
+        "debug": "~2.2.0",
+        "depd": "~1.1.0",
+        "http-errors": "~1.3.1",
         "iconv-lite": "0.4.13",
-        "on-finished": "2.3.0",
+        "on-finished": "~2.3.0",
         "qs": "5.2.0",
-        "raw-body": "2.1.7",
-        "type-is": "1.6.16"
+        "raw-body": "~2.1.5",
+        "type-is": "~1.6.10"
       },
       "dependencies": {
         "debug": {
           "version": "2.2.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "resolved": "http://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
           "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
           "dev": true,
           "requires": {
         }
       }
     },
-    "boom": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz",
-      "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=",
-      "dev": true,
-      "optional": true,
-      "requires": {
-        "hoek": "4.2.1"
-      }
-    },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
       "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
       "dev": true,
       "requires": {
-        "balanced-match": "1.0.0",
+        "balanced-match": "^1.0.0",
         "concat-map": "0.0.1"
       }
     },
       "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=",
       "dev": true,
       "requires": {
-        "expand-range": "1.8.2",
-        "preserve": "0.2.0",
-        "repeat-element": "1.1.2"
+        "expand-range": "^1.8.1",
+        "preserve": "^0.2.0",
+        "repeat-element": "^1.1.2"
       }
     },
     "browserify-zlib": {
       "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=",
       "dev": true,
       "requires": {
-        "pako": "0.2.9"
+        "pako": "~0.2.0"
       }
     },
     "browserslist": {
       "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=",
       "dev": true,
       "requires": {
-        "caniuse-db": "1.0.30000833",
-        "electron-to-chromium": "1.3.45"
+        "caniuse-db": "^1.0.30000639",
+        "electron-to-chromium": "^1.2.7"
       }
     },
     "buffer-from": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.0.0.tgz",
-      "integrity": "sha512-83apNb8KK0Se60UE1+4Ukbe3HbfELJ6UlI4ldtOGs7So4KD26orJM8hIY9lxdzP+UpItH1Yh/Y8GUvNFWFFRxA==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
+      "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
       "dev": true
     },
     "builtin-modules": {
       "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=",
       "dev": true,
       "requires": {
-        "callsites": "0.2.0"
+        "callsites": "^0.2.0"
       }
     },
     "callsites": {
       "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
       "dev": true,
       "requires": {
-        "camelcase": "2.1.1",
-        "map-obj": "1.0.1"
+        "camelcase": "^2.0.0",
+        "map-obj": "^1.0.0"
       }
     },
     "caniuse-db": {
-      "version": "1.0.30000833",
-      "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000833.tgz",
-      "integrity": "sha1-K9e+cqQBZY0svLj012AN7r6xxnY=",
+      "version": "1.0.30000885",
+      "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000885.tgz",
+      "integrity": "sha512-Hy1a+UIXooG+tRlt3WnT9avMf+l999bR9J1MqlQdYKgbsYjKxV4a4rgcmiyMmdCLPBFsiRoDxdl9tnNyaq2RXw==",
       "dev": true
     },
     "caniuse-lite": {
-      "version": "1.0.30000833",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000833.tgz",
-      "integrity": "sha512-tKNuKu4WLImh4NxoTgntxFpDrRiA0Q6Q1NycNhuMST0Kx+Pt8YnRDW6V8xsyH6AtO2CpAoibatEk5eaEhP3O1g==",
+      "version": "1.0.30000885",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000885.tgz",
+      "integrity": "sha512-cXKbYwpxBLd7qHyej16JazPoUacqoVuDhvR61U7Fr5vSxMUiodzcYa1rQYRYfZ5GexV03vGZHd722vNPLjPJGQ==",
       "dev": true
     },
     "caseless": {
       "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=",
       "dev": true,
       "requires": {
-        "align-text": "0.1.4",
-        "lazy-cache": "1.0.4"
+        "align-text": "^0.1.3",
+        "lazy-cache": "^1.0.3"
       }
     },
     "chalk": {
       "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==",
       "dev": true,
       "requires": {
-        "ansi-styles": "3.2.1",
-        "escape-string-regexp": "1.0.5",
-        "supports-color": "5.4.0"
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
       },
       "dependencies": {
         "ansi-styles": {
           "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
           "dev": true,
           "requires": {
-            "color-convert": "1.9.1"
+            "color-convert": "^1.9.0"
           }
         },
         "supports-color": {
-          "version": "5.4.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
-          "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
           "dev": true,
           "requires": {
-            "has-flag": "3.0.0"
+            "has-flag": "^3.0.0"
           }
         }
       }
       "dev": true,
       "requires": {
         "exit": "0.1.2",
-        "glob": "3.2.11"
+        "glob": "~ 3.2.1"
       },
       "dependencies": {
         "glob": {
           "integrity": "sha1-Spc/Y1uRkPcV0QmH1cAP0oFevj0=",
           "dev": true,
           "requires": {
-            "inherits": "2.0.3",
-            "minimatch": "0.3.0"
+            "inherits": "2",
+            "minimatch": "0.3"
           }
         },
         "lru-cache": {
           "integrity": "sha1-J12O2qxPG7MyZHIInnlJyDlGmd0=",
           "dev": true,
           "requires": {
-            "lru-cache": "2.7.3",
-            "sigmund": "1.0.1"
+            "lru-cache": "2",
+            "sigmund": "~1.0.0"
           }
         }
       }
       "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=",
       "dev": true,
       "requires": {
-        "restore-cursor": "2.0.0"
+        "restore-cursor": "^2.0.0"
       }
     },
     "cli-width": {
       "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=",
       "dev": true,
       "requires": {
-        "center-align": "0.1.3",
-        "right-align": "0.1.3",
+        "center-align": "^0.1.1",
+        "right-align": "^0.1.1",
         "wordwrap": "0.0.2"
       },
       "dependencies": {
       "integrity": "sha512-Fcij9IwRW27XedRIJnSOEupS7RVcXtObJXbcUOX93UCLqqOdRpkvzKywOOSizmEK/Is3S/RHX9dLdfo6R1Q1mw==",
       "dev": true,
       "requires": {
-        "is-regexp": "1.0.0",
-        "is-supported-regexp-flag": "1.0.1"
+        "is-regexp": "^1.0.0",
+        "is-supported-regexp-flag": "^1.0.0"
       }
     },
     "co": {
       "dev": true
     },
     "color-convert": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz",
-      "integrity": "sha512-mjGanIiwQJskCC18rPR6OmrZ6fm2Lc7PeGFYwCmy5J34wC6F1PzdGL6xeMfmgicfYcNLGuVFA3WzXtIDCQSZxQ==",
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
       "dev": true,
       "requires": {
         "color-name": "1.1.3"
       "integrity": "sha512-qYVKTg626qpDg4/eBnPXidEPXn5+krbYqHVfyyEFBWV5z3IF4p44HKY/eE2t1ohlcrlIkDgHmFJMfQ8qMLnSFw==",
       "dev": true,
       "requires": {
-        "chalk": "1.1.3",
-        "color-diff": "0.1.7",
-        "log-symbols": "1.0.2",
-        "object-assign": "4.1.1",
-        "pipetteur": "2.0.3",
-        "plur": "2.1.2",
-        "postcss": "5.2.18",
-        "postcss-reporter": "1.4.1",
-        "text-table": "0.2.0",
-        "yargs": "1.3.3"
+        "chalk": "^1.1.1",
+        "color-diff": "^0.1.3",
+        "log-symbols": "^1.0.2",
+        "object-assign": "^4.0.1",
+        "pipetteur": "^2.0.0",
+        "plur": "^2.0.0",
+        "postcss": "^5.0.4",
+        "postcss-reporter": "^1.2.1",
+        "text-table": "^0.2.0",
+        "yargs": "^1.2.6"
       },
       "dependencies": {
         "chalk": {
           "version": "1.1.3",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+          "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
           "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
           "dev": true,
           "requires": {
-            "ansi-styles": "2.2.1",
-            "escape-string-regexp": "1.0.5",
-            "has-ansi": "2.0.0",
-            "strip-ansi": "3.0.1",
-            "supports-color": "2.0.0"
+            "ansi-styles": "^2.2.1",
+            "escape-string-regexp": "^1.0.2",
+            "has-ansi": "^2.0.0",
+            "strip-ansi": "^3.0.0",
+            "supports-color": "^2.0.0"
           }
         },
         "postcss-reporter": {
           "integrity": "sha1-wTbwpbFhkV83ndN2XGEHX357mvI=",
           "dev": true,
           "requires": {
-            "chalk": "1.1.3",
-            "lodash": "4.17.10",
-            "log-symbols": "1.0.2",
-            "postcss": "5.2.18"
+            "chalk": "^1.0.0",
+            "lodash": "^4.1.0",
+            "log-symbols": "^1.0.2",
+            "postcss": "^5.0.0"
           }
         },
         "strip-ansi": {
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "dev": true,
           "requires": {
-            "ansi-regex": "2.1.1"
+            "ansi-regex": "^2.0.0"
           }
         },
         "yargs": {
           "version": "1.3.3",
-          "resolved": "https://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz",
+          "resolved": "http://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz",
           "integrity": "sha1-BU3oth8i7v23IHBZ6u+da4P7kxo=",
           "dev": true
         }
       "dev": true
     },
     "combined-stream": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz",
-      "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=",
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz",
+      "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==",
       "dev": true,
       "requires": {
-        "delayed-stream": "1.0.0"
+        "delayed-stream": "~1.0.0"
       }
     },
     "commander": {
       "version": "2.9.0",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
+      "resolved": "http://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
       "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=",
       "dev": true,
       "requires": {
-        "graceful-readlink": "1.0.1"
+        "graceful-readlink": ">= 1.0.0"
       }
     },
     "concat-map": {
       "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
       "dev": true,
       "requires": {
-        "buffer-from": "1.0.0",
-        "inherits": "2.0.3",
-        "readable-stream": "2.3.6",
-        "typedarray": "0.0.6"
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
       }
     },
     "console-browserify": {
       "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=",
       "dev": true,
       "requires": {
-        "date-now": "0.1.4"
+        "date-now": "^0.1.4"
       }
     },
     "console-control-strings": {
       "integrity": "sha512-GiNXLwAFPYHy25XmTPpafYvn3CLAkJ8FLsscq78MQd1Kh0OU6Yzhn4eV2MVF4G9WEQZoWEGltatdR+ntGPMl5A==",
       "dev": true,
       "requires": {
-        "is-directory": "0.3.1",
-        "js-yaml": "3.11.0",
-        "minimist": "1.2.0",
-        "object-assign": "4.1.1",
-        "os-homedir": "1.0.2",
-        "parse-json": "2.2.0",
-        "require-from-string": "1.2.1"
+        "is-directory": "^0.3.1",
+        "js-yaml": "^3.4.3",
+        "minimist": "^1.2.0",
+        "object-assign": "^4.1.0",
+        "os-homedir": "^1.0.1",
+        "parse-json": "^2.2.0",
+        "require-from-string": "^1.1.0"
       },
       "dependencies": {
         "minimist": {
           "version": "1.2.0",
-          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+          "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
           "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
           "dev": true
         }
       "integrity": "sha1-U43SYnolrlDtoNwPgyKfuWDQhrM=",
       "dev": true,
       "requires": {
-        "graceful-fs": "1.1.14",
-        "mkdirp": "0.3.5",
-        "rimraf": "2.0.3"
+        "graceful-fs": "~1.1.14",
+        "mkdirp": "~0.3.4",
+        "rimraf": "~2.0.2"
       },
       "dependencies": {
         "graceful-fs": {
         },
         "mkdirp": {
           "version": "0.3.5",
-          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz",
+          "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.5.tgz",
           "integrity": "sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=",
           "dev": true
         },
         "rimraf": {
           "version": "2.0.3",
-          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.0.3.tgz",
+          "resolved": "http://registry.npmjs.org/rimraf/-/rimraf-2.0.3.tgz",
           "integrity": "sha1-9QopZecUTpr9mYmC8V33BnMPVqk=",
           "dev": true,
           "requires": {
-            "graceful-fs": "1.1.14"
+            "graceful-fs": "~1.1"
           }
         }
       }
       "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
       "dev": true,
       "requires": {
-        "lru-cache": "4.1.2",
-        "shebang-command": "1.2.0",
-        "which": "1.3.0"
-      }
-    },
-    "cryptiles": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz",
-      "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=",
-      "dev": true,
-      "optional": true,
-      "requires": {
-        "boom": "5.2.0"
-      },
-      "dependencies": {
-        "boom": {
-          "version": "5.2.0",
-          "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz",
-          "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==",
-          "dev": true,
-          "optional": true,
-          "requires": {
-            "hoek": "4.2.1"
-          }
-        }
+        "lru-cache": "^4.0.1",
+        "shebang-command": "^1.2.0",
+        "which": "^1.2.9"
       }
     },
     "css-color-names": {
       "integrity": "sha1-N4bnGYmD2WWibjGVfgkHjLt3BaI=",
       "dev": true,
       "requires": {
-        "css-tokenize": "1.0.1",
+        "css-tokenize": "^1.0.1",
         "duplexer2": "0.0.2",
-        "ldjson-stream": "1.2.1",
-        "through2": "0.6.5"
+        "ldjson-stream": "^1.2.1",
+        "through2": "^0.6.3"
       }
     },
     "css-tokenize": {
       "integrity": "sha1-RiXLHtohwUOFi3+B1oA8HSb8FL4=",
       "dev": true,
       "requires": {
-        "inherits": "2.0.3",
-        "readable-stream": "1.1.14"
+        "inherits": "^2.0.1",
+        "readable-stream": "^1.0.33"
       },
       "dependencies": {
         "isarray": {
         },
         "readable-stream": {
           "version": "1.1.14",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+          "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
           "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
           "dev": true,
           "requires": {
-            "core-util-is": "1.0.2",
-            "inherits": "2.0.3",
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
             "isarray": "0.0.1",
-            "string_decoder": "0.10.31"
+            "string_decoder": "~0.10.x"
           }
         },
         "string_decoder": {
       "integrity": "sha1-bLLN/2lHJZ39r3kGJjM6hi3pSLA=",
       "dev": true,
       "requires": {
-        "source-map": "0.5.7"
+        "source-map": "^0.5.3"
       }
     },
     "csslint": {
       "integrity": "sha1-OmoE51Zcjp0ZvrSXZ8fslug2WAU=",
       "dev": true,
       "requires": {
-        "parserlib": "0.2.5"
+        "parserlib": "~0.2.2"
       }
     },
     "cssproc": {
       "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=",
       "dev": true,
       "requires": {
-        "array-find-index": "1.0.2"
+        "array-find-index": "^1.0.1"
       }
     },
     "dashdash": {
       "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
       "dev": true,
       "requires": {
-        "assert-plus": "1.0.0"
+        "assert-plus": "^1.0.0"
       }
     },
     "date-now": {
       "integrity": "sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk=",
       "dev": true,
       "requires": {
-        "get-stdin": "4.0.1",
-        "meow": "3.7.0"
+        "get-stdin": "^4.0.1",
+        "meow": "^3.3.0"
       }
     },
     "debug": {
       "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=",
       "dev": true,
       "requires": {
-        "globby": "5.0.0",
-        "is-path-cwd": "1.0.0",
-        "is-path-in-cwd": "1.0.1",
-        "object-assign": "4.1.1",
-        "pify": "2.3.0",
-        "pinkie-promise": "2.0.1",
-        "rimraf": "2.6.2"
+        "globby": "^5.0.0",
+        "is-path-cwd": "^1.0.0",
+        "is-path-in-cwd": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "pify": "^2.0.0",
+        "pinkie-promise": "^2.0.0",
+        "rimraf": "^2.2.8"
       }
     },
     "delayed-stream": {
       "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
       "dev": true,
       "requires": {
-        "esutils": "2.0.2"
+        "esutils": "^2.0.2"
       }
     },
     "doiuse": {
       "integrity": "sha1-GJLRC2Gpo1at2/K2FJM+gfi7ODQ=",
       "dev": true,
       "requires": {
-        "browserslist": "1.7.7",
-        "caniuse-db": "1.0.30000833",
-        "css-rule-stream": "1.1.0",
+        "browserslist": "^1.1.1",
+        "caniuse-db": "^1.0.30000187",
+        "css-rule-stream": "^1.1.0",
         "duplexer2": "0.0.2",
-        "jsonfilter": "1.1.2",
-        "ldjson-stream": "1.2.1",
-        "lodash": "4.17.10",
-        "multimatch": "2.1.0",
-        "postcss": "5.2.18",
-        "source-map": "0.4.4",
-        "through2": "0.6.5",
-        "yargs": "3.10.0"
+        "jsonfilter": "^1.1.2",
+        "ldjson-stream": "^1.2.1",
+        "lodash": "^4.0.0",
+        "multimatch": "^2.0.0",
+        "postcss": "^5.0.8",
+        "source-map": "^0.4.2",
+        "through2": "^0.6.3",
+        "yargs": "^3.5.4"
       },
       "dependencies": {
         "source-map": {
           "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=",
           "dev": true,
           "requires": {
-            "amdefine": "1.0.1"
+            "amdefine": ">=0.0.4"
           }
         }
       }
       "integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
       "dev": true,
       "requires": {
-        "domelementtype": "1.1.3",
-        "entities": "1.1.1"
+        "domelementtype": "~1.1.1",
+        "entities": "~1.1.1"
       },
       "dependencies": {
         "domelementtype": {
       "integrity": "sha1-LeWaCCLVAn+r/28DLCsloqir5zg=",
       "dev": true,
       "requires": {
-        "domelementtype": "1.3.0"
+        "domelementtype": "1"
       }
     },
     "domutils": {
       "integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
       "dev": true,
       "requires": {
-        "dom-serializer": "0.1.0",
-        "domelementtype": "1.3.0"
+        "dom-serializer": "0",
+        "domelementtype": "1"
       }
     },
     "duplexer": {
       "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
+      "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
       "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=",
       "dev": true
     },
       "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=",
       "dev": true,
       "requires": {
-        "readable-stream": "1.1.14"
+        "readable-stream": "~1.1.9"
       },
       "dependencies": {
         "isarray": {
         },
         "readable-stream": {
           "version": "1.1.14",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+          "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
           "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
           "dev": true,
           "requires": {
-            "core-util-is": "1.0.2",
-            "inherits": "2.0.3",
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
             "isarray": "0.0.1",
-            "string_decoder": "0.10.31"
+            "string_decoder": "~0.10.x"
           }
         },
         "string_decoder": {
       "integrity": "sha1-3uUim98KtrogEqOV4bhpq/iBNHM=",
       "dev": true,
       "requires": {
-        "onetime": "1.1.0",
-        "set-immediate-shim": "1.0.1"
+        "onetime": "^1.0.0",
+        "set-immediate-shim": "^1.0.0"
       },
       "dependencies": {
         "onetime": {
       }
     },
     "ecc-jsbn": {
-      "version": "0.1.1",
-      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
-      "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=",
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
       "dev": true,
       "optional": true,
       "requires": {
-        "jsbn": "0.1.1"
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
       }
     },
     "ee-first": {
       "dev": true
     },
     "electron-to-chromium": {
-      "version": "1.3.45",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.45.tgz",
-      "integrity": "sha1-RYrBscXHYM6IEaFtK/vZfsMLr7g=",
+      "version": "1.3.70",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.70.tgz",
+      "integrity": "sha512-WYMjqCnPVS5JA+XvwEnpwucJpVi2+q9cdCFpbhxgWGsCtforFBEkuP9+nCyy/wnU/0SyLcLRIeZct9ayMGcXoQ==",
       "dev": true
     },
     "entities": {
       "dev": true,
       "optional": true,
       "requires": {
-        "prr": "1.0.1"
+        "prr": "~1.0.1"
       }
     },
     "error-ex": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
-      "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=",
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
       "dev": true,
       "requires": {
-        "is-arrayish": "0.2.1"
+        "is-arrayish": "^0.2.1"
       }
     },
     "escape-string-regexp": {
       "integrity": "sha1-Dk/xcV8yh3XWyrUaxEpAbNer/9M=",
       "dev": true,
       "requires": {
-        "esprima": "1.0.4",
-        "estraverse": "1.3.2",
-        "source-map": "0.5.7"
+        "esprima": "~1.0.2",
+        "estraverse": "~1.3.0",
+        "source-map": ">= 0.1.2"
       },
       "dependencies": {
         "esprima": {
       "integrity": "sha1-3cf8f9cL+TIFsLNEm7FqHp59SVA=",
       "dev": true,
       "requires": {
-        "ajv": "5.5.2",
-        "babel-code-frame": "6.26.0",
-        "chalk": "2.4.1",
-        "concat-stream": "1.6.2",
-        "cross-spawn": "5.1.0",
-        "debug": "2.6.9",
-        "doctrine": "2.1.0",
-        "eslint-scope": "3.7.1",
-        "espree": "3.5.4",
-        "esquery": "1.0.1",
-        "estraverse": "4.2.0",
-        "esutils": "2.0.2",
-        "file-entry-cache": "2.0.0",
-        "functional-red-black-tree": "1.0.1",
-        "glob": "7.1.2",
-        "globals": "9.18.0",
-        "ignore": "3.3.8",
-        "imurmurhash": "0.1.4",
-        "inquirer": "3.3.0",
-        "is-resolvable": "1.1.0",
-        "js-yaml": "3.11.0",
-        "json-stable-stringify": "1.0.1",
-        "levn": "0.3.0",
-        "lodash": "4.17.10",
-        "minimatch": "3.0.4",
-        "mkdirp": "0.5.1",
-        "natural-compare": "1.4.0",
-        "optionator": "0.8.2",
-        "path-is-inside": "1.0.2",
-        "pluralize": "4.0.0",
-        "progress": "2.0.0",
-        "require-uncached": "1.0.3",
-        "semver": "5.3.0",
-        "strip-ansi": "4.0.0",
-        "strip-json-comments": "2.0.1",
-        "table": "4.0.3",
-        "text-table": "0.2.0"
+        "ajv": "^5.2.0",
+        "babel-code-frame": "^6.22.0",
+        "chalk": "^2.1.0",
+        "concat-stream": "^1.6.0",
+        "cross-spawn": "^5.1.0",
+        "debug": "^2.6.8",
+        "doctrine": "^2.0.0",
+        "eslint-scope": "^3.7.1",
+        "espree": "^3.5.0",
+        "esquery": "^1.0.0",
+        "estraverse": "^4.2.0",
+        "esutils": "^2.0.2",
+        "file-entry-cache": "^2.0.0",
+        "functional-red-black-tree": "^1.0.1",
+        "glob": "^7.1.2",
+        "globals": "^9.17.0",
+        "ignore": "^3.3.3",
+        "imurmurhash": "^0.1.4",
+        "inquirer": "^3.0.6",
+        "is-resolvable": "^1.0.0",
+        "js-yaml": "^3.9.1",
+        "json-stable-stringify": "^1.0.1",
+        "levn": "^0.3.0",
+        "lodash": "^4.17.4",
+        "minimatch": "^3.0.2",
+        "mkdirp": "^0.5.1",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.8.2",
+        "path-is-inside": "^1.0.2",
+        "pluralize": "^4.0.0",
+        "progress": "^2.0.0",
+        "require-uncached": "^1.0.3",
+        "semver": "^5.3.0",
+        "strip-ansi": "^4.0.0",
+        "strip-json-comments": "~2.0.1",
+        "table": "^4.0.1",
+        "text-table": "~0.2.0"
       }
     },
     "eslint-plugin-promise": {
       "dev": true
     },
     "eslint-scope": {
-      "version": "3.7.1",
-      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz",
-      "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=",
+      "version": "3.7.3",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz",
+      "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==",
       "dev": true,
       "requires": {
-        "esrecurse": "4.2.1",
-        "estraverse": "4.2.0"
+        "esrecurse": "^4.1.0",
+        "estraverse": "^4.1.1"
       }
     },
     "espree": {
       "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==",
       "dev": true,
       "requires": {
-        "acorn": "5.5.3",
-        "acorn-jsx": "3.0.1"
+        "acorn": "^5.5.0",
+        "acorn-jsx": "^3.0.0"
       }
     },
     "esprima": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz",
-      "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==",
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
       "dev": true
     },
     "esquery": {
       "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==",
       "dev": true,
       "requires": {
-        "estraverse": "4.2.0"
+        "estraverse": "^4.0.0"
       }
     },
     "esrecurse": {
       "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==",
       "dev": true,
       "requires": {
-        "estraverse": "4.2.0"
+        "estraverse": "^4.1.0"
       }
     },
     "estraverse": {
     },
     "eventemitter2": {
       "version": "0.4.14",
-      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz",
+      "resolved": "http://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz",
       "integrity": "sha1-j2G3XN4BKy6esoTUVFWDtWQ7Yas=",
       "dev": true
     },
       "integrity": "sha1-c9CQTjlbPKsGWLCNCewlMH8pu3M=",
       "dev": true,
       "requires": {
-        "clone-regexp": "1.0.1"
+        "clone-regexp": "^1.0.0"
       }
     },
     "exit": {
       "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=",
       "dev": true,
       "requires": {
-        "is-posix-bracket": "0.1.1"
+        "is-posix-bracket": "^0.1.0"
       }
     },
     "expand-range": {
       "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=",
       "dev": true,
       "requires": {
-        "fill-range": "2.2.3"
+        "fill-range": "^2.1.0"
       }
     },
     "extend": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-      "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
       "dev": true
     },
     "external-editor": {
       "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz",
+      "resolved": "http://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz",
       "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==",
       "dev": true,
       "requires": {
-        "chardet": "0.4.2",
-        "iconv-lite": "0.4.21",
-        "tmp": "0.0.33"
+        "chardet": "^0.4.0",
+        "iconv-lite": "^0.4.17",
+        "tmp": "^0.0.33"
       }
     },
     "extglob": {
       "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=",
       "dev": true,
       "requires": {
-        "is-extglob": "1.0.0"
+        "is-extglob": "^1.0.0"
       }
     },
     "extsprintf": {
       "integrity": "sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=",
       "dev": true,
       "requires": {
-        "websocket-driver": "0.7.0"
+        "websocket-driver": ">=0.5.1"
       }
     },
     "figures": {
       "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=",
       "dev": true,
       "requires": {
-        "escape-string-regexp": "1.0.5"
+        "escape-string-regexp": "^1.0.5"
       }
     },
     "file-entry-cache": {
       "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=",
       "dev": true,
       "requires": {
-        "flat-cache": "1.3.0",
-        "object-assign": "4.1.1"
+        "flat-cache": "^1.2.1",
+        "object-assign": "^4.0.1"
       }
     },
     "filename-regex": {
       "integrity": "sha1-UGuRqTluqn4y+0KoQHfHoMc2t0E=",
       "dev": true,
       "requires": {
-        "glob": "3.2.11",
-        "minimatch": "0.4.0"
+        "glob": "3.x",
+        "minimatch": "0.x"
       },
       "dependencies": {
         "glob": {
           "integrity": "sha1-Spc/Y1uRkPcV0QmH1cAP0oFevj0=",
           "dev": true,
           "requires": {
-            "inherits": "2.0.3",
-            "minimatch": "0.3.0"
+            "inherits": "2",
+            "minimatch": "0.3"
           },
           "dependencies": {
             "minimatch": {
               "integrity": "sha1-J12O2qxPG7MyZHIInnlJyDlGmd0=",
               "dev": true,
               "requires": {
-                "lru-cache": "2.7.3",
-                "sigmund": "1.0.1"
+                "lru-cache": "2",
+                "sigmund": "~1.0.0"
               }
             }
           }
           "integrity": "sha1-vSx9Bg0sjI/Xzefx8u0tWycP2xs=",
           "dev": true,
           "requires": {
-            "lru-cache": "2.7.3",
-            "sigmund": "1.0.1"
+            "lru-cache": "2",
+            "sigmund": "~1.0.0"
           }
         }
       }
     },
    &nbs