Merge branch 'MDL-55283-master' of git://github.com/andrewnicols/moodle
authorDavid Monllao <davidm@moodle.com>
Mon, 25 Jul 2016 21:04:03 +0000 (05:04 +0800)
committerDavid Monllao <davidm@moodle.com>
Mon, 25 Jul 2016 21:04:03 +0000 (05:04 +0800)
191 files changed:
.eslintignore
admin/cli/reset_password.php
admin/searchareas.php
admin/tool/messageinbound/classes/manager.php
admin/tool/task/cli/schedule_task.php
auth/db/auth.php
auth/db/tests/db_test.php
blocks/navigation/renderer.php
blocks/settings/renderer.php
calendar/tests/behat/export.feature
course/externallib.php
course/tests/courselib_test.php
course/tests/externallib_test.php
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
enrol/imsenterprise/lib.php
enrol/imsenterprise/settings.php
enrol/imsenterprise/tests/imsenterprise_test.php
enrol/imsenterprise/version.php
install/lang/ca/error.php
install/lang/ca/langconfig.php
lang/en/admin.php
lang/en/moodle.php
lang/en/search.php
lib/adminlib.php
lib/adodb/drivers/adodb-oci8po.inc.php
lib/adodb/readme_moodle.txt
lib/amd/build/chart_axis.min.js [new file with mode: 0644]
lib/amd/build/chart_bar.min.js [new file with mode: 0644]
lib/amd/build/chart_base.min.js [new file with mode: 0644]
lib/amd/build/chart_builder.min.js [new file with mode: 0644]
lib/amd/build/chart_line.min.js [new file with mode: 0644]
lib/amd/build/chart_output.min.js [new file with mode: 0644]
lib/amd/build/chart_output_base.min.js [new file with mode: 0644]
lib/amd/build/chart_output_chartjs.min.js [new file with mode: 0644]
lib/amd/build/chart_output_htmltable.min.js [new file with mode: 0644]
lib/amd/build/chart_pie.min.js [new file with mode: 0644]
lib/amd/build/chart_series.min.js [new file with mode: 0644]
lib/amd/build/chartjs-lazy.min.js [new file with mode: 0644]
lib/amd/build/chartjs.min.js [new file with mode: 0644]
lib/amd/build/event.min.js
lib/amd/build/tag.min.js
lib/amd/build/templates.min.js
lib/amd/src/chart_axis.js [new file with mode: 0644]
lib/amd/src/chart_bar.js [new file with mode: 0644]
lib/amd/src/chart_base.js [new file with mode: 0644]
lib/amd/src/chart_builder.js [new file with mode: 0644]
lib/amd/src/chart_line.js [new file with mode: 0644]
lib/amd/src/chart_output.js [new file with mode: 0644]
lib/amd/src/chart_output_base.js [new file with mode: 0644]
lib/amd/src/chart_output_chartjs.js [new file with mode: 0644]
lib/amd/src/chart_output_htmltable.js [new file with mode: 0644]
lib/amd/src/chart_pie.js [new file with mode: 0644]
lib/amd/src/chart_series.js [new file with mode: 0644]
lib/amd/src/chartjs-lazy.js [new file with mode: 0644]
lib/amd/src/chartjs.js [new file with mode: 0644]
lib/amd/src/event.js
lib/amd/src/tag.js
lib/amd/src/templates.js
lib/boxlib.php
lib/classes/chart_axis.php [new file with mode: 0644]
lib/classes/chart_bar.php [new file with mode: 0644]
lib/classes/chart_base.php [new file with mode: 0644]
lib/classes/chart_line.php [new file with mode: 0644]
lib/classes/chart_pie.php [new file with mode: 0644]
lib/classes/chart_series.php [new file with mode: 0644]
lib/classes/event/dashboard_reset.php [new file with mode: 0644]
lib/classes/event/dashboard_viewed.php [new file with mode: 0644]
lib/classes/event/dashboards_reset.php [new file with mode: 0644]
lib/db/services.php
lib/deprecatedlib.php
lib/dml/oci_native_moodle_database.php
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js
lib/editor/atto/yui/src/editor/js/autosave.js
lib/editor/atto/yui/src/editor/js/clean.js
lib/filelib.php
lib/jquery/jquery-1.12.1.min.js [deleted file]
lib/jquery/jquery-3.1.0.js [moved from lib/jquery/jquery-1.12.1.js with 67% similarity]
lib/jquery/jquery-3.1.0.min.js [new file with mode: 0644]
lib/jquery/jquery-migrate-1.4.0.js [deleted file]
lib/jquery/jquery-migrate-1.4.0.min.js [deleted file]
lib/jquery/plugins.php
lib/jquery/readme_moodle.txt
lib/moodlelib.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/password_compat/lib/password.php
lib/password_compat/readme_moodle.txt [deleted file]
lib/password_compat/tests/PasswordGetInfoTest.php [deleted file]
lib/password_compat/tests/PasswordHashTest.php [deleted file]
lib/password_compat/tests/PasswordNeedsRehashTest.php [deleted file]
lib/password_compat/tests/PasswordVerifyTest.php [deleted file]
lib/requirejs/moodle-config.js
lib/templates/chart.mustache [new file with mode: 0644]
lib/testing/classes/util.php
lib/tests/behat/behat_forms.php
lib/tests/filelib_test.php
lib/tests/jquery_test.php
lib/tests/other/jquerypage.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/yui/build/moodle-core-event/moodle-core-event-debug.js
lib/yui/build/moodle-core-event/moodle-core-event-min.js
lib/yui/build/moodle-core-event/moodle-core-event.js
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker-debug.js
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker-min.js
lib/yui/build/moodle-core-formchangechecker/moodle-core-formchangechecker.js
lib/yui/src/event/js/event.js
lib/yui/src/formchangechecker/js/formchangechecker.js
lib/yui/src/formchangechecker/meta/formchangechecker.json
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/src/grading_panel.js
mod/assign/db/services.php
mod/assign/externallib.php
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/annotationstamp.js
mod/assign/feedback/editpdf/yui/src/editor/js/comment.js
mod/assign/locallib.php
mod/assign/tests/externallib_test.php
mod/assign/version.php
mod/assign/view.php
mod/chat/lib.php
mod/chat/locallib.php
mod/choice/lib.php
mod/choice/renderer.php
mod/data/db/access.php
mod/data/lang/en/data.php
mod/data/lib.php
mod/data/locallib.php
mod/data/rsslib.php
mod/data/tests/lib_test.php
mod/data/version.php
mod/feedback/analysis.php
mod/feedback/item/info/lib.php
mod/feedback/item/multichoice/lib.php
mod/feedback/item/multichoicerated/lib.php
mod/feedback/item/numeric/lib.php
mod/feedback/item/textarea/lib.php
mod/feedback/item/textfield/lib.php
mod/feedback/lang/en/deprecated.txt
mod/feedback/lang/en/feedback.php
mod/feedback/lib.php
mod/feedback/tests/lib_test.php [new file with mode: 0644]
mod/forum/deprecatedlib.php
mod/forum/externallib.php
mod/forum/tests/subscriptions_test.php
mod/forum/upgrade.txt
mod/lesson/db/access.php
mod/lesson/lang/en/lesson.php
mod/lesson/version.php
mod/quiz/renderer.php
mod/quiz/report/overview/overviewgraph.php
mod/quiz/report/overview/report.php
mod/quiz/report/overview/tests/report_test.php
mod/quiz/report/statistics/report.php
mod/quiz/report/statistics/statistics_graph.php
mod/quiz/report/statistics/statisticslib.php
mod/quiz/report/statistics/upgrade.txt [new file with mode: 0644]
mod/scorm/report/graphs/classes/report.php
mod/scorm/report/graphs/graph.php
mod/survey/lang/en/survey.php
mod/survey/report.php
my/index.php
my/lib.php
my/tests/events_test.php [new file with mode: 0644]
phpunit.xml.dist
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-debug.js
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-min.js
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form.js
question/type/ddmarker/yui/src/form/js/form.js
report/courseoverview/index.php
report/courseoverview/locallib.php [new file with mode: 0644]
report/courseoverview/reportsgraph.php
report/log/graph.php
report/log/locallib.php
report/log/user.php
report/stats/graph.php
report/stats/locallib.php
report/stats/user.php
report/upgrade.txt
search/classes/manager.php
search/engine/solr/classes/engine.php
search/index.php
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/renderers/core_renderer.php
theme/bootstrapbase/style/moodle.css
user/lib.php
version.php

index 4c89fdb..2d7ae1b 100644 (file)
@@ -24,7 +24,6 @@ lib/htmlpurifier/
 lib/jabber/
 lib/minify/
 lib/flowplayer/
-lib/password_compat/
 lib/pear/Auth/RADIUS.php
 lib/pear/Crypt/CHAP.php
 lib/pear/HTML/Common.php
@@ -50,6 +49,7 @@ lib/mustache/
 lib/amd/src/mustache.js
 lib/graphlib.php
 lib/spout/
+lib/amd/src/chartjs-lazy.js
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/bootstrapbase/less/bootstrap/
index a1535d1..9d4dc14 100644 (file)
@@ -68,14 +68,14 @@ Options:
 Example:
 \$sudo -u www-data /usr/bin/php admin/cli/reset_password.php
 \$sudo -u www-data /usr/bin/php admin/cli/reset_password.php --username=rosaura --password=jiu3jiu --ignore-password-policy
-"; //TODO: localize - to be translated later when everything is finished
+";
 
     echo $help;
     die;
 }
 if ($options['username'] == '' ) {
-    cli_heading('Password reset'); // TODO: localize.
-    $prompt = "enter username (manual authentication only)"; // TODO: localize.
+    cli_heading('Password reset');
+    $prompt = "Enter username (manual authentication only)";
     $username = cli_input($prompt);
 } else {
     $username = $options['username'];
@@ -86,7 +86,7 @@ if (!$user = $DB->get_record('user', array('auth'=>'manual', 'username'=>$userna
 }
 
 if ($options['password'] == '' ) {
-    $prompt = "Enter new password"; // TODO: localize.
+    $prompt = "Enter new password";
     $password = cli_input($prompt);
 } else {
     $password = $options['password'];
@@ -95,7 +95,7 @@ if ($options['password'] == '' ) {
 $errmsg = '';//prevent eclipse warning
 if (!$options['ignore-password-policy'] ) {
     if (!check_password_policy($password, $errmsg)) {
-        cli_error($errmsg);
+        cli_error(html_to_text($errmsg, 0));
     }
 }
 
index 884d5b8..dcbc7cb 100644 (file)
@@ -95,7 +95,7 @@ if (empty($searchmanagererror)) {
 }
 
 if (!empty($searchmanagererror)) {
-    $errorstr = get_string($searchmanagererror->errorcode, $searchmanagererror->module);
+    $errorstr = get_string($searchmanagererror->errorcode, $searchmanagererror->module, $searchmanagererror->a);
     echo $OUTPUT->notification($errorstr, \core\output\notification::NOTIFY_ERROR);
 } else {
     echo $OUTPUT->notification(get_string('indexinginfo', 'admin'), \core\output\notification::NOTIFY_INFO);
index 8bb57ac..a352f7c 100644 (file)
@@ -103,6 +103,15 @@ class manager {
             'debug'    => empty($CFG->debugimap) ? null : fopen('php://stderr', 'w'),
         );
 
+        if (strpos($configuration['hostspec'], ':')) {
+            $hostdata = explode(':', $configuration['hostspec']);
+            if (count($hostdata) === 2) {
+                // A hostname in the format hostname:port has been provided.
+                $configuration['hostspec'] = $hostdata[0];
+                $configuration['port'] = $hostdata[1];
+            }
+        }
+
         $this->client = new \Horde_Imap_Client_Socket($configuration);
 
         try {
index 545e190..7e94fcf 100644 (file)
@@ -29,7 +29,7 @@ require_once("$CFG->libdir/clilib.php");
 require_once("$CFG->libdir/cronlib.php");
 
 list($options, $unrecognized) = cli_get_params(
-    array('help' => false, 'list' => false, 'execute' => false),
+    array('help' => false, 'list' => false, 'execute' => false, 'showsql' => false, 'showdebugging' => false),
     array('h' => 'help')
 );
 
@@ -45,6 +45,8 @@ if ($options['help'] or (!$options['list'] and !$options['execute'])) {
 Options:
 --execute=\\\\some\\\\task  Execute scheduled task manually
 --list                List all scheduled tasks
+--showsql             Show sql queries before they are executed
+--showdebugging       Show developer level debugging information
 -h, --help            Print out this help
 
 Example:
@@ -56,6 +58,13 @@ Example:
     die;
 }
 
+if ($options['showdebugging']) {
+    set_debugging(DEBUG_DEVELOPER, true);
+}
+
+if ($options['showsql']) {
+    $DB->set_debug(true);
+}
 if ($options['list']) {
     cli_heading("List of scheduled tasks ($CFG->wwwroot)");
 
index b1fa091..8048a30 100644 (file)
@@ -136,7 +136,6 @@ class auth_plugin_db extends auth_plugin_base {
             } else if ($this->config->passtype === 'sha1') {
                 return (strtolower($fromdb) == sha1($extpassword));
             } else if ($this->config->passtype === 'saltedcrypt') {
-                require_once($CFG->libdir.'/password_compat/lib/password.php');
                 return password_verify($extpassword, $fromdb);
             } else {
                 return false;
index f1471cf..09a287e 100644 (file)
@@ -308,7 +308,6 @@ class auth_db_testcase extends advanced_testcase {
         $DB->update_record('auth_db_users', $user3);
         $this->assertTrue($auth->user_login('u3', 'heslo'));
 
-        require_once($CFG->libdir.'/password_compat/lib/password.php');
         set_config('passtype', 'saltedcrypt', 'auth/db');
         $auth->config->passtype = 'saltedcrypt';
         $user3->pass = password_hash('heslo', PASSWORD_BCRYPT);
index 8ead8fe..c64e55d 100644 (file)
@@ -86,7 +86,7 @@ class block_navigation_renderer extends plugin_renderer_base {
                 continue;
             }
 
-            $id = $item->id ? $item->id : uniqid();
+            $id = $item->id ? $item->id : html_writer::random_id();
             $content = $item->get_content();
             $title = $item->get_title();
             $ulattr = ['id' => $id . '_group', 'role' => 'group'];
index 10afa75..34e99ef 100644 (file)
@@ -75,7 +75,7 @@ class block_settings_renderer extends plugin_renderer_base {
             }
 
             $content = $this->output->render($item);
-            $id = $item->id ? $item->id : uniqid();
+            $id = $item->id ? $item->id : html_writer::random_id();
             $ulattr = ['id' => $id . '_group', 'role' => 'group'];
             $liattr = ['class' => [$item->get_css_type(), 'depth_'.$depth], 'tabindex' => '-1'];
             $pattr = ['class' => ['tree_item'], 'role' => 'treeitem'];
index 97d9d73..c7e9cec 100644 (file)
@@ -28,7 +28,7 @@ Feature: Export calendar events
     Given I follow "This month"
     And I click on "Export calendar" "button"
     And I set the field "All events" to "1"
-    And I set the field "This week" to "1"
+    And I set the field "Recent and next 60 days" to "1"
     When I click on "Get calendar URL" "button"
     Then I should see "&preset_what=all&"
 
@@ -36,7 +36,7 @@ Feature: Export calendar events
     Given I follow "This month"
     And I click on "Export calendar" "button"
     And I set the field "Events related to courses" to "1"
-    And I set the field "This week" to "1"
+    And I set the field "Recent and next 60 days" to "1"
     When I click on "Get calendar URL" "button"
     Then I should see "&preset_what=courses&"
 
@@ -44,7 +44,7 @@ Feature: Export calendar events
     Given I follow "This month"
     And I click on "Export calendar" "button"
     And I set the field "Events related to groups" to "1"
-    And I set the field "This week" to "1"
+    And I set the field "Recent and next 60 days" to "1"
     When I click on "Get calendar URL" "button"
     Then I should see "&preset_what=groups&"
 
@@ -52,6 +52,6 @@ Feature: Export calendar events
     Given I follow "This month"
     And I click on "Export calendar" "button"
     And I set the field "My personal events" to "1"
-    And I set the field "This week" to "1"
+    And I set the field "Recent and next 60 days" to "1"
     When I click on "Get calendar URL" "button"
     Then I should see "&preset_what=user&"
index 3458723..0f2aaf6 100644 (file)
@@ -2501,4 +2501,104 @@ class core_course_external extends external_api {
         return self::get_course_module_returns();
     }
 
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.2
+     */
+    public static function get_activities_overview_parameters() {
+        return new external_function_parameters(
+            array(
+                'courseids' => new external_multiple_structure(new external_value(PARAM_INT, 'Course id.')),
+            )
+        );
+    }
+
+    /**
+     * Return activities overview for the given courses.
+     *
+     * @param array $courseids a list of course ids
+     * @return array of warnings and the activities overview
+     * @since Moodle 3.2
+     * @throws moodle_exception
+     */
+    public static function get_activities_overview($courseids) {
+        global $USER;
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::get_activities_overview_parameters(), array('courseids' => $courseids));
+        $courseoverviews = array();
+
+        list($courses, $warnings) = external_util::validate_courses($params['courseids']);
+
+        if (!empty($courses)) {
+            // Add lastaccess to each course (required by print_overview function).
+            // We need the complete user data, the ws server does not load a complete one.
+            $user = get_complete_user_data('id', $USER->id);
+            foreach ($courses as $course) {
+                if (isset($user->lastcourseaccess[$course->id])) {
+                    $course->lastaccess = $user->lastcourseaccess[$course->id];
+                } else {
+                    $course->lastaccess = 0;
+                }
+            }
+
+            $overviews = array();
+            if ($modules = get_plugin_list_with_function('mod', 'print_overview')) {
+                foreach ($modules as $fname) {
+                    $fname($courses, $overviews);
+                }
+            }
+
+            // Format output.
+            foreach ($overviews as $courseid => $modules) {
+                $courseoverviews[$courseid]['id'] = $courseid;
+                $courseoverviews[$courseid]['overviews'] = array();
+
+                foreach ($modules as $modname => $overviewtext) {
+                    $courseoverviews[$courseid]['overviews'][] = array(
+                        'module' => $modname,
+                        'overviewtext' => $overviewtext // This text doesn't need formatting.
+                    );
+                }
+            }
+        }
+
+        $result = array(
+            'courses' => $courseoverviews,
+            'warnings' => $warnings
+        );
+        return $result;
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.2
+     */
+    public static function get_activities_overview_returns() {
+        return new external_single_structure(
+            array(
+                'courses' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'id' => new external_value(PARAM_INT, 'Course id'),
+                            'overviews' => new external_multiple_structure(
+                                new external_single_structure(
+                                    array(
+                                        'module' => new external_value(PARAM_PLUGIN, 'Module name'),
+                                        'overviewtext' => new external_value(PARAM_RAW, 'Overview text'),
+                                    )
+                                )
+                            )
+                        )
+                    ), 'List of courses'
+                ),
+                'warnings' => new external_warnings()
+            )
+        );
+    }
+
 }
index f0d4869..4c9ed73 100644 (file)
@@ -1622,6 +1622,7 @@ class core_course_courselib_testcase extends advanced_testcase {
         ob_end_clean();
 
         // Create the XML file we want to use.
+        $course->category = (array)$course->category;
         $imstestcase = new enrol_imsenterprise_testcase();
         $imstestcase->imsplugin = enrol_get_plugin('imsenterprise');
         $imstestcase->set_test_config();
@@ -1632,7 +1633,13 @@ class core_course_courselib_testcase extends advanced_testcase {
         $imstestcase->imsplugin->cron();
         $events = $sink->get_events();
         $sink->close();
-        $event = $events[0];
+        $event = null;
+        foreach ($events as $eventinfo) {
+            if ($eventinfo instanceof \core\event\course_created ) {
+                $event = $eventinfo;
+                break;
+            }
+        }
 
         // Validate the event triggered is \core\event\course_created. There is no need to validate the other values
         // as they have already been validated in the previous steps. Here we only want to make sure that when the
index 71372e7..21c38bd 100644 (file)
@@ -724,6 +724,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $context = context_course::instance($course->id);
         $roleid = $this->assignUserCapability('moodle/course:view', $context->id);
         $this->assignUserCapability('moodle/course:update', $context->id, $roleid);
+        $this->assignUserCapability('mod/data:view', $context->id, $roleid);
 
         $conditions = array('course' => $course->id, 'section' => 2);
         $DB->set_field('course_sections', 'summary', 'Text with iframe <iframe src="https://moodle.org"></iframe>', $conditions);
@@ -1691,4 +1692,47 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         }
 
     }
+
+    /**
+     * Test get_activities_overview
+     */
+    public function test_get_activities_overview() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $course1 = self::getDataGenerator()->create_course();
+        $course2 = self::getDataGenerator()->create_course();
+
+        // Create a viewer user.
+        $viewer = self::getDataGenerator()->create_user((object) array('trackforums' => 1));
+        $this->getDataGenerator()->enrol_user($viewer->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($viewer->id, $course2->id);
+
+        // Create two forums - one in each course.
+        $record = new stdClass();
+        $record->course = $course1->id;
+        $forum1 = self::getDataGenerator()->create_module('forum', (object) array('course' => $course1->id));
+        $forum2 = self::getDataGenerator()->create_module('forum', (object) array('course' => $course2->id));
+
+        $this->setAdminUser();
+        // A standard post in the forum.
+        $record = new stdClass();
+        $record->course = $course1->id;
+        $record->userid = $USER->id;
+        $record->forum = $forum1->id;
+        $this->getDataGenerator()->get_plugin_generator('mod_forum')->create_discussion($record);
+
+        $this->setUser($viewer->id);
+        $courses = array($course1->id , $course2->id);
+
+        $result = core_course_external::get_activities_overview($courses);
+        $result = external_api::clean_returnvalue(core_course_external::get_activities_overview_returns(), $result);
+
+        // There should be one entry for course1, and no others.
+        $this->assertCount(1, $result['courses']);
+        $this->assertEquals($course1->id, $result['courses'][0]['id']);
+        // Check expected overview data for the module.
+        $this->assertEquals('forum', $result['courses'][0]['overviews'][0]['module']);
+        $this->assertContains('1 total unread', $result['courses'][0]['overviews'][0]['overviewtext']);
+    }
 }
index 515a1f1..4cebfe9 100644 (file)
@@ -26,6 +26,10 @@ $string['aftersaving...'] = 'Once you have saved your settings, you may wish to'
 $string['allowunenrol'] = 'Allow the IMS data to <strong>unenrol</strong> students/teachers';
 $string['allowunenrol_desc'] = 'If enabled, course enrolments will be removed when specified in the Enterprise data.';
 $string['basicsettings'] = 'Basic settings';
+$string['categoryidnumber'] = 'Allow category idnumber';
+$string['categoryidnumber_desc'] = 'If enabled IMS Enterprise will create category with idnumber';
+$string['categoryseparator'] = 'Category Separator Character';
+$string['categoryseparator_desc'] = 'Required when "Category idnumber" is enabled. Character to separate the category name and idnumber.';
 $string['coursesettings'] = 'Course data options';
 $string['createnewcategories'] = 'Create new (hidden) course categories if not found in Moodle';
 $string['createnewcategories_desc'] = 'If the <org><orgunit> element is present in a course\'s incoming data, its content will be used to specify a category if the course is to be created from scratch. The plugin will NOT re-categorise existing courses.
@@ -55,6 +59,8 @@ $string['mailadmins'] = 'Notify admin by email';
 $string['mailusers'] = 'Notify users by email';
 $string['messageprovider:imsenterprise_enrolment'] = 'IMS Enterprise enrolment messages';
 $string['miscsettings'] = 'Miscellaneous';
+$string['nestedcategories'] = 'Allow nested categories';
+$string['nestedcategories_desc'] = 'If enabled IMS Enterprise will create nested categories';
 $string['pluginname'] = 'IMS Enterprise file';
 $string['pluginname_desc'] = 'This method will repeatedly check for and process a specially-formatted text file in the location that you specify.  The file must follow the IMS Enterprise specifications containing person, group, and membership XML elements.';
 $string['processphoto'] = 'Add user photo data to profile';
@@ -75,6 +81,10 @@ $string['sourcedidfallback_desc'] = 'In IMS data, the <sourcedid> field represen
 Some student information systems fail to output the <userid> field. If this is the case, you should enable this setting to allow for using the <sourcedid> as the Moodle user ID. Otherwise, leave this setting disabled.';
 $string['truncatecoursecodes'] = 'Truncate course codes to this length';
 $string['truncatecoursecodes_desc'] = 'In some situations you may have course codes which you wish to truncate to a specified length before processing. If so, enter the number of characters in this box. Otherwise, leave the box blank and no truncation will occur.';
+$string['updatecourses'] = 'Update course';
+$string['updatecourses_desc'] = 'If enabled, the IMS Enterprise enrolment plugin can update course full and short names (if the "recstatus" flag is set to 2, which represents an update).';
+$string['updateusers'] = 'Update user accounts when specified in IMS data';
+$string['updateusers_desc'] = 'If enabled, IMS Enterprise enrolment data can specify changes to user accounts (if the "recstatus" flag is set to 2, which represents an update).';
 $string['usecapitafix'] = 'Tick this box if using &quot;Capita&quot; (their XML format is slightly wrong)';
 $string['usecapitafix_desc'] = 'The student data system produced by Capita has been found to have one slight error in its XML output. If you are using Capita you should enable this setting - otherwise leave it un-ticked.';
 $string['usersettings'] = 'User data options';
index 56c4d06..ede2700 100644 (file)
@@ -29,7 +29,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot.'/group/lib.php');
-
+require_once($CFG->dirroot.'/lib/coursecatlib.php');
 
 /**
  * IMS Enterprise file enrolment plugin.
@@ -39,6 +39,21 @@ require_once($CFG->dirroot.'/group/lib.php');
  */
 class enrol_imsenterprise_plugin extends enrol_plugin {
 
+    /**
+     * @var IMSENTERPRISE_ADD imsenterprise add action.
+     */
+    const IMSENTERPRISE_ADD = 1;
+
+    /**
+     * @var IMSENTERPRISE_UPDATE imsenterprise update action.
+     */
+    const IMSENTERPRISE_UPDATE = 2;
+
+    /**
+     * @var IMSENTERPRISE_DELETE imsenterprise delete action.
+     */
+    const IMSENTERPRISE_DELETE = 3;
+
     /**
      * @var $logfp resource file pointer for writing log data to.
      */
@@ -64,6 +79,11 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
      */
     protected $rolemappings;
 
+    /**
+     * @var $defaultcategoryid id of default category.
+     */
+    protected $defaultcategoryid;
+
     /**
      * Read in an IMS Enterprise file.
      * Originally designed to handle v1.1 files but should be able to handle
@@ -93,6 +113,8 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             $this->logfp = fopen($logtolocation, 'a');
         }
 
+        $this->defaultcategoryid = null;
+
         $fileisnew = false;
         if ( file_exists($filename) ) {
             core_php_time_limit::raise();
@@ -103,6 +125,9 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             $this->log_line('Found file '.$filename);
             $this->xmlcache = '';
 
+            $categoryseparator = trim($this->get_config('categoryseparator'));
+            $categoryidnumber = $this->get_config('categoryidnumber');
+
             // Make sure we understand how to map the IMS-E roles to Moodle roles.
             $this->load_role_mappings();
             // Make sure we understand how to map the IMS-E course names to Moodle course names.
@@ -113,7 +138,9 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
 
             // Decide if we want to process the file (based on filepath, modification time, and MD5 hash)
             // This is so we avoid wasting the server's efforts processing a file unnecessarily.
-            if (empty($prevpath)  || ($filename != $prevpath)) {
+            if ($categoryidnumber && empty($categoryseparator)) {
+                $this->log_line('Category idnumber is enabled but category separator is not defined - skipping processing.');
+            } else if (empty($prevpath)  || ($filename != $prevpath)) {
                 $fileisnew = true;
             } else if (isset($prevtime) && ($filemtime <= $prevtime)) {
                 $this->log_line('File modification time is not more recent than last update - skipping processing.');
@@ -276,7 +303,7 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
         // Get configs.
         $truncatecoursecodes    = $this->get_config('truncatecoursecodes');
         $createnewcourses       = $this->get_config('createnewcourses');
-        $createnewcategories    = $this->get_config('createnewcategories');
+        $updatecourses          = $this->get_config('updatecourses');
 
         if ($createnewcourses) {
             require_once("$CFG->dirroot/course/lib.php");
@@ -288,18 +315,25 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             $group->coursecode = trim($matches[1]);
         }
 
+        $matches = array();
         if (preg_match('{<description>.*?<long>(.*?)</long>.*?</description>}is', $tagcontents, $matches)) {
             $group->long = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<description>.*?<short>(.*?)</short>.*?</description>}is', $tagcontents, $matches)) {
             $group->short = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<description>.*?<full>(.*?)</full>.*?</description>}is', $tagcontents, $matches)) {
             $group->full = trim($matches[1]);
         }
 
-        if (preg_match('{<org>.*?<orgunit>(.*?)</orgunit>.*?</org>}is', $tagcontents, $matches)) {
-            $group->category = trim($matches[1]);
+        if (preg_match('{<org>(.*?)</org>}is', $tagcontents, $matchesorg)) {
+            if (preg_match_all('{<orgunit>(.*?)</orgunit>}is', $matchesorg[1], $matchesorgunit)) {
+                $group->categories = array_map('trim', $matchesorgunit[1]);
+            }
         }
 
         $recstatus = ($this->get_recstatus($tagcontents, 'group'));
@@ -320,12 +354,13 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             // Third, check if the course(s) exist.
             foreach ($group->coursecode as $coursecode) {
                 $coursecode = trim($coursecode);
-                if (!$DB->get_field('course', 'id', array('idnumber' => $coursecode))) {
+                $dbcourse = $DB->get_record('course', array('idnumber' => $coursecode));
+                if (!$dbcourse) {
                     if (!$createnewcourses) {
                         $this->log_line("Course $coursecode not found in Moodle's course idnumbers.");
                     } else {
 
-                        // Create the (hidden) course(s) if not found
+                        // Create the (hidden) course(s) if not found.
                         $courseconfig = get_config('moodlecourse'); // Load Moodle Course shell defaults.
 
                         // New course.
@@ -360,28 +395,9 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
                         $course->enablecompletion = $courseconfig->enablecompletion;
                         // Insert default names for teachers/students, from the current language.
 
-                        // Handle course categorisation (taken from the group.org.orgunit field if present).
-                        if (!empty($group->category)) {
-                            // If the category is defined and exists in Moodle, we want to store it in that one.
-                            if ($catid = $DB->get_field('course_categories', 'id', array('name' => $group->category))) {
-                                $course->category = $catid;
-                            } else if ($createnewcategories) {
-                                // Else if we're allowed to create new categories, let's create this one.
-                                $newcat = new stdClass();
-                                $newcat->name = $group->category;
-                                $newcat->visible = 0;
-                                $catid = $DB->insert_record('course_categories', $newcat);
-                                $course->category = $catid;
-                                $this->log_line("Created new (hidden) category, #$catid: $newcat->name");
-                            } else {
-                                // If not found and not allowed to create, stick with default.
-                                $this->log_line('Category '.$group->category.' not found in Moodle database, so using '.
-                                    'default category instead.');
-                                $course->category = $this->get_default_category_id();
-                            }
-                        } else {
-                            $course->category = $this->get_default_category_id();
-                        }
+                        // Handle course categorisation (taken from the group.org.orgunit or group.org.id fields if present).
+                        $course->category = $this->get_category_from_group($group->categories);
+
                         $course->startdate = time();
                         // Choose a sort order that puts us at the start of the list!
                         $course->sortorder = 0;
@@ -390,9 +406,38 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
 
                         $this->log_line("Created course $coursecode in Moodle (Moodle ID is $course->id)");
                     }
-                } else if ($recstatus == 3 && ($courseid = $DB->get_field('course', 'id', array('idnumber' => $coursecode)))) {
+                } else if (($recstatus == self::IMSENTERPRISE_UPDATE) && $dbcourse) {
+                    if ($updatecourses) {
+                        // Update course. Allowed fields to be updated are:
+                        // Short Name, and Full Name.
+                        $hasupdates = false;
+                        if (!empty($group->short)) {
+                            if ($group->short != $dbcourse->shortname) {
+                                $dbcourse->shortname = $group->short;
+                                $hasupdates = true;
+                            }
+                        }
+                        if (!empty($group->full)) {
+                            if ($group->full != $dbcourse->fullname) {
+                                $dbcourse->fullname = $group->full;
+                                $hasupdates = true;
+                            }
+                        }
+                        if ($hasupdates) {
+                            update_course($dbcourse);
+                            $courseid = $dbcourse->id;
+                            $this->log_line("Updated course $coursecode in Moodle (Moodle ID is $courseid)");
+                        }
+                    } else {
+                        // Update courses option is not enabled. Ignore.
+                        $this->log_line("Ignoring update to course $coursecode");
+                    }
+                } else if (($recstatus == self::IMSENTERPRISE_DELETE) && $dbcourse) {
                     // If course does exist, but recstatus==3 (delete), then set the course as hidden.
-                    $DB->set_field('course', 'visible', '0', array('id' => $courseid));
+                    $courseid = $dbcourse->id;
+                    $show = false;
+                    course_change_visibility($courseid, $show);
+                    $this->log_line("Updated (set to hidden) course $coursecode in Moodle (Moodle ID is $courseid)");
                 }
             }
         }
@@ -412,34 +457,55 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
         $fixcasepersonalnames   = $this->get_config('fixcasepersonalnames');
         $imsdeleteusers         = $this->get_config('imsdeleteusers');
         $createnewusers         = $this->get_config('createnewusers');
+        $imsupdateusers         = $this->get_config('imsupdateusers');
 
         $person = new stdClass();
         if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
             $person->idnumber = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<name>.*?<n>.*?<given>(.+?)</given>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
             $person->firstname = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<name>.*?<n>.*?<family>(.+?)</family>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
             $person->lastname = trim($matches[1]);
         }
-        if (preg_match('{<userid>(.*?)</userid>}is', $tagcontents, $matches)) {
+
+        $matches = array();
+        if (preg_match('{<userid.*?>(.*?)</userid>}is', $tagcontents, $matches)) {
             $person->username = trim($matches[1]);
         }
+
+        $matches = array();
+        if (preg_match('{<userid\s+authenticationtype\s*=\s*"*(.+?)"*>.*?</userid>}is', $tagcontents, $matches)) {
+            $person->auth = trim($matches[1]);
+        }
+
         if ($imssourcedidfallback && trim($person->username) == '') {
-            // This is the point where we can fall back to useing the "sourcedid" if "userid" is not supplied
+            // This is the point where we can fall back to useing the "sourcedid" if "userid" is not supplied.
             // NB We don't use an "elseif" because the tag may be supplied-but-empty.
             $person->username = $person->idnumber;
         }
+
+        $matches = array();
         if (preg_match('{<email>(.*?)</email>}is', $tagcontents, $matches)) {
             $person->email = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<url>(.*?)</url>}is', $tagcontents, $matches)) {
             $person->url = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<adr>.*?<locality>(.+?)</locality>.*?</adr>}is', $tagcontents, $matches)) {
             $person->city = trim($matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<adr>.*?<country>(.+?)</country>.*?</adr>}is', $tagcontents, $matches)) {
             $person->country = trim($matches[1]);
         }
@@ -460,7 +526,7 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
         $recstatus = ($this->get_recstatus($tagcontents, 'person'));
 
         // Now if the recstatus is 3, we should delete the user if-and-only-if the setting for delete users is turned on.
-        if ($recstatus == 3) {
+        if ($recstatus == self::IMSENTERPRISE_DELETE) {
 
             if ($imsdeleteusers) { // If we're allowed to delete user records.
                 // Do not dare to hack the user.deleted field directly in database!!!
@@ -477,6 +543,18 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             } else {
                 $this->log_line("Ignoring deletion request for user '$person->username' (ID number $person->idnumber).");
             }
+        } else if ($recstatus == self::IMSENTERPRISE_UPDATE) { // Update user.
+            if ($imsupdateusers) {
+                if ($id = $DB->get_field('user', 'id', array('idnumber' => $person->idnumber))) {
+                    $person->id = $id;
+                    $DB->update_record('user', $person);
+                    $this->log_line("Updated user $person->username");
+                } else {
+                    $this->log_line("Ignoring update request for non-existent user $person->username");
+                }
+            } else {
+                $this->log_line("Ignoring update request for user $person->username");
+            }
 
         } else { // Add or update record.
 
@@ -494,9 +572,11 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
                     // If they don't exist and they have a defined username, and $createnewusers == true, we create them.
                     $person->lang = $CFG->lang;
                     // TODO: MDL-15863 this needs more work due to multiauth changes, use first auth for now.
-                    $auth = explode(',', $CFG->auth);
-                    $auth = reset($auth);
-                    $person->auth = $auth;
+                    if (empty($person->auth)) {
+                        $auth = explode(',', $CFG->auth);
+                        $auth = reset($auth);
+                        $person->auth = $auth;
+                    }
                     $person->confirmed = 1;
                     $person->timemodified = time();
                     $person->mnethostid = $CFG->mnet_localhost_id;
@@ -550,9 +630,12 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
             foreach ($membermatches as $mmatch) {
                 $member = new stdClass();
                 $memberstoreobj = new stdClass();
+                $matches = array();
                 if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $mmatch[1], $matches)) {
                     $member->idnumber = trim($matches[1]);
                 }
+
+                $matches = array();
                 if (preg_match('{<role\s+roletype=["\'](.+?)["\'].*?>}is', $mmatch[1], $matches)) {
                     // 01 means Student, 02 means Instructor, 3 means ContentDeveloper, and there are more besides.
                     $member->roletype = trim($matches[1]);
@@ -562,13 +645,15 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
                     // and there are more besides.
                     $member->roletype = trim($matches[1]);
                 }
+
+                $matches = array();
                 if (preg_match('{<role\b.*?<status>(.+?)</status>.*?</role>}is', $mmatch[1], $matches)) {
                     // 1 means active, 0 means inactive - treat this as enrol vs unenrol.
                     $member->status = trim($matches[1]);
                 }
 
                 $recstatus = ($this->get_recstatus($mmatch[1], 'role'));
-                if ($recstatus == 3) {
+                if ($recstatus == self::IMSENTERPRISE_DELETE) {
                     // See above - recstatus of 3 (==delete) is treated the same as status of 0.
                     $member->status = 0;
                 }
@@ -576,9 +661,12 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
                 $timeframe = new stdClass();
                 $timeframe->begin = 0;
                 $timeframe->end = 0;
+                $matches = array();
                 if (preg_match('{<role\b.*?<timeframe>(.+?)</timeframe>.*?</role>}is', $mmatch[1], $matches)) {
                     $timeframe = $this->decode_timeframe($matches[1]);
                 }
+
+                $matches = array();
                 if (preg_match('{<role\b.*?<extension>.*?<cohort>(.+?)</cohort>.*?</extension>.*?</role>}is',
                         $mmatch[1], $matches)) {
                     $member->groupname = trim($matches[1]);
@@ -722,6 +810,8 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
         if (preg_match('{<begin\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</begin>}is', $string, $matches)) {
             $ret->begin = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
         }
+
+        $matches = array();
         if (preg_match('{<end\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</end>}is', $string, $matches)) {
             $ret->end = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
         }
@@ -784,14 +874,101 @@ class enrol_imsenterprise_plugin extends enrol_plugin {
         global $CFG;
         require_once($CFG->libdir.'/coursecatlib.php');
 
-        static $defaultcategoryid = null;
-
-        if ($defaultcategoryid === null) {
+        if ($this->defaultcategoryid === null) {
             $category = coursecat::get_default();
-            $defaultcategoryid = $category->id;
+            $this->defaultcategoryid = $category->id;
+        }
+
+        return $this->defaultcategoryid;
+    }
+
+    /**
+     * Find the category using idnumber or name.
+     *
+     * @param array $categories List of categories
+     *
+     * @return int id of category found.
+     */
+    private function get_category_from_group($categories) {
+        global $DB;
+
+        if (empty($categories)) {
+            $catid = $this->get_default_category_id();
+        } else {
+            $createnewcategories = $this->get_config('createnewcategories');
+            $categoryseparator = trim($this->get_config('categoryseparator'));
+            $nestedcategories = trim($this->get_config('nestedcategories'));
+            $searchbyidnumber = trim($this->get_config('categoryidnumber'));
+
+            if (!empty($categoryseparator)) {
+                $sep = '{\\'.$categoryseparator.'}';
+            }
+
+            $catid = 0;
+            $fullnestedcatname = '';
+
+            foreach ($categories as $categoryinfo) {
+                if ($searchbyidnumber) {
+                    $values = preg_split($sep, $categoryinfo, -1, PREG_SPLIT_NO_EMPTY);
+                    if (count($values) < 2) {
+                        $this->log_line('Category ' . $categoryinfo . ' missing name or idnumber. Using default category instead.');
+                        $catid = $this->get_default_category_id();
+                        break;
+                    }
+                    $categoryname = $values[0];
+                    $categoryidnumber = $values[1];
+                } else {
+                    $categoryname = $categoryinfo;
+                    $categoryidnumber = null;
+                    if (empty($categoryname)) {
+                        $this->log_line('Category ' . $categoryinfo . ' missing name. Using default category instead.');
+                        $catid = $this->get_default_category_id();
+                        break;
+                    }
+                }
+
+                if (!empty($fullnestedcatname)) {
+                    $fullnestedcatname .= ' / ';
+                }
+
+                $fullnestedcatname .= $categoryname;
+                $parentid = $catid;
+
+                // Check if category exist.
+                $params = array();
+                if ($searchbyidnumber) {
+                    $params['idnumber'] = $categoryidnumber;
+                } else {
+                    $params['name'] = $categoryname;
+                }
+                if ($nestedcategories) {
+                    $params['parent'] = $parentid;
+                }
+
+                if ($catid = $DB->get_field('course_categories', 'id', $params)) {
+                    continue; // This category already exists.
+                }
+
+                // If we're allowed to create new categories, let's create this one.
+                if ($createnewcategories) {
+                    $newcat = new stdClass();
+                    $newcat->name = $categoryname;
+                    $newcat->visible = 0;
+                    $newcat->parent = $parentid;
+                    $newcat->idnumber = $categoryidnumber;
+                    $newcat = coursecat::create($newcat);
+                    $catid = $newcat->id;
+                    $this->log_line("Created new (hidden) category '$fullnestedcatname'");
+                } else {
+                    // If not found and not allowed to create, stick with default.
+                    $this->log_line('Category ' . $categoryinfo . ' not found in Moodle database. Using default category instead.');
+                    $catid = $this->get_default_category_id();
+                    break;
+                }
+            }
         }
 
-        return $defaultcategoryid;
+        return $catid;
     }
 
     /**
index 8f7c734..d55b8ee 100644 (file)
@@ -50,6 +50,9 @@ if ($ADMIN->fulltree) {
     $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/createnewusers',
         get_string('createnewusers', 'enrol_imsenterprise'), get_string('createnewusers_desc', 'enrol_imsenterprise'), 0));
 
+    $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/imsupdateusers',
+        get_string('updateusers', 'enrol_imsenterprise'), get_string('updateusers_desc', 'enrol_imsenterprise'), 0));
+
     $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/imsdeleteusers',
         get_string('deleteusers', 'enrol_imsenterprise'), get_string('deleteusers_desc', 'enrol_imsenterprise'), 0));
 
@@ -88,10 +91,23 @@ if ($ADMIN->fulltree) {
     $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/createnewcourses',
         get_string('createnewcourses', 'enrol_imsenterprise'), get_string('createnewcourses_desc', 'enrol_imsenterprise'), 0));
 
+    $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/updatecourses',
+        get_string('updatecourses', 'enrol_imsenterprise'), get_string('updatecourses_desc', 'enrol_imsenterprise'), 0));
+
     $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/createnewcategories',
         get_string('createnewcategories', 'enrol_imsenterprise'), get_string('createnewcategories_desc', 'enrol_imsenterprise'),
         0));
 
+    $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/nestedcategories',
+        get_string('nestedcategories', 'enrol_imsenterprise'), get_string('nestedcategories_desc', 'enrol_imsenterprise'), 0));
+
+    $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/categoryidnumber',
+        get_string('categoryidnumber', 'enrol_imsenterprise'), get_string('categoryidnumber_desc', 'enrol_imsenterprise'), 0));
+
+    $settings->add(new admin_setting_configtext('enrol_imsenterprise/categoryseparator',
+        get_string('categoryseparator', 'enrol_imsenterprise'), get_string('categoryseparator_desc', 'enrol_imsenterprise'), '',
+        PARAM_TEXT, 3));
+
     $settings->add(new admin_setting_configcheckbox('enrol_imsenterprise/imsunenrol',
         get_string('allowunenrol', 'enrol_imsenterprise'), get_string('allowunenrol_desc', 'enrol_imsenterprise'), 0));
 
index 113ef11..e3ed112 100644 (file)
@@ -96,6 +96,7 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $prevnusers = $DB->count_records('user');
 
         $user1 = new StdClass();
+        $user1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $user1->username = 'u1';
         $user1->email = 'u1@example.com';
         $user1->firstname = 'U';
@@ -108,6 +109,127 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->assertEquals(($prevnusers + 1), $DB->count_records('user'));
     }
 
+    /**
+     * Add new users and set an auth type
+     */
+    public function test_users_add_with_auth() {
+        global $DB;
+
+        $prevnusers = $DB->count_records('user');
+
+        $user2 = new StdClass();
+        $user2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $user2->username = 'u2';
+        $user2->auth = 'cas';
+        $user2->email = 'u2@u2.org';
+        $user2->firstname = 'U';
+        $user2->lastname = '2';
+
+        $users = array($user2);
+        $this->set_xml_file($users);
+        $this->imsplugin->cron();
+
+        $dbuser = $DB->get_record('user', array('username' => $user2->username));
+        // TODO: MDL-15863 this needs more work due to multiauth changes, use first auth for now.
+        $dbauth = explode(',', $dbuser->auth);
+        $dbauth = reset($dbauth);
+
+        $this->assertEquals(($prevnusers + 1), $DB->count_records('user'));
+        $this->assertEquals($dbauth, $user2->auth);
+    }
+
+
+    /**
+     * Update user
+     */
+    public function test_user_update() {
+        global $DB;
+
+        $user = $this->getDataGenerator()->create_user(array('idnumber' => 'test-update-user'));
+        $imsuser = new stdClass();
+        $imsuser->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_UPDATE;
+        // THIS SHOULD WORK, surely?: $imsuser->username = $user->username;
+        // But this is required...
+        $imsuser->username = $user->idnumber;
+        $imsuser->email = 'u3@u3.org';
+        $imsuser->firstname = 'U';
+        $imsuser->lastname = '3';
+
+        $this->set_xml_file(array($imsuser));
+        $this->imsplugin->cron();
+        $dbuser = $DB->get_record('user', array('id' => $user->id), '*', MUST_EXIST);
+        $this->assertEquals($imsuser->email, $dbuser->email);
+        $this->assertEquals($imsuser->firstname, $dbuser->firstname);
+        $this->assertEquals($imsuser->lastname, $dbuser->lastname);
+    }
+
+    public function test_user_update_disabled() {
+        global $DB;
+
+        $this->imsplugin->set_config('imsupdateusers', false);
+
+        $user = $this->getDataGenerator()->create_user(array('idnumber' => 'test-update-user'));
+        $imsuser = new stdClass();
+        $imsuser->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_UPDATE;
+        // THIS SHOULD WORK, surely?: $imsuser->username = $user->username;
+        // But this is required...
+        $imsuser->username = $user->idnumber;
+        $imsuser->email = 'u3@u3.org';
+        $imsuser->firstname = 'U';
+        $imsuser->lastname = '3';
+
+        $this->set_xml_file(array($imsuser));
+        $this->imsplugin->cron();
+
+        // Verify no changes have been made.
+        $dbuser = $DB->get_record('user', array('id' => $user->id), '*', MUST_EXIST);
+        $this->assertEquals($user->email, $dbuser->email);
+        $this->assertEquals($user->firstname, $dbuser->firstname);
+        $this->assertEquals($user->lastname, $dbuser->lastname);
+    }
+
+    /**
+     * Delete user
+     */
+    public function test_user_delete() {
+        global $DB;
+
+        $this->imsplugin->set_config('imsdeleteusers', true);
+        $user = $this->getDataGenerator()->create_user(array('idnumber' => 'test-update-user'));
+
+        $imsuser = new stdClass();
+        $imsuser->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_DELETE;
+        $imsuser->username = $user->username;
+        $imsuser->firstname = $user->firstname;
+        $imsuser->lastname = $user->lastname;
+        $imsuser->email = $user->email;
+        $this->set_xml_file(array($imsuser));
+
+        $this->imsplugin->cron();
+        $this->assertEquals(1, $DB->get_field('user', 'deleted', array('id' => $user->id), '*', MUST_EXIST));
+    }
+
+    /**
+     * Delete user disabled
+     */
+    public function test_user_delete_disabled() {
+        global $DB;
+
+        $this->imsplugin->set_config('imsdeleteusers', false);
+        $user = $this->getDataGenerator()->create_user(array('idnumber' => 'test-update-user'));
+
+        $imsuser = new stdClass();
+        $imsuser->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_DELETE;
+        $imsuser->username = $user->username;
+        $imsuser->firstname = $user->firstname;
+        $imsuser->lastname = $user->lastname;
+        $imsuser->email = $user->email;
+        $this->set_xml_file(array($imsuser));
+
+        $this->imsplugin->cron();
+        $this->assertEquals(0, $DB->get_field('user', 'deleted', array('id' => $user->id), '*', MUST_EXIST));
+    }
+
     /**
      * Existing courses are not created again
      */
@@ -120,6 +242,8 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         // Default mapping according to default course attributes - IMS description tags mapping.
         $course1->imsshort = $course1->fullname;
         $course2->imsshort = $course2->fullname;
+        unset($course1->category);
+        unset($course2->category);
 
         $prevncourses = $DB->count_records('course');
 
@@ -139,20 +263,109 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $prevncourses = $DB->count_records('course');
 
         $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $course1->idnumber = 'id1';
         $course1->imsshort = 'id1';
-        $course1->category = 'DEFAULT CATNAME';
+        $course1->category[] = 'DEFAULT CATNAME';
 
         $course2 = new StdClass();
+        $course2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $course2->idnumber = 'id2';
         $course2->imsshort = 'id2';
-        $course2->category = 'DEFAULT CATNAME';
+        $course2->category[] = 'DEFAULT CATNAME';
 
         $courses = array($course1, $course2);
         $this->set_xml_file(false, $courses);
         $this->imsplugin->cron();
 
         $this->assertEquals(($prevncourses + 2), $DB->count_records('course'));
+        $this->assertTrue($DB->record_exists('course', array('idnumber' => $course1->idnumber)));
+        $this->assertTrue($DB->record_exists('course', array('idnumber' => $course2->idnumber)));
+    }
+
+    /**
+     * Verify that courses are not created when createnewcourses
+     * option is diabled.
+     */
+    public function test_courses_add_createnewcourses_disabled() {
+        global $DB;
+
+        $this->imsplugin->set_config('createnewcourses', false);
+        $prevncourses = $DB->count_records('course');
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = 'id1';
+        $course1->imsshort = 'id1';
+        $course1->category[] = 'DEFAULT CATNAME';
+
+        $course2 = new StdClass();
+        $course2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course2->idnumber = 'id2';
+        $course2->imsshort = 'id2';
+        $course2->category[] = 'DEFAULT CATNAME';
+
+        $courses = array($course1, $course2);
+        $this->set_xml_file(false, $courses);
+        $this->imsplugin->cron();
+
+        $courses = array($course1, $course2);
+        $this->set_xml_file(false, $courses);
+        $this->imsplugin->cron();
+
+        // Verify the courses have not ben creased.
+        $this->assertEquals($prevncourses , $DB->count_records('course'));
+        $this->assertFalse($DB->record_exists('course', array('idnumber' => $course1->idnumber)));
+        $this->assertFalse($DB->record_exists('course', array('idnumber' => $course2->idnumber)));
+    }
+
+    /**
+     * Test adding a course with no idnumber.
+     */
+    public function test_courses_no_idnumber() {
+        global $DB;
+
+        $prevncourses = $DB->count_records('course');
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = '';
+        $course1->imsshort = 'id1';
+        $course1->category[] = 'DEFAULT CATNAME';
+
+        $this->set_xml_file(false, array($course1));
+        $this->imsplugin->cron();
+
+        // Verify no action.
+        $this->assertEquals($prevncourses, $DB->count_records('course'));
+    }
+
+    /**
+     * Add new course with the truncateidnumber setting.
+     */
+    public function test_courses_add_truncate_idnumber() {
+        global $DB;
+
+        $truncatelength = 4;
+
+        $this->imsplugin->set_config('truncatecoursecodes', $truncatelength);
+        $prevncourses = $DB->count_records('course');
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = '123456789';
+        $course1->imsshort = 'id1';
+        $course1->category[] = 'DEFAULT CATNAME';
+
+        $this->set_xml_file(false, array($course1));
+        $this->imsplugin->cron();
+
+        // Verify the new course has been added.
+        $this->assertEquals(($prevncourses + 1), $DB->count_records('course'));
+
+        $truncatedidnumber = substr($course1->idnumber, 0, $truncatelength);
+
+        $this->assertTrue($DB->record_exists('course', array('idnumber' => $truncatedidnumber)));
     }
 
     /**
@@ -172,7 +385,7 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $course1 = new stdClass();
         $course1->idnumber = 'id1';
         $course1->imsshort = 'id1';
-        $course1->category = '';
+        $course1->category[] = '';
         $this->set_xml_file(false, array($course1));
         $this->imsplugin->cron();
 
@@ -194,11 +407,12 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->imsplugin->set_config('imscoursemapsummary', 'coursecode');
 
         $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $course1->idnumber = 'id1';
         $course1->imsshort = 'description_short1';
         $course1->imslong = 'description_long';
         $course1->imsfull = 'description_full';
-        $course1->category = 'DEFAULT CATNAME';
+        $course1->category[] = 'DEFAULT CATNAME';
 
         $this->set_xml_file(false, array($course1));
         $this->imsplugin->cron();
@@ -215,11 +429,12 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->imsplugin->set_config('imscoursemapsummary', 'full');
 
         $course2 = new StdClass();
+        $course2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $course2->idnumber = 'id2';
         $course2->imsshort = 'description_short2';
         $course2->imslong = 'description_long';
         $course2->imsfull = 'description_full';
-        $course2->category = 'DEFAULT CATNAME';
+        $course2->category[] = 'DEFAULT CATNAME';
 
         $this->set_xml_file(false, array($course2));
         $this->imsplugin->cron();
@@ -236,9 +451,10 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->imsplugin->set_config('imscoursemapsummary', 'full');
 
         $course3 = new StdClass();
+        $course3->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
         $course3->idnumber = 'id3';
         $course3->imsshort = 'description_short3';
-        $course3->category = 'DEFAULT CATNAME';
+        $course3->category[] = 'DEFAULT CATNAME';
 
         $this->set_xml_file(false, array($course3));
         $this->imsplugin->cron();
@@ -251,6 +467,494 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
 
     }
 
+    /**
+     * Course updates
+     */
+    public function test_course_update() {
+        global $DB;
+
+        $course4 = new StdClass();
+        $course4->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course4->idnumber = 'id4';
+        $course4->imsshort = 'id4';
+        $course4->imsfull = 'id4';
+        $course4->category[] = 'DEFAULT CATNAME';
+
+        $this->set_xml_file(false, array($course4));
+        $this->imsplugin->cron();
+
+        $course4u = $DB->get_record('course', array('idnumber' => $course4->idnumber));
+
+        $course4u->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_UPDATE;
+        $course4u->imsshort = 'description_short_updated';
+        $course4u->imsfull = 'description_full_updated';
+        unset($course4u->category);
+
+        $this->set_xml_file(false, array($course4u));
+        $this->imsplugin->cron();
+
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course4->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->shortname, $course4u->imsshort);
+        $this->assertEquals($dbcourse->fullname, $course4u->imsfull);
+    }
+
+    /**
+     * Course delete. Make it hidden.
+     */
+    public function test_course_delete() {
+        global $DB;
+
+        $course8 = new StdClass();
+        $course8->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course8->idnumber = 'id8';
+        $course8->imsshort = 'id8';
+        $course8->imsfull = 'id8';
+        $course8->category[] = 'DEFAULT CATNAME';
+
+        $this->set_xml_file(false, array($course8));
+        $this->imsplugin->cron();
+
+        $course8d = $DB->get_record('course', array('idnumber' => $course8->idnumber));
+        $this->assertEquals($course8d->visible, 1);
+
+        $course8d->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_DELETE;
+        unset($course8d->category);
+
+        $this->set_xml_file(false, array($course8d));
+        $this->imsplugin->cron();
+
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course8d->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->visible, 0);
+    }
+
+
+    /**
+     * Nested categories with name during course creation
+     */
+    public function test_nested_categories() {
+        global $DB;
+
+        $this->imsplugin->set_config('nestedcategories', true);
+
+        $topcat = 'DEFAULT CATNAME';
+        $subcat = 'DEFAULT SUB CATNAME';
+
+        $course5 = new StdClass();
+        $course5->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course5->idnumber = 'id5';
+        $course5->imsshort = 'description_short';
+        $course5->imslong = 'description_long';
+        $course5->imsfull = 'description_full';
+        $course5->category = array();
+        $course5->category[] = $topcat;
+        $course5->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course5));
+        $this->imsplugin->cron();
+
+        $parentcatid = $DB->get_field('course_categories', 'id', array('name' => $topcat));
+        $subcatid = $DB->get_field('course_categories', 'id', array('name' => $subcat, 'parent' => $parentcatid));
+
+        $this->assertTrue(isset($subcatid));
+        $this->assertTrue($subcatid > 0);
+
+        $topcat = 'DEFAULT CATNAME';
+        $subcat = 'DEFAULT SUB CATNAME TEST2';
+
+        $course6 = new StdClass();
+        $course6->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course6->idnumber = 'id6';
+        $course6->imsshort = 'description_short';
+        $course6->imslong = 'description_long';
+        $course6->imsfull = 'description_full';
+        $course6->category = array();
+        $course6->category[] = $topcat;
+        $course6->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course6));
+        $this->imsplugin->cron();
+
+        $parentcatid = $DB->get_field('course_categories', 'id', array('name' => $topcat));
+        $subcatid = $DB->get_field('course_categories', 'id', array('name' => $subcat, 'parent' => $parentcatid));
+
+        $this->assertTrue(isset($subcatid));
+        $this->assertTrue($subcatid > 0);
+    }
+
+
+    /**
+     * Test that duplicate nested categories with name are not created
+     */
+    public function test_nested_categories_for_dups() {
+        global $DB;
+
+        $this->imsplugin->set_config('nestedcategories', true);
+
+        $topcat = 'DEFAULT CATNAME';
+        $subcat = 'DEFAULT SUB CATNAME DUPTEST';
+
+        $course7 = new StdClass();
+        $course7->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course7->idnumber = 'id7';
+        $course7->imsshort = 'description_short';
+        $course7->imslong = 'description_long';
+        $course7->imsfull = 'description_full';
+        $course7->category[] = $topcat;
+        $course7->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course7));
+        $this->imsplugin->cron();
+
+        $prevncategories = $DB->count_records('course_categories');
+
+        $course8 = new StdClass();
+        $course8->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course8->idnumber = 'id8';
+        $course8->imsshort = 'description_short';
+        $course8->imslong = 'description_long';
+        $course8->imsfull = 'description_full';
+        $course8->category[] = $topcat;
+        $course8->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course8));
+        $this->imsplugin->cron();
+
+        $this->assertEquals($prevncategories, $DB->count_records('course_categories'));
+    }
+
+    /**
+     * Nested categories with idnumber during course creation
+     */
+    public function test_nested_categories_idnumber() {
+        global $DB;
+
+        $this->imsplugin->set_config('nestedcategories', true);
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $topcatname = 'DEFAULT CATNAME';
+        $subcatname = 'DEFAULT SUB CATNAME';
+        $topcatidnumber = '01';
+        $subcatidnumber = '0101';
+
+        $topcat = $topcatname.$catsep.$topcatidnumber;
+        $subcat = $subcatname.$catsep.$subcatidnumber;
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = 'id5';
+        $course1->imsshort = 'description_short';
+        $course1->imslong = 'description_long';
+        $course1->imsfull = 'description_full';
+        $course1->category[] = $topcat;
+        $course1->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course1));
+        $this->imsplugin->cron();
+
+        $parentcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $topcatidnumber));
+        $subcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $subcatidnumber, 'parent' => $parentcatid));
+
+        $this->assertTrue(isset($subcatid));
+        $this->assertTrue($subcatid > 0);
+
+        // Change the category separator character.
+        $this->imsplugin->set_config('categoryseparator', ':');
+
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $topcatname = 'DEFAULT CATNAME';
+        $subcatname = 'DEFAULT SUB CATNAME TEST2';
+        $topcatidnumber = '01';
+        $subcatidnumber = '0102';
+
+        $topcat = $topcatname.$catsep.$topcatidnumber;
+        $subcat = $subcatname.$catsep.$subcatidnumber;
+
+        $course2 = new StdClass();
+        $course2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course2->idnumber = 'id6';
+        $course2->imsshort = 'description_short';
+        $course2->imslong = 'description_long';
+        $course2->imsfull = 'description_full';
+        $course2->category[] = $topcat;
+        $course2->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course2));
+        $this->imsplugin->cron();
+
+        $parentcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $topcatidnumber));
+        $subcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $subcatidnumber, 'parent' => $parentcatid));
+
+        $this->assertTrue(isset($subcatid));
+        $this->assertTrue($subcatid > 0);
+    }
+
+    /**
+     * Test that duplicate nested categories with idnumber are not created
+     */
+    public function test_nested_categories_idnumber_for_dups() {
+        global $DB;
+
+        $this->imsplugin->set_config('nestedcategories', true);
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $topcatname = 'DEFAULT CATNAME';
+        $subcatname = 'DEFAULT SUB CATNAME';
+        $topcatidnumber = '01';
+        $subcatidnumber = '0101';
+
+        $topcat = $topcatname.$catsep.$topcatidnumber;
+        $subcat = $subcatname.$catsep.$subcatidnumber;
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = 'id1';
+        $course1->imsshort = 'description_short';
+        $course1->imslong = 'description_long';
+        $course1->imsfull = 'description_full';
+        $course1->category[] = $topcat;
+        $course1->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course1));
+        $this->imsplugin->cron();
+
+        $prevncategories = $DB->count_records('course_categories');
+
+        $course2 = new StdClass();
+        $course2->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course2->idnumber = 'id2';
+        $course2->imsshort = 'description_short';
+        $course2->imslong = 'description_long';
+        $course2->imsfull = 'description_full';
+        $course2->category[] = $topcat;
+        $course2->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course2));
+        $this->imsplugin->cron();
+
+        $this->assertEquals($prevncategories, $DB->count_records('course_categories'));
+    }
+
+    /**
+     * Test that nested categories with idnumber is not created if name is missing
+     */
+    public function test_categories_idnumber_missing_name() {
+        global $DB, $CFG;
+
+        $this->imsplugin->set_config('nestedcategories', true);
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $topcatname = 'DEFAULT CATNAME';
+        $subcatname = '';
+        $topcatidnumber = '01';
+        $subcatidnumber = '0101';
+
+        $topcat = $topcatname.$catsep.$topcatidnumber;
+        $subcat = $subcatname.$catsep.$subcatidnumber;
+
+        $course1 = new StdClass();
+        $course1->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course1->idnumber = 'id1';
+        $course1->imsshort = 'description_short';
+        $course1->imslong = 'description_long';
+        $course1->imsfull = 'description_full';
+        $course1->category[] = $topcat;
+        $course1->category[] = $subcat;
+
+        $this->set_xml_file(false, array($course1));
+        $this->imsplugin->cron();
+
+        // Check all categories except the last subcategory was created.
+        $parentcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $topcatidnumber));
+        $this->assertTrue((boolean)$parentcatid);
+        $subcatid = $DB->get_field('course_categories', 'id', array('idnumber' => $subcatidnumber, 'parent' => $parentcatid));
+        $this->assertFalse((boolean)$subcatid);
+
+        // Check course was put in default category.
+        $defaultcat = coursecat::get_default();
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course1->idnumber), '*', MUST_EXIST);
+        $this->assertEquals($dbcourse->category, $defaultcat->id);
+
+    }
+
+    /**
+     * Create category with name (nested categories not activated).
+     */
+    public function test_create_category_name_no_nested() {
+        global $DB;
+
+        $course = new StdClass();
+        $course->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course->idnumber = 'id';
+        $course->imsshort = 'description_short';
+        $course->imslong = 'description_long';
+        $course->imsfull = 'description_full';
+        $course->category[] = 'CATNAME';
+
+        $this->set_xml_file(false, array($course));
+        $this->imsplugin->cron();
+
+        $dbcat = $DB->get_record('course_categories', array('name' => $course->category[0]));
+        $this->assertFalse(!$dbcat);
+        $this->assertEquals($dbcat->parent, 0);
+
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->category, $dbcat->id);
+
+    }
+
+    /**
+     * Find a category with name (nested categories not activated).
+     */
+    public function test_find_category_name_no_nested() {
+        global $DB;
+
+        $cattop = $this->getDataGenerator()->create_category(array('name' => 'CAT-TOP'));
+        $catsub = $this->getDataGenerator()->create_category(array('name' => 'CAT-SUB', 'parent' => $cattop->id));
+        $prevcats = $DB->count_records('course_categories');
+
+        $course = new StdClass();
+        $course->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course->idnumber = 'id';
+        $course->imsshort = 'description_short';
+        $course->imslong = 'description_long';
+        $course->imsfull = 'description_full';
+        $course->category[] = 'CAT-SUB';
+
+        $this->set_xml_file(false, array($course));
+        $this->imsplugin->cron();
+
+        $newcats = $DB->count_records('course_categories');
+
+        // Check that no new category was not created.
+        $this->assertEquals($prevcats, $newcats);
+
+        // Check course is associated to CAT-SUB.
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->category, $catsub->id);
+
+    }
+
+    /**
+     * Create category with idnumber (nested categories not activated).
+     */
+    public function test_create_category_idnumber_no_nested() {
+        global $DB;
+
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $course = new StdClass();
+        $course->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course->idnumber = 'id';
+        $course->imsshort = 'description_short';
+        $course->imslong = 'description_long';
+        $course->imsfull = 'description_full';
+        $course->category[] = 'CATNAME'. $catsep .  'CATIDNUMBER';
+
+        $this->set_xml_file(false, array($course));
+        $this->imsplugin->cron();
+
+        $dbcat = $DB->get_record('course_categories', array('idnumber' => 'CATIDNUMBER'));
+        $this->assertFalse(!$dbcat);
+        $this->assertEquals($dbcat->parent, 0);
+        $this->assertEquals($dbcat->name, 'CATNAME');
+
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->category, $dbcat->id);
+
+    }
+
+    /**
+     * Find a category with idnumber (nested categories not activated).
+     */
+    public function test_find_category_idnumber_no_nested() {
+        global $DB;
+
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $topcatname = 'CAT-TOP';
+        $subcatname = 'CAT-SUB';
+        $topcatidnumber = 'ID-TOP';
+        $subcatidnumber = 'ID-SUB';
+
+        $cattop = $this->getDataGenerator()->create_category(array('name' => $topcatname, 'idnumber' => $topcatidnumber));
+        $catsub = $this->getDataGenerator()->create_category(array('name' => $subcatname, 'idnumber' => $subcatidnumber,
+                'parent' => $cattop->id));
+        $prevcats = $DB->count_records('course_categories');
+
+        $course = new StdClass();
+        $course->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course->idnumber = 'id';
+        $course->imsshort = 'description_short';
+        $course->imslong = 'description_long';
+        $course->imsfull = 'description_full';
+        $course->category[] = $subcatname . $catsep . $subcatidnumber;
+
+        $this->set_xml_file(false, array($course));
+        $this->imsplugin->cron();
+
+        $newcats = $DB->count_records('course_categories');
+
+        // Check that no new category was not created.
+        $this->assertEquals($prevcats, $newcats);
+
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course->idnumber));
+        $this->assertFalse(!$dbcourse);
+        $this->assertEquals($dbcourse->category, $catsub->id);
+
+    }
+
+    /**
+     * Test that category with idnumber is not created if name is missing (nested categories not activated).
+     */
+    public function test_category_idnumber_missing_name_no_nested() {
+        global $DB;
+
+        $this->imsplugin->set_config('categoryidnumber', true);
+        $this->imsplugin->set_config('categoryseparator', '|');
+        $catsep = trim($this->imsplugin->get_config('categoryseparator'));
+
+        $catidnumber = '01';
+
+        $course = new StdClass();
+        $course->recstatus = enrol_imsenterprise_plugin::IMSENTERPRISE_ADD;
+        $course->idnumber = 'id1';
+        $course->imsshort = 'description_short';
+        $course->imslong = 'description_long';
+        $course->imsfull = 'description_full';
+        $course->category[] = '' . $catsep . $catidnumber;
+
+        $this->set_xml_file(false, array($course));
+        $this->imsplugin->cron();
+
+        // Check category was not created.
+        $catid = $DB->get_record('course_categories', array('idnumber' => $catidnumber));
+        $this->assertFalse($catid);
+
+        // Check course was put in default category.
+        $defaultcat = coursecat::get_default();
+        $dbcourse = $DB->get_record('course', array('idnumber' => $course->idnumber), '*', MUST_EXIST);
+        $this->assertEquals($dbcourse->category, $defaultcat->id);
+
+    }
+
     /**
      * Sets the plugin configuration for testing
      */
@@ -258,8 +962,13 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         $this->imsplugin->set_config('mailadmins', false);
         $this->imsplugin->set_config('prev_path', '');
         $this->imsplugin->set_config('createnewusers', true);
+        $this->imsplugin->set_config('imsupdateusers', true);
         $this->imsplugin->set_config('createnewcourses', true);
+        $this->imsplugin->set_config('updatecourses', true);
         $this->imsplugin->set_config('createnewcategories', true);
+        $this->imsplugin->set_config('categoryseparator', '');
+        $this->imsplugin->set_config('categoryidnumber', false);
+        $this->imsplugin->set_config('nestedcategories', false);
     }
 
     /**
@@ -276,12 +985,26 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
         if (!empty($users)) {
             foreach ($users as $user) {
                 $xmlcontent .= '
-  <person>
+  <person';
+
+                // Optional recstatus (1=add, 2=update, 3=delete).
+                if (!empty($user->recstatus)) {
+                    $xmlcontent .= ' recstatus="'.$user->recstatus.'"';
+                }
+
+                $xmlcontent .= '>
     <sourcedid>
       <source>TestSource</source>
       <id>'.$user->username.'</id>
     </sourcedid>
-    <userid>'.$user->username.'</userid>
+    <userid';
+
+                // Optional authentication type.
+                if (!empty($user->auth)) {
+                    $xmlcontent .= ' authenticationtype="'.$user->auth.'"';
+                }
+
+                $xmlcontent .= '>'.$user->username.'</userid>
     <name>
       <fn>'.$user->firstname.' '.$user->lastname.'</fn>
       <n>
@@ -300,7 +1023,14 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
             foreach ($courses as $course) {
 
                 $xmlcontent .= '
-  <group>
+  <group';
+
+                // Optional recstatus (1=add, 2=update, 3=delete).
+                if (!empty($course->recstatus)) {
+                    $xmlcontent .= ' recstatus="'.$course->recstatus.'"';
+                }
+
+                $xmlcontent .= '>
     <sourcedid>
       <source>TestSource</source>
       <id>'.$course->idnumber.'</id>
@@ -328,8 +1058,16 @@ class enrol_imsenterprise_testcase extends advanced_testcase {
                 // The orgunit tag value is used by moodle as category name.
                 $xmlcontent .= '
     </description>
-    <org>
-      <orgunit>'.$course->category.'</orgunit>
+    <org>';
+                // Optional category name.
+                if (isset($course->category) && !empty($course->category)) {
+                    foreach ($course->category as $category) {
+                        $xmlcontent .= '
+      <orgunit>'.$category.'</orgunit>';
+                    }
+                }
+
+                $xmlcontent .= '
     </org>
   </group>';
             }
index ca746c7..4b95292 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016052300;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2016070600;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016051900;        // Requires this Moodle version.
 $plugin->component = 'enrol_imsenterprise';
index 55fcb02..cfe5d3d 100644 (file)
@@ -32,20 +32,21 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['cannotcreatedboninstall'] = '<p>No és pot crear la base de dades.</p> <p>La base de dades especificada no existeix i l\'usuari que heu proporcionat no té permís per a crear-la.</p>
 <p>L\'administrador del lloc hauria de verificar la configuració de la base de dades.</p>';
-$string['cannotcreatelangdir'] = 'No s\'ha pogut crear el directori d\'idiomes.';
+$string['cannotcreatelangdir'] = 'No s\'ha pogut crear el directori d\'idiomes';
 $string['cannotcreatetempdir'] = 'No s\'ha pogut crear el directori temporal';
 $string['cannotdownloadcomponents'] = 'No s\'han pogut baixar components';
-$string['cannotdownloadzipfile'] = 'No s\'ha pogut baixar el fitxer zip';
+$string['cannotdownloadzipfile'] = 'No s\'ha pogut baixar el fitxer ZIP';
 $string['cannotfindcomponent'] = 'No s\'ha pogut trobar el component';
 $string['cannotsavemd5file'] = 'No s\'ha pogut desar el fitxer md5';
-$string['cannotsavezipfile'] = 'No s\'ha pogut desar el fitxer zip';
+$string['cannotsavezipfile'] = 'No s\'ha pogut desar el fitxer ZIP';
 $string['cannotunzipfile'] = 'No s\'ha pogut descomprimir el fitxer';
-$string['componentisuptodate'] = 'El component està al dia';
+$string['componentisuptodate'] = 'El component està actualitzat';
 $string['dmlexceptiononinstall'] = '<p>S\'ha produït un error de la base de dades [{$a->errorcode}].<br />{$a->debuginfo}</p>';
 $string['downloadedfilecheckfailed'] = 'Ha fallat la comprovació del fitxer baixat';
 $string['invalidmd5'] = 'L\'md5 no és vàlid. Torneu-ho a provar';
 $string['missingrequiredfield'] = 'Falta algun camp necessari';
-$string['remotedownloaderror'] = 'No s\'ha pogut baixar el component al vostre servidor. Verifiqueu els paràmetres de servidor intermediari. Es recomana l\'extensió cURL.<br /><br />Haureu de baixar manualment el fitxer <a href="{$a->url}">{$a->url}</a>, copiar-lo a la ubicació "{$a->dest}" del vostre servidor i descomprimir-lo allí.';
+$string['remotedownloaderror'] = '<p>No s\'ha pogut baixar el component al vostre servidor. Verifiqueu els paràmetres del servidor intermediari. Es recomana vivament l\'extensió cURL de PHP.</p>
+<p>Haureu de baixar manualment el fitxer <a href="{$a->url}">{$a->url}</a>, copiar-lo a la ubicació «{$a->dest}» del vostre servidor i descomprimir-lo allí.</p>';
 $string['wrongdestpath'] = 'El camí de destinació és erroni';
 $string['wrongsourcebase'] = 'L\'adreça (URL) base de la font és errònia';
 $string['wrongzipfilename'] = 'El nom del fitxer ZIP és erroni';
index 92aa8d0..7540b27 100644 (file)
@@ -30,5 +30,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = '';
 $string['thisdirection'] = 'ltr';
 $string['thislanguage'] = 'Català';
index eafa560..58448b6 100644 (file)
@@ -594,7 +594,7 @@ $string['invalidsection'] = 'Invalid section.';
 $string['invaliduserchangeme'] = 'Username "changeme" is reserved -- you cannot create an account with it.';
 $string['ipblocked'] = 'This site is not available currently.';
 $string['ipblocker'] = 'IP blocker';
-$string['ipblockersyntax'] = 'Put every entry on one line. Valid entries are either full IP address (such as <b>192.168.10.1</b>) which matches a single host; or partial address (such as <b>192.168.</b>) which matches any address starting with those numbers; or CIDR notation (such as <b>231.54.211.0/20</b>); or a range of IP addresses (such as <b>231.3.56.10-20</b>) where the range applies to the last part of the address. Text domain names (like \'example.com\') are not supported. Blank lines are ignored.';
+$string['ipblockersyntax'] = 'Put every entry on one line. Valid entries are either full IP address (such as <b>192.168.10.1</b>) which matches a single host; or partial address (such as <b>192.168</b>) which matches any address starting with those numbers; or CIDR notation (such as <b>231.54.211.0/20</b>); or a range of IP addresses (such as <b>231.3.56.10-20</b>) where the range applies to the last part of the address. Text domain names (like \'example.com\') are not supported. Blank lines are ignored.';
 $string['iplookup'] = 'IP address lookup';
 $string['iplookupgeoplugin'] = '<a href="http://www.geoplugin.com">geoPlugin</a> service is currently being used to look up geographical information. For more accurate results we recommend installing a local copy of the MaxMind GeoLite database.';
 $string['iplookupinfo'] = 'By default Moodle uses the free online NetGeo (The Internet Geographic Database) server to lookup location of IP addresses, unfortunately this database is not maintained anymore and may return <em>wildly incorrect</em> data.
@@ -1173,6 +1173,7 @@ $string['userquota'] = 'User quota';
 $string['usesitenameforsitepages'] = 'Use site name for site pages';
 $string['usetags'] = 'Enable tags functionality';
 $string['validateerror'] = 'This value is not valid';
+$string['validateiperror'] = 'These IP addresses are invalid: {$a}';
 $string['verifychangedemail'] = 'Restrict domains when changing email';
 $string['warningcurrentsetting'] = 'Invalid current value: {$a}';
 $string['warningiconvbuggy'] = 'Your version of the iconv library does not support the //IGNORE modifier. You should install the mbstring extension which can be used instead for cleaning strings containing invalid UTF-8 characters.';
index aa19259..9e1ee63 100644 (file)
@@ -743,6 +743,9 @@ $string['eventcoursesectionupdated'] = 'Course section updated';
 $string['eventcoursemoduleinstancelistviewed'] = 'Course module instance list viewed';
 $string['eventcourseuserreportviewed'] = 'Course user report viewed';
 $string['eventcourseviewed'] = 'Course viewed';
+$string['eventdashboardreset'] = 'Dashboard reset';
+$string['eventdashboardsreset'] = 'Dashboards reset';
+$string['eventdashboardviewed'] = 'Dashboard viewed';
 $string['eventemailfailed'] = 'Email failed to send';
 $string['eventname'] = 'Event name';
 $string['eventrecentactivityviewed'] = 'Recent activity viewed';
@@ -1680,6 +1683,7 @@ $string['showallcourses'] = 'Show all courses';
 $string['showallusers'] = 'Show all users';
 $string['showblockcourse'] = 'Show list of courses containing block';
 $string['showcategory'] = 'Show {$a}';
+$string['showchartdata'] = 'Show chart data';
 $string['showcomments'] = 'Show/hide comments';
 $string['showcommentsnonjs'] = 'Show comments';
 $string['showdescription'] = 'Display description on course page';
index bfe296c..f9e4da9 100644 (file)
@@ -47,7 +47,8 @@ $string['documentsinindex'] = 'Documents in index';
 $string['duration'] = 'Duration';
 $string['emptydatabaseerror'] = 'Database table is not present, or contains no index records.';
 $string['enginenotfound'] = 'Engine {$a} not found.';
-$string['enginenotinstalled'] = '{$a} not installed.';
+$string['enginenotinstalled'] = 'Engine {$a} not installed.';
+$string['enginenotselected'] = 'You have not selected any search engine.';
 $string['engineserverstatus'] = 'The search engine is not available. Please contact your administrator.';
 $string['enteryoursearchquery'] = 'Enter your search query';
 $string['errors'] = 'Errors';
index b5217e5..8a2b6db 100644 (file)
@@ -3526,21 +3526,24 @@ class admin_setting_configiplist extends admin_setting_configtextarea {
             return true;
         }
         $result = true;
+        $badips = array();
         foreach($ips as $ip) {
             $ip = trim($ip);
+            if (empty($ip)) {
+                continue;
+            }
             if (preg_match('#^(\d{1,3})(\.\d{1,3}){0,3}$#', $ip, $match) ||
                 preg_match('#^(\d{1,3})(\.\d{1,3}){0,3}(\/\d{1,2})$#', $ip, $match) ||
                 preg_match('#^(\d{1,3})(\.\d{1,3}){3}(-\d{1,3})$#', $ip, $match)) {
-                $result = true;
             } else {
                 $result = false;
-                break;
+                $badips[] = $ip;
             }
         }
         if($result) {
             return true;
         } else {
-            return get_string('validateerror', 'admin');
+            return get_string('validateiperror', 'admin', join(', ', $badips));
         }
     }
 }
index 7c6049b..15e89fb 100644 (file)
@@ -138,8 +138,10 @@ class ADORecordset_oci8po extends ADORecordset_oci8 {
        // 10% speedup to move MoveNext to child class
        function MoveNext()
        {
-               if(@OCIfetchinto($this->_queryID,$this->fields,$this->fetchMode)) {
+               $ret = @oci_fetch_array($this->_queryID,$this->fetchMode);
+               if($ret !== false) {
                global $ADODB_ANSI_PADDING_OFF;
+                       $this->fields = $ret;
                        $this->_currentRow++;
                        $this->_updatefields();
 
@@ -169,10 +171,12 @@ class ADORecordset_oci8po extends ADORecordset_oci8 {
                                $arr = array();
                                return $arr;
                        }
-               if (!@OCIfetchinto($this->_queryID,$this->fields,$this->fetchMode)) {
+               $ret = @oci_fetch_array($this->_queryID,$this->fetchMode);
+               if ($ret === false) {
                        $arr = array();
                        return $arr;
                }
+               $this->fields = $ret;
                $this->_updatefields();
                $results = array();
                $cnt = 0;
@@ -188,8 +192,9 @@ class ADORecordset_oci8po extends ADORecordset_oci8 {
        {
                global $ADODB_ANSI_PADDING_OFF;
 
-               $ret = @OCIfetchinto($this->_queryID,$this->fields,$this->fetchMode);
+               $ret = @oci_fetch_array($this->_queryID,$this->fetchMode);
                if ($ret) {
+                       $this->fields = $ret;
                        $this->_updatefields();
 
                        if (!empty($ADODB_ANSI_PADDING_OFF)) {
@@ -198,7 +203,7 @@ class ADORecordset_oci8po extends ADORecordset_oci8 {
                                }
                        }
                }
-               return $ret;
+               return $ret !== false;
        }
 
 }
index a088b63..a2590c6 100644 (file)
@@ -27,5 +27,6 @@ Our changes:
  * Removed random seed initialization from lib/adodb/adodb.inc.php:216 (see 038f546 and MDL-41198).
  * MDL-52286 Added muting erros in ADORecordSet::__destruct().
    Check if fixed upstream during the next upgrade and remove this note.
+ * MDL-52544 Pull upstream patch for php7 and ocipo.
 
 skodak, iarenaza, moodler, stronk7, abgreeve
diff --git a/lib/amd/build/chart_axis.min.js b/lib/amd/build/chart_axis.min.js
new file mode 100644 (file)
index 0000000..22ebd64
Binary files /dev/null and b/lib/amd/build/chart_axis.min.js differ
diff --git a/lib/amd/build/chart_bar.min.js b/lib/amd/build/chart_bar.min.js
new file mode 100644 (file)
index 0000000..05364fd
Binary files /dev/null and b/lib/amd/build/chart_bar.min.js differ
diff --git a/lib/amd/build/chart_base.min.js b/lib/amd/build/chart_base.min.js
new file mode 100644 (file)
index 0000000..f78a3a3
Binary files /dev/null and b/lib/amd/build/chart_base.min.js differ
diff --git a/lib/amd/build/chart_builder.min.js b/lib/amd/build/chart_builder.min.js
new file mode 100644 (file)
index 0000000..c746f31
Binary files /dev/null and b/lib/amd/build/chart_builder.min.js differ
diff --git a/lib/amd/build/chart_line.min.js b/lib/amd/build/chart_line.min.js
new file mode 100644 (file)
index 0000000..042acb2
Binary files /dev/null and b/lib/amd/build/chart_line.min.js differ
diff --git a/lib/amd/build/chart_output.min.js b/lib/amd/build/chart_output.min.js
new file mode 100644 (file)
index 0000000..65c0f11
Binary files /dev/null and b/lib/amd/build/chart_output.min.js differ
diff --git a/lib/amd/build/chart_output_base.min.js b/lib/amd/build/chart_output_base.min.js
new file mode 100644 (file)
index 0000000..8bdebe6
Binary files /dev/null and b/lib/amd/build/chart_output_base.min.js differ
diff --git a/lib/amd/build/chart_output_chartjs.min.js b/lib/amd/build/chart_output_chartjs.min.js
new file mode 100644 (file)
index 0000000..fdc8e84
Binary files /dev/null and b/lib/amd/build/chart_output_chartjs.min.js differ
diff --git a/lib/amd/build/chart_output_htmltable.min.js b/lib/amd/build/chart_output_htmltable.min.js
new file mode 100644 (file)
index 0000000..cea5152
Binary files /dev/null and b/lib/amd/build/chart_output_htmltable.min.js differ
diff --git a/lib/amd/build/chart_pie.min.js b/lib/amd/build/chart_pie.min.js
new file mode 100644 (file)
index 0000000..aab6464
Binary files /dev/null and b/lib/amd/build/chart_pie.min.js differ
diff --git a/lib/amd/build/chart_series.min.js b/lib/amd/build/chart_series.min.js
new file mode 100644 (file)
index 0000000..c913cdb
Binary files /dev/null and b/lib/amd/build/chart_series.min.js differ
diff --git a/lib/amd/build/chartjs-lazy.min.js b/lib/amd/build/chartjs-lazy.min.js
new file mode 100644 (file)
index 0000000..0361ea9
Binary files /dev/null and b/lib/amd/build/chartjs-lazy.min.js differ
diff --git a/lib/amd/build/chartjs.min.js b/lib/amd/build/chartjs.min.js
new file mode 100644 (file)
index 0000000..c085a29
Binary files /dev/null and b/lib/amd/build/chartjs.min.js differ
index 86a6407..39b9bb5 100644 (file)
Binary files a/lib/amd/build/event.min.js and b/lib/amd/build/event.min.js differ
index aed3712..9473560 100644 (file)
Binary files a/lib/amd/build/tag.min.js and b/lib/amd/build/tag.min.js differ
index 0ffe642..f95b3a4 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
diff --git a/lib/amd/src/chart_axis.js b/lib/amd/src/chart_axis.js
new file mode 100644 (file)
index 0000000..1ec5234
--- /dev/null
@@ -0,0 +1,299 @@
+// 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/>.
+
+/**
+ * Chart axis.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_axis
+ */
+define([], function() {
+
+    /**
+     * Chart axis class.
+     *
+     * This is used to represent an axis, whether X or Y.
+     *
+     * @alias module:core/chart_axis
+     * @class
+     */
+    function Axis() {
+        // Please eslint no-empty-function.
+    }
+
+    /**
+     * Default axis position.
+     * @const {Null}
+     */
+    Axis.prototype.POS_DEFAULT = null;
+
+    /**
+     * Bottom axis position.
+     * @const {String}
+     */
+    Axis.prototype.POS_BOTTOM = 'bottom';
+
+    /**
+     * Left axis position.
+     * @const {String}
+     */
+    Axis.prototype.POS_LEFT = 'left';
+
+    /**
+     * Right axis position.
+     * @const {String}
+     */
+    Axis.prototype.POS_RIGHT = 'right';
+
+    /**
+     * Top axis position.
+     * @const {String}
+     */
+    Axis.prototype.POS_TOP = 'top';
+
+    /**
+     * Label of the axis.
+     * @type {String}
+     * @protected
+     */
+    Axis.prototype._label = null;
+
+    /**
+     * Labels of the ticks.
+     * @type {String[]}
+     * @protected
+     */
+    Axis.prototype._labels = null;
+
+    /**
+     * Maximum value of the axis.
+     * @type {Number}
+     * @protected
+     */
+    Axis.prototype._max = null;
+
+    /**
+     * Minimum value of the axis.
+     * @type {Number}
+     * @protected
+     */
+    Axis.prototype._min = null;
+
+    /**
+     * Position of the axis.
+     * @type {String}
+     * @protected
+     */
+    Axis.prototype._position = null;
+
+    /**
+     * Steps on the axis.
+     * @type {Number}
+     * @protected
+     */
+    Axis.prototype._stepSize = null;
+
+    /**
+     * Create a new instance of an axis from serialised data.
+     *
+     * @static
+     * @method create
+     * @param {Object} obj The data of the axis.
+     * @return {module:core/chart_axis}
+     */
+    Axis.prototype.create = function(obj) {
+        var s = new Axis();
+        s.setPosition(obj.position);
+        s.setLabel(obj.label);
+        s.setStepSize(obj.stepSize);
+        s.setMax(obj.max);
+        s.setMin(obj.min);
+        s.setLabels(obj.labels);
+        return s;
+    };
+
+    /**
+     * Get the label of the axis.
+     *
+     * @method getLabel
+     * @return {String}
+     */
+    Axis.prototype.getLabel = function() {
+        return this._label;
+    };
+
+    /**
+     * Get the labels of the ticks of the axis.
+     *
+     * @method getLabels
+     * @return {String[]}
+     */
+    Axis.prototype.getLabels = function() {
+        return this._labels;
+    };
+
+    /**
+     * Get the maximum value of the axis.
+     *
+     * @method getMax
+     * @return {Number}
+     */
+    Axis.prototype.getMax = function() {
+        return this._max;
+    };
+
+    /**
+     * Get the minimum value of the axis.
+     *
+     * @method getMin
+     * @return {Number}
+     */
+    Axis.prototype.getMin = function() {
+        return this._min;
+    };
+
+    /**
+     * Get the position of the axis.
+     *
+     * @method getPosition
+     * @return {String}
+     */
+    Axis.prototype.getPosition = function() {
+        return this._position;
+    };
+
+    /**
+     * Get the step size of the axis.
+     *
+     * @method getStepSize
+     * @return {Number}
+     */
+    Axis.prototype.getStepSize = function() {
+        return this._stepSize;
+    };
+
+    /**
+     * Set the label of the axis.
+     *
+     * @method setLabel
+     * @param {String} label The label.
+     */
+    Axis.prototype.setLabel = function(label) {
+        this._label = label || null;
+    };
+
+    /**
+     * Set the labels of the values on the axis.
+     *
+     * This automatically sets the [_stepSize]{@link module:core/chart_axis#_stepSize},
+     * [_min]{@link module:core/chart_axis#_min} and [_max]{@link module:core/chart_axis#_max}
+     * to define a scale from 0 to the number of labels when none of the previously
+     * mentioned values have been modified.
+     *
+     * You can use other values so long that your values in a series are mapped
+     * to the values represented by your _min, _max and _stepSize.
+     *
+     * @method setLabels
+     * @param {String[]} labels The labels.
+     */
+    Axis.prototype.setLabels = function(labels) {
+        this._labels = labels || null;
+
+        // By default we set the grid according to the labels.
+        if (this._labels !== null
+                && this._stepSize === null
+                && (this._min === null || this._min === 0)
+                && this._max === null) {
+            this.setStepSize(1);
+            this.setMin(0);
+            this.setMax(labels.length - 1);
+        }
+    };
+
+    /**
+     * Set the maximum value on the axis.
+     *
+     * When this is not set (or set to null) it is left for the output
+     * library to best guess what should be used.
+     *
+     * @method setMax
+     * @param {Number} max The value.
+     */
+    Axis.prototype.setMax = function(max) {
+        this._max = typeof max !== 'undefined' ? max : null;
+    };
+
+    /**
+     * Set the minimum value on the axis.
+     *
+     * When this is not set (or set to null) it is left for the output
+     * library to best guess what should be used.
+     *
+     * @method setMin
+     * @param {Number} min The value.
+     */
+    Axis.prototype.setMin = function(min) {
+        this._min = typeof min !== 'undefined' ? min : null;
+    };
+
+    /**
+     * Set the position of the axis.
+     *
+     * This does not validate whether or not the constant used is valid
+     * as the axis itself is not aware whether it represents the X or Y axis.
+     *
+     * The output library has to have a fallback in case the values are incorrect.
+     * When this is not set to {@link module:core/chart_axis#POS_DEFAULT} it is up
+     * to the output library to choose what position fits best.
+     *
+     * @method setPosition
+     * @param {String} position The value.
+     */
+    Axis.prototype.setPosition = function(position) {
+        if (position != this.POS_DEFAULT
+                && position != this.POS_BOTTOM
+                && position != this.POS_LEFT
+                && position != this.POS_RIGHT
+                && position != this.POS_TOP) {
+            throw new Error('Invalid axis position.');
+        }
+        this._position = position;
+    };
+
+    /**
+     * Set the stepSize on the axis.
+     *
+     * This is used to determine where ticks are displayed on the axis between min and max.
+     *
+     * @method setStepSize
+     * @param {Number} stepSize The value.
+     */
+    Axis.prototype.setStepSize = function(stepSize) {
+        if (typeof stepSize === 'undefined' || stepSize === null) {
+            stepSize = null;
+        } else if (isNaN(Number(stepSize))) {
+            throw new Error('Value for stepSize is not a number.');
+        } else {
+            stepSize = Number(stepSize);
+        }
+
+        this._stepSize = stepSize;
+    };
+
+    return Axis;
+
+});
diff --git a/lib/amd/src/chart_bar.js b/lib/amd/src/chart_bar.js
new file mode 100644 (file)
index 0000000..13c5596
--- /dev/null
@@ -0,0 +1,89 @@
+// 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/>.
+
+/**
+ * Chart bar.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_bar
+ */
+define(['core/chart_base'], function(Base) {
+
+    /**
+     * Bar chart.
+     *
+     * @alias module:core/chart_bar
+     * @extends {module:core/chart_base}
+     * @class
+     */
+    function Bar() {
+        Base.prototype.constructor.apply(this, arguments);
+    }
+    Bar.prototype = Object.create(Base.prototype);
+
+    /**
+     * Whether the bars should be displayed horizontally or not.
+     *
+     * @type {Bool}
+     * @protected
+     */
+    Bar.prototype._horizontal = false;
+
+    /** @override */
+    Bar.prototype.TYPE = 'bar';
+
+    /** @override */
+    Bar.prototype.create = function(Klass, data) {
+        var chart = Base.prototype.create.apply(this, arguments);
+        chart.setHorizontal(data.horizontal);
+        return chart;
+    };
+
+    /** @override */
+    Bar.prototype._setDefaults = function() {
+        Base.prototype._setDefaults.apply(this, arguments);
+        var axis = this.getYAxis(0, true);
+        axis.setMin(0);
+    };
+
+    /**
+     * Get whether the bars should be displayed horizontally or not.
+     *
+     * @returns {Bool}
+     */
+    Bar.prototype.getHorizontal = function() {
+        return this._horizontal;
+    };
+
+    /**
+     * Set whether the bars should be displayed horizontally or not.
+     *
+     * It sets the X Axis to zero if the min value is null.
+     *
+     * @param {Bool} horizontal True if the bars should be displayed horizontally, false otherwise.
+     */
+    Bar.prototype.setHorizontal = function(horizontal) {
+        var axis = this.getXAxis(0, true);
+        if (axis.getMin() === null) {
+            axis.setMin(0);
+        }
+        this._horizontal = Boolean(horizontal);
+    };
+
+    return Bar;
+
+});
diff --git a/lib/amd/src/chart_base.js b/lib/amd/src/chart_base.js
new file mode 100644 (file)
index 0000000..58bfe40
--- /dev/null
@@ -0,0 +1,359 @@
+// 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/>.
+
+/**
+ * Chart base.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_base
+ */
+define(['core/chart_series', 'core/chart_axis'], function(Series, Axis) {
+
+    /**
+     * Chart base.
+     *
+     * The constructor of a chart must never take any argument.
+     *
+     * {@link module:core/chart_base#_setDefault} to set the defaults on instantiation.
+     *
+     * @alias module:core/chart_base
+     * @class
+     */
+    function Base() {
+        this._series = [];
+        this._labels = [];
+        this._xaxes = [];
+        this._yaxes = [];
+
+        this._setDefaults();
+    }
+
+    /**
+     * The series constituting this chart.
+     *
+     * @protected
+     * @type {module:core/chart_series[]}
+     */
+    Base.prototype._series = null;
+
+    /**
+     * The labels of the X axis when categorised.
+     *
+     * @protected
+     * @type {String[]}
+     */
+    Base.prototype._labels = null;
+
+    /**
+     * The title of the chart.
+     *
+     * @protected
+     * @type {String}
+     */
+    Base.prototype._title = null;
+
+    /**
+     * The X axes.
+     *
+     * @protected
+     * @type {module:core/chart_axis[]}
+     */
+    Base.prototype._xaxes = null;
+
+    /**
+     * The Y axes.
+     *
+     * @protected
+     * @type {module:core/chart_axis[]}
+     */
+    Base.prototype._yaxes = null;
+
+    /**
+     * Colours to pick from when automatically assigning them.
+     *
+     * @const
+     * @type {String[]}
+     */
+    Base.prototype.COLORSET = ['#f3c300', '#875692', '#f38400', '#a1caf1', '#be0032', '#c2b280', '#7f180d', '#008856',
+            '#e68fac', '#0067a5'];
+
+    /**
+     * The type of chart.
+     *
+     * @abstract
+     * @type {String}
+     * @const
+     */
+    Base.prototype.TYPE = null;
+
+    /**
+     * Add a series to the chart.
+     *
+     * This will automatically assign a color to the series if it does not have one.
+     *
+     * @param {module:core/chart_series} series The series to add.
+     */
+    Base.prototype.addSeries = function(series) {
+        this._validateSeries(series);
+        this._series.push(series);
+
+        // Give a default color from the set.
+        if (series.getColor() === null) {
+            series.setColor(Base.prototype.COLORSET[this._series.length % Base.prototype.COLORSET.length]);
+        }
+    };
+
+    /**
+     * Create a new instance of a chart from serialised data.
+     *
+     * the serialised attributes they offer and support.
+     *
+     * @static
+     * @method create
+     * @param {module:core/chart_base} Klass The class oject representing the type of chart to instantiate.
+     * @param {Object} data The data of the chart.
+     * @return {module:core/chart_base}
+     */
+    Base.prototype.create = function(Klass, data) {
+        // TODO Not convinced about the usage of Klass here but I can't figure out a way
+        // to have a reference to the class in the sub classes, in PHP I'd do new self().
+        var Chart = new Klass();
+
+        Chart.setLabels(data.labels);
+        Chart.setTitle(data.title);
+        data.series.forEach(function(seriesData) {
+            Chart.addSeries(Series.prototype.create(seriesData));
+        });
+        data.axes.x.forEach(function(axisData, i) {
+            Chart.setXAxis(Axis.prototype.create(axisData), i);
+        });
+        data.axes.y.forEach(function(axisData, i) {
+            Chart.setYAxis(Axis.prototype.create(axisData), i);
+        });
+        return Chart;
+    };
+
+    /**
+     * Get an axis.
+     *
+     * @private
+     * @param {String} xy Accepts the values 'x' or 'y'.
+     * @param {Number} [index=0] The index of the axis of its type.
+     * @param {Bool} [createIfNotExists=false] When true, create an instance if it does not exist.
+     * @return {module:core/chart_axis}
+     */
+    Base.prototype.__getAxis = function(xy, index, createIfNotExists) {
+        var axes = xy === 'x' ? this._xaxes : this._yaxes,
+            setAxis = (xy === 'x' ? this.setXAxis : this.setYAxis).bind(this),
+            axis;
+
+        index = typeof index === 'undefined' ? 0 : index;
+        createIfNotExists = typeof createIfNotExists === 'undefined' ? false : createIfNotExists;
+        axis = axes[index];
+
+        if (typeof axis === 'undefined') {
+            if (!createIfNotExists) {
+                throw new Error('Unknown axis.');
+            }
+            axis = new Axis();
+            setAxis(axis, index);
+        }
+
+        return axis;
+    };
+
+    /**
+     * Get the labels of the X axis.
+     *
+     * @return {String[]}
+     */
+    Base.prototype.getLabels = function() {
+        return this._labels;
+    };
+
+    /**
+     * Get the series.
+     *
+     * @return {module:core/chart_series[]}
+     */
+    Base.prototype.getSeries = function() {
+        return this._series;
+    };
+
+    /**
+     * Get the title of the chart.
+     *
+     * @return {String}
+     */
+    Base.prototype.getTitle = function() {
+        return this._title;
+    };
+
+    /**
+     * Get the type of chart.
+     *
+     * @see module:core/chart_base#TYPE
+     * @return {String}
+     */
+    Base.prototype.getType = function() {
+        if (!this.TYPE) {
+            throw new Error('The TYPE property has not been set.');
+        }
+        return this.TYPE;
+    };
+
+    /**
+     * Get the X axes.
+     *
+     * @return {module:core/chart_axis[]}
+     */
+    Base.prototype.getXAxes = function() {
+        return this._xaxes;
+    };
+
+    /**
+     * Get an X axis.
+     *
+     * @param {Number} [index=0] The index of the axis.
+     * @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index.
+     * @return {module:core/chart_axis}
+     */
+    Base.prototype.getXAxis = function(index, createIfNotExists) {
+        return this.__getAxis('x', index, createIfNotExists);
+    };
+
+    /**
+     * Get the Y axes.
+     *
+     * @return {module:core/chart_axis[]}
+     */
+    Base.prototype.getYAxes = function() {
+        return this._yaxes;
+    };
+
+    /**
+     * Get an Y axis.
+     *
+     * @param {Number} [index=0] The index of the axis.
+     * @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index.
+     * @return {module:core/chart_axis}
+     */
+    Base.prototype.getYAxis = function(index, createIfNotExists) {
+        return this.__getAxis('y', index, createIfNotExists);
+    };
+
+    /**
+     * Set the defaults for this chart type.
+     *
+     * Child classes can extend this to set defaults values on instantiation.
+     *
+     * emphasize and self-document the defaults values set by the chart type.
+     *
+     * @protected
+     */
+    Base.prototype._setDefaults = function() {
+        // For the children to extend.
+    };
+
+    /**
+     * Set the labels of the X axis.
+     *
+     * This requires for each series to contain strictly as many values as there
+     * are labels.
+     *
+     * @param {String[]} labels The labels.
+     */
+    Base.prototype.setLabels = function(labels) {
+        if (labels.length && this._series.length && this._series[0].length != labels.length) {
+            throw new Error('Series must match label values.');
+        }
+        this._labels = labels;
+    };
+
+    /**
+     * Set the title of the chart.
+     *
+     * @param {String} title The title.
+     */
+    Base.prototype.setTitle = function(title) {
+        this._title = title;
+    };
+
+    /**
+     * Set an X axis.
+     *
+     * Note that this will override any predefined axis without warning.
+     *
+     * @param {module:core/chart_axis} axis The axis.
+     * @param {Number} [index=0] The index of the axis.
+     */
+    Base.prototype.setXAxis = function(axis, index) {
+        index = typeof index === 'undefined' ? 0 : index;
+        this._validateAxis('x', axis, index);
+        this._xaxes[index] = axis;
+    };
+
+    /**
+     * Set a Y axis.
+     *
+     * Note that this will override any predefined axis without warning.
+     *
+     * @param {module:core/chart_axis} axis The axis.
+     * @param {Number} [index=0] The index of the axis.
+     */
+    Base.prototype.setYAxis = function(axis, index) {
+        index = typeof index === 'undefined' ? 0 : index;
+        this._validateAxis('y', axis, index);
+        this._yaxes[index] = axis;
+    };
+
+    /**
+     * Validate an axis.
+     *
+     * @protected
+     * @param {String} xy X or Y axis.
+     * @param {module:core/chart_axis} axis The axis to validate.
+     * @param {Number} [index=0] The index of the axis.
+     */
+    Base.prototype._validateAxis = function(xy, axis, index) {
+        index = typeof index === 'undefined' ? 0 : index;
+        if (index > 0) {
+            var axes = xy == 'x' ? this._xaxes : this._yaxes;
+            if (typeof axes[index - 1] === 'undefined') {
+                throw new Error('Missing ' + xy + ' axis at index lower than ' + index);
+            }
+        }
+    };
+
+    /**
+     * Validate a series.
+     *
+     * @protected
+     * @param {module:core/chart_series} series The series to validate.
+     */
+    Base.prototype._validateSeries = function(series) {
+        if (this._series.length && this._series[0].getCount() != series.getCount()) {
+            throw new Error('Series do not have an equal number of values.');
+
+        } else if (this._labels.length && this._labels.length != series.getCount()) {
+            throw new Error('Series must match label values.');
+        }
+    };
+
+    return Base;
+
+});
diff --git a/lib/amd/src/chart_builder.js b/lib/amd/src/chart_builder.js
new file mode 100644 (file)
index 0000000..5978c89
--- /dev/null
@@ -0,0 +1,53 @@
+// 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/>.
+
+/**
+ * Chart builder.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery'], function($) {
+
+    /**
+     * Chart builder.
+     *
+     * @exports core/chart_builder
+     */
+    var module = {
+
+        /**
+         * Make a chart instance.
+         *
+         * This takes data, most likely generated in PHP, and creates a chart instance from it
+         * deferring most of the logic to {@link module:core/chart_base.create}.
+         *
+         * @param {Object} data The data.
+         * @return {Promise} A promise resolved with the chart instance.
+         */
+        make: function(data) {
+            var deferred = $.Deferred();
+            require(['core/chart_' + data.type], function(Klass) {
+                var instance = Klass.prototype.create(Klass, data);
+                deferred.resolve(instance);
+            });
+            return deferred.promise();
+        }
+    };
+
+    return module;
+
+});
diff --git a/lib/amd/src/chart_line.js b/lib/amd/src/chart_line.js
new file mode 100644 (file)
index 0000000..07ccb9b
--- /dev/null
@@ -0,0 +1,80 @@
+// 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/>.
+
+/**
+ * Chart line.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_line
+ */
+define(['core/chart_base'], function(Base) {
+
+    /**
+     * Line chart.
+     *
+     * @alias module:core/chart_line
+     * @extends {module:core/chart_base}
+     * @class
+     */
+    function Line() {
+        Base.prototype.constructor.apply(this, arguments);
+    }
+    Line.prototype = Object.create(Base.prototype);
+
+    /** @override */
+    Line.prototype.TYPE = 'line';
+
+    /**
+     * Whether the line should be smooth or not.
+     *
+     * By default the chart lines are not smooth.
+     *
+     * @type {Bool}
+     * @protected
+     */
+    Line.prototype._smooth = false;
+
+    /** @override */
+    Line.prototype.create = function(Klass, data) {
+        var chart = Base.prototype.create.apply(this, arguments);
+        chart.setSmooth(data.smooth);
+        return chart;
+    };
+
+    /**
+     * Get whether the line should be smooth or not.
+     *
+     * @method getSmooth
+     * @returns {Bool}
+     */
+    Line.prototype.getSmooth = function() {
+        return this._smooth;
+    };
+
+    /**
+     * Set whether the line should be smooth or not.
+     *
+     * @method setSmooth
+     * @param {Bool} smooth True if the line chart should be smooth, false otherwise.
+     */
+    Line.prototype.setSmooth = function(smooth) {
+        this._smooth = Boolean(smooth);
+    };
+
+    return Line;
+
+});
diff --git a/lib/amd/src/chart_output.js b/lib/amd/src/chart_output.js
new file mode 100644 (file)
index 0000000..6848841
--- /dev/null
@@ -0,0 +1,35 @@
+// 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/>.
+
+/**
+ * Chart output.
+ *
+ * Proxy to the default output module.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['core/chart_output_chartjs'], function(Output) {
+
+    /**
+     * @exports module:core/chart_output
+     * @extends {module:core/chart_output_chartjs}
+     */
+    var defaultModule = Output;
+
+    return defaultModule;
+
+});
diff --git a/lib/amd/src/chart_output_base.js b/lib/amd/src/chart_output_base.js
new file mode 100644 (file)
index 0000000..ac256e7
--- /dev/null
@@ -0,0 +1,66 @@
+// 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/>.
+
+/**
+ * Chart output base.
+ *
+ * This takes a chart object and draws it.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_output_base
+ */
+define(['jquery'], function($) {
+
+    /**
+     * Chart output base.
+     *
+     * The constructor of an output class must instantly generate and display the
+     * chart. It is also the responsability of the output module to check that
+     * the node received is of the appropriate type, if not a new node can be
+     * added within.
+     *
+     * The output module has total control over the content of the node and can
+     * clear it or output anything to it at will. A node should not be shared by
+     * two simultaneous output modules.
+     *
+     * @class
+     * @alias module:core/chart_output_base
+     * @param {Node} node The node to output with/in.
+     * @param {Chart} chart A chart object.
+     */
+    function Base(node, chart) {
+        this._node = $(node);
+        this._chart = chart;
+    }
+
+    /**
+     * Update method.
+     *
+     * This is the public method through which an output instance in informed
+     * that the chart instance has been updated and they need to update the
+     * chart rendering.
+     *
+     * @abstract
+     * @return {Void}
+     */
+    Base.prototype.update = function() {
+        throw new Error('Not supported.');
+    };
+
+    return Base;
+
+});
diff --git a/lib/amd/src/chart_output_chartjs.js b/lib/amd/src/chart_output_chartjs.js
new file mode 100644 (file)
index 0000000..b0075f2
--- /dev/null
@@ -0,0 +1,312 @@
+// 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/>.
+
+/**
+ * Chart output for chart.js.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_output_chartjs
+ */
+define([
+    'jquery',
+    'core/chartjs',
+    'core/chart_axis',
+    'core/chart_bar',
+    'core/chart_output_base',
+    'core/chart_line',
+    'core/chart_pie',
+    'core/chart_series'
+], function($, Chartjs, Axis, Bar, Base, Line, Pie, Series) {
+
+    /**
+     * Makes an axis ID.
+     *
+     * @param {String} xy Accepts 'x' and 'y'.
+     * @param {Number} index The axis index.
+     * @return {String}
+     */
+    var makeAxisId = function(xy, index) {
+        return 'axis-' + xy + '-' + index;
+    };
+
+    /**
+     * Chart output for Chart.js.
+     *
+     * @class
+     * @alias module:core/chart_output_chartjs
+     * @extends {module:core/chart_output_base}
+     */
+    function Output() {
+        Base.prototype.constructor.apply(this, arguments);
+
+        // Make sure that we've got a canvas tag.
+        this._canvas = this._node;
+        if (this._canvas.prop('tagName') != 'CANVAS') {
+            this._canvas = $('<canvas>');
+            this._node.append(this._canvas);
+        }
+
+        this._build();
+    }
+    Output.prototype = Object.create(Base.prototype);
+
+    /**
+     * Reference to the chart config object.
+     *
+     * @type {Object}
+     * @protected
+     */
+    Output.prototype._config = null;
+
+    /**
+     * Reference to the instance of chart.js.
+     *
+     * @type {Object}
+     * @protected
+     */
+    Output.prototype._chartjs = null;
+
+    /**
+     * Reference to the canvas node.
+     *
+     * @type {Jquery}
+     * @protected
+     */
+    Output.prototype._canvas = null;
+
+    /**
+     * Builds the config and the chart.
+     *
+     * @protected
+     */
+    Output.prototype._build = function() {
+        this._config = this._makeConfig();
+        this._chartjs = new Chartjs(this._canvas[0], this._config);
+    };
+
+    /**
+     * Get the chart type.
+     *
+     * It also handles the bar charts positioning, deciding if the bars should be displayed horizontally.
+     * Otherwise, get the chart TYPE value.
+     *
+     * @returns {String} the chart type.
+     * @protected
+     */
+    Output.prototype._getChartType = function() {
+        var type = this._chart.getType();
+
+        // Bars can be displayed vertically and horizontally, defining horizontalBar type.
+        if (this._chart.getType() === Bar.prototype.TYPE && this._chart.getHorizontal() === true) {
+            type = 'horizontalBar';
+        }
+
+        return type;
+    };
+
+    /**
+     * Make the axis config.
+     *
+     * @protected
+     * @param {module:core/chart_axis} axis The axis.
+     * @param {String} xy Accepts 'x' or 'y'.
+     * @param {Number} index The axis index.
+     * @return {Object} The axis config.
+     */
+    Output.prototype._makeAxisConfig = function(axis, xy, index) {
+        var scaleData = {
+            id: makeAxisId(xy, index)
+        };
+
+        if (axis.getPosition() !== Axis.prototype.POS_DEFAULT) {
+            scaleData.position = axis.getPosition();
+        }
+
+        if (axis.getLabel() !== null) {
+            scaleData.scaleLabel = {
+                display: true,
+                labelString: axis.getLabel()
+            };
+        }
+
+        if (axis.getStepSize() !== null) {
+            scaleData.ticks = scaleData.ticks || {};
+            scaleData.ticks.stepSize = axis.getStepSize();
+        }
+
+        if (axis.getMax() !== null) {
+            scaleData.ticks = scaleData.ticks || {};
+            scaleData.ticks.max = axis.getMax();
+        }
+
+        if (axis.getMin() !== null) {
+            scaleData.ticks = scaleData.ticks || {};
+            scaleData.ticks.min = axis.getMin();
+        }
+
+        return scaleData;
+    };
+
+    /**
+     * Make the config config.
+     *
+     * @protected
+     * @param {module:core/chart_axis} axis The axis.
+     * @return {Object} The axis config.
+     */
+    Output.prototype._makeConfig = function() {
+        var config = {
+            type: this._getChartType(),
+            data: {
+                labels: this._chart.getLabels(),
+                datasets: this._makeDatasetsConfig()
+            },
+            options: {
+                title: {
+                    display: this._chart.getTitle() !== null,
+                    text: this._chart.getTitle()
+                }
+            }
+        };
+
+        this._chart.getXAxes().forEach(function(axis, i) {
+            var axisLabels = axis.getLabels();
+
+            config.options.scales = config.options.scales || {};
+            config.options.scales.xAxes = config.options.scales.xAxes || [];
+            config.options.scales.xAxes[i] = this._makeAxisConfig(axis, 'x', i);
+
+            if (axisLabels !== null) {
+                config.options.scales.xAxes[i].ticks.callback = function(value, index) {
+                    return axisLabels[index] || '';
+                };
+            }
+        }.bind(this));
+
+        this._chart.getYAxes().forEach(function(axis, i) {
+            var axisLabels = axis.getLabels();
+
+            config.options.scales = config.options.scales || {};
+            config.options.scales.yAxes = config.options.scales.yAxes || [];
+            config.options.scales.yAxes[i] = this._makeAxisConfig(axis, 'y', i);
+
+            if (axisLabels !== null) {
+                config.options.scales.yAxes[i].ticks.callback = function(value) {
+                    return axisLabels[parseInt(value, 10)] || '';
+                };
+            }
+        }.bind(this));
+
+        config.options.tooltips = {
+            callbacks: {
+                label: this._makeTooltip.bind(this)
+            }
+        };
+
+        return config;
+    };
+
+    /**
+     * Get the datasets configurations.
+     *
+     * @protected
+     * @return {Object[]}
+     */
+    Output.prototype._makeDatasetsConfig = function() {
+        var sets = this._chart.getSeries().map(function(series) {
+            var colors = series.hasColoredValues() ? series.getColors() : series.getColor();
+            var dataset = {
+                label: series.getLabel(),
+                data: series.getValues(),
+                type: series.getType(),
+                fill: false,
+                backgroundColor: colors,
+                // Pie charts look better without borders.
+                borderColor: this._chart.getType() == Pie.prototype.TYPE ? null : colors,
+                lineTension: this._isSmooth(series) ? 0.3 : 0
+            };
+
+            if (series.getXAxis() !== null) {
+                dataset.xAxisID = makeAxisId('x', series.getXAxis());
+            }
+            if (series.getYAxis() !== null) {
+                dataset.yAxisID = makeAxisId('y', series.getYAxis());
+            }
+
+            return dataset;
+        }.bind(this));
+        return sets;
+    };
+
+    /**
+     * Get the chart data, add labels and rebuild the tooltip.
+     *
+     * @param {Object[]} tooltipItem The tooltip item data.
+     * @param {Object[]} data The chart data.
+     * @returns {String}
+     * @protected
+     */
+    Output.prototype._makeTooltip = function(tooltipItem, data) {
+
+        // Get series and chart data to rebuild the tooltip and add labels.
+        var series = this._chart.getSeries()[tooltipItem.datasetIndex];
+        var serieLabel = series.getLabel();
+        var serieLabels = series.getLabels();
+        var chartData = data.datasets[tooltipItem.datasetIndex].data;
+        var tooltipData = chartData[tooltipItem.index];
+
+        // Build default tooltip.
+        var tooltip = serieLabel + ': ' + tooltipData;
+
+        // Add serie labels to the tooltip if any.
+        if (serieLabels !== null) {
+            tooltip += ' ' + serieLabels[tooltipItem.index];
+        }
+
+        return tooltip;
+    };
+
+    /**
+     * Verify if the chart line is smooth or not.
+     *
+     * @protected
+     * @param {module:core/chart_series} series The series.
+     * @returns {Bool}
+     */
+    Output.prototype._isSmooth = function(series) {
+        var smooth = false;
+        if (this._chart.getType() === Line.prototype.TYPE) {
+            smooth = series.getSmooth();
+            if (smooth === null) {
+                smooth = this._chart.getSmooth();
+            }
+        } else if (series.getType() === Series.prototype.TYPE_LINE) {
+            smooth = series.getSmooth();
+        }
+
+        return smooth;
+    };
+
+    /** @override */
+    Output.prototype.update = function() {
+        $.extend(true, this._config, this._makeConfig());
+        this._chartjs.update();
+    };
+
+    return Output;
+
+});
diff --git a/lib/amd/src/chart_output_htmltable.js b/lib/amd/src/chart_output_htmltable.js
new file mode 100644 (file)
index 0000000..ab55bbd
--- /dev/null
@@ -0,0 +1,122 @@
+// 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/>.
+
+/**
+ * Chart output for HTML table.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_output_htmltable
+ */
+define([
+    'jquery',
+    'core/chart_output_base',
+], function($, Base) {
+
+    /**
+     * Render a chart as an HTML table.
+     *
+     * @class
+     * @extends {module:core/chart_output_base}
+     * @alias module:core/chart_output_htmltable
+     */
+    function Output() {
+        Base.prototype.constructor.apply(this, arguments);
+        this._build();
+    }
+    Output.prototype = Object.create(Base.prototype);
+
+    /**
+     * Attach the table to the document.
+     *
+     * @protected
+     */
+    Output.prototype._build = function() {
+        this._node.empty();
+        this._node.append(this._makeTable());
+    };
+
+    /**
+     * Builds the table node.
+     *
+     * @protected
+     * @return {Jquery}
+     */
+    Output.prototype._makeTable = function() {
+        var tbl = $('<table>'),
+            c = this._chart,
+            node,
+            value,
+            labels = c.getLabels(),
+            hasLabel = labels.length > 0,
+            series = c.getSeries(),
+            seriesLabels,
+            rowCount = series[0].getCount();
+
+        // Identify the table.
+        tbl.addClass('chart-output-htmltable');
+
+        // Set the caption.
+        if (c.getTitle() !== null) {
+            tbl.append($('<caption>').text(c.getTitle()));
+        }
+
+        // Write the column headers.
+        node = $('<tr>');
+        if (hasLabel) {
+            node.append($('<td>'));
+        }
+        series.forEach(function(serie) {
+            node.append(
+                $('<th>')
+                .text(serie.getLabel())
+                .attr('scope', 'col')
+            );
+        });
+        tbl.append(node);
+
+        // Write rows.
+        for (var rowId = 0; rowId < rowCount; rowId++) {
+            node = $('<tr>');
+            if (labels.length > 0) {
+                node.append(
+                    $('<th>')
+                    .text(labels[rowId])
+                    .attr('scope', 'row')
+                );
+            }
+            for (var serieId = 0; serieId < series.length; serieId++) {
+                value = series[serieId].getValues()[rowId];
+                seriesLabels = series[serieId].getLabels();
+                if (seriesLabels !== null) {
+                    value += ' ' + series[serieId].getLabels()[rowId];
+                }
+                node.append($('<td>').text(value));
+            }
+            tbl.append(node);
+        }
+
+        return tbl;
+    };
+
+    /** @override */
+    Output.prototype.update = function() {
+        this._build();
+    };
+
+    return Output;
+
+});
diff --git a/lib/amd/src/chart_pie.js b/lib/amd/src/chart_pie.js
new file mode 100644 (file)
index 0000000..632e109
--- /dev/null
@@ -0,0 +1,74 @@
+// 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/>.
+
+/**
+ * Chart pie.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_pie
+ */
+define(['core/chart_base'], function(Base) {
+
+    /**
+     * Pie chart.
+     *
+     * @class
+     * @alias module:core/chart_pie
+     * @extends {module:core/chart_base}
+     */
+    function Pie() {
+        Base.prototype.constructor.apply(this, arguments);
+    }
+    Pie.prototype = Object.create(Base.prototype);
+
+    /** @override */
+    Pie.prototype.TYPE = 'pie';
+
+    /**
+     * Overridden to add appropriate colors to the series.
+     *
+     * @override
+     */
+    Pie.prototype.addSeries = function(series) {
+        if (series.getColor() === null) {
+            var colors = [];
+            for (var i = 0; i < series.getCount(); i++) {
+                colors.push(this.COLORSET[i % Base.prototype.COLORSET.length]);
+            }
+            series.setColors(colors);
+        }
+        return Base.prototype.addSeries.apply(this, arguments);
+    };
+
+    /**
+     * Validate a series.
+     *
+     * Overrides parent implementation to validate that there is only
+     * one series per chart instance.
+     *
+     * @override
+     */
+    Pie.prototype._validateSeries = function() {
+        if (this._series.length >= 1) {
+            throw new Error('Pie charts only support one serie.');
+        }
+        return Base.prototype._validateSeries.apply(this, arguments);
+    };
+
+    return Pie;
+
+});
diff --git a/lib/amd/src/chart_series.js b/lib/amd/src/chart_series.js
new file mode 100644 (file)
index 0000000..90cb281
--- /dev/null
@@ -0,0 +1,344 @@
+// 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/>.
+
+/**
+ * Chart series.
+ *
+ * @package    core
+ * @copyright  2016 Frédéric Massart - FMCorz.net
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @module     core/chart_series
+ */
+define([], function() {
+
+    /**
+     * Chart data series.
+     *
+     * @class
+     * @alias module:core/chart_series
+     * @param {String} label The series label.
+     * @param {Number[]} values The values.
+     */
+    function Series(label, values) {
+        if (typeof label !== 'string') {
+            throw new Error('Invalid label for series.');
+
+        } else if (typeof values !== 'object') {
+            throw new Error('Values for a series must be an array.');
+
+        } else if (values.length < 1) {
+            throw new Error('Invalid values received for series.');
+        }
+
+        this._colors = [];
+        this._label = label;
+        this._values = values;
+    }
+
+    /**
+     * The default type of series.
+     *
+     * @type {Null}
+     * @const
+     */
+    Series.prototype.TYPE_DEFAULT = null;
+
+    /**
+     * Type of series 'line'.
+     *
+     * @type {String}
+     * @const
+     */
+    Series.prototype.TYPE_LINE = 'line';
+
+    /**
+     * The colors of the series.
+     *
+     * @type {String[]}
+     * @protected
+     */
+    Series.prototype._colors = null;
+
+    /**
+     * The label of the series.
+     *
+     * @type {String}
+     * @protected
+     */
+    Series.prototype._label = null;
+
+    /**
+     * The labels for the values of the series.
+     *
+     * @type {String[]}
+     * @protected
+     */
+     Series.prototype._labels = null;
+
+    /**
+     * Whether the line of the serie should be smooth or not.
+     *
+     * @type {Bool}
+     * @protected
+     */
+    Series.prototype._smooth = false;
+
+    /**
+     * The type of the series.
+     *
+     * @type {String}
+     * @protected
+     */
+    Series.prototype._type = Series.prototype.TYPE_DEFAULT;
+
+    /**
+     * The values in the series.
+     *
+     * @type {Number[]}
+     * @protected
+     */
+    Series.prototype._values = null;
+
+    /**
+     * The index of the X axis.
+     *
+     * @type {Number[]}
+     * @protected
+     */
+    Series.prototype._xaxis = null;
+
+    /**
+     * The index of the Y axis.
+     *
+     * @type {Number[]}
+     * @protected
+     */
+    Series.prototype._yaxis = null;
+
+    /**
+     * Create a new instance of a series from serialised data.
+     *
+     * @static
+     * @method create
+     * @param {Object} obj The data of the series.
+     * @return {module:core/chart_series}
+     */
+    Series.prototype.create = function(obj) {
+        var s = new Series(obj.label, obj.values);
+        s.setType(obj.type);
+        s.setXAxis(obj.axes.x);
+        s.setYAxis(obj.axes.y);
+        s.setLabels(obj.labels);
+
+        // Colors are exported as an array with 1, or n values.
+        if (obj.colors && obj.colors.length > 1) {
+            s.setColors(obj.colors);
+        } else {
+            s.setColor(obj.colors[0]);
+        }
+
+        s.setSmooth(obj.smooth);
+        return s;
+    };
+
+    /**
+     * Get the color.
+     *
+     * @return {String}
+     */
+    Series.prototype.getColor = function() {
+        return this._colors[0] || null;
+    };
+
+    /**
+     * Get the colors for each value in the series.
+     *
+     * @return {String[]}
+     */
+    Series.prototype.getColors = function() {
+        return this._colors;
+    };
+
+    /**
+     * Get the number of values in the series.
+     *
+     * @return {Number}
+     */
+    Series.prototype.getCount = function() {
+        return this._values.length;
+    };
+
+    /**
+     * Get the series label.
+     *
+     * @return {String}
+     */
+    Series.prototype.getLabel = function() {
+        return this._label;
+    };
+
+    /**
+     * Get labels for the values of the series.
+     *
+     * @return {String[]}
+     */
+    Series.prototype.getLabels = function() {
+        return this._labels;
+    };
+
+    /**
+     * Get whether the line of the serie should be smooth or not.
+     *
+     * @returns {Bool}
+     */
+    Series.prototype.getSmooth = function() {
+        return this._smooth;
+    };
+
+    /**
+     * Get the series type.
+     *
+     * @return {String}
+     */
+    Series.prototype.getType = function() {
+        return this._type;
+    };
+
+    /**
+     * Get the series values.
+     *
+     * @return {Number[]}
+     */
+    Series.prototype.getValues = function() {
+        return this._values;
+    };
+
+    /**
+     * Get the index of the X axis.
+     *
+     * @return {Number}
+     */
+    Series.prototype.getXAxis = function() {
+        return this._xaxis;
+    };
+
+    /**
+     * Get the index of the Y axis.
+     *
+     * @return {Number}
+     */
+    Series.prototype.getYAxis = function() {
+        return this._yaxis;
+    };
+
+    /**
+     * Whether there is a color per value.
+     *
+     * @return {Bool}
+     */
+    Series.prototype.hasColoredValues = function() {
+        return this._colors.length == this.getCount();
+    };
+
+    /**
+     * Set the series color.
+     *
+     * @param {String} color A CSS-compatible color.
+     */
+    Series.prototype.setColor = function(color) {
+        this._colors = [color];
+    };
+
+    /**
+     * Set a color for each value in the series.
+     *
+     * @param {String[]} colors CSS-compatible colors.
+     */
+    Series.prototype.setColors = function(colors) {
+        if (colors && colors.length != this.getCount()) {
+            throw new Error('When setting multiple colors there must be one per value.');
+        }
+        this._colors = colors || [];
+    };
+
+    /**
+     * Set the labels for the values of the series.
+     *
+     * @param {String[]} labels the labels of the series values.
+     */
+    Series.prototype.setLabels = function(labels) {
+        this._validateLabels(labels);
+        labels = typeof labels === 'undefined' ? null : labels;
+        this._labels = labels;
+    };
+
+    /**
+     * Set Whether the line of the serie should be smooth or not.
+     *
+     * Only applicable for line chart or a line series, if null it assumes the chart default (not smooth).
+     *
+     * @param {Bool} smooth True if the lines should be smooth, false for tensioned lines.
+     */
+    Series.prototype.setSmooth = function(smooth) {
+        smooth = typeof smooth === 'undefined' ? null : smooth;
+        this._smooth = smooth;
+    };
+
+    /**
+     * Set the type of the series.
+     *
+     * @param {String} type A type constant value.
+     */
+    Series.prototype.setType = function(type) {
+        if (type != this.TYPE_DEFAULT && type != this.TYPE_LINE) {
+            throw new Error('Invalid serie type.');
+        }
+        this._type = type || null;
+    };
+
+    /**
+     * Set the index of the X axis.
+     *
+     * @param {Number} index The index.
+     */
+    Series.prototype.setXAxis = function(index) {
+        this._xaxis = index || null;
+    };
+
+
+    /**
+     * Set the index of the Y axis.
+     *
+     * @param {Number} index The index.
+     */
+    Series.prototype.setYAxis = function(index) {
+        this._yaxis = index || null;
+    };
+
+    /**
+     * Validate series labels.
+     *
+     * @protected
+     * @param {String[]} labels The labels of the serie.
+     */
+    Series.prototype._validateLabels = function(labels) {
+        if (labels && labels.length > 0 && labels.length != this.getCount()) {
+            throw new Error('Series labels must match series values.');
+        }
+    };
+
+    return Series;
+
+});
diff --git a/lib/amd/src/chartjs-lazy.js b/lib/amd/src/chartjs-lazy.js
new file mode 100644 (file)
index 0000000..f0261ae
--- /dev/null
@@ -0,0 +1,10253 @@
+/*!\r
+ * Chart.js\r
+ * http://chartjs.org/\r
+ * Version: 2.1.6\r
+ *\r
+ * Copyright 2016 Nick Downie\r
+ * Released under the MIT license\r
+ * https://github.com/chartjs/Chart.js/blob/master/LICENSE.md\r
+ */\r
+\r
+/**\r
+ * Description of import into Moodle:\r
+ *\r
+ * - Download from http://www.chartjs.org/docs/#getting-started-download-chart-js.\r
+ * - Copy Chart.js to lib/amd/src/chartjs.js.\r
+ * - Add these instructions to the file.\r
+ * - Add the jshint ignore rules.\r
+ */\r
+\r
+(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Chart = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){\r
+\r
+},{}],2:[function(require,module,exports){\r
+/* MIT license */\r
+var colorNames = require(6);\r
+\r
+module.exports = {\r
+   getRgba: getRgba,\r
+   getHsla: getHsla,\r
+   getRgb: getRgb,\r
+   getHsl: getHsl,\r
+   getHwb: getHwb,\r
+   getAlpha: getAlpha,\r
+\r
+   hexString: hexString,\r
+   rgbString: rgbString,\r
+   rgbaString: rgbaString,\r
+   percentString: percentString,\r
+   percentaString: percentaString,\r
+   hslString: hslString,\r
+   hslaString: hslaString,\r
+   hwbString: hwbString,\r
+   keyword: keyword\r
+}\r
+\r
+function getRgba(string) {\r
+   if (!string) {\r
+      return;\r
+   }\r
+   var abbr =  /^#([a-fA-F0-9]{3})$/,\r
+       hex =  /^#([a-fA-F0-9]{6})$/,\r
+       rgba = /^rgba?\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/,\r
+       per = /^rgba?\(\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*,\s*([+-]?[\d\.]+)\%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)$/,\r
+       keyword = /(\w+)/;\r
+\r
+   var rgb = [0, 0, 0],\r
+       a = 1,\r
+       match = string.match(abbr);\r
+   if (match) {\r
+      match = match[1];\r
+      for (var i = 0; i < rgb.length; i++) {\r
+         rgb[i] = parseInt(match[i] + match[i], 16);\r
+      }\r
+   }\r
+   else if (match = string.match(hex)) {\r
+      match = match[1];\r
+      for (var i = 0; i < rgb.length; i++) {\r
+         rgb[i] = parseInt(match.slice(i * 2, i * 2 + 2), 16);\r
+      }\r
+   }\r
+   else if (match = string.match(rgba)) {\r
+      for (var i = 0; i < rgb.length; i++) {\r
+         rgb[i] = parseInt(match[i + 1]);\r
+      }\r
+      a = parseFloat(match[4]);\r
+   }\r
+   else if (match = string.match(per)) {\r
+      for (var i = 0; i < rgb.length; i++) {\r
+         rgb[i] = Math.round(parseFloat(match[i + 1]) * 2.55);\r
+      }\r
+      a = parseFloat(match[4]);\r
+   }\r
+   else if (match = string.match(keyword)) {\r
+      if (match[1] == "transparent") {\r
+         return [0, 0, 0, 0];\r
+      }\r
+      rgb = colorNames[match[1]];\r
+      if (!rgb) {\r
+         return;\r
+      }\r
+   }\r
+\r
+   for (var i = 0; i < rgb.length; i++) {\r
+      rgb[i] = scale(rgb[i], 0, 255);\r
+   }\r
+   if (!a && a != 0) {\r
+      a = 1;\r
+   }\r
+   else {\r
+      a = scale(a, 0, 1);\r
+   }\r
+   rgb[3] = a;\r
+   return rgb;\r
+}\r
+\r
+function getHsla(string) {\r
+   if (!string) {\r
+      return;\r
+   }\r
+   var hsl = /^hsla?\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/;\r
+   var match = string.match(hsl);\r
+   if (match) {\r
+      var alpha = parseFloat(match[4]);\r
+      var h = scale(parseInt(match[1]), 0, 360),\r
+          s = scale(parseFloat(match[2]), 0, 100),\r
+          l = scale(parseFloat(match[3]), 0, 100),\r
+          a = scale(isNaN(alpha) ? 1 : alpha, 0, 1);\r
+      return [h, s, l, a];\r
+   }\r
+}\r
+\r
+function getHwb(string) {\r
+   if (!string) {\r
+      return;\r
+   }\r
+   var hwb = /^hwb\(\s*([+-]?\d+)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?[\d\.]+)\s*)?\)/;\r
+   var match = string.match(hwb);\r
+   if (match) {\r
+    var alpha = parseFloat(match[4]);\r
+      var h = scale(parseInt(match[1]), 0, 360),\r
+          w = scale(parseFloat(match[2]), 0, 100),\r
+          b = scale(parseFloat(match[3]), 0, 100),\r
+          a = scale(isNaN(alpha) ? 1 : alpha, 0, 1);\r
+      return [h, w, b, a];\r
+   }\r
+}\r
+\r
+function getRgb(string) {\r
+   var rgba = getRgba(string);\r
+   return rgba && rgba.slice(0, 3);\r
+}\r
+\r
+function getHsl(string) {\r
+  var hsla = getHsla(string);\r
+  return hsla && hsla.slice(0, 3);\r
+}\r
+\r
+function getAlpha(string) {\r
+   var vals = getRgba(string);\r
+   if (vals) {\r
+      return vals[3];\r
+   }\r
+   else if (vals = getHsla(string)) {\r
+      return vals[3];\r
+   }\r
+   else if (vals = getHwb(string)) {\r
+      return vals[3];\r
+   }\r
+}\r
+\r
+// generators\r
+function hexString(rgb) {\r
+   return "#" + hexDouble(rgb[0]) + hexDouble(rgb[1])\r
+              + hexDouble(rgb[2]);\r
+}\r
+\r
+function rgbString(rgba, alpha) {\r
+   if (alpha < 1 || (rgba[3] && rgba[3] < 1)) {\r
+      return rgbaString(rgba, alpha);\r
+   }\r
+   return "rgb(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ")";\r
+}\r
+\r
+function rgbaString(rgba, alpha) {\r
+   if (alpha === undefined) {\r
+      alpha = (rgba[3] !== undefined ? rgba[3] : 1);\r
+   }\r
+   return "rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2]\r
+           + ", " + alpha + ")";\r
+}\r
+\r
+function percentString(rgba, alpha) {\r
+   if (alpha < 1 || (rgba[3] && rgba[3] < 1)) {\r
+      return percentaString(rgba, alpha);\r
+   }\r
+   var r = Math.round(rgba[0]/255 * 100),\r
+       g = Math.round(rgba[1]/255 * 100),\r
+       b = Math.round(rgba[2]/255 * 100);\r
+\r
+   return "rgb(" + r + "%, " + g + "%, " + b + "%)";\r
+}\r
+\r
+function percentaString(rgba, alpha) {\r
+   var r = Math.round(rgba[0]/255 * 100),\r
+       g = Math.round(rgba[1]/255 * 100),\r
+       b = Math.round(rgba[2]/255 * 100);\r
+   return "rgba(" + r + "%, " + g + "%, " + b + "%, " + (alpha || rgba[3] || 1) + ")";\r
+}\r
+\r
+function hslString(hsla, alpha) {\r
+   if (alpha < 1 || (hsla[3] && hsla[3] < 1)) {\r
+      return hslaString(hsla, alpha);\r
+   }\r
+   return "hsl(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%)";\r
+}\r
+\r
+function hslaString(hsla, alpha) {\r
+   if (alpha === undefined) {\r
+      alpha = (hsla[3] !== undefined ? hsla[3] : 1);\r
+   }\r
+   return "hsla(" + hsla[0] + ", " + hsla[1] + "%, " + hsla[2] + "%, "\r
+           + alpha + ")";\r
+}\r
+\r
+// hwb is a bit different than rgb(a) & hsl(a) since there is no alpha specific syntax\r
+// (hwb have alpha optional & 1 is default value)\r
+function hwbString(hwb, alpha) {\r
+   if (alpha === undefined) {\r
+      alpha = (hwb[3] !== undefined ? hwb[3] : 1);\r
+   }\r
+   return "hwb(" + hwb[0] + ", " + hwb[1] + "%, " + hwb[2] + "%"\r
+           + (alpha !== undefined && alpha !== 1 ? ", " + alpha : "") + ")";\r
+}\r
+\r
+function keyword(rgb) {\r
+  return reverseNames[rgb.slice(0, 3)];\r
+}\r
+\r
+// helpers\r
+function scale(num, min, max) {\r
+   return Math.min(Math.max(min, num), max);\r
+}\r
+\r
+function hexDouble(num) {\r
+  var str = num.toString(16).toUpperCase();\r
+  return (str.length < 2) ? "0" + str : str;\r
+}\r
+\r
+\r
+//create a list of reverse color names\r
+var reverseNames = {};\r
+for (var name in colorNames) {\r
+   reverseNames[colorNames[name]] = name;\r
+}\r
+\r
+},{"6":6}],3:[function(require,module,exports){\r
+/* MIT license */\r
+var convert = require(5);\r
+var string = require(2);\r
+\r
+var Color = function (obj) {\r
+       if (obj instanceof Color) {\r
+               return obj;\r
+       }\r
+       if (!(this instanceof Color)) {\r
+               return new Color(obj);\r
+       }\r
+\r
+       this.values = {\r
+               rgb: [0, 0, 0],\r
+               hsl: [0, 0, 0],\r
+               hsv: [0, 0, 0],\r
+               hwb: [0, 0, 0],\r
+               cmyk: [0, 0, 0, 0],\r
+               alpha: 1\r
+       };\r
+\r
+       // parse Color() argument\r
+       var vals;\r
+       if (typeof obj === 'string') {\r
+               vals = string.getRgba(obj);\r
+               if (vals) {\r
+                       this.setValues('rgb', vals);\r
+               } else if (vals = string.getHsla(obj)) {\r
+                       this.setValues('hsl', vals);\r
+               } else if (vals = string.getHwb(obj)) {\r
+                       this.setValues('hwb', vals);\r
+               } else {\r
+                       throw new Error('Unable to parse color from string "' + obj + '"');\r
+               }\r
+       } else if (typeof obj === 'object') {\r
+               vals = obj;\r
+               if (vals.r !== undefined || vals.red !== undefined) {\r
+                       this.setValues('rgb', vals);\r
+               } else if (vals.l !== undefined || vals.lightness !== undefined) {\r
+                       this.setValues('hsl', vals);\r
+               } else if (vals.v !== undefined || vals.value !== undefined) {\r
+                       this.setValues('hsv', vals);\r
+               } else if (vals.w !== undefined || vals.whiteness !== undefined) {\r
+                       this.setValues('hwb', vals);\r
+               } else if (vals.c !== undefined || vals.cyan !== undefined) {\r
+                       this.setValues('cmyk', vals);\r
+               } else {\r
+                       throw new Error('Unable to parse color from object ' + JSON.stringify(obj));\r
+               }\r
+       }\r
+};\r
+\r
+Color.prototype = {\r
+       rgb: function () {\r
+               return this.setSpace('rgb', arguments);\r
+       },\r
+       hsl: function () {\r
+               return this.setSpace('hsl', arguments);\r
+       },\r
+       hsv: function () {\r
+               return this.setSpace('hsv', arguments);\r
+       },\r
+       hwb: function () {\r
+               return this.setSpace('hwb', arguments);\r
+       },\r
+       cmyk: function () {\r
+               return this.setSpace('cmyk', arguments);\r
+       },\r
+\r
+       rgbArray: function () {\r
+               return this.values.rgb;\r
+       },\r
+       hslArray: function () {\r
+               return this.values.hsl;\r
+       },\r
+       hsvArray: function () {\r
+               return this.values.hsv;\r
+       },\r
+       hwbArray: function () {\r
+               var values = this.values;\r
+               if (values.alpha !== 1) {\r
+                       return values.hwb.concat([values.alpha]);\r
+               }\r
+               return values.hwb;\r
+       },\r
+       cmykArray: function () {\r
+               return this.values.cmyk;\r
+       },\r
+       rgbaArray: function () {\r
+               var values = this.values;\r
+               return values.rgb.concat([values.alpha]);\r
+       },\r
+       hslaArray: function () {\r
+               var values = this.values;\r
+               return values.hsl.concat([values.alpha]);\r
+       },\r
+       alpha: function (val) {\r
+               if (val === undefined) {\r
+                       return this.values.alpha;\r
+               }\r
+               this.setValues('alpha', val);\r
+               return this;\r
+       },\r
+\r
+       red: function (val) {\r
+               return this.setChannel('rgb', 0, val);\r
+       },\r
+       green: function (val) {\r
+               return this.setChannel('rgb', 1, val);\r
+       },\r
+       blue: function (val) {\r
+               return this.setChannel('rgb', 2, val);\r
+       },\r
+       hue: function (val) {\r
+               if (val) {\r
+                       val %= 360;\r
+                       val = val < 0 ? 360 + val : val;\r
+               }\r
+               return this.setChannel('hsl', 0, val);\r
+       },\r
+       saturation: function (val) {\r
+               return this.setChannel('hsl', 1, val);\r
+       },\r
+       lightness: function (val) {\r
+               return this.setChannel('hsl', 2, val);\r
+       },\r
+       saturationv: function (val) {\r
+               return this.setChannel('hsv', 1, val);\r
+       },\r
+       whiteness: function (val) {\r
+               return this.setChannel('hwb', 1, val);\r
+       },\r
+       blackness: function (val) {\r
+               return this.setChannel('hwb', 2, val);\r
+       },\r
+       value: function (val) {\r
+               return this.setChannel('hsv', 2, val);\r
+       },\r
+       cyan: function (val) {\r
+               return this.setChannel('cmyk', 0, val);\r
+       },\r
+       magenta: function (val) {\r
+               return this.setChannel('cmyk', 1, val);\r
+       },\r
+       yellow: function (val) {\r
+               return this.setChannel('cmyk', 2, val);\r
+       },\r
+       black: function (val) {\r
+               return this.setChannel('cmyk', 3, val);\r
+       },\r
+\r
+       hexString: function () {\r
+               return string.hexString(this.values.rgb);\r
+       },\r
+       rgbString: function () {\r
+               return string.rgbString(this.values.rgb, this.values.alpha);\r
+       },\r
+       rgbaString: function () {\r
+               return string.rgbaString(this.values.rgb, this.values.alpha);\r
+       },\r
+       percentString: function () {\r
+               return string.percentString(this.values.rgb, this.values.alpha);\r
+       },\r
+       hslString: function () {\r
+               return string.hslString(this.values.hsl, this.values.alpha);\r
+       },\r
+       hslaString: function () {\r
+               return string.hslaString(this.values.hsl, this.values.alpha);\r
+       },\r
+       hwbString: function () {\r
+               return string.hwbString(this.values.hwb, this.values.alpha);\r
+       },\r
+       keyword: function () {\r
+               return string.keyword(this.values.rgb, this.values.alpha);\r
+       },\r
+\r
+       rgbNumber: function () {\r
+               var rgb = this.values.rgb;\r
+               return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];\r
+       },\r
+\r
+       luminosity: function () {\r
+               // http://www.w3.org/TR/WCAG20/#relativeluminancedef\r
+               var rgb = this.values.rgb;\r
+               var lum = [];\r
+               for (var i = 0; i < rgb.length; i++) {\r
+                       var chan = rgb[i] / 255;\r
+                       lum[i] = (chan <= 0.03928) ? chan / 12.92 : Math.pow(((chan + 0.055) / 1.055), 2.4);\r
+               }\r
+               return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2];\r
+       },\r
+\r
+       contrast: function (color2) {\r
+               // http://www.w3.org/TR/WCAG20/#contrast-ratiodef\r
+               var lum1 = this.luminosity();\r
+               var lum2 = color2.luminosity();\r
+               if (lum1 > lum2) {\r
+                       return (lum1 + 0.05) / (lum2 + 0.05);\r
+               }\r
+               return (lum2 + 0.05) / (lum1 + 0.05);\r
+       },\r
+\r
+       level: function (color2) {\r
+               var contrastRatio = this.contrast(color2);\r
+               if (contrastRatio >= 7.1) {\r
+                       return 'AAA';\r
+               }\r
+\r
+               return (contrastRatio >= 4.5) ? 'AA' : '';\r
+       },\r
+\r
+       dark: function () {\r
+               // YIQ equation from http://24ways.org/2010/calculating-color-contrast\r
+               var rgb = this.values.rgb;\r
+               var yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000;\r
+               return yiq < 128;\r
+       },\r
+\r
+       light: function () {\r
+               return !this.dark();\r
+       },\r
+\r
+       negate: function () {\r
+               var rgb = [];\r
+               for (var i = 0; i < 3; i++) {\r
+                       rgb[i] = 255 - this.values.rgb[i];\r
+               }\r
+               this.setValues('rgb', rgb);\r
+               return this;\r
+       },\r
+\r
+       lighten: function (ratio) {\r
+               var hsl = this.values.hsl;\r
+               hsl[2] += hsl[2] * ratio;\r
+               this.setValues('hsl', hsl);\r
+               return this;\r
+       },\r
+\r
+       darken: function (ratio) {\r
+               var hsl = this.values.hsl;\r
+               hsl[2] -= hsl[2] * ratio;\r
+               this.setValues('hsl', hsl);\r
+               return this;\r
+       },\r
+\r
+       saturate: function (ratio) {\r
+               var hsl = this.values.hsl;\r
+               hsl[1] += hsl[1] * ratio;\r
+               this.setValues('hsl', hsl);\r
+               return this;\r
+       },\r
+\r
+       desaturate: function (ratio) {\r
+               var hsl = this.values.hsl;\r
+               hsl[1] -= hsl[1] * ratio;\r
+               this.setValues('hsl', hsl);\r
+               return this;\r
+       },\r
+\r
+       whiten: function (ratio) {\r
+               var hwb = this.values.hwb;\r
+               hwb[1] += hwb[1] * ratio;\r
+               this.setValues('hwb', hwb);\r
+               return this;\r
+       },\r
+\r
+       blacken: function (ratio) {\r
+               var hwb = this.values.hwb;\r
+               hwb[2] += hwb[2] * ratio;\r
+               this.setValues('hwb', hwb);\r
+               return this;\r
+       },\r
+\r
+       greyscale: function () {\r
+               var rgb = this.values.rgb;\r
+               // http://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale\r
+               var val = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11;\r
+               this.setValues('rgb', [val, val, val]);\r
+               return this;\r
+       },\r
+\r
+       clearer: function (ratio) {\r
+               var alpha = this.values.alpha;\r
+               this.setValues('alpha', alpha - (alpha * ratio));\r
+               return this;\r
+       },\r
+\r
+       opaquer: function (ratio) {\r
+               var alpha = this.values.alpha;\r
+               this.setValues('alpha', alpha + (alpha * ratio));\r
+               return this;\r
+       },\r
+\r
+       rotate: function (degrees) {\r
+               var hsl = this.values.hsl;\r
+               var hue = (hsl[0] + degrees) % 360;\r
+               hsl[0] = hue < 0 ? 360 + hue : hue;\r
+               this.setValues('hsl', hsl);\r
+               return this;\r
+       },\r
+\r
+       /**\r
+        * Ported from sass implementation in C\r
+        * https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209\r
+        */\r
+       mix: function (mixinColor, weight) {\r
+               var color1 = this;\r
+               var color2 = mixinColor;\r
+               var p = weight === undefined ? 0.5 : weight;\r
+\r
+               var w = 2 * p - 1;\r
+               var a = color1.alpha() - color2.alpha();\r
+\r
+               var w1 = (((w * a === -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0;\r
+               var w2 = 1 - w1;\r
+\r
+               return this\r
+                       .rgb(\r
+                               w1 * color1.red() + w2 * color2.red(),\r
+                               w1 * color1.green() + w2 * color2.green(),\r
+                               w1 * color1.blue() + w2 * color2.blue()\r
+                       )\r
+                       .alpha(color1.alpha() * p + color2.alpha() * (1 - p));\r
+       },\r
+\r
+       toJSON: function () {\r
+               return this.rgb();\r
+       },\r
+\r
+       clone: function () {\r
+               // NOTE(SB): using node-clone creates a dependency to Buffer when using browserify,\r
+               // making the final build way to big to embed in Chart.js. So let's do it manually,\r
+               // assuming that values to clone are 1 dimension arrays containing only numbers,\r
+               // except 'alpha' which is a number.\r
+               var result = new Color();\r
+               var source = this.values;\r
+               var target = result.values;\r
+               var value, type;\r
+\r
+               for (var prop in source) {\r
+                       if (source.hasOwnProperty(prop)) {\r
+                               value = source[prop];\r
+                               type = ({}).toString.call(value);\r
+                               if (type === '[object Array]') {\r
+                                       target[prop] = value.slice(0);\r
+                               } else if (type === '[object Number]') {\r
+                                       target[prop] = value;\r
+                               } else {\r
+                                       console.error('unexpected color value:', value);\r
+                               }\r
+                       }\r
+               }\r
+\r
+               return result;\r
+       }\r
+};\r
+\r
+Color.prototype.spaces = {\r
+       rgb: ['red', 'green', 'blue'],\r
+       hsl: ['hue', 'saturation', 'lightness'],\r
+       hsv: ['hue', 'saturation', 'value'],\r
+       hwb: ['hue', 'whiteness', 'blackness'],\r
+       cmyk: ['cyan', 'magenta', 'yellow', 'black']\r
+};\r
+\r
+Color.prototype.maxes = {\r
+       rgb: [255, 255, 255],\r
+       hsl: [360, 100, 100],\r
+       hsv: [360, 100, 100],\r
+       hwb: [360, 100, 100],\r
+       cmyk: [100, 100, 100, 100]\r
+};\r
+\r
+Color.prototype.getValues = function (space) {\r
+       var values = this.values;\r
+       var vals = {};\r
+\r
+       for (var i = 0; i < space.length; i++) {\r
+               vals[space.charAt(i)] = values[space][i];\r
+       }\r
+\r
+       if (values.alpha !== 1) {\r
+               vals.a = values.alpha;\r
+       }\r
+\r
+       // {r: 255, g: 255, b: 255, a: 0.4}\r
+       return vals;\r
+};\r
+\r
+Color.prototype.setValues = function (space, vals) {\r
+       var values = this.values;\r
+       var spaces = this.spaces;\r
+       var maxes = this.maxes;\r
+       var alpha = 1;\r
+       var i;\r
+\r
+       if (space === 'alpha') {\r
+               alpha = vals;\r
+       } else if (vals.length) {\r
+               // [10, 10, 10]\r
+               values[space] = vals.slice(0, space.length);\r
+               alpha = vals[space.length];\r
+       } else if (vals[space.charAt(0)] !== undefined) {\r
+               // {r: 10, g: 10, b: 10}\r
+               for (i = 0; i < space.length; i++) {\r
+                       values[space][i] = vals[space.charAt(i)];\r
+               }\r
+\r
+               alpha = vals.a;\r
+       } else if (vals[spaces[space][0]] !== undefined) {\r
+               // {red: 10, green: 10, blue: 10}\r
+               var chans = spaces[space];\r
+\r
+               for (i = 0; i < space.length; i++) {\r
+                       values[space][i] = vals[chans[i]];\r
+               }\r
+\r
+               alpha = vals.alpha;\r
+       }\r
+\r
+       values.alpha = Math.max(0, Math.min(1, (alpha === undefined ? values.alpha : alpha)));\r
+\r
+       if (space === 'alpha') {\r
+               return false;\r
+       }\r
+\r
+       var capped;\r
+\r
+       // cap values of the space prior converting all values\r
+       for (i = 0; i < space.length; i++) {\r
+               capped = Math.max(0, Math.min(maxes[space][i], values[space][i]));\r
+               values[space][i] = Math.round(capped);\r
+       }\r
+\r
+       // convert to all the other color spaces\r
+       for (var sname in spaces) {\r
+               if (sname !== space) {\r
+                       values[sname] = convert[space][sname](values[space]);\r
+               }\r
+       }\r
+\r
+       return true;\r
+};\r
+\r
+Color.prototype.setSpace = function (space, args) {\r
+       var vals = args[0];\r
+\r
+       if (vals === undefined) {\r
+               // color.rgb()\r
+               return this.getValues(space);\r
+       }\r
+\r
+       // color.rgb(10, 10, 10)\r
+       if (typeof vals === 'number') {\r
+               vals = Array.prototype.slice.call(args);\r
+       }\r
+\r
+       this.setValues(space, vals);\r
+       return this;\r
+};\r
+\r
+Color.prototype.setChannel = function (space, index, val) {\r
+       var svalues = this.values[space];\r
+       if (val === undefined) {\r
+               // color.red()\r
+               return svalues[index];\r
+       } else if (val === svalues[index]) {\r
+               // color.red(color.red())\r
+               return this;\r
+       }\r
+\r
+       // color.red(100)\r
+       svalues[index] = val;\r
+       this.setValues(space, svalues);\r
+\r
+       return this;\r
+};\r
+\r
+if (typeof window !== 'undefined') {\r
+       window.Color = Color;\r
+}\r
+\r
+module.exports = Color;\r
+\r
+},{"2":2,"5":5}],4:[function(require,module,exports){\r
+/* MIT license */\r
+\r
+module.exports = {\r
+  rgb2hsl: rgb2hsl,\r
+  rgb2hsv: rgb2hsv,\r
+  rgb2hwb: rgb2hwb,\r
+  rgb2cmyk: rgb2cmyk,\r
+  rgb2keyword: rgb2keyword,\r
+  rgb2xyz: rgb2xyz,\r
+  rgb2lab: rgb2lab,\r
+  rgb2lch: rgb2lch,\r
+\r
+  hsl2rgb: hsl2rgb,\r
+  hsl2hsv: hsl2hsv,\r
+  hsl2hwb: hsl2hwb,\r
+  hsl2cmyk: hsl2cmyk,\r
+  hsl2keyword: hsl2keyword,\r
+\r
+  hsv2rgb: hsv2rgb,\r
+  hsv2hsl: hsv2hsl,\r
+  hsv2hwb: hsv2hwb,\r
+  hsv2cmyk: hsv2cmyk,\r
+  hsv2keyword: hsv2keyword,\r
+\r
+  hwb2rgb: hwb2rgb,\r
+  hwb2hsl: hwb2hsl,\r
+  hwb2hsv: hwb2hsv,\r
+  hwb2cmyk: hwb2cmyk,\r
+  hwb2keyword: hwb2keyword,\r
+\r
+  cmyk2rgb: cmyk2rgb,\r
+  cmyk2hsl: cmyk2hsl,\r
+  cmyk2hsv: cmyk2hsv,\r
+  cmyk2hwb: cmyk2hwb,\r
+  cmyk2keyword: cmyk2keyword,\r
+\r
+  keyword2rgb: keyword2rgb,\r
+  keyword2hsl: keyword2hsl,\r
+  keyword2hsv: keyword2hsv,\r
+  keyword2hwb: keyword2hwb,\r
+  keyword2cmyk: keyword2cmyk,\r
+  keyword2lab: keyword2lab,\r
+  keyword2xyz: keyword2xyz,\r
+\r
+  xyz2rgb: xyz2rgb,\r
+  xyz2lab: xyz2lab,\r
+  xyz2lch: xyz2lch,\r
+\r
+  lab2xyz: lab2xyz,\r
+  lab2rgb: lab2rgb,\r
+  lab2lch: lab2lch,\r
+\r
+  lch2lab: lch2lab,\r
+  lch2xyz: lch2xyz,\r
+  lch2rgb: lch2rgb\r
+}\r
+\r
+\r
+function rgb2hsl(rgb) {\r
+  var r = rgb[0]/255,\r
+      g = rgb[1]/255,\r
+      b = rgb[2]/255,\r
+      min = Math.min(r, g, b),\r
+      max = Math.max(r, g, b),\r
+      delta = max - min,\r
+      h, s, l;\r
+\r
+  if (max == min)\r
+    h = 0;\r
+  else if (r == max)\r
+    h = (g - b) / delta;\r
+  else if (g == max)\r
+    h = 2 + (b - r) / delta;\r
+  else if (b == max)\r
+    h = 4 + (r - g)/ delta;\r
+\r
+  h = Math.min(h * 60, 360);\r
+\r
+  if (h < 0)\r
+    h += 360;\r
+\r
+  l = (min + max) / 2;\r
+\r
+  if (max == min)\r
+    s = 0;\r
+  else if (l <= 0.5)\r
+    s = delta / (max + min);\r
+  else\r
+    s = delta / (2 - max - min);\r
+\r
+  return [h, s * 100, l * 100];\r
+}\r
+\r
+function rgb2hsv(rgb) {\r
+  var r = rgb[0],\r
+      g = rgb[1],\r
+      b = rgb[2],\r
+      min = Math.min(r, g, b),\r
+      max = Math.max(r, g, b),\r
+      delta = max - min,\r
+      h, s, v;\r
+\r
+  if (max == 0)\r
+    s = 0;\r
+  else\r
+    s = (delta/max * 1000)/10;\r
+\r
+  if (max == min)\r
+    h = 0;\r
+  else if (r == max)\r
+    h = (g - b) / delta;\r
+  else if (g == max)\r
+    h = 2 + (b - r) / delta;\r
+  else if (b == max)\r
+    h = 4 + (r - g) / delta;\r
+\r
+  h = Math.min(h * 60, 360);\r
+\r
+  if (h < 0)\r
+    h += 360;\r
+\r
+  v = ((max / 255) * 1000) / 10;\r
+\r
+  return [h, s, v];\r
+}\r
+\r
+function rgb2hwb(rgb) {\r
+  var r = rgb[0],\r
+      g = rgb[1],\r
+      b = rgb[2],\r
+      h = rgb2hsl(rgb)[0],\r
+      w = 1/255 * Math.min(r, Math.min(g, b)),\r
+      b = 1 - 1/255 * Math.max(r, Math.max(g, b));\r
+\r
+  return [h, w * 100, b * 100];\r
+}\r
+\r
+function rgb2cmyk(rgb) {\r
+  var r = rgb[0] / 255,\r
+      g = rgb[1] / 255,\r
+      b = rgb[2] / 255,\r
+      c, m, y, k;\r
+\r
+  k = Math.min(1 - r, 1 - g, 1 - b);\r
+  c = (1 - r - k) / (1 - k) || 0;\r
+  m = (1 - g - k) / (1 - k) || 0;\r
+  y = (1 - b - k) / (1 - k) || 0;\r
+  return [c * 100, m * 100, y * 100, k * 100];\r
+}\r
+\r
+function rgb2keyword(rgb) {\r
+  return reverseKeywords[JSON.stringify(rgb)];\r
+}\r
+\r
+function rgb2xyz(rgb) {\r
+  var r = rgb[0] / 255,\r
+      g = rgb[1] / 255,\r
+      b = rgb[2] / 255;\r
+\r
+  // assume sRGB\r
+  r = r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : (r / 12.92);\r
+  g = g > 0.04045 ? Math.pow(((g + 0.055) / 1.055), 2.4) : (g / 12.92);\r
+  b = b > 0.04045 ? Math.pow(((b + 0.055) / 1.055), 2.4) : (b / 12.92);\r
+\r
+  var x = (r * 0.4124) + (g * 0.3576) + (b * 0.1805);\r
+  var y = (r * 0.2126) + (g * 0.7152) + (b * 0.0722);\r
+  var z = (r * 0.0193) + (g * 0.1192) + (b * 0.9505);\r
+\r
+  return [x * 100, y *100, z * 100];\r
+}\r
+\r
+function rgb2lab(rgb) {\r
+  var xyz = rgb2xyz(rgb),\r
+        x = xyz[0],\r
+        y = xyz[1],\r
+        z = xyz[2],\r
+        l, a, b;\r
+\r
+  x /= 95.047;\r
+  y /= 100;\r
+  z /= 108.883;\r
+\r
+  x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116);\r
+  y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116);\r
+  z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116);\r
+\r
+  l = (116 * y) - 16;\r
+  a = 500 * (x - y);\r
+  b = 200 * (y - z);\r
+\r
+  return [l, a, b];\r
+}\r
+\r
+function rgb2lch(args) {\r
+  return lab2lch(rgb2lab(args));\r
+}\r
+\r
+function hsl2rgb(hsl) {\r
+  var h = hsl[0] / 360,\r
+      s = hsl[1] / 100,\r
+      l = hsl[2] / 100,\r
+      t1, t2, t3, rgb, val;\r
+\r
+  if (s == 0) {\r
+    val = l * 255;\r
+    return [val, val, val];\r
+  }\r
+\r
+  if (l < 0.5)\r
+    t2 = l * (1 + s);\r
+  else\r
+    t2 = l + s - l * s;\r
+  t1 = 2 * l - t2;\r
+\r
+  rgb = [0, 0, 0];\r
+  for (var i = 0; i < 3; i++) {\r
+    t3 = h + 1 / 3 * - (i - 1);\r
+    t3 < 0 && t3++;\r
+    t3 > 1 && t3--;\r
+\r
+    if (6 * t3 < 1)\r
+      val = t1 + (t2 - t1) * 6 * t3;\r
+    else if (2 * t3 < 1)\r
+      val = t2;\r
+    else if (3 * t3 < 2)\r
+      val = t1 + (t2 - t1) * (2 / 3 - t3) * 6;\r
+    else\r
+      val = t1;\r
+\r
+    rgb[i] = val * 255;\r
+  }\r
+\r
+  return rgb;\r
+}\r
+\r
+function hsl2hsv(hsl) {\r
+  var h = hsl[0],\r
+      s = hsl[1] / 100,\r
+      l = hsl[2] / 100,\r
+      sv, v;\r
+\r
+  if(l === 0) {\r
+      // no need to do calc on black\r
+      // also avoids divide by 0 error\r
+      return [0, 0, 0];\r
+  }\r
+\r
+  l *= 2;\r
+  s *= (l <= 1) ? l : 2 - l;\r
+  v = (l + s) / 2;\r
+  sv = (2 * s) / (l + s);\r
+  return [h, sv * 100, v * 100];\r
+}\r
+\r
+function hsl2hwb(args) {\r
+  return rgb2hwb(hsl2rgb(args));\r
+}\r
+\r
+function hsl2cmyk(args) {\r
+  return rgb2cmyk(hsl2rgb(args));\r
+}\r
+\r
+function hsl2keyword(args) {\r
+  return rgb2keyword(hsl2rgb(args));\r
+}\r
+\r
+\r
+function hsv2rgb(hsv) {\r
+  var h = hsv[0] / 60,\r
+      s = hsv[1] / 100,\r
+      v = hsv[2] / 100,\r
+      hi = Math.floor(h) % 6;\r
+\r
+  var f = h - Math.floor(h),\r
+      p = 255 * v * (1 - s),\r
+      q = 255 * v * (1 - (s * f)),\r
+      t = 255 * v * (1 - (s * (1 - f))),\r
+      v = 255 * v;\r
+\r
+  switch(hi) {\r
+    case 0:\r
+      return [v, t, p];\r
+    case 1:\r
+      return [q, v, p];\r
+    case 2:\r
+      return [p, v, t];\r
+    case 3:\r
+      return [p, q, v];\r
+    case 4:\r
+      return [t, p, v];\r
+    case 5:\r
+      return [v, p, q];\r
+  }\r
+}\r
+\r
+function hsv2hsl(hsv) {\r
+  var h = hsv[0],\r
+      s = hsv[1] / 100,\r
+      v = hsv[2] / 100,\r
+      sl, l;\r
+\r
+  l = (2 - s) * v;\r
+  sl = s * v;\r
+  sl /= (l <= 1) ? l : 2 - l;\r
+  sl = sl || 0;\r
+  l /= 2;\r
+  return [h, sl * 100, l * 100];\r
+}\r
+\r
+function hsv2hwb(args) {\r
+  return rgb2hwb(hsv2rgb(args))\r
+}\r
+\r
+function hsv2cmyk(args) {\r
+  return rgb2cmyk(hsv2rgb(args));\r
+}\r
+\r
+function hsv2keyword(args) {\r
+  return rgb2keyword(hsv2rgb(args));\r
+}\r
+\r
+// http://dev.w3.org/csswg/css-color/#hwb-to-rgb\r
+function hwb2rgb(hwb) {\r
+  var h = hwb[0] / 360,\r
+      wh = hwb[1] / 100,\r
+      bl = hwb[2] / 100,\r
+      ratio = wh + bl,\r
+      i, v, f, n;\r
+\r
+  // wh + bl cant be > 1\r
+  if (ratio > 1) {\r
+    wh /= ratio;\r
+    bl /= ratio;\r
+  }\r
+\r
+  i = Math.floor(6 * h);\r
+  v = 1 - bl;\r
+  f = 6 * h - i;\r
+  if ((i & 0x01) != 0) {\r
+    f = 1 - f;\r
+  }\r
+  n = wh + f * (v - wh);  // linear interpolation\r
+\r
+  switch (i) {\r
+    default:\r
+    case 6:\r
+    case 0: r = v; g = n; b = wh; break;\r
+    case 1: r = n; g = v; b = wh; break;\r
+    case 2: r = wh; g = v; b = n; break;\r
+    case 3: r = wh; g = n; b = v; break;\r
+    case 4: r = n; g = wh; b = v; break;\r
+    case 5: r = v; g = wh; b = n; break;\r
+  }\r
+\r
+  return [r * 255, g * 255, b * 255];\r
+}\r
+\r
+function hwb2hsl(args) {\r
+  return rgb2hsl(hwb2rgb(args));\r
+}\r
+\r
+function hwb2hsv(args) {\r
+  return rgb2hsv(hwb2rgb(args));\r
+}\r
+\r
+function hwb2cmyk(args) {\r
+  return rgb2cmyk(hwb2rgb(args));\r
+}\r
+\r
+function hwb2keyword(args) {\r
+  return rgb2keyword(hwb2rgb(args));\r
+}\r
+\r
+function cmyk2rgb(cmyk) {\r
+  var c = cmyk[0] / 100,\r
+      m = cmyk[1] / 100,\r
+      y = cmyk[2] / 100,\r
+      k = cmyk[3] / 100,\r
+      r, g, b;\r
+\r
+  r = 1 - Math.min(1, c * (1 - k) + k);\r
+  g = 1 - Math.min(1, m * (1 - k) + k);\r
+  b = 1 - Math.min(1, y * (1 - k) + k);\r
+  return [r * 255, g * 255, b * 255];\r
+}\r
+\r
+function cmyk2hsl(args) {\r
+  return rgb2hsl(cmyk2rgb(args));\r
+}\r
+\r
+function cmyk2hsv(args) {\r
+  return rgb2hsv(cmyk2rgb(args));\r
+}\r
+\r
+function cmyk2hwb(args) {\r
+  return rgb2hwb(cmyk2rgb(args));\r
+}\r
+\r
+function cmyk2keyword(args) {\r
+  return rgb2keyword(cmyk2rgb(args));\r
+}\r
+\r
+\r
+function xyz2rgb(xyz) {\r
+  var x = xyz[0] / 100,\r
+      y = xyz[1] / 100,\r
+      z = xyz[2] / 100,\r
+      r, g, b;\r
+\r
+  r = (x * 3.2406) + (y * -1.5372) + (z * -0.4986);\r
+  g = (x * -0.9689) + (y * 1.8758) + (z * 0.0415);\r
+  b = (x * 0.0557) + (y * -0.2040) + (z * 1.0570);\r
+\r
+  // assume sRGB\r
+  r = r > 0.0031308 ? ((1.055 * Math.pow(r, 1.0 / 2.4)) - 0.055)\r
+    : r = (r * 12.92);\r
+\r
+  g = g > 0.0031308 ? ((1.055 * Math.pow(g, 1.0 / 2.4)) - 0.055)\r
+    : g = (g * 12.92);\r
+\r
+  b = b > 0.0031308 ? ((1.055 * Math.pow(b, 1.0 / 2.4)) - 0.055)\r
+    : b = (b * 12.92);\r
+\r
+  r = Math.min(Math.max(0, r), 1);\r
+  g = Math.min(Math.max(0, g), 1);\r
+  b = Math.min(Math.max(0, b), 1);\r
+\r
+  return [r * 255, g * 255, b * 255];\r
+}\r
+\r
+function xyz2lab(xyz) {\r
+  var x = xyz[0],\r
+      y = xyz[1],\r
+      z = xyz[2],\r
+      l, a, b;\r
+\r
+  x /= 95.047;\r
+  y /= 100;\r
+  z /= 108.883;\r
+\r
+  x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116);\r
+  y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116);\r
+  z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116);\r
+\r
+  l = (116 * y) - 16;\r
+  a = 500 * (x - y);\r
+  b = 200 * (y - z);\r
+\r
+  return [l, a, b];\r
+}\r
+\r
+function xyz2lch(args) {\r
+  return lab2lch(xyz2lab(args));\r
+}\r
+\r
+function lab2xyz(lab) {\r
+  var l = lab[0],\r
+      a = lab[1],\r
+      b = lab[2],\r
+      x, y, z, y2;\r
+\r
+  if (l <= 8) {\r
+    y = (l * 100) / 903.3;\r
+    y2 = (7.787 * (y / 100)) + (16 / 116);\r
+  } else {\r
+    y = 100 * Math.pow((l + 16) / 116, 3);\r
+    y2 = Math.pow(y / 100, 1/3);\r
+  }\r
+\r
+  x = x / 95.047 <= 0.008856 ? x = (95.047 * ((a / 500) + y2 - (16 / 116))) / 7.787 : 95.047 * Math.pow((a / 500) + y2, 3);\r
+\r
+  z = z / 108.883 <= 0.008859 ? z = (108.883 * (y2 - (b / 200) - (16 / 116))) / 7.787 : 108.883 * Math.pow(y2 - (b / 200), 3);\r
+\r
+  return [x, y, z];\r
+}\r
+\r
+function lab2lch(lab) {\r
+  var l = lab[0],\r
+      a = lab[1],\r
+      b = lab[2],\r
+      hr, h, c;\r
+\r
+  hr = Math.atan2(b, a);\r
+  h = hr * 360 / 2 / Math.PI;\r
+  if (h < 0) {\r
+    h += 360;\r
+  }\r
+  c = Math.sqrt(a * a + b * b);\r
+  return [l, c, h];\r
+}\r
+\r
+function lab2rgb(args) {\r
+  return xyz2rgb(lab2xyz(args));\r
+}\r
+\r
+function lch2lab(lch) {\r
+  var l = lch[0],\r
+      c = lch[1],\r
+      h = lch[2],\r
+      a, b, hr;\r
+\r
+  hr = h / 360 * 2 * Math.PI;\r
+  a = c * Math.cos(hr);\r
+  b = c * Math.sin(hr);\r
+  return [l, a, b];\r
+}\r
+\r
+function lch2xyz(args) {\r
+  return lab2xyz(lch2lab(args));\r
+}\r
+\r
+function lch2rgb(args) {\r
+  return lab2rgb(lch2lab(args));\r
+}\r
+\r
+function keyword2rgb(keyword) {\r
+  return cssKeywords[keyword];\r
+}\r
+\r
+function keyword2hsl(args) {\r
+  return rgb2hsl(keyword2rgb(args));\r
+}\r
+\r
+function keyword2hsv(args) {\r
+  return rgb2hsv(keyword2rgb(args));\r
+}\r
+\r
+function keyword2hwb(args) {\r
+  return rgb2hwb(keyword2rgb(args));\r
+}\r
+\r
+function keyword2cmyk(args) {\r
+  return rgb2cmyk(keyword2rgb(args));\r
+}\r
+\r
+function keyword2lab(args) {\r
+  return rgb2lab(keyword2rgb(args));\r
+}\r
+\r
+function keyword2xyz(args) {\r
+  return rgb2xyz(keyword2rgb(args));\r
+}\r
+\r
+var cssKeywords = {\r
+  aliceblue:  [240,248,255],\r
+  antiquewhite: [250,235,215],\r
+  aqua: [0,255,255],\r
+  aquamarine: [127,255,212],\r
+  azure:  [240,255,255],\r
+  beige:  [245,245,220],\r
+  bisque: [255,228,196],\r
+  black:  [0,0,0],\r
+  blanchedalmond: [255,235,205],\r
+  blue: [0,0,255],\r
+  blueviolet: [138,43,226],\r
+  brown:  [165,42,42],\r
+  burlywood:  [222,184,135],\r
+  cadetblue:  [95,158,160],\r
+  chartreuse: [127,255,0],\r
+  chocolate:  [210,105,30],\r
+  coral:  [255,127,80],\r
+  cornflowerblue: [100,149,237],\r
+  cornsilk: [255,248,220],\r
+  crimson:  [220,20,60],\r
+  cyan: [0,255,255],\r
+  darkblue: [0,0,139],\r
+  darkcyan: [0,139,139],\r
+  darkgoldenrod:  [184,134,11],\r
+  darkgray: [169,169,169],\r
+  darkgreen:  [0,100,0],\r
+  darkgrey: [169,169,169],\r
+  darkkhaki:  [189,183,107],\r
+  darkmagenta:  [139,0,139],\r
+  darkolivegreen: [85,107,47],\r
+  darkorange: [255,140,0],\r
+  darkorchid: [153,50,204],\r
+  darkred:  [139,0,0],\r
+  darksalmon: [233,150,122],\r
+  darkseagreen: [143,188,143],\r
+  darkslateblue:  [72,61,139],\r
+  darkslategray:  [47,79,79],\r
+  darkslategrey:  [47,79,79],\r
+  darkturquoise:  [0,206,209],\r
+  darkviolet: [148,0,211],\r
+  deeppink: [255,20,147],\r
+  deepskyblue:  [0,191,255],\r
+  dimgray:  [105,105,105],\r
+  dimgrey:  [105,105,105],\r
+  dodgerblue: [30,144,255],\r
+  firebrick:  [178,34,34],\r
+  floralwhite:  [255,250,240],\r
+  forestgreen:  [34,139,34],\r
+  fuchsia:  [255,0,255],\r
+  gainsboro:  [220,220,220],\r
+  ghostwhite: [248,248,255],\r
+  gold: [255,215,0],\r
+  goldenrod:  [218,165,32],\r
+  gray: [128,128,128],\r
+  green:  [0,128,0],\r
+  greenyellow:  [173,255,47],\r
+  grey: [128,128,128],\r
+  honeydew: [240,255,240],\r
+  hotpink:  [255,105,180],\r
+  indianred:  [205,92,92],\r
+  indigo: [75,0,130],\r
+  ivory:  [255,255,240],\r
+  khaki:  [240,230,140],\r
+  lavender: [230,230,250],\r
+  lavenderblush:  [255,240,245],\r
+  lawngreen:  [124,252,0],\r
+  lemonchiffon: [255,250,205],\r
+  lightblue:  [173,216,230],\r
+  lightcoral: [240,128,128],\r
+  lightcyan:  [224,255,255],\r
+  lightgoldenrodyellow: [250,250,210],\r
+  lightgray:  [211,211,211],\r
+  lightgreen: [144,238,144],\r
+  lightgrey:  [211,211,211],\r
+  lightpink:  [255,182,193],\r
+  lightsalmon:  [255,160,122],\r
+  lightseagreen:  [32,178,170],\r
+  lightskyblue: [135,206,250],\r
+  lightslategray: [119,136,153],\r
+  lightslategrey: [119,136,153],\r
+  lightsteelblue: [176,196,222],\r
+  lightyellow:  [255,255,224],\r
+  lime: [0,255,0],\r
+  limegreen:  [50,205,50],\r
+  linen:  [250,240,230],\r
+  magenta:  [255,0,255],\r
+  maroon: [128,0,0],\r
+  mediumaquamarine: [102,205,170],\r
+  mediumblue: [0,0,205],\r
+  mediumorchid: [186,85,211],\r
+  mediumpurple: [147,112,219],\r
+  mediumseagreen: [60,179,113],\r
+  mediumslateblue:  [123,104,238],\r
+  mediumspringgreen:  [0,250,154],\r
+  mediumturquoise:  [72,209,204],\r
+  mediumvioletred:  [199,21,133],\r
+  midnightblue: [25,25,112],\r
+  mintcream:  [245,255,250],\r
+  mistyrose:  [255,228,225],\r
+  moccasin: [255,228,181],\r
+  navajowhite:  [255,222,173],\r
+  navy: [0,0,128],\r
+  oldlace:  [253,245,230],\r
+  olive:  [128,128,0],\r
+  olivedrab:  [107,142,35],\r
+  orange: [255,165,0],\r
+  orangered:  [255,69,0],\r
+  orchid: [218,112,214],\r
+  palegoldenrod:  [238,232,170],\r
+  palegreen:  [152,251,152],\r
+  paleturquoise:  [175,238,238],\r
+  palevioletred:  [219,112,147],\r
+  papayawhip: [255,239,213],\r
+  peachpuff:  [255,218,185],\r
+  peru: [205,133,63],\r
+  pink: [255,192,203],\r
+  plum: [221,160,221],\r
+  powderblue: [176,224,230],\r
+  purple: [128,0,128],\r
+  rebeccapurple: [102, 51, 153],\r
+  red:  [255,0,0],\r
+  rosybrown:  [188,143,143],\r
+  royalblue:  [65,105,225],\r
+  saddlebrown:  [139,69,19],\r
+  salmon: [250,128,114],\r
+  sandybrown: [244,164,96],\r
+  seagreen: [46,139,87],\r
+  seashell: [255,245,238],\r
+  sienna: [160,82,45],\r
+  silver: [192,192,192],\r
+  skyblue:  [135,206,235],\r
+  slateblue:  [106,90,205],\r
+  slategray:  [112,128,144],\r
+  slategrey:  [112,128,144],\r
+  snow: [255,250,250],\r
+  springgreen:  [0,255,127],\r
+  steelblue:  [70,130,180],\r
+  tan:  [210,180,140],\r
+  teal: [0,128,128],\r
+  thistle:  [216,191,216],\r
+  tomato: [255,99,71],\r
+  turquoise:  [64,224,208],\r
+  violet: [238,130,238],\r
+  wheat:  [245,222,179],\r
+  white:  [255,255,255],\r
+  whitesmoke: [245,245,245],\r
+  yellow: [255,255,0],\r
+  yellowgreen:  [154,205,50]\r
+};\r
+\r
+var reverseKeywords = {};\r
+for (var key in cssKeywords) {\r
+  reverseKeywords[JSON.stringify(cssKeywords[key])] = key;\r
+}\r
+\r
+},{}],5:[function(require,module,exports){\r
+var conversions = require(4);\r
+\r
+var convert = function() {\r
+   return new Converter();\r
+}\r
+\r
+for (var func in conversions) {\r
+  // export Raw versions\r
+  convert[func + "Raw"] =  (function(func) {\r
+    // accept array or plain args\r
+    return function(arg) {\r
+      if (typeof arg == "number")\r
+        arg = Array.prototype.slice.call(arguments);\r
+      return conversions[func](arg);\r
+    }\r
+  })(func);\r
+\r
+  var pair = /(\w+)2(\w+)/.exec(func),\r
+      from = pair[1],\r
+      to = pair[2];\r
+\r
+  // export rgb2hsl and ["rgb"]["hsl"]\r
+  convert[from] = convert[from] || {};\r
+\r
+  convert[from][to] = convert[func] = (function(func) { \r
+    return function(arg) {\r
+      if (typeof arg == "number")\r
+        arg = Array.prototype.slice.call(arguments);\r
+      \r
+      var val = conversions[func](arg);\r
+      if (typeof val == "string" || val === undefined)\r
+        return val; // keyword\r
+\r
+      for (var i = 0; i < val.length; i++)\r
+        val[i] = Math.round(val[i]);\r
+      return val;\r
+    }\r
+  })(func);\r
+}\r
+\r
+\r
+/* Converter does lazy conversion and caching */\r
+var Converter = function() {\r
+   this.convs = {};\r
+};\r
+\r
+/* Either get the values for a space or\r
+  set the values for a space, depending on args */\r
+Converter.prototype.routeSpace = function(space, args) {\r
+   var values = args[0];\r
+   if (values === undefined) {\r
+      // color.rgb()\r
+      return this.getValues(space);\r
+   }\r
+   // color.rgb(10, 10, 10)\r
+   if (typeof values == "number") {\r
+      values = Array.prototype.slice.call(args);        \r
+   }\r
+\r
+   return this.setValues(space, values);\r
+};\r
+  \r
+/* Set the values for a space, invalidating cache */\r
+Converter.prototype.setValues = function(space, values) {\r
+   this.space = space;\r
+   this.convs = {};\r
+   this.convs[space] = values;\r
+   return this;\r
+};\r
+\r
+/* Get the values for a space. If there's already\r
+  a conversion for the space, fetch it, otherwise\r
+  compute it */\r
+Converter.prototype.getValues = function(space) {\r
+   var vals = this.convs[space];\r
+   if (!vals) {\r
+      var fspace = this.space,\r
+          from = this.convs[fspace];\r
+      vals = convert[fspace][space](from);\r
+\r
+      this.convs[space] = vals;\r
+   }\r
+  return vals;\r
+};\r
+\r
+["rgb", "hsl", "hsv", "cmyk", "keyword"].forEach(function(space) {\r
+   Converter.prototype[space] = function(vals) {\r
+      return this.routeSpace(space, arguments);\r
+   }\r
+});\r
+\r
+module.exports = convert;\r
+},{"4":4}],6:[function(require,module,exports){\r
+module.exports = {\r
+       "aliceblue": [240, 248, 255],\r
+       "antiquewhite": [250, 235, 215],\r
+       "aqua": [0, 255, 255],\r
+       "aquamarine": [127, 255, 212],\r
+       "azure": [240, 255, 255],\r
+       "beige": [245, 245, 220],\r
+       "bisque": [255, 228, 196],\r
+       "black": [0, 0, 0],\r
+       "blanchedalmond": [255, 235, 205],\r
+       "blue": [0, 0, 255],\r
+       "blueviolet": [138, 43, 226],\r
+       "brown": [165, 42, 42],\r
+       "burlywood": [222, 184, 135],\r
+       "cadetblue": [95, 158, 160],\r
+       "chartreuse": [127, 255, 0],\r
+       "chocolate": [210, 105, 30],\r
+       "coral": [255, 127, 80],\r
+       "cornflowerblue": [100, 149, 237],\r
+       "cornsilk": [255, 248, 220],\r
+       "crimson": [220, 20, 60],\r
+       "cyan": [0, 255, 255],\r
+       "darkblue": [0, 0, 139],\r
+       "darkcyan": [0, 139, 139],\r
+       "darkgoldenrod": [184, 134, 11],\r
+       "darkgray": [169, 169, 169],\r
+       "darkgreen": [0, 100, 0],\r
+       "darkgrey": [169, 169, 169],\r
+       "darkkhaki": [189, 183, 107],\r
+       "darkmagenta": [139, 0, 139],\r
+       "darkolivegreen": [85, 107, 47],\r
+       "darkorange": [255, 140, 0],\r
+       "darkorchid": [153, 50, 204],\r
+       "darkred": [139, 0, 0],\r
+       "darksalmon": [233, 150, 122],\r
+       "darkseagreen": [143, 188, 143],\r
+       "darkslateblue": [72, 61, 139],\r
+       "darkslategray": [47, 79, 79],\r
+       "darkslategrey": [47, 79, 79],\r
+       "darkturquoise": [0, 206, 209],\r
+       "darkviolet": [148, 0, 211],\r
+       "deeppink": [255, 20, 147],\r
+       "deepskyblue": [0, 191, 255],\r
+       "dimgray": [105, 105, 105],\r
+       "dimgrey": [105, 105, 105],\r
+       "dodgerblue": [30, 144, 255],\r
+       "firebrick": [178, 34, 34],\r
+       "floralwhite": [255, 250, 240],\r
+       "forestgreen": [34, 139, 34],\r
+       "fuchsia": [255, 0, 255],\r
+       "gainsboro": [220, 220, 220],\r
+       "ghostwhite": [248, 248, 255],\r
+       "gold": [255, 215, 0],\r
+       "goldenrod": [218, 165, 32],\r
+       "gray": [128, 128, 128],\r
+       "green": [0, 128, 0],\r
+       "greenyellow": [173, 255, 47],\r
+       "grey": [128, 128, 128],\r
+       "honeydew": [240, 255, 240],\r
+       "hotpink": [255, 105, 180],\r
+       "indianred": [205, 92, 92],\r
+       "indigo": [75, 0, 130],\r
+       "ivory": [255, 255, 240],\r
+       "khaki": [240, 230, 140],\r
+       "lavender": [230, 230, 250],\r
+       "lavenderblush": [255, 240, 245],\r
+       "lawngreen": [124, 252, 0],\r
+       "lemonchiffon": [255, 250, 205],\r
+       "lightblue": [173, 216, 230],\r
+       "lightcoral": [240, 128, 128],\r
+       "lightcyan": [224, 255, 255],\r
+       "lightgoldenrodyellow": [250, 250, 210],\r
+       "lightgray": [211, 211, 211],\r
+       "lightgreen": [144, 238, 144],\r
+       "lightgrey": [211, 211, 211],\r
+       "lightpink": [255, 182, 193],\r
+       "lightsalmon": [255, 160, 122],\r
+       "lightseagreen": [32, 178, 170],\r
+       "lightskyblue": [135, 206, 250],\r
+       "lightslategray": [119, 136, 153],\r
+       "lightslategrey": [119, 136, 153],\r
+       "lightsteelblue": [176, 196, 222],\r
+       "lightyellow": [255, 255, 224],\r
+       "lime": [0, 255, 0],\r
+       "limegreen": [50, 205, 50],\r
+       "linen": [250, 240, 230],\r
+       "magenta": [255, 0, 255],\r
+       "maroon": [128, 0, 0],\r
+       "mediumaquamarine": [102, 205, 170],\r
+       "mediumblue": [0, 0, 205],\r
+       "mediumorchid": [186, 85, 211],\r
+       "mediumpurple": [147, 112, 219],\r
+       "mediumseagreen": [60, 179, 113],\r
+       "mediumslateblue": [123, 104, 238],\r
+       "mediumspringgreen": [0, 250, 154],\r
+       "mediumturquoise": [72, 209, 204],\r
+       "mediumvioletred": [199, 21, 133],\r
+       "midnightblue": [25, 25, 112],\r
+       "mintcream": [245, 255, 250],\r
+       "mistyrose": [255, 228, 225],\r
+       "moccasin": [255, 228, 181],\r
+       "navajowhite": [255, 222, 173],\r
+       "navy": [0, 0, 128],\r
+       "oldlace": [253, 245, 230],\r
+       "olive": [128, 128, 0],\r
+       "olivedrab": [107, 142, 35],\r
+       "orange": [255, 165, 0],\r
+       "orangered": [255, 69, 0],\r
+       "orchid": [218, 112, 214],\r
+       "palegoldenrod": [238, 232, 170],\r
+       "palegreen": [152, 251, 152],\r
+       "paleturquoise": [175, 238, 238],\r
+       "palevioletred": [219, 112, 147],\r
+       "papayawhip": [255, 239, 213],\r
+       "peachpuff": [255, 218, 185],\r
+       "peru": [205, 133, 63],\r
+       "pink": [255, 192, 203],\r
+       "plum": [221, 160, 221],\r
+       "powderblue": [176, 224, 230],\r
+       "purple": [128, 0, 128],\r
+       "rebeccapurple": [102, 51, 153],\r
+       "red": [255, 0, 0],\r
+       "rosybrown": [188, 143, 143],\r
+       "royalblue": [65, 105, 225],\r
+       "saddlebrown": [139, 69, 19],\r
+       "salmon": [250, 128, 114],\r
+       "sandybrown": [244, 164, 96],\r
+       "seagreen": [46, 139, 87],\r
+       "seashell": [255, 245, 238],\r
+       "sienna": [160, 82, 45],\r
+       "silver": [192, 192, 192],\r
+       "skyblue": [135, 206, 235],\r
+       "slateblue": [106, 90, 205],\r
+       "slategray": [112, 128, 144],\r
+       "slategrey": [112, 128, 144],\r
+       "snow": [255, 250, 250],\r
+       "springgreen": [0, 255, 127],\r
+       "steelblue": [70, 130, 180],\r
+       "tan": [210, 180, 140],\r
+       "teal": [0, 128, 128],\r
+       "thistle": [216, 191, 216],\r
+       "tomato": [255, 99, 71],\r
+       "turquoise": [64, 224, 208],\r
+       "violet": [238, 130, 238],\r
+       "wheat": [245, 222, 179],\r
+       "white": [255, 255, 255],\r
+       "whitesmoke": [245, 245, 245],\r
+       "yellow": [255, 255, 0],\r
+       "yellowgreen": [154, 205, 50]\r
+};\r
+},{}],7:[function(require,module,exports){\r
+/**\r
+ * @namespace Chart\r
+ */\r
+var Chart = require(26)();\r
+\r
+require(25)(Chart);\r
+require(24)(Chart);\r
+require(21)(Chart);\r
+require(22)(Chart);\r
+require(23)(Chart);\r
+require(27)(Chart);\r
+require(31)(Chart);\r
+require(29)(Chart);\r
+require(30)(Chart);\r
+require(32)(Chart);\r
+require(28)(Chart);\r
+require(33)(Chart);\r
+\r
+require(34)(Chart);\r
+require(35)(Chart);\r
+require(36)(Chart);\r
+require(37)(Chart);\r
+\r
+require(40)(Chart);\r
+require(38)(Chart);\r
+require(39)(Chart);\r
+require(41)(Chart);\r
+require(42)(Chart);\r
+require(43)(Chart);\r
+\r
+// Controllers must be loaded after elements\r
+// See Chart.core.datasetController.dataElementType\r
+require(15)(Chart);\r
+require(16)(Chart);\r
+require(17)(Chart);\r
+require(18)(Chart);\r
+require(19)(Chart);\r
+require(20)(Chart);\r
+\r
+require(8)(Chart);\r
+require(9)(Chart);\r
+require(10)(Chart);\r
+require(11)(Chart);\r
+require(12)(Chart);\r
+require(13)(Chart);\r
+require(14)(Chart);\r
+\r
+window.Chart = module.exports = Chart;\r
+\r
+},{"10":10,"11":11,"12":12,"13":13,"14":14,"15":15,"16":16,"17":17,"18":18,"19":19,"20":20,"21":21,"22":22,"23":23,"24":24,"25":25,"26":26,"27":27,"28":28,"29":29,"30":30,"31":31,"32":32,"33":33,"34":34,"35":35,"36":36,"37":37,"38":38,"39":39,"40":40,"41":41,"42":42,"43":43,"8":8,"9":9}],8:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       Chart.Bar = function(context, config) {\r
+               config.type = 'bar';\r
+\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],9:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       Chart.Bubble = function(context, config) {\r
+               config.type = 'bubble';\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],10:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       Chart.Doughnut = function(context, config) {\r
+               config.type = 'doughnut';\r
+\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],11:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       Chart.Line = function(context, config) {\r
+               config.type = 'line';\r
+\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],12:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       Chart.PolarArea = function(context, config) {\r
+               config.type = 'polarArea';\r
+\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],13:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+       \r
+       Chart.Radar = function(context, config) {\r
+               config.options = Chart.helpers.configMerge({ aspectRatio: 1 }, config.options);\r
+               config.type = 'radar';\r
+\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+\r
+},{}],14:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       var defaultConfig = {\r
+               hover: {\r
+                       mode: 'single'\r
+               },\r
+\r
+               scales: {\r
+                       xAxes: [{\r
+                               type: "linear", // scatter should not use a category axis\r
+                               position: "bottom",\r
+                               id: "x-axis-1" // need an ID so datasets can reference the scale\r
+                       }],\r
+                       yAxes: [{\r
+                               type: "linear",\r
+                               position: "left",\r
+                               id: "y-axis-1"\r
+                       }]\r
+               },\r
+\r
+               tooltips: {\r
+                       callbacks: {\r
+                               title: function(tooltipItems, data) {\r
+                                       // Title doesn't make sense for scatter since we format the data as a point\r
+                                       return '';\r
+                               },\r
+                               label: function(tooltipItem, data) {\r
+                                       return '(' + tooltipItem.xLabel + ', ' + tooltipItem.yLabel + ')';\r
+                               }\r
+                       }\r
+               }\r
+       };\r
+\r
+       // Register the default config for this type\r
+       Chart.defaults.scatter = defaultConfig;\r
+\r
+       // Scatter charts use line controllers\r
+       Chart.controllers.scatter = Chart.controllers.line;\r
+\r
+       Chart.Scatter = function(context, config) {\r
+               config.type = 'scatter';\r
+               return new Chart(context, config);\r
+       };\r
+\r
+};\r
+},{}],15:[function(require,module,exports){\r
+"use strict";\r
+\r
+module.exports = function(Chart) {\r
+\r
+       var helpers = Chart.helpers;\r
+\r
+       Chart.defaults.bar = {\r
+               hover: {\r
+                       mode: "label"\r
+               },\r
+\r
+               scales: {\r
+                       xAxes: [{\r
+                               type: "category",\r
+\r
+                               // Specific to Bar Controller\r
+                               categoryPercentage: 0.8,\r
+                               barPercentage: 0.9,\r
+\r
+                               // grid line settings\r
+                               gridLines: {\r
+                                       offsetGridLines: true\r
+                               }\r
+                       }],\r
+                       yAxes: [{\r
+                               type: "linear"\r
+                       }]\r
+               }\r
+       };\r
+\r
+       Chart.controllers.bar = Chart.DatasetController.extend({\r
+\r
+               dataElementType: Chart.elements.Rectangle,\r
+\r
+               initialize: function(chart, datasetIndex) {\r
+                       Chart.DatasetController.prototype.initialize.call(this, chart, datasetIndex);\r
+\r
+                       // Use this to indicate that this is a bar dataset.\r
+                       this.getMeta().bar = true;\r
+               },\r
+\r
+               // Get the number of datasets that display bars. We use this to correctly calculate the bar width\r
+               getBarCount: function getBarCount() {\r
+                       var me = this;\r
+                       var barCount = 0;\r
+                       helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) {\r
+                               var meta = me.chart.getDatasetMeta(datasetIndex);\r
+                               if (meta.bar && me.chart.isDatasetVisible(datasetIndex)) {\r
+                                       ++barCount;\r
+                               }\r
+                       }, me);\r
+                       return barCount;\r
+               },\r
+\r
+               update: function update(reset) {\r
+                       var me = this;\r
+                       helpers.each(me.getMeta().data, function(rectangle, index) {\r
+                               me.updateElement(rectangle, index, reset);\r
+                       }, me);\r
+               },\r
+\r
+               updateElement: function updateElement(rectangle, index, reset) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var yScale = me.getScaleForId(meta.yAxisID);\r
+                       var scaleBase = yScale.getBasePixel();\r
+                       var rectangleElementOptions = me.chart.options.elements.rectangle;\r
+                       var custom = rectangle.custom || {};\r
+                       var dataset = me.getDataset();\r
+\r
+                       helpers.extend(rectangle, {\r
+                               // Utility\r
+                               _xScale: xScale,\r
+                               _yScale: yScale,\r
+                               _datasetIndex: me.index,\r
+                               _index: index,\r
+\r
+                               // Desired view properties\r
+                               _model: {\r
+                                       x: me.calculateBarX(index, me.index),\r
+                                       y: reset ? scaleBase : me.calculateBarY(index, me.index),\r
+\r
+                                       // Tooltip\r
+                                       label: me.chart.data.labels[index],\r
+                                       datasetLabel: dataset.label,\r
+\r
+                                       // Appearance\r
+                                       base: reset ? scaleBase : me.calculateBarBase(me.index, index),\r
+                                       width: me.calculateBarWidth(index),\r
+                                       backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor),\r
+                                       borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,\r
+                                       borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),\r
+                                       borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth)\r
+                               }\r
+                       });\r
+                       rectangle.pivot();\r
+               },\r
+\r
+               calculateBarBase: function(datasetIndex, index) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var yScale = me.getScaleForId(meta.yAxisID);\r
+                       var base = 0;\r
+\r
+                       if (yScale.options.stacked) {\r
+                               var chart = me.chart;\r
+                               var datasets = chart.data.datasets;\r
+                               var value = datasets[datasetIndex].data[index];\r
+\r
+                               if (value < 0) {\r
+                                       for (var i = 0; i < datasetIndex; i++) {\r
+                                               var negDS = datasets[i];\r
+                                               var negDSMeta = chart.getDatasetMeta(i);\r
+                                               if (negDSMeta.bar && negDSMeta.yAxisID === yScale.id && chart.isDatasetVisible(i)) {\r
+                                                       base += negDS.data[index] < 0 ? negDS.data[index] : 0;\r
+                                               }\r
+                                       }\r
+                               } else {\r
+                                       for (var j = 0; j < datasetIndex; j++) {\r
+                                               var posDS = datasets[j];\r
+                                               var posDSMeta = chart.getDatasetMeta(j);\r
+                                               if (posDSMeta.bar && posDSMeta.yAxisID === yScale.id && chart.isDatasetVisible(j)) {\r
+                                                       base += posDS.data[index] > 0 ? posDS.data[index] : 0;\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               return yScale.getPixelForValue(base);\r
+                       }\r
+\r
+                       return yScale.getBasePixel();\r
+               },\r
+\r
+               getRuler: function(index) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var datasetCount = me.getBarCount();\r
+\r
+                       var tickWidth;\r
+\r
+                       if (xScale.options.type === 'category') {\r
+                               tickWidth = xScale.getPixelForTick(index + 1) - xScale.getPixelForTick(index);\r
+                       } else {\r
+                               // Average width\r
+                               tickWidth = xScale.width / xScale.ticks.length;\r
+                       }\r
+                       var categoryWidth = tickWidth * xScale.options.categoryPercentage;\r
+                       var categorySpacing = (tickWidth - (tickWidth * xScale.options.categoryPercentage)) / 2;\r
+                       var fullBarWidth = categoryWidth / datasetCount;\r
+\r
+                       if (xScale.ticks.length !== me.chart.data.labels.length) {\r
+                           var perc = xScale.ticks.length / me.chart.data.labels.length;\r
+                           fullBarWidth = fullBarWidth * perc;\r
+                       }\r
+\r
+                       var barWidth = fullBarWidth * xScale.options.barPercentage;\r
+                       var barSpacing = fullBarWidth - (fullBarWidth * xScale.options.barPercentage);\r
+\r
+                       return {\r
+                               datasetCount: datasetCount,\r
+                               tickWidth: tickWidth,\r
+                               categoryWidth: categoryWidth,\r
+                               categorySpacing: categorySpacing,\r
+                               fullBarWidth: fullBarWidth,\r
+                               barWidth: barWidth,\r
+                               barSpacing: barSpacing\r
+                       };\r
+               },\r
+\r
+               calculateBarWidth: function(index) {\r
+                       var xScale = this.getScaleForId(this.getMeta().xAxisID);\r
+                       var ruler = this.getRuler(index);\r
+                       return xScale.options.stacked ? ruler.categoryWidth : ruler.barWidth;\r
+               },\r
+\r
+               // Get bar index from the given dataset index accounting for the fact that not all bars are visible\r
+               getBarIndex: function(datasetIndex) {\r
+                       var barIndex = 0;\r
+                       var meta, j;\r
+\r
+                       for (j = 0; j < datasetIndex; ++j) {\r
+                               meta = this.chart.getDatasetMeta(j);\r
+                               if (meta.bar && this.chart.isDatasetVisible(j)) {\r
+                                       ++barIndex;\r
+                               }\r
+                       }\r
+\r
+                       return barIndex;\r
+               },\r
+\r
+               calculateBarX: function(index, datasetIndex) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var barIndex = me.getBarIndex(datasetIndex);\r
+\r
+                       var ruler = me.getRuler(index);\r
+                       var leftTick = xScale.getPixelForValue(null, index, datasetIndex, me.chart.isCombo);\r
+                       leftTick -= me.chart.isCombo ? (ruler.tickWidth / 2) : 0;\r
+\r
+                       if (xScale.options.stacked) {\r
+                               return leftTick + (ruler.categoryWidth / 2) + ruler.categorySpacing;\r
+                       }\r
+\r
+                       return leftTick +\r
+                               (ruler.barWidth / 2) +\r
+                               ruler.categorySpacing +\r
+                               (ruler.barWidth * barIndex) +\r
+                               (ruler.barSpacing / 2) +\r
+                               (ruler.barSpacing * barIndex);\r
+               },\r
+\r
+               calculateBarY: function(index, datasetIndex) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var yScale = me.getScaleForId(meta.yAxisID);\r
+                       var value = me.getDataset().data[index];\r
+\r
+                       if (yScale.options.stacked) {\r
+\r
+                               var sumPos = 0,\r
+                                       sumNeg = 0;\r
+\r
+                               for (var i = 0; i < datasetIndex; i++) {\r
+                                       var ds = me.chart.data.datasets[i];\r
+                                       var dsMeta = me.chart.getDatasetMeta(i);\r
+                                       if (dsMeta.bar && dsMeta.yAxisID === yScale.id && me.chart.isDatasetVisible(i)) {\r
+                                               if (ds.data[index] < 0) {\r
+                                                       sumNeg += ds.data[index] || 0;\r
+                                               } else {\r
+                                                       sumPos += ds.data[index] || 0;\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               if (value < 0) {\r
+                                       return yScale.getPixelForValue(sumNeg + value);\r
+                               } else {\r
+                                       return yScale.getPixelForValue(sumPos + value);\r
+                               }\r
+                       }\r
+\r
+                       return yScale.getPixelForValue(value);\r
+               },\r
+\r
+               draw: function(ease) {\r
+                       var me = this;\r
+                       var easingDecimal = ease || 1;\r
+                       helpers.each(me.getMeta().data, function(rectangle, index) {\r
+                               var d = me.getDataset().data[index];\r
+                               if (d !== null && d !== undefined && !isNaN(d)) {\r
+                                       rectangle.transition(easingDecimal).draw();\r
+                               }\r
+                       }, me);\r
+               },\r
+\r
+               setHoverStyle: function(rectangle) {\r
+                       var dataset = this.chart.data.datasets[rectangle._datasetIndex];\r
+                       var index = rectangle._index;\r
+\r
+                       var custom = rectangle.custom || {};\r
+                       var model = rectangle._model;\r
+                       model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.getValueAtIndexOrDefault(dataset.hoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor));\r
+                       model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.getValueAtIndexOrDefault(dataset.hoverBorderColor, index, helpers.getHoverColor(model.borderColor));\r
+                       model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.getValueAtIndexOrDefault(dataset.hoverBorderWidth, index, model.borderWidth);\r
+               },\r
+\r
+               removeHoverStyle: function(rectangle) {\r
+                       var dataset = this.chart.data.datasets[rectangle._datasetIndex];\r
+                       var index = rectangle._index;\r
+                       var custom = rectangle.custom || {};\r
+                       var model = rectangle._model;\r
+                       var rectangleElementOptions = this.chart.options.elements.rectangle;\r
+\r
+                       model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor);\r
+                       model.borderColor = custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor);\r
+                       model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth);\r
+               }\r
+\r
+       });\r
+\r
+\r
+       // including horizontalBar in the bar file, instead of a file of its own\r
+       // it extends bar (like pie extends doughnut)\r
+       Chart.defaults.horizontalBar = {\r
+               hover: {\r
+                       mode: "label"\r
+               },\r
+\r
+               scales: {\r
+                       xAxes: [{\r
+                               type: "linear",\r
+                               position: "bottom"\r
+                       }],\r
+                       yAxes: [{\r
+                               position: "left",\r
+                               type: "category",\r
+\r
+                               // Specific to Horizontal Bar Controller\r
+                               categoryPercentage: 0.8,\r
+                               barPercentage: 0.9,\r
+\r
+                               // grid line settings\r
+                               gridLines: {\r
+                                       offsetGridLines: true\r
+                               }\r
+                       }]\r
+               },\r
+               elements: {\r
+                       rectangle: {\r
+                               borderSkipped: 'left'\r
+                       }\r
+               },\r
+               tooltips: {\r
+                       callbacks: {\r
+                               title: function(tooltipItems, data) {\r
+                                       // Pick first xLabel for now\r
+                                       var title = '';\r
+\r
+                                       if (tooltipItems.length > 0) {\r
+                                               if (tooltipItems[0].yLabel) {\r
+                                                       title = tooltipItems[0].yLabel;\r
+                                               } else if (data.labels.length > 0 && tooltipItems[0].index < data.labels.length) {\r
+                                                       title = data.labels[tooltipItems[0].index];\r
+                                               }\r
+                                       }\r
+\r
+                                       return title;\r
+                               },\r
+                               label: function(tooltipItem, data) {\r
+                                       var datasetLabel = data.datasets[tooltipItem.datasetIndex].label || '';\r
+                               return datasetLabel + ': ' + tooltipItem.xLabel;\r
+                               }\r
+                       }\r
+               }\r
+       };\r
+\r
+       Chart.controllers.horizontalBar = Chart.controllers.bar.extend({\r
+               updateElement: function updateElement(rectangle, index, reset, numBars) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var yScale = me.getScaleForId(meta.yAxisID);\r
+                       var scaleBase = xScale.getBasePixel();\r
+                       var custom = rectangle.custom || {};\r
+                       var dataset = me.getDataset();\r
+                       var rectangleElementOptions = me.chart.options.elements.rectangle;\r
+\r
+                       helpers.extend(rectangle, {\r
+                               // Utility\r
+                               _xScale: xScale,\r
+                               _yScale: yScale,\r
+                               _datasetIndex: me.index,\r
+                               _index: index,\r
+\r
+                               // Desired view properties\r
+                               _model: {\r
+                                       x: reset ? scaleBase : me.calculateBarX(index, me.index),\r
+                                       y: me.calculateBarY(index, me.index),\r
+\r
+                                       // Tooltip\r
+                                       label: me.chart.data.labels[index],\r
+                                       datasetLabel: dataset.label,\r
+\r
+                                       // Appearance\r
+                                       base: reset ? scaleBase : me.calculateBarBase(me.index, index),\r
+                                       height: me.calculateBarHeight(index),\r
+                                       backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor),\r
+                                       borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped,\r
+                                       borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor),\r
+                                       borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth)\r
+                               },\r
+\r
+                               draw: function () {\r
+                                       var ctx = this._chart.ctx;\r
+                                       var vm = this._view;\r
+\r
+                                       var halfHeight = vm.height / 2,\r
+                                               topY = vm.y - halfHeight,\r
+                                               bottomY = vm.y + halfHeight,\r
+                                               right = vm.base - (vm.base - vm.x),\r
+                                               halfStroke = vm.borderWidth / 2;\r
+\r
+                                       // Canvas doesn't allow us to stroke inside the width so we can\r
+                                       // adjust the sizes to fit if we're setting a stroke on the line\r
+                                       if (vm.borderWidth) {\r
+                                               topY += halfStroke;\r
+                                               bottomY -= halfStroke;\r
+                                               right += halfStroke;\r
+                                       }\r
+\r
+                                       ctx.beginPath();\r
+\r
+                                       ctx.fillStyle = vm.backgroundColor;\r
+                                       ctx.strokeStyle = vm.borderColor;\r
+                                       ctx.lineWidth = vm.borderWidth;\r
+\r
+                                       // Corner points, from bottom-left to bottom-right clockwise\r
+                                       // | 1 2 |\r
+                                       // | 0 3 |\r
+                                       var corners = [\r
+                                               [vm.base, bottomY],\r
+                                               [vm.base, topY],\r
+                                               [right, topY],\r
+                                               [right, bottomY]\r
+                                       ];\r
+\r
+                                       // Find first (starting) corner with fallback to 'bottom'\r
+                                       var borders = ['bottom', 'left', 'top', 'right'];\r
+                                       var startCorner = borders.indexOf(vm.borderSkipped, 0);\r
+                                       if (startCorner === -1)\r
+                                               startCorner = 0;\r
+\r
+                                       function cornerAt(index) {\r
+                                               return corners[(startCorner + index) % 4];\r
+                                       }\r
+\r
+                                       // Draw rectangle from 'startCorner'\r
+                                       ctx.moveTo.apply(ctx, cornerAt(0));\r
+                                       for (var i = 1; i < 4; i++)\r
+                                               ctx.lineTo.apply(ctx, cornerAt(i));\r
+\r
+                                       ctx.fill();\r
+                                       if (vm.borderWidth) {\r
+                                               ctx.stroke();\r
+                                       }\r
+                               },\r
+\r
+                               inRange: function (mouseX, mouseY) {\r
+                                       var vm = this._view;\r
+                                       var inRange = false;\r
+\r
+                                       if (vm) {\r
+                                               if (vm.x < vm.base) {\r
+                                                       inRange = (mouseY >= vm.y - vm.height / 2 && mouseY <= vm.y + vm.height / 2) && (mouseX >= vm.x && mouseX <= vm.base);\r
+                                               } else {\r
+                                                       inRange = (mouseY >= vm.y - vm.height / 2 && mouseY <= vm.y + vm.height / 2) && (mouseX >= vm.base && mouseX <= vm.x);\r
+                                               }\r
+                                       }\r
+\r
+                                       return inRange;\r
+                               }\r
+                       });\r
+\r
+                       rectangle.pivot();\r
+               },\r
+\r
+               calculateBarBase: function (datasetIndex, index) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var base = 0;\r
+\r
+                       if (xScale.options.stacked) {\r
+\r
+                               var value = me.chart.data.datasets[datasetIndex].data[index];\r
+\r
+                               if (value < 0) {\r
+                                       for (var i = 0; i < datasetIndex; i++) {\r
+                                               var negDS = me.chart.data.datasets[i];\r
+                                               var negDSMeta = me.chart.getDatasetMeta(i);\r
+                                               if (negDSMeta.bar && negDSMeta.xAxisID === xScale.id && me.chart.isDatasetVisible(i)) {\r
+                                                       base += negDS.data[index] < 0 ? negDS.data[index] : 0;\r
+                                               }\r
+                                       }\r
+                               } else {\r
+                                       for (var j = 0; j < datasetIndex; j++) {\r
+                                               var posDS = me.chart.data.datasets[j];\r
+                                               var posDSMeta = me.chart.getDatasetMeta(j);\r
+                                               if (posDSMeta.bar && posDSMeta.xAxisID === xScale.id && me.chart.isDatasetVisible(j)) {\r
+                                                       base += posDS.data[index] > 0 ? posDS.data[index] : 0;\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               return xScale.getPixelForValue(base);\r
+                       }\r
+\r
+                       return xScale.getBasePixel();\r
+               },\r
+\r
+               getRuler: function (index) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var yScale = me.getScaleForId(meta.yAxisID);\r
+                       var datasetCount = me.getBarCount();\r
+\r
+                       var tickHeight;\r
+                       if (yScale.options.type === 'category') {\r
+                               tickHeight = yScale.getPixelForTick(index + 1) - yScale.getPixelForTick(index);\r
+                       } else {\r
+                               // Average width\r
+                               tickHeight = yScale.width / yScale.ticks.length;\r
+                       }\r
+                       var categoryHeight = tickHeight * yScale.options.categoryPercentage;\r
+                       var categorySpacing = (tickHeight - (tickHeight * yScale.options.categoryPercentage)) / 2;\r
+                       var fullBarHeight = categoryHeight / datasetCount;\r
+\r
+                       if (yScale.ticks.length !== me.chart.data.labels.length) {\r
+                               var perc = yScale.ticks.length / me.chart.data.labels.length;\r
+                               fullBarHeight = fullBarHeight * perc;\r
+                       }\r
+\r
+                       var barHeight = fullBarHeight * yScale.options.barPercentage;\r
+                       var barSpacing = fullBarHeight - (fullBarHeight * yScale.options.barPercentage);\r
+\r
+                       return {\r
+                               datasetCount: datasetCount,\r
+                               tickHeight: tickHeight,\r
+                               categoryHeight: categoryHeight,\r
+                               categorySpacing: categorySpacing,\r
+                               fullBarHeight: fullBarHeight,\r
+                               barHeight: barHeight,\r
+                               barSpacing: barSpacing,\r
+                       };\r
+               },\r
+\r
+               calculateBarHeight: function (index) {\r
+                       var me = this;\r
+                       var yScale = me.getScaleForId(me.getMeta().yAxisID);\r
+                       var ruler = me.getRuler(index);\r
+                       return yScale.options.stacked ? ruler.categoryHeight : ruler.barHeight;\r
+               },\r
+\r
+               calculateBarX: function (index, datasetIndex) {\r
+                       var me = this;\r
+                       var meta = me.getMeta();\r
+                       var xScale = me.getScaleForId(meta.xAxisID);\r
+                       var value = me.getDataset().data[index];\r
+\r
+                       if (xScale.options.stacked) {\r
+\r
+                               var sumPos = 0,\r
+                                       sumNeg = 0;\r
+\r
+                               for (var i = 0; i < datasetIndex; i++) {\r
+                                       var ds = me.chart.data.datasets[i];\r
+                                       var dsMeta = me.chart.getDatasetMeta(i);\r
+                                       if (dsMeta.bar && dsMeta.xAxisID === xScale.id && me.chart.isDatasetVisible(i)) {\r
+                                               if (ds.data[index] < 0) {\r
+                                                 &nb